"""
Base classes for representing and validating survey schedules.
A survey schedule is a chronological table of spacecraft actions (observations, slews,
downlinks, ...). :class:`SurveySchedule` wraps such a table and enforces a schema against it:
per-column type/unit/dtype checks (:class:`~uvex_transients.surveys.utils.QTableColumnSpec`),
plus per-action required-column and custom checks
(:class:`~uvex_transients.surveys.utils.ActionSpec`). All validation errors are collected and
reported together via :class:`ScheduleValidationError`, rather than stopping at the first
failure.
"""
from collections.abc import Iterator
from pathlib import Path
import astropy_healpix as ah
import numpy as np
from astropy import units as u
from astropy.coordinates import EarthLocation, SkyCoord, SkyOffsetFrame
from astropy.table import QTable, Row, vstack
from astropy.time import Time
from astropy.utils.masked import Masked
from m4opt.fov import contains, footprint, footprint_healpix
from regions import CircleSkyRegion, PointSkyRegion, PolygonSkyRegion, Regions, SkyRegion
from tqdm.auto import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
from uvex_transients.utils import config, logger, resolve_healpix_resolution
from .utils import ActionSpec, QTableColumnSpec
# =========================================================================== #
# EXCEPTIONS #
# =========================================================================== #
class ScheduleValidationError(ValueError):
"""
Raised when a survey schedule table fails schema validation.
Collects every validation failure found across both the column-level and
action-level validation stages, rather than surfacing only the first one, so a user
fixing an invalid schedule can address every problem in a single pass.
Parameters
----------
errors : list of str
Every validation failure message collected, in the order found.
"""
def __init__(self, errors: list[str]):
"""
Store `errors` and build the combined multi-line exception message.
Parameters
----------
errors : list of str
Every validation failure message collected, in the order found.
"""
self.errors = list(errors)
message = "Survey schedule failed validation with the following errors:\n" + "\n".join(
f" - {error}" for error in self.errors
)
super().__init__(message)
def _sanitize_masked_value(value):
"""
Rebuild a value using only :class:`~astropy.utils.masked.MaskedANDArray`.
Discards whatever ndarray subclass a masked value's wrapper happened to pick up
upstream (e.g. through repeated :func:`~astropy.table.vstack`), so that
:meth:`SurveySchedule.to_disk` always writes columns in the one form astropy's
ECSV reader is guaranteed to be able to reconstruct. Non-masked values pass
through unchanged.
Parameters
----------
value : ~astropy.utils.masked.Masked or any
A column value, possibly (but not necessarily) a
:class:`~astropy.utils.masked.Masked` array.
Returns
-------
same type as `value`
`value` unchanged if it isn't `Masked`; otherwise a fresh `Masked`
wrapping a plain `numpy.ndarray` and mask.
"""
if isinstance(value, Masked):
return Masked(np.asarray(np.ma.getdata(value)), mask=np.ma.getmaskarray(value))
return value
def _max_angular_offset(region: SkyRegion | Regions, origin: SkyCoord) -> u.Quantity:
"""
Greatest angular separation from ``origin`` to any point of ``region``.
Recurses into :class:`~regions.Regions` collections (e.g. a chip-gapped instrument
footprint made of several detector tiles) by taking the max across members --
*not* the sum -- since the quantity of interest is how far the union's boundary
reaches from ``origin``, not each member's own individual extent.
Parameters
----------
region : ~regions.SkyRegion or ~regions.Regions
A region already normalized by :func:`m4opt.fov.footprint` (or a
:class:`~regions.Regions` collection of such), so the only leaf shapes that
can appear are :class:`~regions.PolygonSkyRegion`, :class:`~regions.CircleSkyRegion`,
and :class:`~regions.PointSkyRegion`.
origin : ~astropy.coordinates.SkyCoord
The point to measure separation from.
Returns
-------
~astropy.units.Quantity
The greatest angular separation found.
Raises
------
TypeError
If a leaf region is a shape :func:`m4opt.fov.footprint` doesn't normalize
into one of the three above (e.g. it doesn't support that shape at all).
"""
if isinstance(region, Regions):
if not region.regions:
return 0 * u.deg
return max(_max_angular_offset(member, origin) for member in region.regions)
elif isinstance(region, PolygonSkyRegion):
return origin.separation(region.vertices).max()
elif isinstance(region, CircleSkyRegion):
return origin.separation(region.center) + region.radius
elif isinstance(region, PointSkyRegion):
return origin.separation(region.center)
else:
raise TypeError(
f"Cannot compute an angular offset for normalized FOV region of type {type(region).__name__!r}."
)
def _restyle_footprint(template: SkyRegion | Regions, positioned: SkyRegion | Regions) -> SkyRegion | Regions:
"""
Copy ``visual``/``meta`` styling from ``template`` onto its :func:`~m4opt.fov.footprint`.
:func:`~m4opt.fov.footprint` (used by :meth:`SurveySchedule.get_observed_regions`) rebuilds
each positioned footprint from scratch -- e.g. turning a `RectangleSkyRegion` into a
`PolygonSkyRegion` -- which drops any plotting style set on ``template``. Since that
transform preserves structure exactly (same nesting, same member order, for a
`~regions.Regions` collection), the two trees can be walked in lockstep to restore it.
Parameters
----------
template : ~regions.SkyRegion or ~regions.Regions
The original, unpositioned FOV (or one of its members).
positioned : ~regions.SkyRegion or ~regions.Regions
The corresponding output of :func:`~m4opt.fov.footprint`, mutated in place.
Returns
-------
regions.SkyRegion or regions.Regions
``positioned``, for convenience.
"""
if isinstance(template, Regions):
for template_member, positioned_member in zip(template.regions, positioned.regions):
_restyle_footprint(template_member, positioned_member)
else:
positioned.visual = dict(template.visual)
positioned.meta = dict(template.meta)
return positioned
def _flatten_regions(regions_or_collections: list[SkyRegion | Regions]) -> list[SkyRegion]:
"""
Flatten a list of `~regions.SkyRegion`/`~regions.Regions` into one list of plain regions.
Each `~regions.Regions` collection is treated as the union of its members (the same
convention :func:`m4opt.fov.contains` and :func:`~m4opt.fov.footprint_healpix` use), so it
is expanded in place rather than kept as a nested element.
Parameters
----------
regions_or_collections : list of (~regions.SkyRegion or ~regions.Regions)
The regions/collections to flatten.
Returns
-------
list of ~regions.SkyRegion
Every leaf region, in order, with any `~regions.Regions` collections
expanded into their members.
"""
flattened: list[SkyRegion] = []
for region in regions_or_collections:
if isinstance(region, Regions):
flattened.extend(region.regions)
else:
flattened.append(region)
return flattened
def _bounding_radius(region: SkyRegion | Regions) -> u.Quantity:
"""
Angular radius of the smallest cone, centered at RA=0deg/Dec=0deg, that fully contains ``region``.
Used by :attr:`SurveySchedule.bounding_radius` as a cheap pre-filter -- plain
angular separation -- before falling back to an exact but more expensive
containment test such as :func:`m4opt.fov.contains`.
``region`` is first run through :func:`m4opt.fov.footprint` (positioned at, and
unrotated from, the origin), which normalizes every supported shape --
including a :class:`~regions.RectangleSkyRegion` not centered on the origin, as
happens for the individual tiles of a chip-gapped :class:`~regions.Regions`
collection -- into vertex/center-and-radius form using the same spherical
geometry ``m4opt`` itself relies on for ``contains``/``footprint_healpix``,
rather than this function re-deriving rotated-rectangle corners by hand.
:func:`_max_angular_offset` then walks the (possibly nested, for a ``Regions``
collection) result to find the single farthest point from the origin.
Parameters
----------
region : ~regions.SkyRegion or ~regions.Regions
The field-of-view region, defined at RA=0deg/Dec=0deg/PA=0deg (the
convention used throughout ``m4opt``, e.g. :attr:`m4opt.missions.Mission.fov`).
May be a single :class:`~regions.SkyRegion` or a :class:`~regions.Regions`
collection of several (e.g. a real instrument's chip-gapped footprint).
Returns
-------
~astropy.units.Quantity
The bounding radius, inflated by ``config["surveys.footprint_bounding_margin"]``
(1% out of the box) to stay conservative at the boundary.
Raises
------
TypeError
If ``region`` (or a member of it) is not one of the shapes
:func:`m4opt.fov.footprint` supports.
"""
origin = SkyCoord(0 * u.deg, 0 * u.deg)
normalized = footprint(region, origin)
return config["surveys.footprint_bounding_margin"] * _max_angular_offset(normalized, origin)
# =========================================================================== #
# SURVEY SCHEDULE #
# =========================================================================== #
[docs]
class SurveySchedule:
"""
A validated, chronological table of scheduled spacecraft actions.
Wraps an :class:`~astropy.table.QTable` whose rows describe individual scheduled
actions (observations, slews, downlinks, ...) and enforces the schema declared in
:attr:`_SCHEMA` (per-column checks) and :attr:`_ACTION_SCHEMA` (per-action required
columns and custom checks). See :class:`ScheduleValidationError` for how validation
failures are reported.
Parameters
----------
schedule_table : ~astropy.table.QTable
A chronological table of scheduled spacecraft actions. See :meth:`__init__`.
instrument_fov : ~regions.SkyRegion or ~regions.Regions
The instrument's field of view. See :meth:`__init__`.
**kwargs
Forwarded to :meth:`_validate_table_columns`/:meth:`_validate_table_semantics`.
See :meth:`__init__`.
"""
_SCHEMA = {
"start_time": QTableColumnSpec(
column_type=Time,
description="Start time of the scheduled action.",
),
"duration": QTableColumnSpec(
unit=u.s,
validator=lambda col: np.all(col >= 0 * u.s),
description="Duration of the scheduled action.",
),
"observer_location": QTableColumnSpec(
column_type=EarthLocation,
description="Observer position.",
),
"action": QTableColumnSpec(
dtype=np.str_,
description="Scheduled action type.",
),
"target_coord": QTableColumnSpec(
column_type=SkyCoord,
description="Target coordinate, masked for non-target actions.",
),
"roll": QTableColumnSpec(
unit=u.deg,
description="Spacecraft roll angle.",
),
"field_id": QTableColumnSpec(
dtype=np.integer,
description="Survey field identifier.",
),
"block_id": QTableColumnSpec(
dtype=np.integer,
description="Scheduling block identifier.",
),
"phase": QTableColumnSpec(
dtype=np.str_,
required=False,
description=(
"Optional label identifying which combined survey phase a row "
"originated from (see SurveySchedule.with_phase/__add__)."
),
),
}
_ACTION_COLUMN = "action"
_ACTION_SCHEMA: dict[str, ActionSpec] = {
"observe": ActionSpec(
required_columns=(
"target_coord",
"roll",
"field_id",
"block_id",
),
validator=lambda rows: True if np.all(rows["duration"] > 0) else "observation durations must be positive",
description="Science observation.",
),
"slew": ActionSpec(
required_columns=("observer_location",),
description="Spacecraft slew.",
),
"downlink": ActionSpec(
required_columns=("observer_location",),
description="Communications downlink.",
),
}
# ----------------------------------------- #
# Initialization #
# ----------------------------------------- #
def __init__(self, schedule_table: QTable, instrument_fov: SkyRegion | Regions, **kwargs):
"""
Construct and validate a survey schedule.
Parameters
----------
schedule_table : ~astropy.table.QTable
A chronological table of scheduled spacecraft actions. Copied on
construction, so mutating it afterwards has no effect on this instance.
instrument_fov : ~regions.SkyRegion or ~regions.Regions
The instrument's field of view, defined at RA=0deg/Dec=0deg/PA=0deg
(the convention used throughout ``m4opt``, e.g.
:attr:`m4opt.missions.Mission.fov`). Either a single `~regions.SkyRegion`
or a `~regions.Regions` collection of several -- e.g. a real instrument's
chip-gapped footprint made of multiple detector tiles, such as
:attr:`m4opt.missions.Mission.fov` for missions with a segmented focal
plane -- of any shape :func:`m4opt.fov.footprint` supports; see
:func:`_bounding_radius`.
**kwargs
Forwarded to :meth:`_validate_table_columns` and
:meth:`_validate_table_semantics`, so subclasses that override those
hooks to add extra validation rules can accept extra constructor
arguments without touching ``__init__`` itself.
Raises
------
TypeError
If ``schedule_table`` is not a `~astropy.table.QTable`, or
``instrument_fov`` is not a `~regions.SkyRegion` or `~regions.Regions`
of a supported shape.
ScheduleValidationError
If ``schedule_table`` fails schema validation.
"""
# Ensure that the schedule is a QTable and make a copy to avoid mutating the caller's table.
if not isinstance(schedule_table, QTable):
raise TypeError(f"Parameter 'schedule_table' must be of type QTable, not {type(schedule_table)}")
self._schedule_table = schedule_table.copy()
self._ensure_chronological()
errors = [
*self._validate_table_columns(**kwargs),
*self._validate_table_semantics(**kwargs),
]
if errors:
raise ScheduleValidationError(errors)
# Read in the instrument FOV and ensure that it is a valid region (or collection thereof).
if not isinstance(instrument_fov, (SkyRegion, Regions)):
raise TypeError(
f"Parameter 'instrument_fov' must be of type SkyRegion or Regions, not {type(instrument_fov)}"
)
self._instrument_fov = instrument_fov
self._instrument_bounding_radius = _bounding_radius(instrument_fov)
# Cached once here (rather than recomputed per query) for `get_rows_between_times`'s
# binary-search lower bound -- see that method for why the longest action in the
# whole schedule is the relevant quantity.
self._max_action_duration = self._schedule_table["duration"].max() if len(self._schedule_table) else 0 * u.s
self._max_action_duration_days = self._max_action_duration.to_value(u.day)
# A plain float64 view of `start_time`'s Julian date, in a fixed scale, cached once
# here so `get_rows_between_times` can binary-search a real numpy array instead of
# calling `np.searchsorted` on the `Time` column directly. `astropy.time.Time` has no
# vectorized `searchsorted` of its own, so `np.searchsorted` against a `Time` array
# dispatches through a generic, unvectorized fallback that reconstructs the array
# element by element -- for a single query against a schedule of a few thousand rows
# this turns an O(log n) bisection into thousands of `Time` object reconstructions.
# Query boundary times are converted into this same scale (an O(1) conversion, not
# O(n)) before being compared against this array -- see `get_rows_between_times`.
self._start_time_scale = self._schedule_table["start_time"].scale
self._start_time_jd = np.asarray(self._schedule_table["start_time"].jd, dtype=np.float64)
# Lazily built by `get_healpix_coverage_index`, keyed by `(nside, order)`; see
# that method's docstring.
self._HPX_MAP_CACHE = {}
logger.debug(
"SurveySchedule constructed: %d rows (%s).",
len(self._schedule_table),
", ".join(
f"{action}={count}"
for action, count in zip(
*np.unique(np.asarray(self._schedule_table[self._ACTION_COLUMN]).astype(str), return_counts=True)
)
)
if len(self._schedule_table)
else "empty",
)
# ----------------------------------------- #
# Schema Validation #
# ----------------------------------------- #
def _ensure_chronological(self) -> None:
"""Sort the schedule table by ``start_time`` in place, if it isn't already."""
if "start_time" not in self._schedule_table.colnames:
return
start_time = self._schedule_table["start_time"]
if not isinstance(start_time, Time) or start_time.ndim != 1:
return
order = np.argsort(start_time)
if not np.array_equal(order, np.arange(len(order))):
logger.debug("Schedule table was not chronological; reordering %d rows by 'start_time'.", len(order))
self._schedule_table = self._schedule_table[order]
def _validate_table_columns(self, **_) -> list[str]:
"""
Validate each column against :attr:`_SCHEMA`, collecting every error found.
Parameters
----------
**_
Accepted and ignored; lets a subclass override this hook with
extra constructor keyword arguments without disturbing the base
call signature.
Returns
-------
list of str
Every column-schema validation failure found, if any.
"""
errors: list[str] = []
for column_name, column_spec in self._SCHEMA.items():
errors.extend(column_spec.validate_against_table(self._schedule_table, column_name))
return errors
def _validate_table_semantics(self, **_) -> list[str]:
"""
Validate that the table's actions satisfy :attr:`_ACTION_SCHEMA`.
Checks that every value in the action column is declared, then delegates to each
:class:`~uvex_transients.surveys.utils.ActionSpec` to check its action's required
columns and any custom rules.
Parameters
----------
**_
Accepted and ignored; lets a subclass override this hook with
extra constructor keyword arguments without disturbing the base
call signature.
Returns
-------
list of str
Every action-schema validation failure found, if any.
"""
table = self._schedule_table
if self._ACTION_COLUMN not in table.colnames:
return [f"Missing required action column {self._ACTION_COLUMN!r}."]
actions = np.asarray(table[self._ACTION_COLUMN]).astype(str)
declared_actions = set(self._ACTION_SCHEMA)
unknown_actions = set(actions) - declared_actions
errors: list[str] = []
if unknown_actions:
errors.append(
f"Unknown action values: {sorted(unknown_actions)}. Allowed values are {sorted(declared_actions)}."
)
for action_name, spec in self._ACTION_SCHEMA.items():
errors.extend(
spec.validate_against_table(
table,
action_name=action_name,
action_column=self._ACTION_COLUMN,
)
)
return errors
# ----------------------------------------- #
# Dunder Methods #
# ----------------------------------------- #
def __len__(self) -> int:
"""
Return the number of scheduled actions (rows of :attr:`table`).
Returns
-------
int
``len(self.table)``.
"""
return len(self._schedule_table)
def __iter__(self) -> Iterator[Row]:
"""
Iterate over :attr:`table`'s rows, in schedule order.
Returns
-------
Iterator of ~astropy.table.Row
An iterator over the underlying table's rows.
"""
return iter(self._schedule_table)
def __getitem__(self, key: str | int | slice | np.ndarray):
"""
Index into the underlying schedule table.
Parameters
----------
key : str, int, slice, or numpy.ndarray
Forwarded directly to :attr:`table`'s own ``__getitem__`` -- a
column name, row index/slice, or boolean/integer row mask.
Returns
-------
column, row, or ~astropy.table.QTable
Whatever :attr:`table`'s own indexing returns for `key`.
"""
return self._schedule_table[key]
def __contains__(self, action: str) -> bool:
"""
Check whether `action` is one of the action types present in :attr:`actions`.
Parameters
----------
action : str
The action label to look for.
Returns
-------
bool
Whether any row has this action label.
"""
return action in self.actions
def __add__(self, other: "SurveySchedule") -> "SurveySchedule":
"""
Concatenate two chronologically non-overlapping schedules.
``self + other`` appends ``other`` onto the end of ``self``, so ``other``
must pick up where ``self`` leaves off: its earliest action may not start
before :attr:`self.end_time <end_time>`. Combine more than two schedules by
chaining, e.g. ``a + b + c``, since each ``+`` is evaluated left to right.
Rows keep whatever ``"phase"`` label they already carry -- use
:meth:`with_phase` beforehand to tag either schedule's rows with a label
(e.g. so the combined schedule can be grouped or sorted by which input
survey each row came from).
Parameters
----------
other : SurveySchedule
The schedule to append after ``self``. Must share the same
:attr:`fov` as ``self``.
Returns
-------
SurveySchedule
A new schedule, of ``type(self)``, whose table is the chronological
concatenation of both inputs.
Raises
------
ValueError
If ``other`` does not share ``self``'s :attr:`fov`, or if ``other``
has any action, and ``self`` has any action, and ``other``'s
earliest action starts before ``self``'s latest action ends.
"""
if not isinstance(other, SurveySchedule):
return NotImplemented
if self.fov != other.fov:
raise ValueError("Cannot combine schedules with different instrument fields of view.")
if len(self) and len(other) and other.start_time < self.end_time:
raise ValueError(
f"Cannot combine schedules: the second schedule's first action starts at "
f"{other.start_time.iso!r}, before the first schedule's last action ends at "
f"{self.end_time.iso!r}."
)
combined_table = vstack([self.table, other.table], join_type="outer")
return type(self)(combined_table, self.fov)
def __repr__(self) -> str:
"""
Return a one-line summary showing the action count and time span.
Returns
-------
str
``<ClassName n_actions=... start_time=... end_time=...>``.
"""
return (
f"<{type(self).__name__} n_actions={self.n_actions} "
f"start_time={self.start_time.iso!r} end_time={self.end_time.iso!r}>"
)
# ----------------------------------------- #
# Properties #
# ----------------------------------------- #
@property
def table(self) -> QTable:
"""QTable: The underlying, validated schedule table."""
return self._schedule_table
@property
def actions(self) -> np.ndarray:
"""numpy.ndarray: The action label of every row, as strings."""
return np.asarray(self._schedule_table[self._ACTION_COLUMN]).astype(str)
@property
def n_actions(self) -> int:
"""int: The total number of scheduled actions."""
return len(self)
@property
def start_time(self) -> Time:
"""~astropy.time.Time: The start of the earliest scheduled action."""
return self._schedule_table["start_time"].min()
@property
def end_time(self) -> Time:
"""~astropy.time.Time: The end of the latest scheduled action."""
end_times = self._schedule_table["start_time"] + self._schedule_table["duration"]
return end_times.max()
@property
def duration(self) -> u.Quantity:
"""~astropy.units.Quantity: The total elapsed time spanned by the survey."""
return (self.end_time - self.start_time).to(u.s)
@property
def observing_time(self) -> u.Quantity:
"""~astropy.units.Quantity: Total time spent on ``observe`` actions."""
return self.time_spent("observe")
@property
def action_summary(self) -> QTable:
"""QTable: Per-action row counts and total durations, one row per action type present."""
actions = self.actions
unique_actions = sorted(set(actions))
return QTable(
{
"action": unique_actions,
"count": [int(np.sum(actions == name)) for name in unique_actions],
"total_duration": u.Quantity([self.time_spent(name) for name in unique_actions]),
}
)
@property
def summary(self) -> QTable:
"""QTable: A single-row overview of the whole survey (start/end time, action count, ...)."""
return QTable(
{
"start_time": [self.start_time],
"end_time": [self.end_time],
"duration": u.Quantity([self.duration]),
"n_actions": [self.n_actions],
"observing_time": u.Quantity([self.observing_time]),
}
)
@property
def fov(self) -> SkyRegion | Regions:
"""~regions.SkyRegion or ~regions.Regions: The instrument field of view at RA=0deg/Dec=0deg/PA=0deg."""
return self._instrument_fov
@property
def observing_mask(self) -> np.ndarray:
"""numpy.ndarray: Boolean mask, one per row of :attr:`table`, flagging ``"observe"`` actions."""
return self.actions == "observe"
@property
def bounding_radius(self) -> u.Quantity:
"""
~astropy.units.Quantity: Angular radius of the smallest cone that fully contains :attr:`fov`.
Inflated by 1% for a conservative margin. Used internally (e.g. by
:meth:`get_observations_of`) as a cheap pre-filter -- plain angular
separation -- before falling back to an exact but more expensive
containment test such as :func:`m4opt.fov.contains`.
"""
return self._instrument_bounding_radius
# ----------------------------------------- #
# Utility Methods #
# ----------------------------------------- #
def _resolve_tstart_tend(
self,
start_time: Time | None = None,
end_time: Time | None = None,
) -> tuple[Time, Time]:
"""
Fill in omitted survey-interval bounds with the survey's own extent.
Shared by every ``compute_*``/query method that accepts optional
``start_time``/``end_time`` bounds, so ``None`` uniformly means "the
whole survey" rather than each call site re-deriving that default.
Parameters
----------
start_time : ~astropy.time.Time, optional
Start of the interval, or `None` to use :attr:`start_time`.
end_time : ~astropy.time.Time, optional
End of the interval, or `None` to use :attr:`end_time`.
Returns
-------
tuple[~astropy.time.Time, ~astropy.time.Time]
``(start_time, end_time)`` with defaults filled in.
"""
if start_time is None:
start_time = self.start_time
if end_time is None:
end_time = self.end_time
return start_time, end_time
[docs]
def with_phase(self, phase: str) -> "SurveySchedule":
"""
Return a copy of this schedule with every row labeled by ``phase``.
Sets (or overwrites) the optional ``"phase"`` column so that, once this
schedule has been combined with another via :meth:`__add__`, rows can be
told apart by which input schedule they came from -- e.g. to sort or
group the combined table by phase.
Parameters
----------
phase : str
Label to assign to every row.
Returns
-------
SurveySchedule
A new schedule, of ``type(self)``, identical to this one except for
its ``"phase"`` column.
"""
table = self.table.copy()
table["phase"] = phase
return type(self)(table, self.fov)
[docs]
def time_spent(self, action: str) -> u.Quantity:
"""
Total duration spent on a given action across the whole survey.
Parameters
----------
action : str
One of the action names declared in :attr:`_ACTION_SCHEMA`.
Returns
-------
~astropy.units.Quantity
The summed ``duration`` of every row with this action, or ``0 * u.s`` if the
action does not occur in the schedule.
"""
mask = self.actions == action
if not np.any(mask):
return 0 * u.s
return np.sum(self._schedule_table["duration"][mask])
[docs]
def get_row_at_time(self, time: Time) -> Row:
"""
Return the scheduled action active at a given time.
An action with ``start_time`` ``s`` and ``duration`` ``d`` is treated as covering
the half-open interval ``[s, s + d)``.
Parameters
----------
time : ~astropy.time.Time
A scalar time to look up.
Returns
-------
~astropy.table.Row
The row of :attr:`table` whose interval contains ``time``.
Raises
------
ValueError
If ``time`` is not scalar, or no scheduled action covers it.
"""
if not time.isscalar:
raise ValueError("Parameter 'time' must be a scalar Time.")
action_start = self._schedule_table["start_time"]
action_end = action_start + self._schedule_table["duration"]
matches = np.flatnonzero((action_start <= time) & (time < action_end))
if matches.size == 0:
raise ValueError(f"No scheduled action covers time {time.iso!r}.")
return self._schedule_table[matches[0]]
[docs]
def get_rows_between_times(self, start_time: Time, end_time: Time) -> QTable:
"""
Return the scheduled actions overlapping a time window.
An action with ``start_time`` ``s`` and ``duration`` ``d`` is treated as covering
the half-open interval ``[s, s + d)``; it is included if that interval overlaps
``[start_time, end_time)``.
Parameters
----------
start_time : ~astropy.time.Time
Start of the query window (inclusive).
end_time : ~astropy.time.Time
End of the query window (exclusive).
Returns
-------
QTable
The subset of :attr:`table` overlapping the window, in chronological order.
Raises
------
ValueError
If ``end_time`` is not after ``start_time``.
"""
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
if end_time <= start_time:
raise ValueError(
f"Parameter 'end_time' ({end_time.iso!r}) must be after 'start_time' ({start_time.iso!r})."
)
if len(self._start_time_jd) == 0:
return self._schedule_table
# `self._start_time_jd` is sorted ascending (see `_ensure_chronological`, which runs
# before it's cached in `__init__`), so narrow down to a small candidate slice via
# binary search before falling back to an exact boolean mask, instead of scanning
# every row in the schedule on every call. The lower bound is pushed back by
# `_max_action_duration` -- the longest action anywhere in the schedule -- since an
# action starting before `start_time` can still overlap the window if it runs long
# enough (e.g. a downlink), even though a plain `>= start_time` search on
# `action_start` alone would miss it.
#
# The search itself runs against `self._start_time_jd` -- a plain float64 array
# cached once in `__init__` -- rather than `np.searchsorted` on the `Time` column
# directly: `Time` has no vectorized `searchsorted`, so searching the column itself
# dispatches through an unvectorized fallback that reconstructs `Time` objects
# element by element (see `__init__`'s comment for the cache). Only the two query
# boundaries need an (O(1)) scale conversion to match the cached array's scale.
scale = self._start_time_scale
lo_bound_jd = getattr(start_time, scale).jd - self._max_action_duration_days
hi_bound_jd = getattr(end_time, scale).jd
lo = np.searchsorted(self._start_time_jd, lo_bound_jd, side="left")
hi = np.searchsorted(self._start_time_jd, hi_bound_jd, side="left")
candidates = self._schedule_table[lo:hi]
if len(candidates) == 0:
return candidates
candidate_start = candidates["start_time"]
candidate_end = candidate_start + candidates["duration"]
mask = (candidate_start < end_time) & (candidate_end > start_time)
return candidates[mask]
@property
def observe_rows(self) -> QTable:
"""
QTable : The ``"observe"`` subset of :attr:`table`, in schedule order.
Recomputed on every access -- a boolean-mask slice, cheap enough that it isn't
worth caching alongside :meth:`get_healpix_coverage_index`'s pixel index, which
its row indices (as returned by :meth:`get_observation_indices_of`) are
relative to.
"""
return self._schedule_table[self.actions == "observe"]
[docs]
def get_observed_regions(
self,
start_time: Time,
end_time: Time,
) -> list[SkyRegion | Regions]:
"""
Return the instrument footprints observed during a time interval.
Schedule rows are included when their action interval overlaps the
half-open query interval ``[start_time, end_time)``. Only rows whose
action is ``"observe"`` contribute footprints.
Parameters
----------
start_time : ~astropy.time.Time
Beginning of the query interval, inclusive.
end_time : ~astropy.time.Time
End of the query interval, exclusive.
Returns
-------
list[regions.SkyRegion or regions.Regions]
One positioned and rotated footprint per selected observation, via
:func:`m4opt.fov.footprint`. Each entry is a `~regions.Regions` collection
rather than a single `~regions.SkyRegion` if :attr:`fov` itself is one --
e.g. a chip-gapped footprint made of several detector tiles.
"""
rows = self.get_rows_between_times(start_time, end_time)
actions = np.asarray(rows[self._ACTION_COLUMN]).astype(str)
rows = rows[actions == "observe"]
if len(rows) == 0:
return []
positioned = footprint(self._instrument_fov, rows["target_coord"], rows["roll"])
return [_restyle_footprint(self._instrument_fov, region) for region in positioned]
[docs]
def get_observed_region(
self,
start_time: Time,
end_time: Time,
) -> Regions | None:
"""
Return the union of every instrument footprint observed during a time interval.
Parameters
----------
start_time : ~astropy.time.Time
Beginning of the query interval, inclusive.
end_time : ~astropy.time.Time
End of the query interval, exclusive.
Returns
-------
regions.Regions or None
A flat `~regions.Regions` collection -- the same union-of-members
convention :func:`m4opt.fov.contains`/:func:`~m4opt.fov.footprint_healpix`
use -- of every footprint returned by :meth:`get_observed_regions`
(expanding any per-observation `~regions.Regions` collection into its
individual members rather than nesting it), or `None` if no observation
occurred during the interval.
"""
observed_regions = self.get_observed_regions(start_time, end_time)
if not observed_regions:
return None
return Regions(_flatten_regions(observed_regions))
[docs]
def get_healpix_coverage_index(
self,
nside: int | None = None,
order: str | None = None,
cache: bool = True,
overwrite: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Lazily build the HEALPix coverage index as a CSR (compressed-sparse-row) structure.
Rasterizes *every* ``"observe"`` row's rolled footprint in a single, vectorized
:func:`~m4opt.fov.footprint_healpix` call -- not one call per row -- then groups
the resulting ``(row, pixel)`` hits by pixel via one vectorized sort plus
:func:`numpy.bincount`/:func:`numpy.cumsum`, into two flat arrays rather than a
Python ``dict``: pixel id doubles directly as an array index into
``pixel_offsets``, so a lookup is a memory offset, not a hash-table probe, and a
whole batch of pixel ids can be resolved in one fancy-index call instead of a
per-query Python loop.
`footprint_healpix` follows HEALPix's own convention of pixel-*center*
membership, not full-pixel overlap: a row is registered under pixel ``p`` only
if ``p``'s center falls inside that row's rolled footprint. A query point can
therefore sit inside a footprint while its own pixel goes unregistered (a
one-directional miss, never a false claim -- see :meth:`get_observation_indices_of`,
which confirms every candidate this index returns with an exact geometric test,
so nothing reached through this index is ever wrongly included, only
occasionally left out near a footprint's edge). Raise ``nside`` to shrink how
often that happens; there is no dilation margin built into the index itself.
Parameters
----------
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix pixel ordering scheme, ``"nested"`` or ``"ring"``, or `None` to use
``config["healpix.default_order"]``.
cache : bool
If `True` (the default), reuse a previously built index for this
``(nside, order)`` when available, and store the freshly built one for later
reuse. If `False`, always rebuild and never store the result -- useful for a
one-off query at a resolution not worth caching.
overwrite : bool
If `True`, rebuild even if a cached index for this ``(nside, order)``
already exists. Ignored if ``cache`` is `False`, since every call already
rebuilds in that case.
Returns
-------
pixel_offsets : numpy.ndarray
``int64`` array of shape ``(12 * nside**2 + 1,)``. Row indices whose
footprint covers pixel ``p`` are
``sorted_rows[pixel_offsets[p]:pixel_offsets[p + 1]]`` -- an empty slice if
pixel ``p`` is not covered by any observation.
sorted_rows : numpy.ndarray
``int64`` array of row indices into :attr:`observe_rows`, grouped
contiguously by pixel and ordered to match ``pixel_offsets``.
"""
nside, order = resolve_healpix_resolution(nside, order)
if cache and not overwrite:
cached = self._HPX_MAP_CACHE.get((nside, order))
if cached is not None:
logger.debug("Reusing cached HEALPix coverage index for (nside=%d, order=%r).", nside, order)
return cached
logger.debug("Building HEALPix coverage index for (nside=%d, order=%r).", nside, order)
observe_rows = self.observe_rows
npix = ah.nside_to_npix(nside)
if len(observe_rows) == 0:
pixel_offsets = np.zeros(npix + 1, dtype=np.int64)
sorted_rows = np.array([], dtype=np.int64)
else:
hpx = ah.HEALPix(nside=nside, order=order, frame=observe_rows["target_coord"].frame)
pixel_arrays = footprint_healpix(
hpx,
self._instrument_fov,
observe_rows["target_coord"],
observe_rows["roll"],
)
# Flatten every (row, pixel) hit, then group by pixel with one vectorized
# sort plus a bincount/cumsum offsets array, instead of a Python
# dict.setdefault/append per hit -- the sort and bincount are each a single
# C-level pass over every pixel hit in the whole schedule, rather than a
# Python-level dict operation for each one.
pixel_counts = np.fromiter(
(len(pixels) for pixels in pixel_arrays),
dtype=np.int64,
count=len(pixel_arrays),
)
row_indices = np.repeat(np.arange(len(pixel_arrays), dtype=np.int64), pixel_counts)
pixels = np.concatenate(pixel_arrays)
sort_order = np.argsort(pixels, kind="stable")
sorted_pixels = pixels[sort_order]
sorted_rows = row_indices[sort_order]
counts = np.bincount(sorted_pixels, minlength=npix)
pixel_offsets = np.empty(npix + 1, dtype=np.int64)
pixel_offsets[0] = 0
np.cumsum(counts, out=pixel_offsets[1:])
result = (pixel_offsets, sorted_rows)
if cache:
self._HPX_MAP_CACHE[(nside, order)] = result
return result
[docs]
def get_observed_healpix_ids(
self,
start_time: Time,
end_time: Time,
nside: int | None = None,
order: str | None = None,
) -> np.ndarray:
"""
Return the HEALPix pixel indices covered by observations in a time interval.
Built directly from :meth:`get_healpix_coverage_index`'s cached, whole-schedule
CSR structure (rasterized once via :func:`m4opt.fov.footprint_healpix` and cached
per ``(nside, order)``) rather than re-rasterizing footprints for this time
window -- a per-pixel segmented reduction over the cached index, not a second
geometric pass. Repeated calls with the same ``(nside, order)`` across different
(even overlapping) time windows -- as in a per-time-bin sampling loop -- pay for
the rasterization at most once.
Parameters
----------
start_time : ~astropy.time.Time
Beginning of the query interval, inclusive.
end_time : ~astropy.time.Time
End of the query interval, exclusive.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix pixel ordering scheme, ``"nested"`` or ``"ring"``, or `None` to use
``config["healpix.default_order"]``.
Returns
-------
numpy.ndarray
Sorted, deduplicated HEALPix pixel indices covered by any
observation in the interval; empty if there were none.
"""
pixel_offsets, sorted_rows = self.get_healpix_coverage_index(nside=nside, order=order)
if len(sorted_rows) == 0:
return np.array([], dtype=np.int64)
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
if end_time <= start_time:
raise ValueError(
f"Parameter 'end_time' ({end_time.iso!r}) must be after 'start_time' ({start_time.iso!r})."
)
# `sorted_rows` holds indices into `observe_rows` (see `get_healpix_coverage_index`),
# so the window test is evaluated there directly rather than against the full
# schedule table, matching `get_rows_between_times`'s half-open overlap semantics.
observe_rows = self.observe_rows
row_start = observe_rows["start_time"]
row_end = row_start + observe_rows["duration"]
in_window = np.asarray((row_start < end_time) & (row_end > start_time))
# Segmented "any row in window covers this pixel" test: a per-pixel prefix-sum
# difference over `in_window[sorted_rows]`, rather than a Python loop over pixels.
covered = in_window[sorted_rows]
cumulative_covered = np.concatenate(([0], np.cumsum(covered)))
hits_per_pixel = cumulative_covered[pixel_offsets[1:]] - cumulative_covered[pixel_offsets[:-1]]
return np.flatnonzero(hits_per_pixel > 0).astype(np.int64)
[docs]
def get_observation_indices_of(
self,
coord: SkyCoord,
nside: int | None = None,
order: str | None = None,
start_time: Time | None = None,
end_time: Time | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""
Row indices of observations whose footprint covers each query position.
The batched, index-returning counterpart to :meth:`get_observations_of`: each
query position's own HEALPix pixel is looked up directly in the cached
coverage index (see :meth:`get_healpix_coverage_index`) -- a plain,
single-pixel broad-phase filter, not the final answer -- and just that
(typically small) candidate set is confirmed with the same exact, vectorized
:func:`m4opt.fov.contains` test :meth:`get_observations_of` uses, run once over
every query position at once rather than in a Python loop.
Because :meth:`get_healpix_coverage_index` registers a pointing under a pixel
only if that pixel's *center* falls inside the rolled footprint, a query point
can sit inside a footprint while its own pixel goes unregistered -- such a
match is silently missed here, never falsely included (the exact `contains`
test can only remove a candidate, never add one back once the pixel lookup
already missed it). Raise ``nside`` to shrink how often that happens; there is
no neighbour-pixel margin here to compensate for a coarse index.
Parameters
----------
coord : ~astropy.coordinates.SkyCoord
Sky position(s) to test -- scalar or array, any shape (flattened
internally).
nside : int, optional
Resolution and pixel ordering of the coverage index to query -- see
:meth:`get_healpix_coverage_index`. Either may be `None` (the default) to
use ``config["healpix.default_nside"]``/``config["healpix.default_order"]``.
order : str, optional
See ``nside`` above.
start_time, end_time : ~astropy.time.Time, optional
Optional time window to restrict matches to. Must be given together. Each
may be scalar (one shared window for every query position) or an array the
same length as flattened ``coord`` (one window per query position, e.g. an
explosion time and duration limit that differs per event -- the batched
equivalent of calling this once per event with its own window). If both
are `None` (the default), every ``"observe"`` row in the schedule is
considered.
Returns
-------
query_index : numpy.ndarray
``int64`` array, one entry per matched observation, giving which element
of flattened ``coord`` it belongs to (values in ``[0, coord.size)`` --
always ``0`` if ``coord`` is scalar). Chronological within each query
position.
row_index : numpy.ndarray
``int64`` array, the same length as ``query_index``, of row indices into
:attr:`observe_rows` for the matched observations.
Raises
------
ValueError
If exactly one of ``start_time``/``end_time`` is given, or either is an
array not the same length as flattened ``coord``.
"""
if (start_time is None) != (end_time is None):
raise ValueError("Parameters 'start_time' and 'end_time' must be given together.")
nside, order = resolve_healpix_resolution(nside, order)
pixel_offsets, sorted_rows = self.get_healpix_coverage_index(nside=nside, order=order)
observe_rows = self.observe_rows
empty = np.array([], dtype=np.int64)
if len(observe_rows) == 0:
return empty, empty
coord_array = coord.reshape((-1,))
n = len(coord_array)
if start_time is not None and not start_time.isscalar:
if len(start_time) != n or len(end_time) != n:
raise ValueError(
"Array 'start_time'/'end_time' must be the same length as "
f"flattened 'coord' ({n}), got {len(start_time)} and "
f"{len(end_time)}."
)
hpx = ah.HEALPix(nside=nside, order=order, frame=observe_rows["target_coord"].frame)
query_pixel = np.asarray(hpx.skycoord_to_healpix(coord_array), dtype=np.int64)
# Each query's candidate range is resolved by fancy-indexing `pixel_offsets`
# with the whole `query_pixel` array at once -- no per-query Python loop. What
# remains is gathering each query's own (variable-length) slice of
# `sorted_rows` and concatenating them in query order; that ragged gather is
# itself vectorized via the standard repeat/arange/cumsum trick below.
starts = pixel_offsets[query_pixel]
ends = pixel_offsets[query_pixel + 1]
counts = ends - starts
total = int(counts.sum())
if total == 0:
return empty, empty
query_index = np.repeat(np.arange(n, dtype=np.int64), counts)
group_start_in_output = np.zeros(n, dtype=np.int64)
np.cumsum(counts[:-1], out=group_start_in_output[1:])
within_group_index = np.arange(total, dtype=np.int64) - group_start_in_output[query_index]
row_index = sorted_rows[starts[query_index] + within_group_index]
# Exact confirmation: the same geometric test `get_observations_of` uses, run
# once over every candidate pair at once instead of once per query position.
# This can only remove a candidate the pixel lookup shouldn't have offered, not
# discard a genuine match -- see the docstring's note on this method's
# one-directional (miss, never false-claim) error.
candidate_rows = observe_rows[row_index]
local_frame = SkyOffsetFrame(origin=candidate_rows["target_coord"], rotation=candidate_rows["roll"])
local_coord = coord_array[query_index].transform_to(local_frame)
local_coord_as_icrs = SkyCoord(local_coord.lon, local_coord.lat, frame="icrs")
contains_mask = np.asarray(contains(self._instrument_fov, local_coord_as_icrs), dtype=bool)
query_index = query_index[contains_mask]
row_index = row_index[contains_mask]
candidate_rows = candidate_rows[contains_mask]
if start_time is not None:
matched_start = candidate_rows["start_time"]
# Scalar window broadcasts against every candidate as-is; a per-query
# window is indexed by `query_index` so each candidate is checked against
# *its own* query's window, not a shared one.
if start_time.isscalar:
window_start, window_end = start_time, end_time
else:
window_start = start_time[query_index]
window_end = end_time[query_index]
time_mask = (matched_start >= window_start) & (matched_start < window_end)
query_index = query_index[time_mask]
row_index = row_index[time_mask]
candidate_rows = candidate_rows[time_mask]
# Chronological within each query position.
sort_order = np.lexsort((candidate_rows["start_time"].jd, query_index))
return query_index[sort_order], row_index[sort_order]
[docs]
def get_observations_of(
self,
coord: SkyCoord,
start_time: Time | None = None,
end_time: Time | None = None,
) -> QTable:
"""
Return the ``"observe"`` rows whose footprint covers a sky position.
Useful for checking whether -- and when -- the survey observed a given
position, e.g. to evaluate detectability of a simulated transient.
As a performance optimization, candidate rows are first narrowed down with a
cheap angular-separation pre-filter using :attr:`bounding_radius`, then
confirmed with a single, vectorized exact containment test
(:func:`m4opt.fov.contains`): rather than rotating :attr:`instrument_fov` out
to each candidate pointing and testing ``coord`` against each rotated copy in
a loop (which rebuilds a WCS -- the dominant cost -- once per candidate row),
``coord`` is transformed into each candidate's own local pointing frame and
tested against the single, unrotated ``instrument_fov`` all at once. This is
the same geometric test, just inverted to build one WCS per call instead of
one per candidate row.
Parameters
----------
coord : ~astropy.coordinates.SkyCoord
Scalar sky position to test.
start_time, end_time : ~astropy.time.Time, optional
Optional time window to restrict the search to (see
:meth:`get_rows_between_times`). Must be given together. If both are
`None` (the default), every ``"observe"`` row in the schedule is
considered.
Returns
-------
QTable
The subset of :attr:`table` whose observed footprint contains
``coord``, in chronological order. Empty if there is no such row.
Raises
------
ValueError
If ``coord`` is not scalar, or exactly one of ``start_time``/``end_time``
is given.
"""
if not coord.isscalar:
raise ValueError("Parameter 'coord' must be a scalar SkyCoord.")
if (start_time is None) != (end_time is None):
raise ValueError("Parameters 'start_time' and 'end_time' must be given together.")
rows = self.get_rows_between_times(start_time, end_time) if start_time is not None else self._schedule_table
actions = np.asarray(rows[self._ACTION_COLUMN]).astype(str)
rows = rows[actions == "observe"]
if len(rows) == 0:
return rows
candidate_mask = coord.separation(rows["target_coord"]) <= self.bounding_radius
rows = rows[candidate_mask]
if len(rows) == 0:
return rows
# Transform `coord` into each candidate row's own pointing frame (an ordinary,
# vectorized SkyCoord transform -- cheap), then reinterpret those per-row
# offset angles as literal ICRS coordinates (the same trick `m4opt.fov`'s own
# `footprint`/`skycoord_to_offset` uses, just inverted: there, a *local* FOV
# vertex offset gets reinterpreted as sitting *at* the rotated pointing; here,
# the *global* query point's local-frame offset gets reinterpreted as sitting
# on `instrument_fov`'s own unrotated, RA=0/Dec=0-centered copy). `contains`
# is not itself a coordinate-frame-aware test -- it treats both its region and
# its target_coord as literal ICRS-like positions -- so this only works
# because both sides of the comparison are consistently expressed that way.
local_frame = SkyOffsetFrame(origin=rows["target_coord"], rotation=rows["roll"])
local_coord = coord.transform_to(local_frame)
local_coord_as_icrs = SkyCoord(local_coord.lon, local_coord.lat, frame="icrs")
contains_mask = np.asarray(contains(self._instrument_fov, local_coord_as_icrs), dtype=bool)
return rows[contains_mask]
# ----------------------------------------- #
# Cadence Calculations #
# ----------------------------------------- #
# These functions are concerned with computing various features of the overlap cadence
# for analysis.
[docs]
def compute_visit_count(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> np.ndarray:
"""
Compute the number of visits to each HEALPix pixel.
Each ``"observe"`` action contributes one visit to every HEALPix pixel
covered by the rolled instrument footprint.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute visit counts. If omitted,
the full survey duration is used.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
numpy.ndarray
Integer array of shape ``(12 * nside**2,)``. Entry ``i`` gives the
number of observations whose footprint covered HEALPix pixel ``i``.
"""
nside, order = resolve_healpix_resolution(nside, order)
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
rows = self.get_rows_between_times(start_time, end_time)
rows = rows[np.asarray(rows[self._ACTION_COLUMN]).astype(str) == "observe"]
hpx = ah.HEALPix(
nside=nside,
order=order,
frame=self._schedule_table["target_coord"].frame,
)
if len(rows) == 0:
return np.zeros(hpx.npix, dtype=np.int64)
pixel_arrays = footprint_healpix(
hpx,
self._instrument_fov,
rows["target_coord"],
rows["roll"],
)
pixels = np.concatenate(pixel_arrays)
return np.bincount(
pixels,
minlength=hpx.npix,
).astype(np.int64, copy=False)
[docs]
def compute_visit_times(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> tuple[u.Quantity, np.ndarray]:
"""
Compute observation times for each HEALPix pixel.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute visit times. If omitted,
the full survey duration is used.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
visit_times : ~astropy.units.Quantity
Flattened elapsed observation times, grouped by HEALPix pixel.
offsets : numpy.ndarray
Integer offsets of shape ``(npix + 1,)``. Observation times for
pixel ``i`` are given by
``visit_times[offsets[i]:offsets[i + 1]]``.
"""
nside, order = resolve_healpix_resolution(nside, order)
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
rows = self.get_rows_between_times(start_time, end_time)
rows = rows[np.asarray(rows[self._ACTION_COLUMN]).astype(str) == "observe"]
hpx = ah.HEALPix(
nside=nside,
order=order,
frame=self._schedule_table["target_coord"].frame,
)
if len(rows) == 0:
return (
np.array([]) * u.day,
np.zeros(hpx.npix + 1, dtype=np.int64),
)
pixel_arrays = footprint_healpix(
hpx,
self._instrument_fov,
rows["target_coord"],
rows["roll"],
)
pixels = np.concatenate(pixel_arrays)
elapsed_times = (rows["start_time"] - start_time).to(u.day)
times = np.repeat(
elapsed_times,
[len(pixel_ids) for pixel_ids in pixel_arrays],
)
# Group entries by pixel.
order_idx = np.argsort(pixels, kind="stable")
pixels = pixels[order_idx]
times = times[order_idx]
counts = np.bincount(pixels, minlength=hpx.npix)
offsets = np.empty(hpx.npix + 1, dtype=np.int64)
offsets[0] = 0
np.cumsum(counts, out=offsets[1:])
return times, offsets
[docs]
def compute_cadence_time_differences(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
pairs: str = "all",
) -> tuple[u.Quantity, np.ndarray]:
"""
Compute pairwise observation-time separations for each HEALPix pixel.
For a pixel observed at times ``t_0, ..., t_N``, the cadence time
differences are the positive pairwise separations ``t_j - t_i`` for
``j > i``, either over every such pair (``pairs='all'``) or only over
consecutive-in-time pairs ``t_{i+1} - t_i`` (``pairs='consecutive'``,
i.e. the successive-gaps distribution).
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute cadence separations.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
pairs : str
Which pairs of observations to include: ``"all"`` for every
unique pair, or ``"consecutive"`` for only pairs of
temporally-adjacent visits.
Returns
-------
time_differences : ~astropy.units.Quantity
Flattened pairwise time differences, grouped by HEALPix pixel.
offsets : numpy.ndarray
Integer offsets of shape ``(npix + 1,)``. Pairwise differences
for pixel ``i`` are given by
``time_differences[offsets[i]:offsets[i + 1]]``.
Pixels with fewer than two observations have no entries.
"""
self._validate_pairs_mode(pairs)
visit_times, visit_offsets = self.compute_visit_times(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
npix = len(visit_offsets) - 1
unit = visit_times.unit
# Number of visits to each pixel.
visit_counts = np.diff(visit_offsets)
if pairs == "all":
# A pixel with N visits has N(N - 1) / 2 unique pairs.
pair_counts = visit_counts * (visit_counts - 1) // 2
else:
# A pixel with N visits has N - 1 consecutive pairs.
pair_counts = np.maximum(visit_counts - 1, 0)
pair_offsets = np.empty(npix + 1, dtype=np.int64)
pair_offsets[0] = 0
np.cumsum(pair_counts, out=pair_offsets[1:])
time_differences = np.empty(pair_offsets[-1], dtype=float)
for pixel in np.flatnonzero(pair_counts):
times = visit_times[visit_offsets[pixel] : visit_offsets[pixel + 1]].to_value(unit)
if pairs == "all":
# The visit times are already chronological, so taking the upper
# triangle gives every unique positive pairwise separation.
i, j = np.triu_indices(len(times), k=1)
differences = times[j] - times[i]
else:
differences = np.diff(times)
time_differences[pair_offsets[pixel] : pair_offsets[pixel + 1]] = differences
return time_differences * unit, pair_offsets
[docs]
def compute_cadence_statistics(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
pairs: str = "all",
) -> dict[str, u.Quantity]:
"""
Compute per-pixel statistics of pairwise temporal baselines.
Cadence is represented either by every unique pairwise separation
between observations of the same HEALPix pixel (``pairs='all'``) or
only by separations between consecutive observations
(``pairs='consecutive'``); see :meth:`compute_cadence_time_differences`.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute cadence statistics.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
pairs : str
Which pairs of observations to include: ``"all"`` for every
unique pair, or ``"consecutive"`` for only pairs of
temporally-adjacent visits.
Returns
-------
dict[str, ~astropy.units.Quantity]
Full-sky HEALPix maps containing the ``mean``, ``median``,
``min``, ``max``, and ``std`` of the pairwise temporal baselines
for each pixel.
Pixels observed fewer than twice are assigned ``NaN``.
"""
time_differences, offsets = self.compute_cadence_time_differences(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
pairs=pairs,
)
npix = len(offsets) - 1
unit = time_differences.unit
values = time_differences.to_value(unit)
mean = np.full(npix, np.nan)
median = np.full(npix, np.nan)
minimum = np.full(npix, np.nan)
maximum = np.full(npix, np.nan)
std = np.full(npix, np.nan)
for pixel in np.flatnonzero(np.diff(offsets)):
baselines = values[offsets[pixel] : offsets[pixel + 1]]
mean[pixel] = np.mean(baselines)
median[pixel] = np.median(baselines)
minimum[pixel] = np.min(baselines)
maximum[pixel] = np.max(baselines)
std[pixel] = np.std(baselines)
return {
"mean": mean * unit,
"median": median * unit,
"min": minimum * unit,
"max": maximum * unit,
"std": std * unit,
}
[docs]
def compute_max_gap(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> u.Quantity:
"""
Compute each pixel's worst-case (maximum) successive observation gap.
For a pixel observed at times ``t_0, ..., t_N``, this is
``max_i(t_{i+1} - t_i)``, i.e. the maximum of the same
consecutive-pair separations returned by
:meth:`compute_cadence_time_differences` with ``pairs='consecutive'``.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute the maximum gap.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
~astropy.units.Quantity
Full-sky HEALPix map of each pixel's maximum successive-visit
gap, shape ``(12 * nside**2,)``. Pixels observed fewer than
twice are assigned ``NaN``.
"""
gaps, offsets = self.compute_cadence_time_differences(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
pairs="consecutive",
)
npix = len(offsets) - 1
unit = gaps.unit
values = gaps.to_value(unit)
max_gap = np.full(npix, np.nan)
for pixel in np.flatnonzero(np.diff(offsets)):
max_gap[pixel] = np.max(values[offsets[pixel] : offsets[pixel + 1]])
return max_gap * unit
@staticmethod
def _validate_timescale(timescale: u.Quantity) -> None:
"""
Check that ``timescale`` is a single, positive `~astropy.units.Quantity`.
Parameters
----------
timescale : ~astropy.units.Quantity
The value to validate.
Raises
------
ValueError
If ``timescale`` is not scalar, or is not strictly positive.
"""
if not u.Quantity(timescale).isscalar:
raise ValueError("Timescale must be scalar.")
if timescale <= 0 * timescale.unit:
raise ValueError("Timescale must be positive.")
@staticmethod
def _validate_pair_window_factors(minimum_factor: float, maximum_factor: float) -> None:
"""
Check that a pair-separation window's bounding factors are well-formed.
Parameters
----------
minimum_factor, maximum_factor : float
The bounds to validate.
Raises
------
ValueError
If ``minimum_factor`` is negative, or ``maximum_factor`` does not
exceed ``minimum_factor``.
"""
if minimum_factor < 0:
raise ValueError("Parameter 'minimum_factor' must be non-negative.")
if maximum_factor <= minimum_factor:
raise ValueError("Parameter 'maximum_factor' must be greater than 'minimum_factor'.")
@staticmethod
def _validate_pairs_mode(pairs: str) -> None:
"""
Check that a ``pairs`` mode selector is one of the supported values.
Parameters
----------
pairs : str
The mode selector to validate; must be ``"all"`` or ``"consecutive"``.
Raises
------
ValueError
If ``pairs`` is not ``"all"`` or ``"consecutive"``.
"""
if pairs not in ("all", "consecutive"):
raise ValueError(f"Parameter 'pairs' must be 'all' or 'consecutive', got {pairs!r}.")
@staticmethod
def _pixel_pair_count(visit_times: np.ndarray, lo: float, hi: float) -> int:
"""
Count one pixel's own visit pairs with separation in ``[lo, hi]``.
Uses two vectorized :func:`numpy.searchsorted` calls (one query per
visit, all evaluated at once) rather than materializing every pairwise
difference the way :meth:`compute_cadence_time_differences` does. That
distinction matters once a pixel has been revisited across many
repeated full-sky survey passes: the number of *all* pairs grows with
the square of the number of passes even though only nearby ones are
ever relevant to a given timescale, so this keeps per-pixel cost at
O(n log n) regardless of how many times the survey has covered the
whole sky.
Parameters
----------
visit_times : numpy.ndarray
Sorted elapsed observation times for one pixel.
lo, hi : float
Bounds of the qualifying pair-separation window, in the same units
as ``visit_times``.
Returns
-------
int
Number of qualifying pairs for this pixel.
"""
if len(visit_times) < 2:
return 0
# Earlier visits satisfying lo <= later - earlier <= hi lie in
# [later - hi, later - lo]. `minimum_factor` (and hence `lo`) may be
# exactly 0, in which case `visit_times - lo` collides with
# `visit_times` itself, so `hi_idx` is explicitly capped at each
# visit's own index to exclude pairing a visit with itself.
lo_idx = np.searchsorted(visit_times, visit_times - hi, side="left")
hi_idx = np.searchsorted(visit_times, visit_times - lo, side="right")
hi_idx = np.minimum(hi_idx, np.arange(len(visit_times)))
return int(np.sum(np.maximum(hi_idx - lo_idx, 0)))
@staticmethod
def _pixel_pair_count_consecutive(visit_times: np.ndarray, lo: float, hi: float) -> int:
"""
Count one pixel's own consecutive-in-time visit pairs with separation in ``[lo, hi]``.
Unlike :meth:`_pixel_pair_count`, which considers every unique pair of
visits, this only considers the ``N - 1`` pairs of temporally-adjacent
visits ``(t_i, t_{i+1})``.
Parameters
----------
visit_times : numpy.ndarray
Sorted elapsed observation times for one pixel.
lo, hi : float
Bounds of the qualifying pair-separation window, in the same units
as ``visit_times``.
Returns
-------
int
Number of qualifying consecutive pairs for this pixel.
"""
if len(visit_times) < 2:
return 0
gaps = np.diff(visit_times)
return int(np.count_nonzero((gaps >= lo) & (gaps <= hi)))
@staticmethod
def _validate_visibility_factor(visibility_factor: float) -> None:
"""
Check that a control-time visibility factor is well-formed.
Parameters
----------
visibility_factor : float
The value to validate.
Raises
------
ValueError
If ``visibility_factor`` is not strictly positive.
"""
if visibility_factor <= 0:
raise ValueError("Parameter 'visibility_factor' must be positive.")
@staticmethod
def _pixel_control_time(
visit_times: np.ndarray,
minimum_separation: float,
maximum_separation: float,
visibility_window: float,
maximum_time: float,
) -> float:
"""
Total control-time duration for one pixel's sorted visit times.
For each visit, the *latest* earlier visit satisfying
``minimum_separation <= later - earlier <= maximum_separation``
defines an interval of transient start times,
``[later - visibility_window, earlier]`` (clipped to
``[0, maximum_time]``), for which that pair would provide useful
temporal sampling -- the latest qualifying earlier visit is used
because it produces the largest such interval, which therefore
contains the interval any earlier qualifying visit would have
produced. Overlapping intervals across all visits are merged before
summing their total duration, so no candidate start time is double
counted.
Both the per-visit interval construction (via array-form
:func:`numpy.searchsorted`, one query per visit, evaluated all at
once) and the interval merge (via a running-max trick, since the
intervals already come out in non-decreasing start-time order) are
fully vectorized -- no Python-level loop over visits -- so this stays
O(n log n) per pixel regardless of how many times a pixel has been
revisited (e.g. across repeated full-sky survey passes), unlike the
sequential per-visit Python loop this replaces.
Parameters
----------
visit_times : numpy.ndarray
Sorted elapsed observation times for one pixel.
minimum_separation, maximum_separation : float
Bounds of the qualifying pair-separation window, in the same
units as ``visit_times``.
visibility_window : float
Maximum time after transient onset over which an observation is
considered useful, in the same units as ``visit_times``.
maximum_time : float
Upper boundary for allowed transient start times, in the same
units as ``visit_times``.
Returns
-------
float
Total control time, in the same units as ``visit_times``.
"""
n = len(visit_times)
if n < 2:
return 0.0
t = visit_times
lo_idx = np.searchsorted(t, t - maximum_separation, side="left")
hi_idx = np.searchsorted(t, t - minimum_separation, side="right")
hi_idx = np.minimum(hi_idx, np.arange(n))
valid = lo_idx < hi_idx
if not np.any(valid):
return 0.0
earlier_time = t[np.clip(hi_idx - 1, 0, n - 1)]
interval_start = np.maximum(t - visibility_window, 0.0)
interval_end = np.minimum(earlier_time, maximum_time)
keep = valid & (interval_end > interval_start)
if not np.any(keep):
return 0.0
starts = interval_start[keep]
ends = interval_end[keep]
# `starts` is already non-decreasing -- as the visit index (and hence
# `t`) increases, `t - visibility_window` (clipped at 0) can only
# increase or stay flat -- so the intervals are already in the
# chronological order a merge pass needs, without an explicit sort.
running_max_end = np.maximum.accumulate(ends)
prev_max_end = np.concatenate(([-np.inf], running_max_end[:-1]))
new_group = starts > prev_max_end
group_start_indices = np.flatnonzero(new_group)
group_starts = starts[group_start_indices]
group_end_indices = np.concatenate((group_start_indices[1:], [len(starts)])) - 1
group_ends = running_max_end[group_end_indices]
return float(np.sum(group_ends - group_starts))
[docs]
def compute_pair_counts(
self,
timescale: u.Quantity,
minimum_factor: float | None = None,
maximum_factor: float | None = None,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
pairs: str = "all",
) -> tuple[np.ndarray, u.Quantity]:
"""
Compute per-pixel counts of visit pairs bracketing a transient timescale.
For each HEALPix pixel, counts the number of observation pairs
separated by
``minimum_factor * timescale <= dt <= maximum_factor * timescale``,
i.e. pairs of visits able to catch a timescale-``timescale`` transient
rising and/or fading -- either over every unique pair of visits to
that pixel (``pairs='all'``) or only over pairs of temporally-adjacent
visits (``pairs='consecutive'``). This is the same idea as LSST/Rubin's
pair-count cadence metrics (e.g. ``rubin_sim.maf.metrics.PairMetric``,
used to assess sensitivity to kilonova- and fast-transient-like
timescales): a cheap proxy for "can this survey's cadence constrain a
timescale-``T`` transient here?" that avoids the interval-union
bookkeeping (and per-pixel, per-timescale cost) of an exact
control-time calculation.
Built on :meth:`compute_visit_times` and :meth:`_pixel_pair_count` /
:meth:`_pixel_pair_count_consecutive` rather than
:meth:`compute_cadence_time_differences`, so cost scales as
O(n log n) in the number of visits to each pixel rather than O(n^2) --
important for a survey that covers the whole sky many times over,
where a given pixel's total visit count (and hence its number of
*all* pairs) grows with the number of repeated full-sky passes.
Parameters
----------
timescale : ~astropy.units.Quantity
Characteristic transient timescale.
minimum_factor, maximum_factor : float, optional
Bounds of the qualifying pair-separation window, relative to
``timescale``. Either may be `None` (the default) to use
``config["surveys.cadence.minimum_factor"]``/
``config["surveys.cadence.maximum_factor"]``.
start_time, end_time : ~astropy.time.Time, optional
Optional survey interval to restrict the calculation to.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
pairs : str
Which pairs of observations to include: ``"all"`` for every
unique pair, or ``"consecutive"`` for only pairs of
temporally-adjacent visits.
Returns
-------
pair_counts : numpy.ndarray
Full-sky HEALPix map of qualifying pair counts, shape
``(12 * nside**2,)``.
sensitive_area : ~astropy.units.Quantity
Total solid angle of pixels with at least one qualifying pair --
the area of sky with any cadence sensitivity to this timescale.
"""
minimum_factor = minimum_factor if minimum_factor is not None else config["surveys.cadence.minimum_factor"]
maximum_factor = maximum_factor if maximum_factor is not None else config["surveys.cadence.maximum_factor"]
self._validate_timescale(timescale)
self._validate_pair_window_factors(minimum_factor, maximum_factor)
self._validate_pairs_mode(pairs)
visit_times, offsets = self.compute_visit_times(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
unit = visit_times.unit
times = visit_times.to_value(unit)
npix = len(offsets) - 1
visit_counts = np.diff(offsets)
lo = (minimum_factor * timescale).to_value(unit)
hi = (maximum_factor * timescale).to_value(unit)
count_pixel_pairs = self._pixel_pair_count if pairs == "all" else self._pixel_pair_count_consecutive
pair_counts = np.zeros(npix, dtype=np.int64)
for pixel in np.flatnonzero(visit_counts >= 2):
pixel_times = times[offsets[pixel] : offsets[pixel + 1]]
pair_counts[pixel] = count_pixel_pairs(pixel_times, lo, hi)
pixel_area = (4 * np.pi / npix) * u.sr
sensitive_area = np.count_nonzero(pair_counts) * pixel_area
return pair_counts, sensitive_area
[docs]
def compute_pair_count_curve(
self,
timescales: u.Quantity,
minimum_factor: float | None = None,
maximum_factor: float | None = None,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
pairs: str = "all",
) -> u.Quantity:
"""
Compute sensitive sky area over a sequence of transient timescales.
The fast, curve-friendly counterpart to :meth:`compute_pair_counts`:
the (expensive, HEALPix-footprint-rasterizing) per-pixel visit times
are computed once and reused for every timescale, rather than redoing
that rasterization inside a loop the way an exact interval-union
control-time curve would.
Each timescale still re-scans every observed pixel (via
:meth:`_pixel_pair_count` / :meth:`_pixel_pair_count_consecutive`,
selected by ``pairs``), so cost scales as
O(n_timescales * n_observed_pixels * log(visits per pixel)) -- no
repeated rasterization, and no O(n^2) blowup with the number of
repeated full-sky passes, but also no way to get a timescale "for
free" the way a precomputed, timescale-independent statistic could.
If this loop itself becomes the bottleneck for very fine timescale
grids on a very deep multi-pass survey, the per-pixel loop can be
vectorized away too (encoding pixel id and time into one sortable key
so the whole sky is searched in a single vectorized pass) -- not done
here since it adds real complexity that isn't needed until it is.
Parameters
----------
timescales : ~astropy.units.Quantity
Sequence of characteristic transient timescales.
minimum_factor, maximum_factor : float, optional
Bounds of the qualifying pair-separation window, relative to each
timescale. Either may be `None` (the default) to use
``config["surveys.cadence.minimum_factor"]``/
``config["surveys.cadence.maximum_factor"]``.
start_time, end_time : ~astropy.time.Time, optional
Optional survey interval to restrict the calculation to.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
pairs : str
Which pairs of observations to include: ``"all"`` for every
unique pair, or ``"consecutive"`` for only pairs of
temporally-adjacent visits.
Returns
-------
~astropy.units.Quantity
Sensitive sky area (see :meth:`compute_pair_counts`) for each input
timescale.
"""
minimum_factor = minimum_factor if minimum_factor is not None else config["surveys.cadence.minimum_factor"]
maximum_factor = maximum_factor if maximum_factor is not None else config["surveys.cadence.maximum_factor"]
timescales = u.Quantity(timescales, copy=False, ndmin=1)
self._validate_pair_window_factors(minimum_factor, maximum_factor)
self._validate_pairs_mode(pairs)
visit_times, offsets = self.compute_visit_times(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
unit = visit_times.unit
times = visit_times.to_value(unit)
npix = len(offsets) - 1
visit_counts = np.diff(offsets)
observed_pixels = np.flatnonzero(visit_counts >= 2)
pixel_area = (4 * np.pi / npix) * u.sr
count_pixel_pairs = self._pixel_pair_count if pairs == "all" else self._pixel_pair_count_consecutive
sensitive_pixel_counts = []
with logging_redirect_tqdm(loggers=[logger]):
for timescale in tqdm(timescales, desc="Computing pair-count curve", unit="timescale"):
self._validate_timescale(timescale)
lo = (minimum_factor * timescale).to_value(unit)
hi = (maximum_factor * timescale).to_value(unit)
n_sensitive = 0
for pixel in observed_pixels:
pixel_times = times[offsets[pixel] : offsets[pixel + 1]]
if count_pixel_pairs(pixel_times, lo, hi) > 0:
n_sensitive += 1
logger.debug(
"Timescale %.3g %s: %d/%d observed pixels sensitive.",
timescale.to_value(unit),
unit,
n_sensitive,
len(observed_pixels),
)
sensitive_pixel_counts.append(n_sensitive)
return u.Quantity(sensitive_pixel_counts) * pixel_area
[docs]
def compute_control_time(
self,
timescale: u.Quantity,
minimum_factor: float | None = None,
maximum_factor: float | None = None,
visibility_factor: float | None = None,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> tuple[u.Quantity, u.Quantity]:
"""
Compute transient control time as a function of sky position.
For each HEALPix pixel, the control time is the total *duration* over
which a transient with characteristic timescale ``timescale`` could
begin and still receive useful temporal sampling from the survey: at
least one pair of observations separated by
``minimum_factor * timescale <= dt <= maximum_factor * timescale``,
with the later observation occurring within
``visibility_factor * timescale`` of transient onset. Overlapping
intervals of allowed start times are merged before summing, so no
candidate start time is counted twice.
This is the statistic to reach for when you need to know not just
whether a pixel can ever catch a timescale-``T`` transient (see
:meth:`compute_pair_counts`, which only answers that existence
question), but for how much of the survey it remains able to --
important once a pixel is revisited many times (e.g. across repeated
full-sky passes), since a pixel hit by one lucky pair and a pixel with
continuous cadence support look identical under an existence-only
statistic but very different here.
Built on :meth:`compute_visit_times` and :meth:`_pixel_control_time`,
both the per-visit interval construction and the interval merge are
fully vectorized (no Python-level loop over visits), so cost stays
O(n log n) per pixel rather than the O(n) sequential Python loop the
original implementation of this statistic used.
Parameters
----------
timescale : ~astropy.units.Quantity
Characteristic transient timescale.
minimum_factor : float, optional
Minimum useful observation separation relative to ``timescale``, or
`None` (the default) to use ``config["surveys.cadence.minimum_factor"]``.
maximum_factor : float, optional
Maximum useful observation separation relative to ``timescale``, or
`None` (the default) to use ``config["surveys.cadence.maximum_factor"]``.
visibility_factor : float, optional
Duration over which the transient is assumed useful for temporal
characterization, relative to ``timescale``, or `None` (the default) to
use ``config["surveys.cadence.visibility_factor"]``.
start_time, end_time : ~astropy.time.Time, optional
Optional survey interval over which to calculate control time.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
control_time : ~astropy.units.Quantity
Full-sky HEALPix map of control times with shape
``(12 * nside**2,)``.
exposure : ~astropy.units.Quantity
Survey-integrated area-time exposure,
``sum(control_time * pixel_area)``,
with dimensions of solid angle times time.
"""
minimum_factor = minimum_factor if minimum_factor is not None else config["surveys.cadence.minimum_factor"]
maximum_factor = maximum_factor if maximum_factor is not None else config["surveys.cadence.maximum_factor"]
visibility_factor = (
visibility_factor if visibility_factor is not None else config["surveys.cadence.visibility_factor"]
)
self._validate_timescale(timescale)
self._validate_pair_window_factors(minimum_factor, maximum_factor)
self._validate_visibility_factor(visibility_factor)
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
visit_times, offsets = self.compute_visit_times(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
unit = visit_times.unit
times = visit_times.to_value(unit)
npix = len(offsets) - 1
visit_counts = np.diff(offsets)
minimum_separation = (minimum_factor * timescale).to_value(unit)
maximum_separation = (maximum_factor * timescale).to_value(unit)
visibility_window = (visibility_factor * timescale).to_value(unit)
maximum_time = (end_time - start_time).to_value(unit)
control_time = np.zeros(npix, dtype=float)
for pixel in np.flatnonzero(visit_counts >= 2):
pixel_times = times[offsets[pixel] : offsets[pixel + 1]]
control_time[pixel] = self._pixel_control_time(
pixel_times,
minimum_separation,
maximum_separation,
visibility_window,
maximum_time,
)
control_time = control_time * unit
pixel_area = (4 * np.pi / npix) * u.sr
exposure = np.sum(control_time) * pixel_area
return control_time, exposure
[docs]
def compute_control_time_curve(
self,
timescales: u.Quantity,
minimum_factor: float | None = None,
maximum_factor: float | None = None,
visibility_factor: float | None = None,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> u.Quantity:
"""
Compute survey area-time exposure over a sequence of transient timescales.
The curve-friendly counterpart to :meth:`compute_control_time`:
per-pixel visit times (the expensive, HEALPix-footprint-rasterizing
part) are computed once and reused for every timescale, rather than
redone inside the loop the way the original implementation of this
curve did. Each timescale still re-scans every observed pixel to
rebuild and re-merge its intervals, since the merged intervals
themselves depend on the timescale.
Parameters
----------
timescales : ~astropy.units.Quantity
Sequence of characteristic transient timescales.
minimum_factor, maximum_factor : float, optional
Bounds of the qualifying pair-separation window, relative to each
timescale. Either may be `None` (the default) to use
``config["surveys.cadence.minimum_factor"]``/
``config["surveys.cadence.maximum_factor"]``.
visibility_factor : float, optional
Duration over which the transient is assumed useful for temporal
characterization, relative to each timescale, or `None` (the default) to
use ``config["surveys.cadence.visibility_factor"]``.
start_time, end_time : ~astropy.time.Time, optional
Optional survey interval over which to calculate control time.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
~astropy.units.Quantity
Area-time exposure for each input timescale.
"""
minimum_factor = minimum_factor if minimum_factor is not None else config["surveys.cadence.minimum_factor"]
maximum_factor = maximum_factor if maximum_factor is not None else config["surveys.cadence.maximum_factor"]
visibility_factor = (
visibility_factor if visibility_factor is not None else config["surveys.cadence.visibility_factor"]
)
timescales = u.Quantity(timescales, copy=False, ndmin=1)
self._validate_pair_window_factors(minimum_factor, maximum_factor)
self._validate_visibility_factor(visibility_factor)
start_time, end_time = self._resolve_tstart_tend(start_time, end_time)
visit_times, offsets = self.compute_visit_times(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
unit = visit_times.unit
times = visit_times.to_value(unit)
npix = len(offsets) - 1
visit_counts = np.diff(offsets)
observed_pixels = np.flatnonzero(visit_counts >= 2)
maximum_time = (end_time - start_time).to_value(unit)
pixel_area = (4 * np.pi / npix) * u.sr
exposures = []
# A single progress bar over timescales, not one nested per-pixel bar per
# timescale: `observed_pixels` is re-scanned in full for every timescale (see the
# docstring above), so a fresh inner `tqdm` on each iteration would be re-created
# thousands of times over a fine timescale grid, adding real overhead for a bar
# that starts over each time rather than tracking overall progress.
with logging_redirect_tqdm(loggers=[logger]):
progress = tqdm(timescales, desc="Computing control-time curve", unit="timescale")
for timescale in progress:
self._validate_timescale(timescale)
minimum_separation = (minimum_factor * timescale).to_value(unit)
maximum_separation = (maximum_factor * timescale).to_value(unit)
visibility_window = (visibility_factor * timescale).to_value(unit)
total_control_time = 0.0
for pixel in observed_pixels:
pixel_times = times[offsets[pixel] : offsets[pixel + 1]]
total_control_time += self._pixel_control_time(
pixel_times,
minimum_separation,
maximum_separation,
visibility_window,
maximum_time,
)
logger.debug(
"Timescale %.3g %s: total control time %.3g %s over %d observed pixels.",
timescale.to_value(unit),
unit,
total_control_time,
unit,
len(observed_pixels),
)
exposures.append(total_control_time)
progress.set_postfix(exposure=f"{total_control_time:.3g} {unit}")
return u.Quantity(exposures, unit=unit) * pixel_area
[docs]
def compute_visit_count_histogram(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the distribution of HEALPix pixels by visit count.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute visit counts.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
visit_counts : numpy.ndarray
Possible numbers of visits: ``0, 1, ..., N``.
pixel_counts : numpy.ndarray
Number of HEALPix pixels receiving each corresponding number of visits.
Notes
-----
The zero-visit bin includes every HEALPix pixel on the sky that was not
observed during the requested interval.
"""
visits_per_pixel = self.compute_visit_count(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
pixel_counts = np.bincount(visits_per_pixel)
visit_counts = np.arange(pixel_counts.size, dtype=np.int64)
return visit_counts, pixel_counts
[docs]
def compute_visit_count_cdf(
self,
start_time: Time | None = None,
end_time: Time | None = None,
nside: int | None = None,
order: str | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the complementary cumulative visit-count distribution.
The returned fraction gives the fraction of *observed* HEALPix pixels
receiving at least a given number of visits.
Parameters
----------
start_time, end_time : ~astropy.time.Time, optional
Optional time range over which to compute visit counts.
nside : int, optional
HEALPix resolution parameter, or `None` to use
``config["healpix.default_nside"]``.
order : str, optional
HEALPix ordering scheme, either ``"nested"`` or ``"ring"``, or `None` to
use ``config["healpix.default_order"]``.
Returns
-------
visit_counts : numpy.ndarray
Visit-count thresholds ``1, 2, ..., N``.
fractions : numpy.ndarray
Fraction of observed HEALPix pixels receiving at least the
corresponding number of visits.
"""
visit_counts, pixel_counts = self.compute_visit_count_histogram(
start_time=start_time,
end_time=end_time,
nside=nside,
order=order,
)
# Exclude pixels that were never observed. For cadence analysis, including
# the rest of the full sky in the denominator usually obscures the useful
# distribution over the actual survey footprint.
observed_pixel_counts = pixel_counts[1:]
visit_counts = visit_counts[1:]
if observed_pixel_counts.size == 0 or observed_pixel_counts.sum() == 0:
return (
np.array([], dtype=np.int64),
np.array([], dtype=float),
)
# Reverse cumulative sum:
#
# fractions[k] = P(number of visits >= visit_counts[k])
#
cumulative = np.cumsum(observed_pixel_counts[::-1])[::-1]
return visit_counts, cumulative / cumulative[0]
# ----------------------------------------- #
# IO Methods #
# ----------------------------------------- #
def _sanitized_table(self) -> QTable:
"""
Return a copy of :attr:`table` with every mixin column rebuilt via :func:`_sanitize_masked_value`.
Used by :meth:`to_disk` so that a written file's masked columns always use
astropy's officially ECSV-round-trippable
:class:`~astropy.utils.masked.MaskedANDArray`, regardless of what ndarray
subclass they may have picked up upstream (e.g. through repeated
:func:`~astropy.table.vstack` while a schedule is being assembled).
Returns
-------
~astropy.table.QTable
The sanitized copy.
"""
table = self._schedule_table.copy()
for column_name, spec in self._SCHEMA.items():
if column_name not in table.colnames:
continue
col = table[column_name]
if spec.column_type is EarthLocation:
table[column_name] = EarthLocation.from_geocentric(
_sanitize_masked_value(col.x.value) * col.x.unit,
_sanitize_masked_value(col.y.value) * col.y.unit,
_sanitize_masked_value(col.z.value) * col.z.unit,
)
elif spec.column_type is SkyCoord:
table[column_name] = SkyCoord(
_sanitize_masked_value(col.ra.value) * col.ra.unit,
_sanitize_masked_value(col.dec.value) * col.dec.unit,
frame=col.frame.name,
)
elif spec.unit is not None:
table[column_name] = _sanitize_masked_value(col.value) * col.unit
return table
[docs]
def to_disk(
self,
path: str | Path,
fov_path: str | Path | None = None,
table_format: str | None = None,
overwrite: bool = False,
) -> None:
"""
Write the schedule table -- and, optionally, the instrument FOV -- to disk.
See :meth:`_sanitized_table` for why this doesn't just call
``self.table.write(...)`` directly: without it, a file can fail to read
back with ``ValueError: unsupported class for construct ...`` if a masked
column's value picked up an ndarray subclass along the way (e.g. through
repeated :func:`~astropy.table.vstack`) that isn't one astropy's ECSV
reader knows how to reconstruct.
:meth:`from_disk` always requires a companion ``fov_path`` to reconstruct
:attr:`fov`, so pass ``fov_path`` here too if you intend to round-trip via
:meth:`from_disk` -- otherwise :attr:`fov` is not persisted at all.
Parameters
----------
path : str or ~pathlib.Path
Destination path for the schedule table.
fov_path : str or ~pathlib.Path, optional
Destination path for :attr:`fov`. If `None` (the default), the FOV is
not written.
table_format : str, optional
Passed through to :meth:`~astropy.table.QTable.write`; if `None`,
the format is inferred from ``path``'s suffix.
overwrite : bool
Whether to overwrite an existing file at ``path`` and ``fov_path``.
"""
self._sanitized_table().write(Path(path), format=table_format, overwrite=overwrite)
logger.info("Wrote schedule table (%d rows) to %s.", len(self._schedule_table), path)
if fov_path is not None:
fov = self._instrument_fov
(fov if isinstance(fov, Regions) else Regions([fov])).write(str(fov_path), overwrite=overwrite)
logger.info("Wrote instrument FOV to %s.", fov_path)
[docs]
@classmethod
def from_disk(
cls,
path: str | Path,
fov_path: str | Path,
table_format: str | None = None,
**kwargs,
) -> "SurveySchedule":
"""
Read a schedule table and its companion instrument FOV back from disk.
The inverse of :meth:`to_disk` (when it was called with a ``fov_path``). The
reconstructed :attr:`fov` is always a `~regions.Regions` collection -- even if
the original ``instrument_fov`` passed to the constructor was a single, bare
`~regions.SkyRegion` -- since a DS9 region file doesn't distinguish "one region"
from "a one-member collection". Every `SurveySchedule` method treats the two
forms identically, so this is transparent to callers.
Parameters
----------
path : str or ~pathlib.Path
Path to the schedule table, as written by :meth:`to_disk`.
fov_path : str or ~pathlib.Path
Path to the instrument FOV region file, as written by :meth:`to_disk`.
table_format : str, optional
Passed through to :meth:`~astropy.table.QTable.read`; if `None`, the
format is inferred from ``path``'s suffix.
**kwargs
Forwarded to the constructor.
Returns
-------
SurveySchedule
The reconstructed schedule.
Raises
------
FileNotFoundError
If ``path`` or ``fov_path`` does not exist.
ScheduleValidationError
If the table read from ``path`` fails schema validation.
"""
# Ensure that the path exists before proceeding.
path = Path(path)
fov_path = Path(fov_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
if not fov_path.exists():
raise FileNotFoundError(f"File not found: {fov_path}")
# Read the Qtable ecsv file from disk.
survey_table = QTable.read(path, format=table_format)
# read the FOV file.
fov_regions = Regions.read(fov_path)
logger.info("Read schedule table (%d rows) from %s (FOV from %s).", len(survey_table), path, fov_path)
return cls(survey_table, fov_regions, **kwargs)