From 7661229b8abba068707fcbf9bf78ced29da6c352 Mon Sep 17 00:00:00 2001 From: Michael Baisch Date: Wed, 11 Feb 2026 15:25:57 +0100 Subject: [PATCH 1/5] ENH: Add AltAzTarget and support time-dependent targets - Add AltAzTarget for targets defined in horizontal coordinates at a fixed EarthLocation, evaluated to time-dependent ICRS SkyCoord when needed; support vacuum (pressure=0) and apparent (pressure>0) interpretations via stored atmospheric parameters. - Export AltAzTarget and make get_skycoord(time-aware): accept `times`, evaluate time-dependent targets via get_skycoord(times), and broadcast coords. - Pass `time` into get_skycoord from Observer._preprocess_inputs to enable time-dependent target evaluation during constraint calculations. - Update Constraint.__call__ to delegate gridding and time-dependent evaluation to Observer._preprocess_inputs. - In scheduling add `target_type`/`target_info` columns in Schedule.to_table. - Add tests. --- astroplan/constraints.py | 43 ++-- astroplan/observer.py | 39 +++- astroplan/scheduling.py | 81 +++++-- astroplan/target.py | 339 +++++++++++++++++++++++++--- astroplan/tests/test_constraints.py | 39 +++- astroplan/tests/test_observer.py | 57 ++++- astroplan/tests/test_scheduling.py | 57 ++++- astroplan/tests/test_target.py | 210 ++++++++++++++++- 8 files changed, 771 insertions(+), 94 deletions(-) diff --git a/astroplan/constraints.py b/astroplan/constraints.py index 17b74f84..34604174 100644 --- a/astroplan/constraints.py +++ b/astroplan/constraints.py @@ -15,7 +15,7 @@ import numpy as np from astropy import table from astropy.time import Time -from astropy.coordinates import get_body, get_sun, Galactic, SkyCoord +from astropy.coordinates import get_body, get_sun, Galactic from numpy.lib.stride_tricks import as_strided # Package @@ -223,48 +223,45 @@ def __call__(self, observer, targets, times=None, time_range=None, time_grid_resolution=0.5*u.hour, grid_times_targets=False): """ - Compute the constraint for this class + Compute the constraint for this class. Parameters ---------- observer : `~astroplan.Observer` - the observation location from which to apply the constraints - targets : sequence of `~astroplan.Target` + The observation location from which to apply the constraints. + + targets : sequence of `~astroplan.Target` or `~astropy.coordinates.SkyCoord` The targets on which to apply the constraints. - times : `~astropy.time.Time` + times : `~astropy.time.Time` (optional) The times to compute the constraint. - WHAT HAPPENS WHEN BOTH TIMES AND TIME_RANGE ARE SET? - time_range : `~astropy.time.Time` (length = 2) + time_range : `~astropy.time.Time` (length = 2) (optional) Lower and upper bounds on time sequence. + Only used when ``times`` is not provided. time_grid_resolution : `~astropy.units.Quantity` - Time-grid spacing + Time-grid spacing. grid_times_targets : bool - if True, grids the constraint result with targets along the first + If True, grids the constraint result with targets along the first index and times along the second. Otherwise, we rely on broadcasting the shapes together using standard numpy rules. + Returns ------- constraint_result : 1D or 2D array of float or bool - The constraints. If 2D with targets along the first index and times along + The constraint values. If 2D with targets along the first index and times along the second. """ - if times is None and time_range is not None: times = time_grid_from_range(time_range, time_resolution=time_grid_resolution) - if grid_times_targets: - targets = get_skycoord(targets) - # 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] - times, targets = observer._preprocess_inputs(times, targets, grid_times_targets=False) + # TODO: broadcasting operations in _preprocess_inputs 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. + times, targets = observer._preprocess_inputs( + times, targets, grid_times_targets=grid_times_targets + ) + result = self.compute_constraint(times, observer, targets) # make sure the output has the same shape as would result from diff --git a/astroplan/observer.py b/astroplan/observer.py index a86b4793..c697115e 100644 --- a/astroplan/observer.py +++ b/astroplan/observer.py @@ -507,22 +507,47 @@ def _preprocess_inputs(self, time, target=None, grid_times_targets=False): the shapes together using standard numpy rules. Useful for grid searches for rise/set times etc. """ - # make sure we have a non-scalar time if not isinstance(time, Time): time = Time(time) + # In grid mode, scalar time should still behave like a length-1 time axis + if grid_times_targets and time.isscalar: + time = time[None] # shape (1,) + if target is None: return time, None + # Remember whether target is a single time-dependent target + is_multiple_targets = ( + isinstance(target, (list, tuple)) or + (isinstance(target, SkyCoord) and not target.isscalar) + ) + is_target_time_dependent = ( + callable(getattr(target, "get_skycoord", None)) and not + hasattr(target, "coord") + ) + is_single_time_dependent_target = (not is_multiple_targets) and is_target_time_dependent + # convert any kind of target argument to non-scalar SkyCoord - target = get_skycoord(target) + target = get_skycoord(target, times=time) + if grid_times_targets: + # Only ambiguous case: a single time-dependent target produces shape == time.shape + # but grid mode requires a leading target axis (1, ...). + if ( + is_single_time_dependent_target + and (not target.isscalar) + and (target.shape == time.shape) + ): + target = target[np.newaxis, ...] + + # Ensure at least one targets axis for scalar targets if target.isscalar: - # ensure we have a (1, 1) shape coord - target = SkyCoord(np.tile(target, 1))[:, np.newaxis] - else: - while target.ndim <= time.ndim: - target = target[:, np.newaxis] + target = SkyCoord(np.tile(target, 1)) # shape (1,) + + # Make target have one more dim than time (first targets axis, then time axes) + while target.ndim < 1 + time.ndim: + target = target[..., np.newaxis] elif not self._is_broadcastable(target.shape, time.shape): raise ValueError('Time and Target arguments cannot be broadcast ' diff --git a/astroplan/scheduling.py b/astroplan/scheduling.py index 8fda4a2b..70d8171f 100644 --- a/astroplan/scheduling.py +++ b/astroplan/scheduling.py @@ -118,7 +118,7 @@ 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]) + self.targets = [block.target for block in self.blocks] def create_score_array(self, time_resolution=1*u.minute): """ @@ -147,8 +147,9 @@ def create_score_array(self, time_resolution=1*u.minute): applied_score = constraint(self.observer, block.target, times=times) score_array[i] *= applied_score + targets = get_skycoord(self.targets, times=times) 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 @@ -265,45 +266,82 @@ def open_slots(self): return [slot for slot in self.slots if not slot.occupied] def to_table(self, show_transitions=True, show_unused=False): - # TODO: allow different coordinate types + def _format_target_info(target): + if hasattr(target, "coord"): + try: + return target.coord.icrs.to_string("hmsdms") + except Exception: + return repr(target.coord) + if hasattr(target, "alt") and hasattr(target, "az"): + parts = [ + "alt={:.6f} deg".format(u.Quantity(target.alt).to_value(u.deg)), + "az={:.6f} deg".format(u.Quantity(target.az).to_value(u.deg)), + ] + if hasattr(target, "pressure"): + try: + p = u.Quantity(target.pressure) + if p.to_value(u.hPa) != 0.0: + parts.append("pressure={:.3f} hPa".format(p.to_value(u.hPa))) + except Exception: + pass + return ", ".join(parts) + if hasattr(target, "satellite"): + return ( + f"#{target.satellite.model.satnum} " + f"epoch {target.satellite.epoch.utc_strftime(format='%Y-%m-%d %H:%M:%S')}" + ) + return "" + target_names = [] start_times = [] end_times = [] durations = [] - ra = [] - dec = [] + target_types = [] + target_info = [] config = [] + for slot in self.slots: - if hasattr(slot.block, 'target'): + 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)) + target_types.append(slot.block.target.__class__.__name__) + target_info.append(_format_target_info(slot.block.target)) 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) - target_names.append('TransitionBlock') - ra.append('') - dec.append('') + target_names.append("TransitionBlock") + target_types.append("TransitionBlock") + target_info.append("") changes = list(slot.block.components.keys()) - if 'slew_time' in changes: - changes.remove('slew_time') + if "slew_time" in changes: + changes.remove("slew_time") config.append(changes) 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) - target_names.append('Unused Time') - ra.append('') - dec.append('') - config.append('') - return Table([target_names, start_times, end_times, durations, ra, dec, config], - names=('target', 'start time (UTC)', 'end time (UTC)', - 'duration (minutes)', 'ra', 'dec', 'configuration')) + target_names.append("Unused Time") + target_types.append("") + target_info.append("") + config.append("") + + return Table( + [target_names, start_times, end_times, durations, target_types, target_info, config], + names=( + "target", + "start time (UTC)", + "end time (UTC)", + "duration (minutes)", + "target type", + "target info", + "configuration", + ), + ) + def new_slots(self, slot_index, start_time, end_time): """ @@ -996,9 +1034,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], times=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 e82c7e5e..bae1871a 100644 --- a/astroplan/target.py +++ b/astroplan/target.py @@ -4,10 +4,19 @@ from abc import ABCMeta # Third-party +import numpy as np 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, +) + +__all__ = ["Target", "FixedTarget", "AltAzTarget", "NonFixedTarget"] -__all__ = ["Target", "FixedTarget", "NonFixedTarget"] # 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 @@ -182,41 +191,265 @@ def _from_name_mock(cls, query_name, name=None): "method".format(query_name)) +class AltAzTarget(Target): + """ + Coordinates and metadata for a target defined in horizontal coordinates + (altitude/azimuth) at a fixed observing location. + + Unlike `~astroplan.FixedTarget`, an `~astroplan.AltAzTarget` is *time-dependent*: + the stored AltAz direction is evaluated at requested time(s) by transforming + to ICRS, yielding an ICRS coordinate that varies with ``obstime``. + + This class is useful for targets defined by a local pointing direction (e.g., + “look at az=120°, alt=30° from this observatory”), rather than a fixed celestial + coordinate. + + Notes + ----- + The stored direction can be interpreted as geometric (vacuum) or apparent + (refracted) depending on the atmospheric parameters provided. A pressure of + ``0 hPa`` disables refraction. + + Downstream computations that transform the evaluated coordinate back to AltAz + use the atmospheric parameters on the `~astroplan.Observer`. To preserve the + exact apparent direction implied by this target's atmospheric parameters, use + matching atmospheric parameters on the `~astroplan.Observer`. + + Examples + -------- + Define a fixed horizontal direction at a given observatory: + + >>> import astropy.units as u + >>> from astropy.coordinates import EarthLocation + >>> from astroplan import AltAzTarget + >>> location = EarthLocation.of_site("greenwich") # doctest: +REMOTE_DATA + >>> t = AltAzTarget(alt=30*u.deg, az=120*u.deg, location=location, name="Pointing") + """ + + @u.quantity_input(alt=u.deg, az=u.deg) + def __init__( + self, + alt, + az, + location, + name=None, + pressure=None, + temperature=None, + relative_humidity=None, + obswl=None, + marker=None, + **kwargs, + ): + """ + Parameters + ---------- + alt : `~astropy.units.Quantity` + Altitude angle. Must have angular units (e.g., ``u.deg``). + + az : `~astropy.units.Quantity` + Azimuth angle. Must have angular units (e.g., ``u.deg``). By convention, + azimuth is measured East of North. + + location : `~astropy.coordinates.EarthLocation` + The observing location to which these AltAz coordinates apply. + + name : str, optional + Name of the target, used for plotting and representing the target + as a string. + + pressure : `~astropy.units.Quantity`, optional + Atmospheric pressure used to interpret the stored AltAz direction. + If set to ``0 hPa`` (default), the direction is treated as vacuum + (geometric). If non-zero, the direction is treated as apparent + (refracted) under the supplied atmospheric conditions. + + temperature : `~astropy.units.Quantity`, optional + Ambient temperature for the refraction model (used when ``pressure`` + is non-zero). Default is ``0 deg_C``. + + relative_humidity : float, optional + Relative humidity for the refraction model (used when ``pressure`` is + non-zero). Must be in the interval [0, 1]. Default is 0. + + obswl : `~astropy.units.Quantity`, optional + Observation wavelength for the refraction model (used when ``pressure`` + is non-zero). Default is ``1 micron``. + + marker : str, optional + User-defined marker to differentiate between different types of targets + (e.g., guides, high-priority, etc.). + """ + if not isinstance(location, EarthLocation): + raise TypeError("`location` must be an `astropy.coordinates.EarthLocation`.") + + self.name = name + self.marker = marker + + self.alt = u.Quantity(alt).to(u.deg) + self.az = u.Quantity(az).to(u.deg) + self.location = location + + # Store atmosphere parameters for interpreting the stored AltAz direction. + self.pressure = pressure + self.temperature = temperature + self.relative_humidity = relative_humidity + self.obswl = obswl + + @classmethod + def from_observer(cls, *, alt, az, observer, obswl=None, **kwargs): + """ + Initialize an `~astroplan.AltAzTarget` from an `~astroplan.Observer`. + + This is a convenience constructor that uses the observer's location and + atmospheric parameters to interpret the supplied AltAz direction. + + Parameters + ---------- + alt : `~astropy.units.Quantity` + Altitude angle. + + az : `~astropy.units.Quantity` + Azimuth angle. By convention, azimuth is measured East of North. + + observer : `~astroplan.Observer` + Observer that provides the location (and atmospheric parameters if + present). + + obswl : `~astropy.units.Quantity`, optional + Observation wavelength for the refraction model (used when ``pressure`` + is non-zero). Default is ``1 micron``. + + **kwargs + Additional keywords passed to `~astroplan.AltAzTarget` (e.g., ``name``, + ``marker``). + + Returns + ------- + target : `~astroplan.AltAzTarget` + The constructed target. + """ + return cls( + alt=alt, az=az, location=observer.location, + pressure=observer.pressure, temperature=observer.temperature, + relative_humidity=observer.relative_humidity, obswl=obswl, + **kwargs + ) + + + def __repr__(self): + class_name = self.__class__.__name__ + alt = self.alt.to(u.deg).value + az = self.az.to(u.deg).value + return '<{} "{}" at (alt, az)=({:.6f} deg, {:.6f} deg)>'.format( + class_name, self.name, alt, az + ) + + def get_skycoord(self, times): + """ + Evaluate this target to an ICRS `~astropy.coordinates.SkyCoord` at ``times``. + + Parameters + ---------- + times : `~astropy.time.Time` or time-like + Times at which to evaluate the target. + + Returns + ------- + coord : `~astropy.coordinates.SkyCoord` + ICRS coordinate evaluated at ``times`` (time-dependent). + """ + if times is None: + raise ValueError("`times` is required to evaluate an AltAzTarget.") + if not isinstance(times, Time): + times = Time(times) + + # Construct the AltAz frame used to interpret the stored alt/az direction. + altaz_frame = AltAz( + location=self.location, + obstime=times, + pressure=self.pressure, + temperature=self.temperature, + relative_humidity=self.relative_humidity, + obswl=self.obswl, + ) + + # The stored alt/az are treated as scalar directions and broadcast to `times`. + alt = u.Quantity(np.broadcast_to(self.alt.to_value(u.deg), times.shape), u.deg) + az = u.Quantity(np.broadcast_to(self.az.to_value(u.deg), times.shape), u.deg) + + return SkyCoord(az=az, alt=alt, frame=altaz_frame).icrs + + + class NonFixedTarget(Target): """ Placeholder for future function. """ -def get_skycoord(targets): +def get_skycoord(targets, times=None): """ 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. + This is a convenience routine to do that, and it also supports targets + that require evaluation at specific times (e.g., AltAz-defined targets). Parameters - ----------- - targets : list, `~astropy.coordinates.SkyCoord`, `Fixedtarget` - either a single target or a list of targets + ---------- + targets : list, `~astropy.coordinates.SkyCoord`, `~astroplan.Target` + Either a single target or a list of targets. + + times : `~astropy.time.Time` or time-like (optional) + Times at which to evaluate time-dependent targets. Required if any + target in ``targets`` needs evaluation at a time. Returns - -------- + ------- coord : `~astropy.coordinates.SkyCoord` - a single SkyCoord object, which may be non-scalar + A single SkyCoord object, which may be non-scalar. If ``times`` + is provided and any target is time-dependent, coordinates are broadcast + or evaluated across time along subsequent axes. """ - if not isinstance(targets, list): - return getattr(targets, 'coord', targets) - - # get the SkyCoord object itself - coords = [getattr(target, 'coord', target) for target in targets] - - # are all SkyCoordinate's in equivalent frames? If not, convert to ICRS + if times is not None and not isinstance(times, Time): + times = Time(times) + + def _is_time_dependent(obj): + return callable(getattr(obj, "get_skycoord", None)) and not hasattr(obj, "coord") + + def _as_coord(obj): + if hasattr(obj, "coord"): + return obj.coord + if callable(getattr(obj, "get_skycoord", None)): + return obj.get_skycoord(times) + return obj + + is_multiple_targets = ( + isinstance(targets, (list, tuple)) or + (isinstance(targets, SkyCoord) and not targets.isscalar) + ) + if not is_multiple_targets: + return _as_coord(targets) + + coords = [_as_coord(t) for t in targets] + + # If any target is time dependent, broadcast fixed coords to match times.shape + time_dependent = (times is not None) and any(_is_time_dependent(t) for t in targets) + times_shape = times.shape if time_dependent else None + + def _broadcast_quantity(q, shape): + """Broadcast quantity to target shape if needed.""" + if shape is None or q.shape == shape: + return q + return u.Quantity(np.broadcast_to(q.to_value(q.unit), shape), q.unit) + + # Are all SkyCoord's in equivalent frames? If not, convert to ICRS convert_to_icrs = not all( - [coord.frame.is_equivalent_frame(coords[0].frame) for coord in coords[1:]]) + [coord.frame.is_equivalent_frame(coords[0].frame) for coord in coords[1:]] + ) # we also need to be careful about handling mixtures of # UnitSphericalRepresentations and others @@ -231,10 +464,18 @@ def get_skycoord(targets): # mixture of frames for coordinate in coords: icrs_coordinate = coordinate.icrs - longitudes.append(icrs_coordinate.ra) - latitudes.append(icrs_coordinate.dec) + lon = icrs_coordinate.ra + lat = icrs_coordinate.dec + if times_shape is not None: + lon = _broadcast_quantity(lon, times_shape) + lat = _broadcast_quantity(lat, times_shape) + longitudes.append(lon) + latitudes.append(lat) if get_distances: - distances.append(icrs_coordinate.distance) + dist = icrs_coordinate.distance + if times_shape is not None: + dist = _broadcast_quantity(dist, times_shape) + distances.append(dist) frame = ICRS() else: # all the same frame, get the longitude and latitude names @@ -249,27 +490,53 @@ def get_skycoord(targets): frame = coords[0].frame for coordinate in coords: - longitudes.append(getattr(coordinate, lon_name)) - latitudes.append(getattr(coordinate, lat_name)) + lon = getattr(coordinate, lon_name) + lat = getattr(coordinate, lat_name) + if times_shape is not None: + lon = _broadcast_quantity(lon, times_shape) + lat = _broadcast_quantity(lat, times_shape) + longitudes.append(lon) + latitudes.append(lat) if get_distances: - distances.append(coordinate.distance) + dist = coordinate.distance + if times_shape is not None: + dist = _broadcast_quantity(dist, times_shape) + distances.append(dist) + + # Convert all longitude/latitude quantities to a common unit + # and plain ndarrays before stacking (robust across units/Quantity subclasses). + lon_unit = longitudes[0].unit + lat_unit = latitudes[0].unit + lon_vals = np.stack([lon.to_value(lon_unit) for lon in longitudes], axis=0) + lat_vals = np.stack([lat.to_value(lat_unit) for lat in latitudes], axis=0) + lon_q = u.Quantity(lon_vals, unit=lon_unit) + lat_q = u.Quantity(lat_vals, unit=lat_unit) # now let's deal with the fact that we may have a mixture of coords with distances and # coords with UnitSphericalRepresentations if all(targets_is_unitsphericalrep): - return SkyCoord(longitudes, latitudes, frame=frame) - elif not any(targets_is_unitsphericalrep): - return SkyCoord(longitudes, latitudes, distances, frame=frame) - else: - """ - We have a mixture of coords with distances and without. - Since we don't know in advance the origin of the frame where further transformation - will take place, it's not safe to drop the distances from those coords with them set. + return SkyCoord(lon_q, lat_q, frame=frame) + + if not any(targets_is_unitsphericalrep): + dist_unit = distances[0].unit + dist_vals = np.stack([d.to_value(dist_unit) for d in distances], axis=0) + dist_q = u.Quantity(dist_vals, unit=dist_unit) + return SkyCoord(lon_q, lat_q, dist_q, frame=frame) + + # Mixture of coords with distances and without. + # Assign large distances to UnitSphericalRepresentation objects. + filled_distances = [] + for dist, is_unitspherical in zip(distances, targets_is_unitsphericalrep): + if is_unitspherical: + fill_vals = np.broadcast_to(100.0, dist.shape if dist.shape else ()) + filled_distances.append(u.Quantity(fill_vals, u.kpc)) + else: + filled_distances.append(dist) - Instead, let's assign large distances to those objects with none. - """ - distances = [distance if distance != 1 else 100*u.kpc for distance in distances] - return SkyCoord(longitudes, latitudes, distances, frame=frame) + dist_unit = filled_distances[0].unit + dist_vals = np.stack([d.to_value(dist_unit) for d in filled_distances], axis=0) + dist_q = u.Quantity(dist_vals, unit=dist_unit) + return SkyCoord(lon_q, lat_q, dist_q, frame=frame) class SpecialObjectFlag: diff --git a/astroplan/tests/test_constraints.py b/astroplan/tests/test_constraints.py index e4db748a..414a36fa 100644 --- a/astroplan/tests/test_constraints.py +++ b/astroplan/tests/test_constraints.py @@ -3,7 +3,7 @@ import astropy.units as u import numpy as np import pytest -from astropy.coordinates import Galactic, SkyCoord, get_sun, get_body +from astropy.coordinates import Galactic, SkyCoord, EarthLocation, get_sun, get_body from astropy.time import Time from astroplan.constraints import ( @@ -20,7 +20,7 @@ from astroplan.exceptions import MissingConstraintWarning from astroplan.observer import Observer from astroplan.periodic import EclipsingSystem -from astroplan.target import FixedTarget, get_skycoord +from astroplan.target import FixedTarget, AltAzTarget, get_skycoord vega = FixedTarget(coord=SkyCoord(ra=279.23473479*u.deg, dec=38.78368896*u.deg), name="Vega") @@ -107,6 +107,41 @@ def test_altitude_constraint(): assert np.all([results != 0][0] == [False, False, True, True, False, False]) +def test_altitude_constraint_accepts_altaztarget(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + observer = Observer(location=location) + target = AltAzTarget(alt=60*u.deg, az=180*u.deg, location=location, name="horiz") + c = AltitudeConstraint(min=30*u.deg) + + # Scalar Time + t = Time("2026-02-05T00:00:00", scale="utc") + result = c(observer, target, times=t) + assert result.shape == () + assert np.all(result) + + # Vector time, no grid + t0 = Time("2026-01-01T00:00:00", scale="utc") + times = t0 + np.arange(5) * u.hour + result = c(observer, target, times=times, grid_times_targets=False) + assert result.shape == times.shape + assert np.all(result) + + # Vector time, grid + result = c(observer, target, times=times, grid_times_targets=True) + assert result.shape == (1, len(times)) + assert np.all(result) + + # Vector time, grid, multiple targets + targets = [ + AltAzTarget(alt=60*u.deg, az=180*u.deg, location=location, name="h1"), + AltAzTarget(alt=40*u.deg, az=90*u.deg, location=location, name="h2"), + ] + result = c(observer, targets, times=times, grid_times_targets=True) + assert result.shape == (len(targets), len(times)) + assert np.all(result[0, :]) + assert np.all(result[1, :]) + + @pytest.mark.remote_data def test_compare_altitude_constraint_and_observer(): time = Time('2001-02-03 04:05:06') diff --git a/astroplan/tests/test_observer.py b/astroplan/tests/test_observer.py index 0f63b0af..59a067f4 100644 --- a/astroplan/tests/test_observer.py +++ b/astroplan/tests/test_observer.py @@ -16,7 +16,7 @@ # Package from astroplan.observer import Observer -from astroplan.target import FixedTarget +from astroplan.target import FixedTarget, AltAzTarget from astroplan.exceptions import TargetAlwaysUpWarning, TargetNeverUpWarning @@ -146,6 +146,61 @@ def test_altaz_multiple_targets(): assert all(ft_vector_alt[2, :] == sirius_alt) +def test_altaz_roundtrip_for_altaztarget(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + observer = Observer(location=location) + + t = Time("2026-01-01T00:00:00", scale="utc") + alt = 42*u.deg + az = 123*u.deg + + target = AltAzTarget(alt=alt, az=az, location=location, name="horiz") + + # If AltAzTarget evaluates to ICRS at time t, transforming back to AltAz at t + # should recover the original horizontal direction (within numerical tolerance). + a = observer.altaz(t, target) + + assert np.allclose(a.alt.to_value(u.deg), alt.to_value(u.deg), atol=1e-10) + assert np.allclose(a.az.wrap_at(360*u.deg).to_value(u.deg), + az.to_value(u.deg), atol=1e-10) + + +def test_altaz_accepts_altaztarget(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + obs = Observer(location=location) + target = AltAzTarget(alt=60*u.deg, az=180*u.deg, location=location, name="horiz") + + # Vector time, no grid + t0 = Time("2026-02-05T00:00:00", scale="utc") + times = t0 + np.arange(5) * u.hour + altaz = obs.altaz(times, target) + assert altaz.alt.shape == times.shape + assert altaz.az.shape == times.shape + # AltAzTarget evaluates to ICRS at time t, transforming back to AltAz at t + # should recover the original horizontal direction (within numerical tolerance). + assert np.allclose(altaz.alt.to_value(u.deg), 60.0) + assert np.allclose(altaz.az.to_value(u.deg), 180.0) + + # Vector time, grid + altaz_grid = obs.altaz(times, target, grid_times_targets=True) + assert altaz_grid.alt.shape == (1, len(times)) + assert altaz_grid.az.shape == (1, len(times)) + assert np.allclose(altaz_grid.alt[0].to_value(u.deg), 60.0) + assert np.allclose(altaz_grid.az[0].to_value(u.deg), 180.0) + + # Vector time, grid, multiple targets + targets = [ + AltAzTarget(alt=60*u.deg, az=180*u.deg, location=location, name="h1"), + AltAzTarget(alt=45*u.deg, az=90*u.deg, location=location, name="h2"), + AltAzTarget(alt=35*u.deg, az=10*u.deg, location=location, name="h3"), + ] + altaz_grid = obs.altaz(times, targets, grid_times_targets=True) + assert altaz_grid.alt.shape == (len(targets), len(times)) + assert np.allclose(altaz_grid.alt[0].to_value(u.deg), 60.0) + assert np.allclose(altaz_grid.alt[1].to_value(u.deg), 45.0) + assert np.allclose(altaz_grid.alt[2].to_value(u.deg), 35.0) + + def test_rise_set_transit_nearest_vector(): vega = SkyCoord(279.23473479*u.deg, 38.78368896*u.deg) mira = SkyCoord(34.83663376*u.deg, -2.97763767*u.deg) diff --git a/astroplan/tests/test_scheduling.py b/astroplan/tests/test_scheduling.py index 5bfcd874..ee47ec75 100644 --- a/astroplan/tests/test_scheduling.py +++ b/astroplan/tests/test_scheduling.py @@ -8,7 +8,7 @@ from astroplan.utils import time_grid_from_range from astroplan.observer import Observer -from astroplan.target import FixedTarget, get_skycoord +from astroplan.target import FixedTarget, AltAzTarget, get_skycoord from astroplan.constraints import (AirmassConstraint, AtNightConstraint, _get_altaz, MoonIlluminationConstraint, PhaseConstraint) from astroplan.periodic import EclipsingSystem @@ -67,6 +67,37 @@ def test_schedule(): assert np.abs(new_slots[2].duration - 20*u.hour) < 1*u.second +def test_schedule_to_table(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + start = Time("2026-02-05T00:00:00", scale="utc") + end = Time("2026-02-05T00:20:00", scale="utc") + + fixed = FixedTarget(SkyCoord(ra=10*u.deg, dec=20*u.deg), name="fixed") + horiz = AltAzTarget(alt=50*u.deg, az=200*u.deg, location=location, name="horiz") + block_fixed = ObservingBlock(fixed, 600*u.second, priority=1, constraints=[AirmassConstraint(max=4)]) + block_horiz = ObservingBlock(horiz, 600*u.second, priority=1, constraints=[AirmassConstraint(max=4)]) + + schedule = Schedule(start, end) + schedule.insert_slot(start, block_fixed) + schedule.insert_slot(start + 600*u.second, block_horiz) + + tab = schedule.to_table(show_transitions=False, show_unused=False) + + assert "target type" in tab.colnames + assert "target info" in tab.colnames + + assert tab["target"][0] == "fixed" + assert tab["target type"][0] == "FixedTarget" + info = tab["target info"][0] + assert "00h40m00s +20d00m00s" in info.lower() + + assert tab["target"][1] == "horiz" + assert tab["target type"][1] == "AltAzTarget" + info = tab["target info"][1] + assert "alt=50" in info.lower() + assert "az=200" in info.lower() + + def test_schedule_insert_slot(): start = Time('2016-02-06 03:00:00') schedule = Schedule(start, start + 5*u.hour) @@ -329,3 +360,27 @@ 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) + + +def test_scorer_mixed_targets(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + observer = Observer(location=location, pressure=0*u.bar, temperature=0*u.deg_C, + relative_humidity=0.0, timezone="UTC") + + start = Time("2026-02-05T00:00:00", scale="utc") + end = Time("2026-02-05T01:00:00", scale="utc") + + fixed = FixedTarget(SkyCoord(ra=10*u.deg, dec=20*u.deg), name="fixed") + horiz = AltAzTarget(alt=50*u.deg, az=200*u.deg, location=location, name="horiz") + + blocks = [ + ObservingBlock(fixed, 300*u.second, priority=1, constraints=[AirmassConstraint(max=4)]), + ObservingBlock(horiz, 300*u.second, priority=1, constraints=[AirmassConstraint(min=4)]), + ] + + schedule = Schedule(start, end) + scorer = Scorer(blocks, observer, schedule, global_constraints=[AirmassConstraint(max=4)]) + + score = scorer.create_score_array(time_resolution=10*u.minute) + + assert score.shape == (len(blocks), 6) # 1 hour / 10 min diff --git a/astroplan/tests/test_target.py b/astroplan/tests/test_target.py index ba2dde47..3a756435 100644 --- a/astroplan/tests/test_target.py +++ b/astroplan/tests/test_target.py @@ -1,13 +1,14 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst # Third-party +import numpy as np import astropy.units as u import pytest -from astropy.coordinates import SkyCoord, GCRS, ICRS +from astropy.coordinates import SkyCoord, GCRS, ICRS, EarthLocation from astropy.time import Time # Package -from astroplan.target import FixedTarget, get_skycoord +from astroplan.target import FixedTarget, AltAzTarget, get_skycoord from astroplan.observer import Observer @@ -45,6 +46,139 @@ def test_FixedTarget_ra_dec(): 'SkyCoord') +def test_AltAzTarget_quantity_validation(): + """ + `AltAzTarget` should require angle quantities for `alt` and `az`, and a valid + EarthLocation. + """ + location = EarthLocation.from_geodetic(lon=0 * u.deg, lat=0 * u.deg, height=0 * u.m) + + with pytest.raises(TypeError): + AltAzTarget(alt=30, az=120, location=location) + + with pytest.raises(TypeError): + AltAzTarget(alt=30 * u.deg, az=120 * u.deg, location="not a location") + + +def test_AltAzTarget_get_skycoord_requires_times(): + """ + `AltAzTarget.get_skycoord` and `get_skycoord` should require `times` when + evaluating time-dependent targets. + """ + location = EarthLocation.from_geodetic(lon=0 * u.deg, lat=0 * u.deg, height=0 * u.m) + target = AltAzTarget(alt=45 * u.deg, az=0 * u.deg, location=location) + + with pytest.raises(ValueError): + target.get_skycoord(None) + + with pytest.raises(ValueError): + get_skycoord([target]) + + +def test_AltAzTarget_from_observer(): + """ + `AltAzTarget.from_observer` should construct an AltAzTarget that inherits the + observer's location and atmospheric parameters, and evaluates identically to + a manually-constructed AltAzTarget with the same inputs. + """ + location = EarthLocation.from_geodetic( + lon=10 * u.deg, lat=45 * u.deg, height=100 * u.m + ) + observer = Observer( + location=location, + pressure=800 * u.hPa, + temperature=10 * u.deg_C, + relative_humidity=0.25, + timezone="UTC", + name="Test Observer", + ) + + target = AltAzTarget.from_observer( + alt=30 * u.deg, + az=120 * u.deg, + observer=observer, + name="AltAz via Observer", + marker="test", + obswl=2 * u.micron, + ) + + assert target.location == observer.location + assert target.pressure == observer.pressure + assert target.temperature == observer.temperature + assert target.relative_humidity == observer.relative_humidity + assert target.name == "AltAz via Observer" + assert target.marker == "test" + + manual = AltAzTarget( + alt=30 * u.deg, + az=120 * u.deg, + location=observer.location, + pressure=observer.pressure, + temperature=observer.temperature, + relative_humidity=observer.relative_humidity, + obswl=2 * u.micron, + ) + + times = Time( + ["2026-02-05 00:00", "2026-02-05 06:00", "2026-02-05 12:00"] + ) + + coord_from_observer = target.get_skycoord(times) + coord_manual = manual.get_skycoord(times) + + # They should be effectively identical + assert coord_from_observer.separation(coord_manual).max() < 1e-6 * u.arcsec + + +def test_AltAzTarget_get_skycoord_vector_times_shape_and_frame(): + """ + Evaluating an `AltAzTarget` at vector times should return an ICRS SkyCoord + with shape matching `times.shape` and values that vary with time. + """ + location = EarthLocation.from_geodetic(lon=0 * u.deg, lat=0 * u.deg, height=0 * u.m) + target = AltAzTarget( + alt=45 * u.deg, + az=0 * u.deg, + location=location, + pressure=None, + temperature=None, + relative_humidity=None, + obswl=None, + ) + + t0 = Time("2026-02-05 00:00") + times = t0 + np.array([0, 3, 6]) * u.hour + + coord = target.get_skycoord(times) + + assert coord.is_equivalent_frame(ICRS()) + assert coord.shape == times.shape + assert coord.size == times.size + + # Coordinate should change with time + assert coord[0].separation(coord[-1]) > 50 * u.deg + + +def test_AltAzTarget_apparent_vs_vacuum_differ_when_pressure_nonzero(): + location = EarthLocation.from_geodetic(10*u.deg, 45*u.deg, 0*u.m) + + t = Time("2026-01-01T00:00:00", scale="utc") + + # Use a low altitude where refraction matters + alt = 10*u.deg + az = 90*u.deg + + apparent = AltAzTarget(alt=alt, az=az, location=location, name="apparent", + pressure=1*u.bar, temperature=10*u.deg_C, relative_humidity=0.5) + vacuum = AltAzTarget(alt=alt, az=az, location=location, name="vacuum") + + ca = apparent.get_skycoord(t) + cv = vacuum.get_skycoord(t) + + # Require a non-trivial difference + assert ca.separation(cv) > 100*u.arcsec + + @pytest.mark.remote_data def test_get_skycoord(): m31 = SkyCoord(10.6847083*u.deg, 41.26875*u.deg) @@ -80,3 +214,75 @@ 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 + + +def test_get_skycoord_broadcasts_fixed_targets_when_time_dependent_present(): + """ + When at least one target is time-dependent and `times` is provided, + `get_skycoord` should broadcast fixed targets to match `times.shape` and + stack along the target axis. + """ + location = EarthLocation.from_geodetic(lon=0 * u.deg, lat=0 * u.deg, height=0 * u.m) + altaz_target = AltAzTarget(alt=45 * u.deg, az=0 * u.deg, location=location) + + m31 = SkyCoord(10.6847083 * u.deg, 41.26875 * u.deg) + + t0 = Time("2026-02-05 00:00") + times = t0 + np.arange(4) * u.hour + + coo = get_skycoord([m31, altaz_target], times=times) + + assert coo.is_equivalent_frame(ICRS()) + assert coo.shape == (2,) + times.shape + + # Fixed target should be repeated across time + assert np.allclose(coo[0].ra.to_value(u.deg), m31.ra.to_value(u.deg)) + assert np.allclose(coo[0].dec.to_value(u.deg), m31.dec.to_value(u.deg)) + + # Time-dependent target should vary across time + assert coo[1][0].separation(coo[1][-1]) > 10 * u.deg + + +def test_get_skycoord_does_not_broadcast_when_all_targets_are_fixed(): + """ + If all targets are fixed, providing `times` should not change the output + shape (backwards-compatible behavior). + """ + m31 = SkyCoord(10.6847083 * u.deg, 41.26875 * u.deg) + m32 = SkyCoord(10.6747083 * u.deg, 40.26875 * u.deg) + + t0 = Time("2026-02-05 00:00") + times = t0 + np.arange(3) * u.hour + + coo = get_skycoord([m31, m32], times=times) + + assert coo.is_equivalent_frame(ICRS()) + assert coo.shape == (2,) + + +def test_get_skycoord_mixed_distances_with_time_dependent_target_fills_unitspherical(): + """ + With a mixture of targets with distances and unit-spherical targets, and at + least one time-dependent target present, `get_skycoord` should return a + distance-bearing SkyCoord and fill large distances for unit-spherical entries. + """ + location = EarthLocation.from_geodetic(lon=0 * u.deg, lat=0 * u.deg, height=0 * u.m) + altaz_target = AltAzTarget(alt=45 * u.deg, az=0 * u.deg, location=location) + + m31 = SkyCoord(10.6847083 * u.deg, 41.26875 * u.deg) # unit-spherical + m31_with_distance = SkyCoord(10.6847083 * u.deg, 41.26875 * u.deg, 780 * u.kpc) + + t0 = Time("2026-02-05 00:00") + times = t0 + np.arange(4) * u.hour + + coo = get_skycoord([m31, m31_with_distance, altaz_target], times=times) + + assert coo.is_equivalent_frame(ICRS()) + assert coo.shape == (3,) + times.shape + assert coo.distance.shape == (3,) + times.shape + + # Filled distances for unit-spherical targets + assert np.allclose(coo.distance[0].to_value(u.kpc), 100.0) + assert np.allclose(coo.distance[2].to_value(u.kpc), 100.0) + # Preserved distance for the distance-bearing target + assert np.allclose(coo.distance[1].to_value(u.kpc), 780.0) From e6445bde2fad4feb568f73c3bad74e3bfe7f98f6 Mon Sep 17 00:00:00 2001 From: Michael Baisch Date: Thu, 12 Feb 2026 14:20:17 +0100 Subject: [PATCH 2/5] Fix code style checks --- astroplan/scheduling.py | 1 - astroplan/target.py | 5 ++--- astroplan/tests/test_observer.py | 2 +- astroplan/tests/test_scheduling.py | 8 ++++++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/astroplan/scheduling.py b/astroplan/scheduling.py index 70d8171f..24220d68 100644 --- a/astroplan/scheduling.py +++ b/astroplan/scheduling.py @@ -342,7 +342,6 @@ def _format_target_info(target): ), ) - def new_slots(self, slot_index, start_time, end_time): """ Create new slots by splitting a current slot. diff --git a/astroplan/target.py b/astroplan/target.py index bae1871a..2647c9b8 100644 --- a/astroplan/target.py +++ b/astroplan/target.py @@ -222,7 +222,8 @@ class AltAzTarget(Target): >>> import astropy.units as u >>> from astropy.coordinates import EarthLocation >>> from astroplan import AltAzTarget - >>> location = EarthLocation.of_site("greenwich") # doctest: +REMOTE_DATA + >>> location = EarthLocation.from_geodetic(-155.4761*u.deg, 19.825*u.deg, + ... 4139*u.m) >>> t = AltAzTarget(alt=30*u.deg, az=120*u.deg, location=location, name="Pointing") """ @@ -335,7 +336,6 @@ def from_observer(cls, *, alt, az, observer, obswl=None, **kwargs): **kwargs ) - def __repr__(self): class_name = self.__class__.__name__ alt = self.alt.to(u.deg).value @@ -380,7 +380,6 @@ def get_skycoord(self, times): return SkyCoord(az=az, alt=alt, frame=altaz_frame).icrs - class NonFixedTarget(Target): """ Placeholder for future function. diff --git a/astroplan/tests/test_observer.py b/astroplan/tests/test_observer.py index 59a067f4..38888321 100644 --- a/astroplan/tests/test_observer.py +++ b/astroplan/tests/test_observer.py @@ -198,7 +198,7 @@ def test_altaz_accepts_altaztarget(): assert altaz_grid.alt.shape == (len(targets), len(times)) assert np.allclose(altaz_grid.alt[0].to_value(u.deg), 60.0) assert np.allclose(altaz_grid.alt[1].to_value(u.deg), 45.0) - assert np.allclose(altaz_grid.alt[2].to_value(u.deg), 35.0) + assert np.allclose(altaz_grid.alt[2].to_value(u.deg), 35.0) def test_rise_set_transit_nearest_vector(): diff --git a/astroplan/tests/test_scheduling.py b/astroplan/tests/test_scheduling.py index ee47ec75..cd89dadc 100644 --- a/astroplan/tests/test_scheduling.py +++ b/astroplan/tests/test_scheduling.py @@ -74,8 +74,12 @@ def test_schedule_to_table(): fixed = FixedTarget(SkyCoord(ra=10*u.deg, dec=20*u.deg), name="fixed") horiz = AltAzTarget(alt=50*u.deg, az=200*u.deg, location=location, name="horiz") - block_fixed = ObservingBlock(fixed, 600*u.second, priority=1, constraints=[AirmassConstraint(max=4)]) - block_horiz = ObservingBlock(horiz, 600*u.second, priority=1, constraints=[AirmassConstraint(max=4)]) + block_fixed = ObservingBlock( + fixed, 600 * u.second, priority=1, constraints=[AirmassConstraint(max=4)] + ) + block_horiz = ObservingBlock( + horiz, 600 * u.second, priority=1, constraints=[AirmassConstraint(max=4)] + ) schedule = Schedule(start, end) schedule.insert_slot(start, block_fixed) From adbfb64282b632951777a0fa7a55c5a9f9e530de Mon Sep 17 00:00:00 2001 From: Michael Baisch Date: Tue, 17 Feb 2026 12:58:04 +0100 Subject: [PATCH 3/5] get_skycoord: improve performance by continuing to ignore non-scalar SkyCoord targets when checking for multiple targets --- astroplan/observer.py | 5 +---- astroplan/target.py | 7 +++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/astroplan/observer.py b/astroplan/observer.py index c697115e..30ba6de5 100644 --- a/astroplan/observer.py +++ b/astroplan/observer.py @@ -518,10 +518,7 @@ def _preprocess_inputs(self, time, target=None, grid_times_targets=False): return time, None # Remember whether target is a single time-dependent target - is_multiple_targets = ( - isinstance(target, (list, tuple)) or - (isinstance(target, SkyCoord) and not target.isscalar) - ) + is_multiple_targets = isinstance(target, (list, tuple)) is_target_time_dependent = ( callable(getattr(target, "get_skycoord", None)) and not hasattr(target, "coord") diff --git a/astroplan/target.py b/astroplan/target.py index 2647c9b8..1494b2dd 100644 --- a/astroplan/target.py +++ b/astroplan/target.py @@ -426,10 +426,9 @@ def _as_coord(obj): return obj.get_skycoord(times) return obj - is_multiple_targets = ( - isinstance(targets, (list, tuple)) or - (isinstance(targets, SkyCoord) and not targets.isscalar) - ) + # Ignore non-scalar SkyCoords targets here + # e.g. from get_body/get_sun, because they represent a single target + is_multiple_targets = isinstance(targets, (list, tuple)) if not is_multiple_targets: return _as_coord(targets) From 1a1eadbb48c865f543e69dc803494bd069f24391 Mon Sep 17 00:00:00 2001 From: Michael Baisch Date: Tue, 3 Mar 2026 12:53:33 +0100 Subject: [PATCH 4/5] scheduling: prefer earliest slot when scores are near-identical --- astroplan/scheduling.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astroplan/scheduling.py b/astroplan/scheduling.py index 24220d68..8146964c 100644 --- a/astroplan/scheduling.py +++ b/astroplan/scheduling.py @@ -811,6 +811,8 @@ def _make_schedule(self, blocks): good = np.all(_strided_scores > 1e-5, axis=1) sum_scores = np.zeros(len(_strided_scores)) sum_scores[good] = np.sum(_strided_scores[good], axis=1) + # Treat scores equal within 6 decimal places + score_key = np.round(sum_scores, 6) if np.all(constraint_scores == 0) or np.all(~good): # No further calculation if no times meet the constraints @@ -820,7 +822,7 @@ def _make_schedule(self, blocks): # does not prevent us from fitting it in. # loop over valid times and see if it fits # TODO: speed up by searching multiples of time resolution? - for idx in np.argsort(-sum_scores, kind='mergesort'): + for idx in np.argsort(-score_key, kind="mergesort"): if sum_scores[idx] <= 0.0: # we've run through all optimal blocks _is_scheduled = False From 1d8bf50280db5c4187ed7d640098a819f51a2706 Mon Sep 17 00:00:00 2001 From: Michael Baisch Date: Mon, 16 Mar 2026 09:57:37 +0100 Subject: [PATCH 5/5] Target, Scheduling: implement review feedback * Schedule.to_table(): Re-add RA and Dec columns to avoid a breaking change, and replace broad `except Exception` handlers with narrower exception handling. * Target: Add an explicit `is_time_dependent` property to label targets that require evaluation at a specific time. * AltAzTarget: Explicitly set pressure to 0 hPa instead of relying on the `AltAz()` default. --- astroplan/scheduling.py | 40 +++++++++++++++++++++++++----- astroplan/target.py | 24 ++++++++++++++++-- astroplan/tests/test_scheduling.py | 19 +++++++++++++- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/astroplan/scheduling.py b/astroplan/scheduling.py index 8146964c..3b481dce 100644 --- a/astroplan/scheduling.py +++ b/astroplan/scheduling.py @@ -11,6 +11,7 @@ from astropy import units as u from astropy.time import Time from astropy.table import Table +from astropy.coordinates import ConvertError from .utils import time_grid_from_range, stride_array from .constraints import AltitudeConstraint @@ -270,7 +271,7 @@ def _format_target_info(target): if hasattr(target, "coord"): try: return target.coord.icrs.to_string("hmsdms") - except Exception: + except ConvertError: return repr(target.coord) if hasattr(target, "alt") and hasattr(target, "az"): parts = [ @@ -279,11 +280,12 @@ def _format_target_info(target): ] if hasattr(target, "pressure"): try: - p = u.Quantity(target.pressure) - if p.to_value(u.hPa) != 0.0: - parts.append("pressure={:.3f} hPa".format(p.to_value(u.hPa))) - except Exception: + p_hpa = u.Quantity(target.pressure).to_value(u.hPa) + except (TypeError, ValueError, u.UnitConversionError): pass + else: + if p_hpa != 0.0: + parts.append("pressure={:.3f} hPa".format(p_hpa)) return ", ".join(parts) if hasattr(target, "satellite"): return ( @@ -298,6 +300,8 @@ def _format_target_info(target): durations = [] target_types = [] target_info = [] + ra = [] + dec = [] config = [] for slot in self.slots: @@ -307,6 +311,14 @@ def _format_target_info(target): durations.append(slot.duration.to(u.minute).value) target_names.append(slot.block.target.name) target_types.append(slot.block.target.__class__.__name__) + try: + ra.append(u.Quantity(slot.block.target.ra)) + except (AttributeError, NotImplementedError): + ra.append("") + try: + dec.append(u.Quantity(slot.block.target.dec)) + except (AttributeError, NotImplementedError): + dec.append("") target_info.append(_format_target_info(slot.block.target)) config.append(slot.block.configuration) elif show_transitions and slot.block: @@ -315,6 +327,8 @@ def _format_target_info(target): durations.append(slot.duration.to(u.minute).value) target_names.append("TransitionBlock") target_types.append("TransitionBlock") + ra.append("") + dec.append("") target_info.append("") changes = list(slot.block.components.keys()) if "slew_time" in changes: @@ -326,16 +340,30 @@ def _format_target_info(target): durations.append(slot.duration.to(u.minute).value) target_names.append("Unused Time") target_types.append("") + ra.append("") + dec.append("") target_info.append("") config.append("") return Table( - [target_names, start_times, end_times, durations, target_types, target_info, config], + [ + target_names, + start_times, + end_times, + durations, + ra, + dec, + target_types, + target_info, + config, + ], names=( "target", "start time (UTC)", "end time (UTC)", "duration (minutes)", + "ra", + "dec", "target type", "target info", "configuration", diff --git a/astroplan/target.py b/astroplan/target.py index 1494b2dd..6ff295a5 100644 --- a/astroplan/target.py +++ b/astroplan/target.py @@ -54,6 +54,19 @@ def __init__(self, name=None, ra=None, dec=None, marker=None): """ raise NotImplementedError() + @property + def is_time_dependent(self): + """ + Whether this target requires evaluation at a specific time. + + Returns + ------- + is_time_dependent : bool + `True` for targets whose coordinates depend on ``obstime``, + otherwise `False`. + """ + return False + @property def ra(self): """ @@ -227,6 +240,13 @@ class AltAzTarget(Target): >>> t = AltAzTarget(alt=30*u.deg, az=120*u.deg, location=location, name="Pointing") """ + @property + def is_time_dependent(self): + """ + Whether this target requires evaluation at a specific time. + """ + return True + @u.quantity_input(alt=u.deg, az=u.deg) def __init__( self, @@ -234,7 +254,7 @@ def __init__( az, location, name=None, - pressure=None, + pressure=0 * u.hPa, temperature=None, relative_humidity=None, obswl=None, @@ -417,7 +437,7 @@ def get_skycoord(targets, times=None): times = Time(times) def _is_time_dependent(obj): - return callable(getattr(obj, "get_skycoord", None)) and not hasattr(obj, "coord") + return isinstance(obj, Target) and obj.is_time_dependent def _as_coord(obj): if hasattr(obj, "coord"): diff --git a/astroplan/tests/test_scheduling.py b/astroplan/tests/test_scheduling.py index cd89dadc..c78a6a07 100644 --- a/astroplan/tests/test_scheduling.py +++ b/astroplan/tests/test_scheduling.py @@ -73,7 +73,13 @@ def test_schedule_to_table(): end = Time("2026-02-05T00:20:00", scale="utc") fixed = FixedTarget(SkyCoord(ra=10*u.deg, dec=20*u.deg), name="fixed") - horiz = AltAzTarget(alt=50*u.deg, az=200*u.deg, location=location, name="horiz") + horiz = AltAzTarget( + alt=50 * u.deg, + az=200 * u.deg, + location=location, + name="horiz", + pressure=1000.0 * u.hPa, + ) block_fixed = ObservingBlock( fixed, 600 * u.second, priority=1, constraints=[AirmassConstraint(max=4)] ) @@ -95,11 +101,22 @@ def test_schedule_to_table(): info = tab["target info"][0] assert "00h40m00s +20d00m00s" in info.lower() + def _as_quantity(value): + if isinstance(value, u.Quantity): + return value + return float(value) * u.deg + + assert _as_quantity(tab["ra"][0]) == 10.0 * u.deg + assert _as_quantity(tab["dec"][0]) == 20.0 * u.deg + assert tab["target"][1] == "horiz" assert tab["target type"][1] == "AltAzTarget" info = tab["target info"][1] assert "alt=50" in info.lower() assert "az=200" in info.lower() + assert "pressure=1000.000 hPa" in info + assert tab["ra"][1] == "" + assert tab["dec"][1] == "" def test_schedule_insert_slot():