374 lines
14 KiB
Python
374 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""An interface between Skyfield and the Python ``sgp4`` library."""
|
||
|
||
from numpy import (
|
||
array, concatenate, identity, multiply, ones_like, repeat,
|
||
)
|
||
from sgp4 import omm
|
||
from sgp4.api import SGP4_ERRORS, Satrec
|
||
|
||
from .constants import AU_KM, DAY_S, T0, tau
|
||
from .functions import _T, mxm, mxv, rot_x, rot_y, rot_z
|
||
from .searchlib import _find_discrete, find_maxima
|
||
from .timelib import compute_calendar_date
|
||
from .vectorlib import VectorFunction
|
||
|
||
_identity = identity(3)
|
||
|
||
class EarthSatellite(VectorFunction):
|
||
"""An Earth satellite loaded from a TLE file and propagated with SGP4.
|
||
|
||
An earth satellite object is a Skyfield vector function, so you can
|
||
either call its ``at()`` method to generate its position in the sky
|
||
or else use addition and subtraction to combine it with other
|
||
vectors.
|
||
|
||
Satellite parameters are generally only accurate for a week or two
|
||
around the *epoch* of the parameters, the date for which they were
|
||
generated, which is available as an attribute:
|
||
|
||
``epoch``
|
||
A Skyfield :class:`~skyfield.timelib.Time` giving the exact
|
||
epoch moment for these satellite orbit parameters.
|
||
``name``
|
||
Satellite name
|
||
|
||
When building a satellite, use the arguments ``line1`` and ``line2``
|
||
to provide the two data lines from a TLE file as separate strings.
|
||
Optional ``name`` lets you give a name to the satellite, accessible
|
||
later through the ``name`` attribute. ``ts`` is a
|
||
:class:`~skyfield.timelib.Timescale` object, used to generate the
|
||
``epoch`` value; if it is not provided, the satellite will use a
|
||
built in ``Timescale`` object.
|
||
|
||
If you are interested in the catalog entry details, the SGP4 model
|
||
parameters for a particular satellite can be accessed through its
|
||
``model`` attribute:
|
||
|
||
``model.satnum``
|
||
The unique satellite NORAD catalog number given in the TLE file.
|
||
``model.classification``
|
||
Satellite classification, or else ``'U'`` for “Unknown”
|
||
``model.intldesg``
|
||
International designator
|
||
``model.epochyr``
|
||
Full four-digit year of this element set's epoch moment.
|
||
``model.epochdays``
|
||
Fractional days into the year of the epoch moment.
|
||
``model.jdsatepoch``
|
||
Julian date of the epoch (computed from ``epochyr`` and ``epochdays``).
|
||
``model.ndot``
|
||
First time derivative of the mean motion (ignored by SGP4).
|
||
``model.nddot``
|
||
Second time derivative of the mean motion (ignored by SGP4).
|
||
``model.bstar``
|
||
Ballistic drag coefficient B* in inverse earth radii.
|
||
``model.ephtype``
|
||
Ephemeris type (ignored by SGP4 as determination now automatic)
|
||
``model.elnum``
|
||
Element number
|
||
``model.inclo``
|
||
Inclination in radians.
|
||
``model.nodeo``
|
||
Right ascension of ascending node in radians.
|
||
``model.ecco``
|
||
Eccentricity.
|
||
``model.argpo``
|
||
Argument of perigee in radians.
|
||
``model.mo``
|
||
Mean anomaly in radians.
|
||
``model.no_kozai``
|
||
Mean motion in radians per minute.
|
||
``model.revnum``
|
||
Revolution number at epoch [Revs]
|
||
|
||
"""
|
||
center = 399
|
||
ts = None # see __init__()
|
||
|
||
def __init__(self, line1, line2, name=None, ts=None):
|
||
if ts is None:
|
||
ts = self.ts
|
||
if ts is None:
|
||
from .api import load # avoid import loop
|
||
ts = EarthSatellite.ts = load.timescale()
|
||
|
||
self.name = None if name is None else name.strip()
|
||
satrec = Satrec.twoline2rv(line1, line2)
|
||
self.model = satrec
|
||
|
||
two_digit_year = satrec.epochyr
|
||
if two_digit_year < 57:
|
||
year = two_digit_year + 2000
|
||
else:
|
||
year = two_digit_year + 1900
|
||
|
||
self.epoch = ts.utc(year, 1, satrec.epochdays)
|
||
|
||
self._setup(satrec)
|
||
|
||
def _setup(self, satrec):
|
||
# If only I had not made __init__() specific to TLE lines, but
|
||
# had put them in an alternate construtor instead, this would
|
||
# simply have lived in __init__(). Alas! I was so young then.
|
||
|
||
self.target = -100000 - satrec.satnum
|
||
|
||
@classmethod
|
||
def from_satrec(cls, satrec, ts):
|
||
"""Build an EarthSatellite from a raw sgp4 Satrec object.
|
||
|
||
This lets you provide raw numeric orbital elements instead of
|
||
the text of a TLE set. See :ref:`from-satrec` for detais.
|
||
|
||
"""
|
||
self = cls.__new__(cls)
|
||
self.model = satrec
|
||
self.name = None
|
||
|
||
# TODO: once sgp4 starts filling in epochyr and epochdays in
|
||
# sgp4init(), the separate epoch code here and in __init__() can
|
||
# be unified to always use epochyr and epochdays.
|
||
whole, fraction = divmod(satrec.jdsatepoch, 1.0)
|
||
year, month, day = compute_calendar_date(whole)
|
||
day += 0.5 # convert integer Julian day into Julian date float
|
||
self.epoch = ts.utc(year, month, day + fraction + satrec.jdsatepochF)
|
||
|
||
self._setup(satrec)
|
||
return self
|
||
|
||
@classmethod
|
||
def from_omm(cls, ts, element_dict):
|
||
"""Build an EarthSatellite from OMM text fields.
|
||
|
||
Provide a ``ts`` timescale object, and a Python dict of OMM
|
||
field names and values. The timescale is used to build the
|
||
satellite's ``.epoch`` time.
|
||
|
||
"""
|
||
self = cls.__new__(cls)
|
||
self.name = element_dict.get('OBJECT_NAME', None)
|
||
self.model = satrec = Satrec()
|
||
omm.initialize(satrec, element_dict)
|
||
self.epoch = ts._utc_jd(satrec.jdsatepoch, satrec.jdsatepochF)
|
||
self._setup(satrec)
|
||
return self
|
||
|
||
def __str__(self):
|
||
return self.target_name
|
||
|
||
@property
|
||
def target_name(self):
|
||
return '{0}{1}catalog #{2} epoch {3}'.format(
|
||
self.name or '',
|
||
' ' if self.name else '',
|
||
self.model.satnum,
|
||
self.epoch.utc_strftime(),
|
||
)
|
||
|
||
def _position_and_velocity_TEME_km(self, t):
|
||
"""Return the raw true equator mean equinox (TEME) vectors from SGP4.
|
||
|
||
Returns a tuple of NumPy arrays ``([x y z], [xdot ydot zdot])``
|
||
expressed in kilometers and kilometers per second. Note that we
|
||
assume the TLE epoch to be a UTC date, per AIAA 2006-6753.
|
||
|
||
"""
|
||
sat = self.model
|
||
jd = t.whole
|
||
fraction = t.tai_fraction - t._leap_seconds() / DAY_S
|
||
|
||
if getattr(jd, 'shape', None):
|
||
e, r, v = sat.sgp4_array(jd, fraction)
|
||
messages = [SGP4_ERRORS[error] if error else None for error in e]
|
||
return r.T, v.T, messages
|
||
else:
|
||
error, position, velocity = sat.sgp4(jd, fraction)
|
||
message = SGP4_ERRORS[error] if error else None
|
||
return array(position), array(velocity), message
|
||
|
||
def ITRF_position_velocity_error(self, t):
|
||
"""Deprecated: use the TEME and ITRS frame objects instead."""
|
||
# TODO: can we teach frame objects to figure out that the
|
||
# transform TEME -> ITRS can not only skip the t.M rotation, but
|
||
# can also subtract the angles of their two competing z-axis
|
||
# rotations and call rot_z() only once instead of twice?
|
||
rTEME, vTEME, error = self._position_and_velocity_TEME_km(t)
|
||
rTEME /= AU_KM
|
||
vTEME /= AU_KM
|
||
vTEME *= DAY_S
|
||
rITRF, vITRF = TEME_to_ITRF(t.whole, rTEME, vTEME, 0.0, 0.0,
|
||
t.ut1_fraction)
|
||
return rITRF, vITRF, error
|
||
|
||
def _at(self, t):
|
||
"""Compute this satellite's GCRS position and velocity at time `t`."""
|
||
r, v, error = self._position_and_velocity_TEME_km(t)
|
||
r /= AU_KM
|
||
v /= AU_KM
|
||
v *= DAY_S
|
||
R = _T(TEME.rotation_at(t))
|
||
r = mxv(R, r)
|
||
v = mxv(R, v)
|
||
return r, v, None, error
|
||
|
||
def find_events(self, topos, t0, t1, altitude_degrees=0.0):
|
||
"""Return the times at which the satellite rises, culminates, and sets.
|
||
|
||
Searches between ``t0`` and ``t1``, which should each be a
|
||
Skyfield :class:`~skyfield.timelib.Time` object, for passes of
|
||
this satellite above the location ``topos`` that reach at least
|
||
``altitude_degrees`` above the horizon.
|
||
|
||
Returns a tuple ``(t, events)`` whose first element is a
|
||
:class:`~skyfield.timelib.Time` array and whose second element
|
||
is an array of events:
|
||
|
||
* 0 — Satellite rose above ``altitude_degrees``.
|
||
* 1 — Satellite culminated and started to descend again.
|
||
* 2 — Satellite fell below ``altitude_degrees``.
|
||
|
||
Note that multiple culminations in a row are possible when,
|
||
without setting, the satellite reaches a second peak altitude
|
||
after descending partway down the sky from the first one.
|
||
|
||
"""
|
||
# First, we find the moments of maximum altitude over the time
|
||
# period. Some of these maxima will be negative, meaning the
|
||
# satellite failed to crest the horizon.
|
||
|
||
ts = t0.ts
|
||
at = (self - topos).at
|
||
half_second = 0.5 / DAY_S
|
||
orbits_per_minute = self.model.no_kozai / tau
|
||
orbits_per_day = 24 * 60 * orbits_per_minute
|
||
|
||
# Note the protection against zero orbits_per_day.
|
||
# TODO: why isn't 3 samples per orbit enough?
|
||
step_days = 0.05 / max(orbits_per_day, 1.0)
|
||
|
||
# Long-period satellites might rise each day not because of
|
||
# their own motion, but because the Earth rotates under them, so
|
||
# check position at least each quarter-day. We might need to
|
||
# tighten this even further if experience someday shows it
|
||
# missing a pass of a particular satellite.
|
||
if step_days > 0.25:
|
||
step_days = 0.25
|
||
|
||
def cheat(t):
|
||
"""Avoid computing expensive values that cancel out anyway."""
|
||
t.gast = t.tt * 0.0
|
||
t.M = t.MT = _identity
|
||
|
||
def altitude_at(t):
|
||
cheat(t)
|
||
return at(t).altaz()[0].degrees
|
||
|
||
altitude_at.step_days = step_days
|
||
tmax, altitude = find_maxima(t0, t1, altitude_at, half_second, 12)
|
||
|
||
# Next, filter out the maxima that are not high enough.
|
||
|
||
keepers = altitude >= altitude_degrees
|
||
jdmax = tmax.tt[keepers]
|
||
ones = ones_like(jdmax, 'uint8')
|
||
|
||
# Finally, find the rising and setting that bracket each maximum
|
||
# altitude. We guess that the satellite will be back below the
|
||
# horizon in between each pair of adjancent maxima.
|
||
|
||
def below_horizon_at(t):
|
||
cheat(t)
|
||
return at(t).altaz()[0].degrees < altitude_degrees
|
||
|
||
# The `jdo` array are the times of maxima, with their averages
|
||
# in between them. The start and end times are thrown in too,
|
||
# in case a rising or setting is lingering out between a maxima
|
||
# and the ends of our range. Could this perhaps still miss a
|
||
# stubborn rising or setting near the ends?
|
||
doublets = repeat(concatenate(((t0.tt,), jdmax, (t1.tt,))), 2)
|
||
jdo = (doublets[:-1] + doublets[1:]) / 2.0
|
||
|
||
trs, rs = _find_discrete(t0.ts, jdo, below_horizon_at, half_second, 8)
|
||
|
||
jd = concatenate((jdmax, trs.tt))
|
||
v = concatenate((ones, rs * 2))
|
||
|
||
i = jd.argsort()
|
||
return ts.tt_jd(jd[i]), v[i]
|
||
|
||
class TEME(object):
|
||
"""The satellite-specific True Equator Mean Equinox frame of reference.
|
||
|
||
This TEME frame is used to measure right ascension and declination,
|
||
and is the reference frame of the SGP4 Earth satellite orbit model.
|
||
It is a bit quirky. Instead measuring right ascension from the true
|
||
vernal equinox point, it uses the ‘mean’ equniox that considers only
|
||
precession but not nutation (the same equinox used for Greenwich
|
||
Mean Sidereal Time). This made the reference frame more tractable
|
||
for the 1970s computers that first implemented SGP4.
|
||
|
||
Defined in AIAA 2006-6753 Appendix C. See :ref:`reference_frames`
|
||
for a guide to using Skyfield reference frames like this one.
|
||
|
||
"""
|
||
@staticmethod
|
||
def rotation_at(t):
|
||
theta, theta_dot = theta_GMST1982(t.whole, t.ut1_fraction)
|
||
angle = theta - t.gast / 24.0 * tau
|
||
return mxm(rot_z(angle), t.M)
|
||
|
||
# TODO: Are there any applications that will need us to include the
|
||
# tiny affect on velocity of the rate of change of the difference
|
||
# between GMST1982 and GAST?
|
||
|
||
def theta_GMST1982(jd_ut1, fraction_ut1=0.0):
|
||
"""Return the angle of Greenwich Mean Standard Time 1982 given the JD.
|
||
|
||
This angle defines the difference between the idiosyncratic True
|
||
Equator Mean Equinox (TEME) frame of reference used by SGP4 and the
|
||
more standard Pseudo Earth Fixed (PEF) frame of reference. The UT1
|
||
time should be provided as a Julian date. Theta is returned in
|
||
radians, and its velocity in radians per day of UT1 time.
|
||
|
||
From AIAA 2006-6753 Appendix C.
|
||
|
||
"""
|
||
t = (jd_ut1 - T0 + fraction_ut1) / 36525.0
|
||
g = 67310.54841 + (8640184.812866 + (0.093104 + (-6.2e-6) * t) * t) * t
|
||
dg = 8640184.812866 + (0.093104 * 2.0 + (-6.2e-6 * 3.0) * t) * t
|
||
theta = (jd_ut1 % 1.0 + fraction_ut1 + g / DAY_S % 1.0) % 1.0 * tau
|
||
theta_dot = (1.0 + dg / (DAY_S * 36525.0)) * tau
|
||
return theta, theta_dot
|
||
|
||
_zero_zero_minus_one = array((0.0, 0.0, -1.0))
|
||
_cross120 = array((1,2,0))
|
||
_cross201 = array((2,0,1))
|
||
|
||
def _cross(a, b):
|
||
# Nearly 4x speedup over numpy cross(). TODO: Maybe move to .functions?
|
||
return a[_cross120] * b[_cross201] - a[_cross201] * b[_cross120]
|
||
|
||
def TEME_to_ITRF(jd_ut1, rTEME, vTEME, xp=0.0, yp=0.0, fraction_ut1=0.0):
|
||
"""Deprecated: use the TEME and ITRS frame objects instead."""
|
||
theta, theta_dot = theta_GMST1982(jd_ut1, fraction_ut1)
|
||
angular_velocity = multiply.outer(_zero_zero_minus_one, theta_dot)
|
||
|
||
R = rot_z(-theta)
|
||
|
||
if len(rTEME.shape) == 1:
|
||
rPEF = (R).dot(rTEME)
|
||
vPEF = (R).dot(vTEME) + _cross(angular_velocity, rPEF)
|
||
else:
|
||
rPEF = mxv(R, rTEME)
|
||
vPEF = mxv(R, vTEME) + _cross(angular_velocity, rPEF)
|
||
|
||
if xp == 0.0 and yp == 0.0:
|
||
rITRF = rPEF
|
||
vITRF = vPEF
|
||
else:
|
||
W = (rot_x(yp)).dot(rot_y(xp))
|
||
rITRF = (W).dot(rPEF)
|
||
vITRF = (W).dot(vPEF)
|
||
return rITRF, vITRF
|