diff --git a/astroplan/conftest.py b/astroplan/conftest.py index cfaf7aad..866ad8c8 100644 --- a/astroplan/conftest.py +++ b/astroplan/conftest.py @@ -41,6 +41,7 @@ PYTEST_HEADER_MODULES['pyephem'] = 'ephem' PYTEST_HEADER_MODULES['matplotlib'] = 'matplotlib' PYTEST_HEADER_MODULES['pytest-mpl'] = 'pytest_mpl' + PYTEST_HEADER_MODULES['skyfield'] = 'skyfield' del PYTEST_HEADER_MODULES['h5py'] except KeyError: pass diff --git a/astroplan/constraints.py b/astroplan/constraints.py index 4df4af50..14d4e789 100644 --- a/astroplan/constraints.py +++ b/astroplan/constraints.py @@ -258,16 +258,17 @@ def __call__(self, observer, targets, times=None, time_resolution=time_grid_resolution) if grid_times_targets: - targets = get_skycoord(targets) + targets = get_skycoord(targets, times) # TODO: these broadcasting operations are relatively slow # but there is potential for huge speedup if the end user # disables gridding and re-shapes the coords themselves # prior to evaluating multiple constraints. - if targets.isscalar: - # ensure we have a (1, 1) shape coord - targets = SkyCoord(np.tile(targets, 1))[:, np.newaxis] - else: - targets = targets[..., np.newaxis] + if not observer._is_broadcastable(targets.shape, times.shape): + if targets.isscalar: + # ensure we have a (1, 1) shape coord + targets = SkyCoord(np.tile(targets, 1))[:, np.newaxis] + else: + targets = targets[..., np.newaxis] times, targets = observer._preprocess_inputs(times, targets, grid_times_targets=False) result = self.compute_constraint(times, observer, targets) diff --git a/astroplan/exceptions.py b/astroplan/exceptions.py index 46710f5c..05b6a0ae 100644 --- a/astroplan/exceptions.py +++ b/astroplan/exceptions.py @@ -42,3 +42,8 @@ class PlotBelowHorizonWarning(PlotWarning): class MissingConstraintWarning(AstroplanWarning): """Triggered when a constraint is expected but not supplied""" pass + + +class InvalidTLEDataWarning(AstroplanWarning): + """TLE data invalid for the requested time""" + pass diff --git a/astroplan/observer.py b/astroplan/observer.py index 03f148dc..408d41d8 100644 --- a/astroplan/observer.py +++ b/astroplan/observer.py @@ -516,8 +516,8 @@ def _preprocess_inputs(self, time, target=None, grid_times_targets=False): return time, None # convert any kind of target argument to non-scalar SkyCoord - target = get_skycoord(target) - if grid_times_targets: + target = get_skycoord(target, time) + if grid_times_targets and not self._is_broadcastable(target.shape, time.shape): if target.isscalar: # ensure we have a (1, 1) shape coord target = SkyCoord(np.tile(target, 1))[:, np.newaxis] diff --git a/astroplan/scheduling.py b/astroplan/scheduling.py index 32f49273..cbdad361 100644 --- a/astroplan/scheduling.py +++ b/astroplan/scheduling.py @@ -17,7 +17,7 @@ from .utils import time_grid_from_range, stride_array from .constraints import AltitudeConstraint -from .target import get_skycoord +from .target import get_skycoord, FixedTarget, TLETarget __all__ = ['ObservingBlock', 'TransitionBlock', 'Schedule', 'Slot', 'Scheduler', 'SequentialScheduler', 'PriorityScheduler', @@ -121,7 +121,6 @@ def __init__(self, blocks, observer, schedule, global_constraints=[]): self.observer = observer self.schedule = schedule self.global_constraints = global_constraints - self.targets = get_skycoord([block.target for block in self.blocks]) def create_score_array(self, time_resolution=1*u.minute): """ @@ -142,6 +141,7 @@ def create_score_array(self, time_resolution=1*u.minute): start = self.schedule.start_time end = self.schedule.end_time times = time_grid_from_range((start, end), time_resolution) + targets = get_skycoord([block.target for block in self.blocks], times) score_array = np.ones((len(self.blocks), len(times))) for i, block in enumerate(self.blocks): # TODO: change the default constraints from None to [] @@ -151,7 +151,7 @@ def create_score_array(self, time_resolution=1*u.minute): times=times) score_array[i] *= applied_score for constraint in self.global_constraints: - score_array *= constraint(self.observer, self.targets, times, + score_array *= constraint(self.observer, targets, times, grid_times_targets=True) return score_array @@ -273,25 +273,33 @@ def to_table(self, show_transitions=True, show_unused=False): start_times = [] end_times = [] durations = [] - ra = [] - dec = [] + coordiante_type = [] + coordinate_info = [] config = [] for slot in self.slots: if hasattr(slot.block, 'target'): start_times.append(slot.start.iso) end_times.append(slot.end.iso) - durations.append(slot.duration.to(u.minute).value) - target_names.append(slot.block.target.name) - ra.append(u.Quantity(slot.block.target.ra)) - dec.append(u.Quantity(slot.block.target.dec)) + durations.append('{:.4f}'.format(slot.duration.to(u.minute).value)) + target = slot.block.target + target_names.append(target.name) + if isinstance(target, FixedTarget): + coordiante_type.append("RA/Dec") + coordinate_info.append(target.coord.to_string('hmsdms')) + elif isinstance(target, TLETarget): + coordiante_type.append("TLE") + coordinate_info.append( + f"#{target.satellite.model.satnum} " + f"epoch {target.satellite.epoch.utc_strftime(format='%Y-%m-%d %H:%M:%S')}" + ) config.append(slot.block.configuration) elif show_transitions and slot.block: start_times.append(slot.start.iso) end_times.append(slot.end.iso) - durations.append(slot.duration.to(u.minute).value) + durations.append('{:.4f}'.format(slot.duration.to(u.minute).value)) target_names.append('TransitionBlock') - ra.append('') - dec.append('') + coordiante_type.append('') + coordinate_info.append('') changes = list(slot.block.components.keys()) if 'slew_time' in changes: changes.remove('slew_time') @@ -299,14 +307,15 @@ def to_table(self, show_transitions=True, show_unused=False): elif slot.block is None and show_unused: start_times.append(slot.start.iso) end_times.append(slot.end.iso) - durations.append(slot.duration.to(u.minute).value) + durations.append('{:.4f}'.format(slot.duration.to(u.minute).value)) target_names.append('Unused Time') - ra.append('') - dec.append('') + coordiante_type.append('') + coordinate_info.append('') config.append('') - return Table([target_names, start_times, end_times, durations, ra, dec, config], + return Table([target_names, start_times, end_times, + durations, coordiante_type, coordinate_info, config], names=('target', 'start time (UTC)', 'end time (UTC)', - 'duration (minutes)', 'ra', 'dec', 'configuration')) + 'duration (min)', 'type', 'coordinates/info', 'configuration')) def new_slots(self, slot_index, start_time, end_time): """ @@ -999,9 +1008,8 @@ def __call__(self, oldblock, newblock, start_time, observer): # use the constraints cache for now, but should move that machinery # to observer from .constraints import _get_altaz - from .target import get_skycoord if oldblock.target != newblock.target: - targets = get_skycoord([oldblock.target, newblock.target]) + targets = get_skycoord([oldblock.target, newblock.target], start_time) aaz = _get_altaz(start_time, observer, targets)['altaz'] sep = aaz[0].separation(aaz[1]) if sep/self.slew_rate > 1 * u.second: diff --git a/astroplan/target.py b/astroplan/target.py index 8a05dfca..82e2c1c4 100644 --- a/astroplan/target.py +++ b/astroplan/target.py @@ -4,12 +4,26 @@ # Standard library from abc import ABCMeta +import warnings # Third-party import astropy.units as u -from astropy.coordinates import SkyCoord, ICRS, UnitSphericalRepresentation +from astropy.time import Time +from astropy.coordinates import SkyCoord, ICRS, UnitSphericalRepresentation, AltAz, EarthLocation +try: + from sgp4.io import twoline2rv + from sgp4.earth_gravity import wgs84 as sgp4_wgs84 + from skyfield.api import load, wgs84 + from skyfield.sgp4lib import EarthSatellite + skyfield_available = True +except ImportError: + skyfield_available = False -__all__ = ["Target", "FixedTarget", "NonFixedTarget"] +# Package +from .exceptions import InvalidTLEDataWarning + + +__all__ = ["Target", "FixedTarget", "NonFixedTarget", "TLETarget"] # Docstring code examples include printed SkyCoords, but the format changed # in astropy 1.3. Thus the doctest needs astropy >=1.3 and this is the @@ -190,31 +204,267 @@ class NonFixedTarget(Target): """ -def get_skycoord(targets): +class TLETarget(Target): + """ + A target defined by TLE (Two-Line Element set) for satellites. + """ + def __init__(self, line1, line2, name=None, observer=None, skip_tle_check=False): + """ + Parameters + ---------- + line1 : str + The first line of the TLE set + + line2 : str + The second line of the TLE set + + name : str, optional + Name of the target, used for plotting and representing the target + as a string + + observer : `~astropy.coordinates.EarthLocation`, `~astroplan.Observer`, optional + The location of observer. + If `None`, the observer is assumed to be at sea level at the equator. + + skip_tle_check : bool, optional + Whether to skip TLE validation + """ + if not skyfield_available: + raise ImportError("Please install the skyfield package to use the TLETarget class.") + + if not skip_tle_check: + twoline2rv(line1, line2, sgp4_wgs84) # Raises ValueError if TLE is invalid + + self.name = name + self.satellite = EarthSatellite(line1, line2, name, load.timescale()) + + if observer is None: + self.observer = wgs84.latlon(0, 0, 0) + else: + observer = getattr(observer, 'location', observer) + longitude, latitude, height = observer.to_geodetic() + self.observer = wgs84.latlon(latitude.to(u.deg).value, + longitude.to(u.deg).value, + height.to(u.m).value) + + @classmethod + def from_string(cls, tle_string, name=None, *args, **kwargs): + """ + Creates a TLETarget instance from a complete TLE string. + + Parameters + ---------- + tle_string : str + String to be parsed, expected to contain 2 or 3 newline-separated lines. + + name : str, optional + Name of the target. If not provided and the tle_string contains 3 lines, + the first line will be used as the name. + + args, kwargs : tuple, dict, optional + Additional arguments and keyword arguments to be passed to the TLETarget constructor. + """ + lines = tle_string.strip().splitlines() + + if len(lines) not in (2, 3): + raise ValueError(f"Expected TLE string to contain 2 or 3 lines, got {len(lines)}") + + if len(lines) == 3: + line1, line2, name = lines[1], lines[2], name or lines[0] + else: # len(lines) == 2 + line1, line2 = lines + return cls(line1, line2, name, *args, **kwargs) + + @property + def ra(self): + raise NotImplementedError("Compute satellite RA at a specific time with self.coord(time)") + + @property + def dec(self): + raise NotImplementedError("Compute satellite RA at a specific time with self.coord(time)") + + def _compute_topocentric(self, time=None): + """ + Compute the topocentric coordinates (relative to observer) at a particular time. + + Parameters + ---------- + time : `~astropy.time.Time`, optional + The time(s) to use in the calculation. + + Returns + ------- + topocentric : `skyfield.positionlib.ICRF` + The topocentric object representing the relative coordinates of the target. + """ + if time is None: + time = Time.now() + ts = load.timescale() + t = ts.from_astropy(time) + + topocentric = (self.satellite - self.observer).at(t) + + # Check for invalid TLE data. A non-None usually message means the computation went beyond + # the physically sensible point. Details: + # https://rhodesmill.org/skyfield/earth-satellites.html#detecting-propagation-errors + message = topocentric.message + if ( + (message is not None and not isinstance(message, list)) or + (isinstance(message, list) and not all(x is None for x in message)) + ): + warnings.warn(f"Invalid TLE Data: {message}", InvalidTLEDataWarning) + + return topocentric + + def coord(self, time=None): + """ + Get the coordinates of the target at a particular time. + + Parameters + ---------- + time : `~astropy.time.Time`, optional + The time(s) to use in the calculation. + + Returns + ------- + coord : `~astropy.coordinates.SkyCoord` + A single SkyCoord object, which may be non-scalar, representing the target's + RA/Dec coordinates at the specified time(s). Might return np.nan and output a + warning for times where the elements stop making physical sense. + """ + topocentric = self._compute_topocentric(time) + ra, dec, distance = topocentric.radec() + # No distance, in SkyCoord, distance is from frame origin, but here, it's from observer. + return SkyCoord(ra.hours*u.hourangle, dec.degrees*u.deg, obstime=time, frame='icrs') + + def altaz(self, time=None): + """ + Get the altitude and azimuth of the target at a particular time. + + Parameters + ---------- + time : `~astropy.time.Time`, optional + The time(s) to use in the calculation. + + Returns + ------- + altaz_coord : `~astropy.coordinates.SkyCoord` + SkyCoord object representing the target's altitude and azimuth at the specified time(s) + """ + topocentric = self._compute_topocentric(time) + alt, az, distance = topocentric.altaz() + + earth_location = EarthLocation(lat=self.observer.latitude.degrees*u.deg, + lon=self.observer.longitude.degrees*u.deg, + height=self.observer.elevation.m*u.m) + + altaz = AltAz(alt=alt.degrees*u.deg, az=az.degrees*u.deg, + obstime=time, location=earth_location) + return SkyCoord(altaz) + + def __repr__(self): + return f'<{self.__class__.__name__} "{self.name}">' + + def __str__(self): + return self.name + + +def repeat_skycoord(coord, times): + """ + Repeats the coordinates of a SkyCoord object 'times.size' number of times. + + Parameters + ---------- + coord : `~astropy.coordinates.SkyCoord` + The original SkyCoord object whose coordinates need to be repeated. + + times : `~astropy.time.Time` + The size of times determines the number of times the coordinates should be repeated. + + Returns + -------- + SkyCoord : `~astropy.coordinates.SkyCoord` + A new SkyCoord object with the coordinates of the original object + repeated 'times.size' number of times. If the SkyCoord object is scalar + or 'times' is None or a scalar, this function returns the + original SkyCoord object. + """ + if coord.size != 1 or times is None or times.size == 1: + return coord + return SkyCoord( + ra=coord.ra.repeat(times.size), + dec=coord.dec.repeat(times.size), + distance=None if coord.distance.unit is u.one else coord.distance.repeat(times.size), + frame=coord.frame, + obstime=times + ) + + +def get_skycoord(targets, time=None, backwards_compatible=True): """ Return an `~astropy.coordinates.SkyCoord` object. When performing calculations it is usually most efficient to have a single `~astropy.coordinates.SkyCoord` object, rather than a - list of `FixedTarget` or `~astropy.coordinates.SkyCoord` objects. + list of `Target` or `~astropy.coordinates.SkyCoord` objects. This is a convenience routine to do that. Parameters ----------- - targets : list, `~astropy.coordinates.SkyCoord`, `Fixedtarget` + targets : list, `~astropy.coordinates.SkyCoord`, `Target` either a single target or a list of targets + time : `~astropy.time.Time`, optional + The time(s) to use in the calculation. + + backwards_compatible : bool, optional + Controls output format when FixedTarget or SkyCoord targets are used with a time argument. + If False, it will return (targets x times), where all coordinates per target are the same. + If True, it will return one coordinate per target (default is True). + Returns -------- coord : `~astropy.coordinates.SkyCoord` a single SkyCoord object, which may be non-scalar """ - if not isinstance(targets, list): - return getattr(targets, 'coord', targets) - # get the SkyCoord object itself - coords = [getattr(target, 'coord', target) for target in targets] + # Note on backwards_compatible: + # Method always returns (targets x times) with TLETarget in targets, as RA/Dec changes with time + # Do we want to be 100% backwards compatible, or do we prefer consistent output, + # for FixedTarget or SkyCoord targets combined with multiple times? + # backwards_compatible = True will continue to return one coordinate per target + # backwards_compatible = False, returns (targets x times) with identical coordinates per target + + # Early exit for single target + if not isinstance(targets, list): + if isinstance(targets, TLETarget): + return targets.coord(time) + else: + if backwards_compatible: + return getattr(targets, 'coord', targets) + else: + return repeat_skycoord(getattr(targets, 'coord', targets), time) + + # Identify if any of the targets is not FixedTarget or SkyCoord + has_non_fixed_target = any( + not isinstance(target, (FixedTarget, SkyCoord)) + for target in targets + ) + + # Get the SkyCoord object itself + coords = [ + target.coord(time) if isinstance(target, TLETarget) + else getattr(target, 'coord', target) + for target in targets + ] + + # Fill time dimension for SkyCoords that only have a single coordinate + if ( + (backwards_compatible and has_non_fixed_target or not backwards_compatible) and + time is not None + ): + coords = [repeat_skycoord(coord, time) for coord in coords] # are all SkyCoordinate's in equivalent frames? If not, convert to ICRS convert_to_icrs = not all( diff --git a/astroplan/tests/test_constraints.py b/astroplan/tests/test_constraints.py index c0f66f39..c78d6b46 100644 --- a/astroplan/tests/test_constraints.py +++ b/astroplan/tests/test_constraints.py @@ -447,7 +447,17 @@ def test_caches_shapes(): targets = get_skycoord([m31, ippeg, htcas]) observer = Observer.at_site('lapalma') ac = AltitudeConstraint(min=30*u.deg) - assert ac(observer, targets, times, grid_times_targets=True).shape == (3, 3) + + # When time and targets are the same size, + # they're broadcastable and grid_times_targets is ignored + assert ac(observer, targets, times, grid_times_targets=True).shape == (3,) + targets = get_skycoord([m31, ippeg]) + assert ac(observer, targets, times, grid_times_targets=True).shape == (2, 3) + + # When time and targets don't have the same size this fails with grid_times_targets=False + with pytest.raises(ValueError): + ac(observer, targets, times, grid_times_targets=False) + targets = get_skycoord([m31, ippeg, htcas]) assert ac(observer, targets, times, grid_times_targets=False).shape == (3,) diff --git a/astroplan/tests/test_scheduling.py b/astroplan/tests/test_scheduling.py index 49d88627..ea77c10f 100644 --- a/astroplan/tests/test_scheduling.py +++ b/astroplan/tests/test_scheduling.py @@ -6,15 +6,22 @@ from astropy.time import Time import astropy.units as u from astropy.coordinates import SkyCoord, EarthLocation +import pytest +try: + import skyfield # noqa + HAS_SKYFIELD = True +except ImportError: + HAS_SKYFIELD = False from ..utils import time_grid_from_range from ..observer import Observer -from ..target import FixedTarget, get_skycoord +from ..target import FixedTarget, TLETarget, get_skycoord from ..constraints import (AirmassConstraint, AtNightConstraint, _get_altaz, MoonIlluminationConstraint, PhaseConstraint) from ..periodic import EclipsingSystem from ..scheduling import (ObservingBlock, PriorityScheduler, SequentialScheduler, Transitioner, TransitionBlock, Schedule, Slot, Scorer) +from ..exceptions import InvalidTLEDataWarning vega = FixedTarget(coord=SkyCoord(ra=279.23473479 * u.deg, dec=38.78368896 * u.deg), name="Vega") @@ -326,3 +333,43 @@ def test_scorer(): scores = scorer.create_score_array(time_resolution=20 * u.minute) # the ``global_constraint``: constraint2 should have applied to the blocks assert np.array_equal(c2, scores) + + +@pytest.mark.skipif('not HAS_SKYFIELD') +def test_priority_scheduler_TLETarget(): + line1 = "1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990" + line2 = "2 25544 51.6403 95.2411 0000623 157.9606 345.0624 15.50085581409092" + iss = TLETarget(name="ISS (ZARYA)", line1=line1, line2=line2, observer=apo) + constraints = [AirmassConstraint(3, boolean_constraint=False)] + blocks = [ObservingBlock(t, 5*u.minute, i) for i, t in enumerate(targets)] + blocks.append(ObservingBlock(iss, 0.5*u.minute, 4)) + start_time = Time('2016-02-06 03:00:00') + end_time = start_time + 1*u.hour + scheduler = PriorityScheduler(transitioner=default_transitioner, + constraints=constraints, observer=apo, + time_resolution=0.5*u.minute) + schedule = Schedule(start_time, end_time) + scheduler(blocks, schedule) + assert len(schedule.observing_blocks) == 3 + assert all(np.abs(block.end_time - block.start_time - block.duration) < + 1*u.second for block in schedule.scheduled_blocks) + assert all([schedule.observing_blocks[0].target == polaris, + schedule.observing_blocks[1].target == rigel, + schedule.observing_blocks[2].target == iss]) + + # test that the scheduler does not error when called with a partially + # filled schedule + scheduler(blocks, schedule) + scheduler(blocks, schedule) + + # Time too far in the future where elements stop making physical sense + with pytest.warns(): + start_time = Time('2035-08-02 10:00:00') + end_time = start_time + 1*u.hour + schedule = Schedule(start_time, end_time) + with pytest.warns(InvalidTLEDataWarning): + scheduler(blocks, schedule) + assert len(schedule.observing_blocks) == 3 + assert all([schedule.observing_blocks[0].target == vega, + schedule.observing_blocks[1].target == rigel, + schedule.observing_blocks[2].target == polaris]) diff --git a/astroplan/tests/test_target.py b/astroplan/tests/test_target.py index 208a0a12..5cd98e7c 100644 --- a/astroplan/tests/test_target.py +++ b/astroplan/tests/test_target.py @@ -8,10 +8,18 @@ import astropy.units as u from astropy.coordinates import SkyCoord, GCRS, ICRS from astropy.time import Time +import numpy as np +try: + import skyfield # noqa + HAS_SKYFIELD = True +except ImportError: + HAS_SKYFIELD = False # Package -from ..target import FixedTarget, get_skycoord +from ..target import FixedTarget, TLETarget, get_skycoord from ..observer import Observer +from ..utils import time_grid_from_range +from ..exceptions import InvalidTLEDataWarning def test_FixedTarget_from_name(): @@ -46,6 +54,114 @@ def test_FixedTarget_ra_dec(): 'SkyCoord') +@pytest.mark.skipif('not HAS_SKYFIELD') +def test_TLETarget(): + tle_string = ("ISS (ZARYA)\n" + "1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990\n" + "2 25544 51.6403 95.2411 0000623 157.9606 345.0624 15.50085581409092") + line1 = "1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990" + line2 = "2 25544 51.6403 95.2411 0000623 157.9606 345.0624 15.50085581409092" + subaru = Observer.at_site('subaru') # (lon, lat, el)=(-155.476111 deg, 19.825555 deg, 4139.0 m) + time = Time("2023-08-02 10:00", scale='utc') + times = time_grid_from_range([time, time + 3.1*u.hour], + time_resolution=1 * u.hour) + + tle_target1 = TLETarget(name="ISS (ZARYA)", line1=line1, line2=line2, observer=subaru) + tle_target2 = TLETarget.from_string(tle_string=tle_string, observer=subaru) + + assert tle_target1.name == "ISS (ZARYA)" + assert tle_target2.name == "ISS (ZARYA)" + assert repr(tle_target1) == repr(tle_target2) + assert str(tle_target1) == str(tle_target2) + + # Single time (Below Horizon) + ra_dec1 = tle_target1.coord(time) # '08h29m26.00003243s +07d31m36.65950907s' + ra_dec2 = tle_target2.coord(time) + assert ra_dec1.to_string('hmsdms') == ra_dec2.to_string('hmsdms') + + # Comparison with the JPL Horizons System + ra_dec_horizon_icrf = SkyCoord("08h29m27.029117s +07d31m28.35610s") + # ICRF: Compensated for the down-leg light-time delay aberration + assert ra_dec1.separation(ra_dec_horizon_icrf) < 20*u.arcsec # 17.41″ + # Distance estimation: ~ 2 * tan(17,41/2/3600) * 11801,56 = 57 km + + ra_dec_horizon_ref_apparent = SkyCoord("08h30m54.567398s +08d05m32.72764s") + # Refracted Apparent: In an equatorial coordinate system with all compensations + assert ra_dec1.separation(ra_dec_horizon_ref_apparent) > 2000*u.arcsec # 2424.44″ + + ra_dec_horizon_icrf_ref_apparent = SkyCoord("08h29m37.373866s +08d10m14.78811s") + # ICRF Refracted Apparent: In the ICRF reference frame with all compensations + assert ra_dec1.separation(ra_dec_horizon_icrf_ref_apparent) > 2000*u.arcsec # 2324.28″ + + # Skyfield appears to use no compensations. According to this, it's not even recommended to + # compensate for light travel time. Compensating changes the difference to ra_dec_horizon_icrf + # to 20.05 arcsec. + # https://rhodesmill.org/skyfield/earth-satellites.html#avoid-calling-the-observe-method + + # Single time (Above Horizon) + time_ah = Time("2023-08-02 07:20", scale='utc') + ra_dec_ah = tle_target1.coord(time_ah) # '11h19m48.53631001s +44d49m45.22194611s' + + ra_dec_ah_horizon_icrf = SkyCoord("11h19m49.660349s +44d49m34.65875s") + assert ra_dec_ah.separation(ra_dec_ah_horizon_icrf) < 20*u.arcsec # 15.95″ + ra_dec_ah_horizon_ref_apparent = SkyCoord("11h21m34.102381s +44d43m40.06899s") + assert ra_dec_ah.separation(ra_dec_ah_horizon_ref_apparent) > 1000*u.arcsec # 1181.84″ + ra_dec_ah_horizon_icrf_ref_apparent = SkyCoord("11h20m16.627261s +44d51m24.25337s") + assert ra_dec_ah.separation(ra_dec_ah_horizon_icrf_ref_apparent) > 300*u.arcsec # 314.75″ + + # Default is WGS72 for Skyfield. Coordinates with WGS84 gravity model that Horizon uses: + # '11h19m48.28084569s +44d49m46.33649241s' - 18.75″ + # See 'Build a satellite with a specific gravity model' in Skyfield's Earth Satellites docu + + # There are many potential sources of inaccuracies, and it's not all super precise. + # Should the accuracy be better than < 25*u.arcsec when compared to the JPL Horizons System? + + # Multiple times + ra_dec1 = tle_target1.coord(times) + ra_dec2 = tle_target2.coord(times) + ra_dec_from_horizon = SkyCoord(["08h29m27.029117s +07d31m28.35610s", + "06h25m46.672661s -54d32m16.77533s", + "13h52m08.854291s +04d26m49.56432s", + "09h20m04.872215s -00d51m21.17432s"]) + # 17.41″, 22.05″, 3.20″, 17.55″ + assert all(list(ra_dec1.separation(ra_dec_from_horizon) < 25*u.arcsec)) + assert ra_dec1.to_string('hmsdms') == ra_dec2.to_string('hmsdms') + + # TLE Check + line1_invalid = "1 25544U 98067A .00041610 00000-0 73103-3 0 9990" + with pytest.raises(ValueError): + TLETarget(name="ISS (ZARYA)", line1=line1_invalid, line2=line2, observer=subaru) + TLETarget(name="ISS (ZARYA)", line1=line1_invalid, line2=line2, observer=subaru, + skip_tle_check=True) + + # from_string + tle_string = ("1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990\n" + "2 25544 51.6403 95.2411 0000623 157.9606 345.0624 15.50085581409092") + tle_target3 = TLETarget.from_string(tle_string=tle_string, observer=subaru, name="ISS") + assert tle_target3.name == "ISS" + + tle_string = "1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990" + with pytest.raises(ValueError): + TLETarget.from_string(tle_string=tle_string, observer=subaru) + + # AltAz (This is slow) + altaz_observer = subaru.altaz(time, tle_target1) + altaz_skyfield = tle_target1.altaz(time) + + assert altaz_observer.separation(altaz_skyfield) < 20*u.arcsec + + # Time too far in the future where elements stop making physical sense + with pytest.warns(): # ErfaWarning: ERFA function "dtf2d" yielded 1 of "dubious year (Note 6) + time_invalid = Time("2035-08-02 10:00", scale='utc') + times_list = list(times) + times_list[2] = Time("2035-08-02 10:00", scale='utc') + times_invalid = Time(times_list) + with pytest.warns(InvalidTLEDataWarning): + assert np.isnan(tle_target1.coord(time_invalid).ra) + with pytest.warns(InvalidTLEDataWarning): + assert np.isnan(tle_target1.coord(times_invalid)[2].ra) + + def test_get_skycoord(): m31 = SkyCoord(10.6847083*u.deg, 41.26875*u.deg) m31_with_distance = SkyCoord(10.6847083*u.deg, 41.26875*u.deg, 780*u.kpc) @@ -80,3 +196,48 @@ def test_get_skycoord(): coo = get_skycoord([m31_gcrs, m31_gcrs_with_distance]) assert coo.is_equivalent_frame(m31_gcrs.frame) assert len(coo) == 2 + + +@pytest.mark.skipif('not HAS_SKYFIELD') +def test_get_skycoord_with_TLETarget(): + skycoord_targed = SkyCoord(10.6847083*u.deg, 41.26875*u.deg) + fixed_target1 = FixedTarget(name="fixed1", coord=SkyCoord(279.23458, 38.78369, unit='deg')) + line1 = "1 25544U 98067A 23215.27256123 .00041610 00000-0 73103-3 0 9990" + line2 = "2 25544 51.6403 95.2411 0000623 157.9606 345.0624 15.50085581409092" + subaru = Observer.at_site('subaru') + tle_target1 = TLETarget(name="ISS (ZARYA)", line1=line1, line2=line2, observer=subaru) + + time = Time("2023-08-02 10:00", scale='utc') + times = time_grid_from_range([time, time + 3.1*u.hour], + time_resolution=1 * u.hour) + + tle_output = get_skycoord(tle_target1, time) + assert tle_output.size == 1 + tle_output = get_skycoord(tle_target1, times) + assert tle_output.shape == (4,) + tle_output = get_skycoord([tle_target1, tle_target1], time) + assert tle_output.shape == (2,) + tle_output = get_skycoord([tle_target1, tle_target1], times) + assert tle_output.shape == (2, 4) + + mixed_output = get_skycoord([skycoord_targed, fixed_target1, tle_target1], time) + assert mixed_output.shape == (3,) + mixed_output = get_skycoord([skycoord_targed, fixed_target1, tle_target1], times) + assert mixed_output.shape == (3, 4) + + # backwards_compatible + tle_output = get_skycoord(skycoord_targed, time, backwards_compatible=False) + assert tle_output.size == 1 + + tle_output = get_skycoord(skycoord_targed, times) + assert tle_output.size == 1 + tle_output = get_skycoord(skycoord_targed, times, backwards_compatible=False) + assert tle_output.shape == (4,) + + tle_output = get_skycoord([skycoord_targed, skycoord_targed], time, backwards_compatible=False) + assert tle_output.shape == (2,) + + tle_output = get_skycoord([skycoord_targed, skycoord_targed], times) + assert tle_output.shape == (2,) + tle_output = get_skycoord([skycoord_targed, skycoord_targed], times, backwards_compatible=False) + assert tle_output.shape == (2, 4) diff --git a/setup.cfg b/setup.cfg index 3c8d3693..e774feaa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ install_requires = all = matplotlib>=1.4 astroquery + skyfield test = pytest-astropy pytest-mpl @@ -37,6 +38,8 @@ docs = plotting = astroquery matplotlib>=1.4 +tle = + skyfield [options.package_data] astroplan = data/*