"""Tools for running the set of simulations used for a pre-night briefing."""
__all__ = ["AnomalousOverheadFunc", "run_prenight_sim_cli"]
import argparse
import logging
import pickle
import warnings
from copy import deepcopy
from datetime import date
from pathlib import Path
from typing import Any, Callable, Mapping, Protocol
import numpy as np
import scipy.stats
from matplotlib.pylab import Generator
from rubin_scheduler.scheduler import sim_runner
from rubin_scheduler.scheduler.utils.observation_array import ObservationArray
from rubin_scheduler.site_models import Almanac
from rubin_sim.sim_archive import make_sim_data_dir
from .util import dayobs_to_date
LOGGER = logging.getLogger(__name__)
# Type hint to match scipy.stats style distribution classes
class ContinuousDistribution(Protocol):
def rvs(
self,
size: int | tuple[int, ...] | None = None,
random_state: np.random.RandomState | np.random.Generator | int | None = None,
) -> float: ...
[docs]
class AnomalousOverheadFunc:
"""Callable to return random overhead.
Parameters
----------
seed : `int`
Random number seed.
slew_scale : `float`
The scale for the scatter in the slew offest (seconds).
Defaults to 0.0.
visit_scale : `float`, optional
The paramater no longer does anything, and will be
removed in future versions.
Defaults to 0.0.
slew_loc : `float`, optional
The location of the scatter in the slew offest (seconds).
It is unlikely that this should ever be non-zero.
Defaults to 0.0.
visit_loc : `float`, optional
The paramater no longer does anything, and will be
removed in future versions.
Defaults to 0.0.
scatter_distribution: `str` or `None`, optional
The distribution from which the scatter should be taken.
This must be the name of a method of `numpy.random.Generator`.
If `None`, the distribution is taken from
`DEFAULT_OVERHEAD_SCATTER_DIST`.
Defaults to ``None``.
scatter_kwargs: `dict` or `None`, optional
Dictionary of arguments passed to ``scatter_distribution``.
If `None`, the argumentns taken from
`DEFAULT_OVERHEAD_SCATTER_KWARGS`.
min_overhead : `float`, optional
The minimum possible overhead
Notes
-----
- The ``slew_scale`` and ``slew_loc`` parameters introduce an offset
proportional to the slew time following a normal distribution.
A non-zero ``slew_loc`` will sysetmatically move the center of
the distribution.
- The ``visit_scale`` and ``visit_loc`` parameters are no longer
functional, and persist only to support backward compatibility.
- The ``scatter_distribution`` and ``scatter_kwargs`` introduce an
offset independent of the modeled slew and visit times, and support
any distribution offered by `numpy.random.Generator`.
"""
default_overhead_scatter_dist: ContinuousDistribution = scipy.stats.halfnorm
default_overhead_scatter_kwargs = {"scale": 0.0, "loc": 0.0}
def __init__(
self,
seed: int,
slew_scale: float = 0.0,
visit_scale: float = 0.0,
slew_loc: float = 0.0,
visit_loc: float = 0.0,
min_overhead: float = 0.0,
scatter_distribution: str | ContinuousDistribution | None = None,
scatter_kwargs: dict | None = None,
) -> None:
self.rng: Generator = np.random.default_rng(seed)
self.visit_loc: float = visit_loc
self.visit_scale: float = visit_scale
self.slew_loc: float = slew_loc
self.slew_scale: float = slew_scale
if scatter_kwargs is None:
scatter_kwargs = self.default_overhead_scatter_kwargs
assert isinstance(scatter_kwargs, dict)
self.scatter_kwargs = deepcopy(scatter_kwargs)
self.scatter_dist_func: Callable
if scatter_distribution is None:
scatter_distribution = self.default_overhead_scatter_dist
if isinstance(scatter_distribution, str):
# If scatter_distribution is a string, interpret it
# as a numpy random number distribution.
try:
maybe_scatter_dist_func = getattr(self.rng, scatter_distribution)
# If scatter_distribution is a plain attribute
# rather than a true
# method, it won't work for us, and confuses the type checker.
except AttributeError as exc:
raise AttributeError(
"'scatter_distribution' must be valid NumPy random Generator method, "
f"and '{scatter_distribution}' is not."
) from exc
assert callable(maybe_scatter_dist_func), "'scatter_distribution' must be callable"
self.scatter_dist_func = maybe_scatter_dist_func
else:
maybe_scatter_dist_func = getattr(scatter_distribution, "rvs", None)
assert callable(
maybe_scatter_dist_func
), f"Cannot use scatter distribution of type {str(scatter_distribution.__class__.__name__)}"
if "random_state" not in self.scatter_kwargs:
self.scatter_kwargs["random_state"] = self.rng
self.scatter_dist_func = maybe_scatter_dist_func
self.min_overhead: float = min_overhead
if self.visit_scale > 0:
raise NotImplementedError("Visit scale scatter is no longer implemented.")
[docs]
def __call__(
self,
obs: Mapping[str, Any] | ObservationArray | None = None,
) -> float:
"""Return a randomized offset for the visit overhead.
Parameters
----------
obs : `Mapping[str, Any]`
Observation record.
Returns
-------
offset: `float`
The offset (in seconds) between the modeled overhead
between exposures and the overhead to be applied.
"Overhead" is the difference in start times between
succesive expusures, minus the exposure times:
``overhead = exp_start_2 - exp_start_1 - exptime_1``.
So, the ``offset`` here is time to be added to
the modeled start of an exposure.
"""
slew_overhead_offset: float
if self.slew_scale > 0 or self.slew_loc > 0:
# we are actually using slew time scattering, so
# get the model slewtime.
if isinstance(obs, Mapping):
slewtime = obs["slewtime"]
else:
assert isinstance(obs, ObservationArray)
assert len(obs) == 1
slewtime = obs["slewtime"].item()
assert isinstance(slewtime, float)
slew_overhead_offset = slewtime * self.rng.normal(self.slew_loc, self.slew_scale)
# Slew might be faster than expected,
# but it will never take negative time.
if (slewtime + slew_overhead_offset) < 0:
slew_overhead_offset = -1 * slewtime
else:
slew_overhead_offset = 0.0
scatter_offset: float = self.scatter_dist_func(**self.scatter_kwargs)
overhead_offset: float = slew_overhead_offset + scatter_offset
# If we have the available parameters, make sure the final overhead
# is more than min_overhead
overhead_checked: bool = False
if obs is not None:
needed_data_found: bool = False
try:
model_overhead = obs["slewtime"] + obs["visittime"] - obs["exptime"]
needed_data_found = True
except KeyError, ValueError:
pass
if needed_data_found:
after_offset_overhead = model_overhead + overhead_offset
if after_offset_overhead < self.min_overhead:
overhead_offset = self.min_overhead - model_overhead
overhead_checked = True
if not overhead_checked:
warnings.warn("Could not verify that the overhead was greater than the minimum.")
return overhead_offset
def compute_sim_start_and_end(day_obs: int, sim_nights: int, delay: float = 0) -> tuple[float, float]:
"""Compute simulation start and end time for a prenight simulation.
Parameters
----------
day_obs: `int`
The starting dayobs as YYYYMMDD.
sim_nights: `int`
The number of nights to simulate
delay: `float`
A delayed start, in minutes.
Return
------
sim_start_mjd, sim_duration : `tuple`
The starting MJD and simulation duration in days.
"""
# Find the start of observing for the specified day_obs.
# Almanac.get_sunset_info does not use day_obs, so
# translate to DJD, Dublin Julian Date.
# Like plain Julian Date, DJD rolls over at the
# same time as day_obs, but unlike JD, the year
# of the epoch works with python's datetime.date.
# Note that MJD - DJD = 15019.5
# and has epoch 1899-12-31T12:00:00.
sunsets = Almanac().sunsets
djd_mjd_offset = 15019.5
djd0 = date(1899, 12, 31)
night0_djd = int(np.floor(sunsets["sunset"][0] - djd_mjd_offset))
day_obs_date = dayobs_to_date(day_obs)
day_obs_djd = (day_obs_date - djd0).days
day_obs_start_night = day_obs_djd - night0_djd
sim_start_mjd = sunsets["sun_n12_setting"][day_obs_start_night] + delay / (24 * 60.0)
day_obs_end_night = day_obs_start_night + sim_nights - 1
sim_end_mjd = sunsets["sunrise"][day_obs_end_night]
sim_duration = sim_end_mjd - sim_start_mjd
assert np.ceil(sim_duration) == sim_nights
return sim_start_mjd, sim_duration
def run_prenight_sim_cli(cli_args: list = []) -> int:
parser = argparse.ArgumentParser(description="Run an SV simulation.")
parser.add_argument("scheduler", type=str, help="scheduler pickle file.")
parser.add_argument("observatory", type=str, help="model observatory pickle file.")
parser.add_argument("day_obs", type=int, help="start day obs.")
parser.add_argument("sim_nights", type=int, help="number of nights to run.")
parser.add_argument("--keep_rewards", action="store_true", help="Compute rewards data.")
parser.add_argument("--telescope", type=str, default="simonyi", help="The telescope simulated.")
parser.add_argument("--label", type=str, default="", help="The tags on the simulation.")
parser.add_argument("--delay", type=float, default=0.0, help="Minutes after nominal to start.")
parser.add_argument("--anom_overhead_scale", type=float, default=0.0, help="scale of scatter in the slew")
parser.add_argument(
"--anom_overhead_scatter", type=float, default=0.0, help="absolute scatter in the overhead"
)
parser.add_argument(
"--anom_overhead_seed",
type=int,
default=1,
help="random number seed for anomalous scatter in overhead",
)
parser.add_argument("--tags", type=str, default=[], nargs="*", help="The tags on the simulation.")
parser.add_argument("--results", type=str, default="", help="Results directory.")
args = parser.parse_args() if len(cli_args) == 0 else parser.parse_args(cli_args)
day_obs = args.day_obs
sim_nights = args.sim_nights
keep_rewards = args.keep_rewards
tags = args.tags
label = args.label
telescope = args.telescope
delay = args.delay
anom_overhead_scale = args.anom_overhead_scale
anom_overhead_scatter = args.anom_overhead_scatter
anom_overhead_seed = args.anom_overhead_seed
results_dir = args.results if len(args.results) > 0 else None
with open(args.scheduler, "rb") as scheduler_io:
scheduler = pickle.load(scheduler_io)
with open(args.observatory, "rb") as observatory_io:
observatory = pickle.load(observatory_io)
anomalous_overhead_func: Callable | None = None
anom_overhead_args = {}
if anom_overhead_scale != 0.0:
anom_overhead_args["slew_scale"] = anom_overhead_scale
if anom_overhead_scatter != 0.0:
anom_overhead_args["scatter_kwargs"] = {"scale": anom_overhead_scatter}
if len(anom_overhead_args) > 0:
anom_overhead_args["seed"] = anom_overhead_seed
anomalous_overhead_func = AnomalousOverheadFunc(**anom_overhead_args)
if keep_rewards:
scheduler.keep_rewards = keep_rewards
sim_start_mjd, sim_duration = compute_sim_start_and_end(day_obs, sim_nights, delay)
observatory.mjd = sim_start_mjd
LOGGER.info("Starting simulation")
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
results = sim_runner(
observatory,
scheduler,
sim_start_mjd=sim_start_mjd,
sim_duration=sim_duration,
record_rewards=keep_rewards,
verbose=True,
anomalous_overhead_func=anomalous_overhead_func,
)
observatory, scheduler, obs = results[:3]
rewards, obs_rewards = (None, None) if len(results) < 5 else results[3:5]
LOGGER.info("Simulation complete.")
LOGGER.info("Writing simulation results")
data_path = make_sim_data_dir(
obs,
rewards,
obs_rewards,
in_files={"scheduler": args.scheduler, "observatory": args.observatory},
tags=tags,
label=label,
opsim_metadata={"telescope": telescope},
data_path=results_dir,
)
output_dirname: str = "Unknown"
if isinstance(data_path, Path):
assert isinstance(data_path, Path)
output_dirname = data_path.name
else:
assert isinstance(data_path, str)
output_dirname = data_path
LOGGER.info(f"Wrote results in directory: {output_dirname}")
return 0