Source code for rubin_sim.sim_archive.sim_archive

"""Tools for maintaining the archive of opsim output and metadata."""

__all__ = [
    "make_sim_data_dir",
    "drive_sim",
    "find_latest_prenight_sim_for_nights",
    "fetch_sim_for_nights",
    "fetch_obsloctap_visits",
    "fetch_sim_stats_for_night",
    "NoMatchingSimulationsFoundError",
]

import hashlib
import logging
import lzma
import os
import pickle
import shutil
import socket
from datetime import date, datetime, timedelta
from numbers import Integral, Number
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Optional, Sequence, cast
from zoneinfo import ZoneInfo

import numpy as np
import pandas as pd
import psycopg2
import rubin_scheduler
import yaml
from astropy.time import Time
from lsst.resources import ResourcePath
from pandas.api.types import is_numeric_dtype
from rubin_scheduler.scheduler import sim_runner
from rubin_scheduler.scheduler.model_observatory import ModelObservatory
from rubin_scheduler.scheduler.schedulers import CoreScheduler
from rubin_scheduler.scheduler.utils import SchemaConverter
from rubin_scheduler.site_models.almanac import Almanac

from rubin_sim import maf
from rubin_sim.maf.stackers import BaseStacker, DayObsISOStacker
from rubin_sim.maf.utils.opsim_utils import get_sim_data
from rubin_sim.sim_archive.prenightindex import get_prenight_index, select_latest_prenight_sim
from rubin_sim.sim_archive.util import dayobs_to_date
from rubin_sim.sim_archive.vseqarchive import compute_nightly_stats, get_visits
from rubin_sim.sim_archive.vseqmetadata import VisitSequenceArchiveMetadata

LOGGER = logging.getLogger(__name__)


[docs] class NoMatchingSimulationsFoundError(Exception): """Raised when no simulations match requested criteria."""
[docs] def make_sim_data_dir( observations: np.recarray, reward_df: pd.DataFrame | None = None, obs_rewards: pd.DataFrame | None = None, in_files: dict = {}, sim_runner_kwargs: dict = {}, tags: list = [], label: str | None = None, data_path: str | Path | None = None, opsim_metadata: dict | None = None, ) -> Path | str: """Create or fill a local simulation archive directory. Parameters ---------- observations : `np.recarray` The observations data, in the "obs" format as accepted and created by `SchemaConverter`. reward_df : `pd.DataFrame`, optional The reward data, by default None. obs_rewards : `pd.DataFrame`, optional The observation rewards data, by default None. in_files : `dict`, optional Additional input files to be included in the archive, by default {}. sim_runner_kwargs : `dict`, optional Additional simulation runner keyword arguments, by default {}. tags : `list` [`str`], optional A list of tags/keywords to be included in the metadata, by default []. label : `str`, optional A label to be included in the metadata, by default None. data_path : `str` or `Path`, optional The path to the simulation archive directory, by default None. opsim_metadata : `dict` Metadata to be included. Returns ------- data_dir : `Path` or `TemporaryDirectory` The temporary directory containing the simulation archive. """ if data_path is None: data_dir = TemporaryDirectory() data_path = Path(data_dir.name) else: data_dir = None if not isinstance(data_path, Path): data_path = Path(data_path) files = {} # Save the observations files["observations"] = {"name": "opsim.db"} opsim_output_fname = data_path.joinpath(files["observations"]["name"]) SchemaConverter().obs2opsim(observations, filename=opsim_output_fname) # Save the rewards if reward_df is not None and obs_rewards is not None: files["rewards"] = {"name": "rewards.h5"} rewards_fname = data_path.joinpath(files["rewards"]["name"]) if reward_df is not None: reward_df.to_hdf(rewards_fname, key="reward_df") if obs_rewards is not None: obs_rewards.to_hdf(rewards_fname, key="obs_rewards") # Save basic statistics files["statistics"] = {"name": "obs_stats.txt"} stats_fname = data_path.joinpath(files["statistics"]["name"]) with open(stats_fname, "w") as stats_io: print(SchemaConverter().obs2opsim(observations).describe().T.to_csv(sep="\t"), file=stats_io) # Add supplied files for file_type, fname in in_files.items(): files[file_type] = {"name": Path(fname).name} try: shutil.copyfile(fname, data_path.joinpath(files[file_type]["name"])) except shutil.SameFileError: pass # Add file hashes for file_type in files: fname = data_path.joinpath(files[file_type]["name"]) with open(fname, "rb") as file_io: content = file_io.read() files[file_type]["md5"] = hashlib.md5(content).hexdigest() def convert_mjd_to_dayobs(mjd: float) -> str: # Use dayObs defn. from SITCOMTN-32: https://sitcomtn-032.lsst.io/ evening_local_mjd = np.floor(mjd - 0.5).astype(int) evening_local_iso = Time(evening_local_mjd, format="mjd").iso[:10] assert isinstance(evening_local_iso, str) return evening_local_iso if opsim_metadata is None: opsim_metadata = {} opsim_metadata["scheduler_version"] = rubin_scheduler.__version__ opsim_metadata["host"] = socket.getfqdn() opsim_metadata["username"] = os.environ["USER"] simulation_dates = {} if "sim_start_mjd" in sim_runner_kwargs: simulation_dates["first"] = convert_mjd_to_dayobs(sim_runner_kwargs["sim_start_mjd"]) if "sim_duration" in sim_runner_kwargs: simulation_dates["last"] = convert_mjd_to_dayobs( sim_runner_kwargs["sim_start_mjd"] + sim_runner_kwargs["sim_duration"] - 1 ) else: simulation_dates["first"] = convert_mjd_to_dayobs(observations["mjd"].min()) simulation_dates["last"] = convert_mjd_to_dayobs(observations["mjd"].max()) if len(sim_runner_kwargs) > 0: opsim_metadata["sim_runner_kwargs"] = {} for key, value in sim_runner_kwargs.items(): # Cast numpy number types to ints, floats, and reals to avoid # confusing the yaml module. match value: case bool(): opsim_metadata["sim_runner_kwargs"][key] = value case Integral(): opsim_metadata["sim_runner_kwargs"][key] = int(value) case Number(): opsim_metadata["sim_runner_kwargs"][key] = float(value) case _: opsim_metadata["sim_runner_kwargs"][key] = str(value) opsim_metadata["simulated_dates"] = simulation_dates opsim_metadata["files"] = files if len(tags) > 0: for tag in tags: assert isinstance(tag, str), "Tags must be strings." opsim_metadata["tags"] = tags if label is not None: assert isinstance(label, str), "The sim label must be a string." opsim_metadata["label"] = label sim_metadata_fname = data_path.joinpath("sim_metadata.yaml") with open(sim_metadata_fname, "w") as sim_metadata_io: print(yaml.dump(opsim_metadata, indent=4), file=sim_metadata_io) files["metadata"] = {"name": sim_metadata_fname} if data_dir is not None: # If we created a temporary directory, if we do not return it, it # will get automatically cleaned up, losing our work. # So, return it. return data_dir return data_path
[docs] def drive_sim( observatory: ModelObservatory, scheduler: CoreScheduler, label: str | None = None, tags: list = [], script: str | None = None, notebook: str | None = None, opsim_metadata: dict | None = None, **kwargs: Any, ) -> tuple: """Run a simulation and archive the results. Parameters ---------- observatory : `ModelObservatory` The model for the observatory. scheduler : `CoreScheduler` The scheduler to use. archive_uri : `str`, optional The root URI of the archive resource into which the results should be stored. Defaults to None. label : `str`, optional The label for the simulation in the archive. Defaults to None. tags : `list` of `str`, optional The tags for the simulation in the archive. Defaults to an empty list. script : `str` The filename of the script producing this simulation. Defaults to None. notebook : `str`, optional The filename of the notebook producing the simulation. Defaults to None. opsim_metadata : `dict`, optional Extra metadata to store in the archive. Returns ------- observatory : `ModelObservatory` The model for the observatory. scheduler : `CoreScheduler` The scheduler used. observations : `numpy.recarray` The observations produced. reward_df : `pandas.DataFrame`, optional The table of rewards. Present if ``record_rewards`` or ``scheduler.keep_rewards`` is True. obs_rewards : `pandas.Series`, optional The mapping of entries in reward_df to observations. Present if ``record_rewards``` or ``scheduler.keep_rewards`` is True. resource_path : `ResourcePath`, optional The resource path to the archive of the simulation. Present if ``archive_uri`` was set. Notes ----- Additional parameters not described above will be passed into ``sim_runner``. If the ``archive_uri`` parameter is not supplied, ``sim_runner`` is run directly, so that ``drive_sim`` can act as a drop-in replacement of ``sim_runner``. In a jupyter notebook, the notebook can be saved for the notebook paramater using ``%notebook $notebook_fname`` (where ``notebook_fname`` is variable holding the filename for the notebook) in the cell prior to calling ``drive_sim``. """ if "record_rewards" in kwargs: if kwargs["record_rewards"] and not scheduler.keep_rewards: raise ValueError("To keep rewards, scheduler.keep_rewards must be True") else: kwargs["record_rewards"] = scheduler.keep_rewards in_files = {} if script is not None: in_files["script"] = script if notebook is not None: in_files["notebook"] = notebook with TemporaryDirectory() as local_data_dir: LOGGER.debug(f"Using temporary directory {local_data_dir}.") # We want to store the state of the scheduler at the start of # the sim, so we need to save it now before we run the simulation. scheduler_path = Path(local_data_dir).joinpath("scheduler.pickle.xz") with lzma.open(scheduler_path, "wb", format=lzma.FORMAT_XZ) as pio: pickle.dump(scheduler, pio) in_files["scheduler"] = scheduler_path.as_posix() LOGGER.debug("About to call sim_runner.") sim_results = sim_runner(observatory, scheduler, **kwargs) LOGGER.debug("sim_runner complete.") observations = sim_results[2] reward_df = sim_results[3] if scheduler.keep_rewards else None obs_rewards = sim_results[4] if scheduler.keep_rewards else None data_dir = make_sim_data_dir( observations, reward_df=reward_df, obs_rewards=obs_rewards, in_files=in_files, sim_runner_kwargs=kwargs, tags=tags, label=label, opsim_metadata=opsim_metadata, ) resource_path = ResourcePath(data_dir.name, forceDirctory=True) # type: ignore results = sim_results + (resource_path,) return results
[docs] def find_latest_prenight_sim_for_nights( first_day_obs: str | None = None, last_day_obs: str | None = None, tags: tuple[str, ...] = ("ideal", "nominal"), telescope: str = "simonyi", max_simulation_age: int = 2, get_prenight_index_kwargs: dict | None = None, ) -> dict: """Find the most recent prenight simulation that covers a night. Parameters ---------- first_day_obs : `str` or `None` The date of the evening for the first night for which to get a simulation. If `None`, then the current date will be used. last_day_obs : `str` or `None` The date of the evening for the last night for which to get a simulation. If `None`, then the current date will be used. tags : `tuple[str]` A tuple of tags to filter simulations by. Defaults to ``('ideal', 'nominal')``. telescope : `str` The telescope to search for (simonyi or auxtel). Defaults to simonyi. max_simulation_age : `int` The maximum age of simulations to consider, in days. Simulations older than ``max_simulation_age`` will not be considered. Defaults to 2. Returns ------- sim_metadata : `dict` A dictionary with metadata for the simulation. Raises ------ NoMatchingSimulationsFoundError If no prenight simulations match the requested criteria. """ if first_day_obs is None: first_day_obs = datetime.now(ZoneInfo("Etc/GMT+12")).date().isoformat() if last_day_obs is None: last_day_obs = first_day_obs if get_prenight_index_kwargs is None: get_prenight_index_kwargs = {} assert isinstance(get_prenight_index_kwargs, dict) sims_for_first_night = get_prenight_index(first_day_obs, telescope, **get_prenight_index_kwargs) if first_day_obs != last_day_obs: sims_for_last_night = get_prenight_index(last_day_obs, telescope, **get_prenight_index_kwargs) full_range_sims = set(sims_for_first_night.index) & set(sims_for_last_night.index) else: full_range_sims = set(sims_for_first_night.index) result: dict = {} if full_range_sims: candidate_sims = sims_for_first_night.loc[tuple(full_range_sims), :] maybe_result = select_latest_prenight_sim(candidate_sims, tags, max_simulation_age) if maybe_result is not None: assert isinstance(maybe_result, pd.Series) result = maybe_result.to_dict() if not result: raise NoMatchingSimulationsFoundError("No simulations match the requested criteria.") return result
[docs] def fetch_sim_for_nights( first_day_obs: str | None = None, last_day_obs: str | None = None, which_sim: ResourcePath | str | dict | None = None, stackers: Sequence[BaseStacker] = (), get_sim_data_kwargs: dict | None = None, ) -> pd.DataFrame | None: """Fetches visit metadata from an opsim database for specified nights. Parameters ---------- first_day_obs : `str` or `None` The date of the evening for the first night for which to get a simulation. If `None`, then the current date will be used. last_day_obs : `str` or `None` The date of the evening for the last night for which to get a simulation. If `None`, then the current date will be used. which_sim : `ResourcePath` or `str` or `dict` on `None` The ``resourcePath`` or URL of the opsim file from which to load visits, or the arguments to ``find_latest_prenight_sim_for_nights`` to use to determine which simulation to load. ``None`` uses default arguments to ``find_latest_prenight_sim_for_nights``. Defaults to ``None``. stackers : `Sequence[BaseStacker]` A sequence of maf stackers to apply. get_sim_data_kwargs : `dict` Additional arguments to ``get_sim_data`` to use to load the visits. Returns ------- visits : `pd.DataFrame` A pandas DataFrame containing visit parameters. """ if first_day_obs is None: first_day_obs = datetime.now(ZoneInfo("Etc/GMT+12")).date().isoformat() if last_day_obs is None: last_day_obs = first_day_obs opsim_rp: ResourcePath | None = None match which_sim: case ResourcePath(): opsim_rp = which_sim case str(): opsim_rp = ResourcePath(which_sim) case dict(): this_sim = find_latest_prenight_sim_for_nights(first_day_obs, last_day_obs, **which_sim) if len(this_sim) == 0: raise ValueError("No matching simulations found") visitseq_url = this_sim["visitseq_url"] if visitseq_url is not None: opsim_rp = ResourcePath(visitseq_url) elif "opsim" in this_sim["files"]: opsim_rp = ResourcePath(this_sim["files"]["opsim"]) else: raise ValueError("No visits found") case None: this_sim = find_latest_prenight_sim_for_nights(first_day_obs, last_day_obs) if len(this_sim) == 0: raise ValueError("No matching simulations found") visitseq_url = this_sim["visitseq_url"] if visitseq_url is not None: opsim_rp = ResourcePath(visitseq_url) elif "opsim" in this_sim["files"]: opsim_rp = ResourcePath(this_sim["files"]["opsim"]) else: raise ValueError("Ne visits found") case _: raise NotImplementedError() assert isinstance(opsim_rp, ResourcePath) if len(stackers) == 0: if isinstance(get_sim_data_kwargs, dict) and "stackers" in get_sim_data_kwargs: stackers = get_sim_data_kwargs["stackers"] else: stackers = [] assert isinstance(stackers, list) dayobsiso_requested = maf.DayObsISOStacker in [s.__class__ for s in stackers] if not dayobsiso_requested: # We want it to filter out dates that were not requested, # so add it to the stacker even if it was not requested. stackers.append(maf.DayObsISOStacker()) if opsim_rp.getExtension() in (".db", ".sqlite3"): if get_sim_data_kwargs is None: get_sim_data_kwargs = {} assert isinstance(get_sim_data_kwargs, dict) get_sim_data_kwargs["stackers"] = stackers visits_recarray = get_sim_data(opsim_rp, **get_sim_data_kwargs) visits = pd.DataFrame(visits_recarray) else: visits = get_visits(opsim_rp, stackers=stackers) LOGGER.debug(f"Loaded {len(visits)} from {opsim_rp}") on_requested_dates = (first_day_obs <= visits["day_obs_iso8601"]) & ( visits["day_obs_iso8601"] <= last_day_obs ) visits = visits.loc[on_requested_dates, :] # If it dayobsiso was not requested, do not return it. if not dayobsiso_requested: visits.drop(columns="day_obs_iso8601", inplace=True) return visits
[docs] def fetch_obsloctap_visits( day_obs: str | None = None, nights: int = 2, telescope: str = "simonyi", columns: Sequence[str] = ( "observationStartMJD", "fieldRA", "fieldDec", "rotSkyPos", "band", "visitExposureTime", "night", "target_name", ), max_simulation_age: Optional[int] = None, ) -> pd.DataFrame | None: """Return visits from latest nominal prenight briefing simulation. Parameters ---------- day_obs : `str` The day_obs of the night, in YYYY-MM-DD format (e.g. 2025-03-26). Default None will use the date of the next sunset. nights : `int` The number of nights of observations to return. Defaults to 2. telescope : `str` The telescope to get visits for: "simonyi" or "auxtel". Defaults to "simonyi". columns : `Sequence` A sequence of columns from the simulation to include. max_simulation_age : `int` or `None` Age of oldest simulation to consider. Returns ------- visits : `pd.DataFrame` The visits from the prenight simulation. """ # Start with the first night that starts after the reference time, # which is the current time by default. # So, if the reference time is during a night, it starts with the # following night. night_bounds = pd.DataFrame(Almanac().sunsets) current_mjd = Time.now().mjd assert isinstance(current_mjd, float) reference_mjd = (Time.now() if day_obs is None else Time(day_obs, format="iso", scale="utc")).mjd assert isinstance(reference_mjd, float) first_night = night_bounds.query(f"sunset > {reference_mjd}").night.min() last_night = first_night + nights - 1 night_bounds.set_index("night", inplace=True) start_mjd = night_bounds.loc[first_night, "sunset"] assert isinstance(start_mjd, float) end_mjd = night_bounds.loc[last_night, "sunrise"] assert isinstance(end_mjd, float) first_day_obs = Time(start_mjd - 0.5, format="mjd").iso[:10] assert isinstance(first_day_obs, str) last_day_obs = Time(end_mjd - 0.5, format="mjd").iso[:10] assert isinstance(last_day_obs, str) which_sim = { "tags": ("ideal", "nominal"), "telescope": telescope, "max_simulation_age": int(np.ceil(current_mjd - reference_mjd)) + 1, } if max_simulation_age is not None: which_sim["max_simulation_age"] = max_simulation_age visits = fetch_sim_for_nights(first_day_obs, last_day_obs, which_sim=which_sim) if visits is not None: assert isinstance(visits, pd.DataFrame) visits = visits.loc[:, list(columns)] return visits
[docs] def fetch_sim_stats_for_night( day_obs: str | int | None = None, tags: tuple[str, ...] = ("ideal", "nominal"), telescope: str = "simonyi", max_simulation_age: int = 2, host: Optional[str] = None, user: Optional[str] = None, database: Optional[str] = None, schema: Optional[str] = None, prenight_index_path: str | ResourcePath | None = None, ) -> dict: """Count the visits on a night in the latest nominal sim for a night. Parameters ---------- day_obs : `str` or 'int' or `None` Integer dayobs (int(YYYYMMDD)) or ISO string (``YYYY-MM-DD``) or `None` (day_obs including the evening of yesterday in local time). tags : `tuple[str]` A tuple of tags to filter simulations by. Defaults to ``('ideal', 'nominal')``. telescope : `str` The telescope to search for (simonyi or auxtel). Defaults to simonyi. max_simulation_age : `int` The maximum age of simulations to consider, in days. Simulations older than ``max_simulation_age`` will not be considered. Defaults to 2. host : `str` or `None`, optional Database host address. user : `str` or `None`, optional Database user name. database : `str` or `None`, optional Database name. schema : `str` or None, optional The schema in the database to use. prenight_index_path : `str` or `ResourcePath`, optional Root path to the bucket files (used only if the database call fails). Returns ------- sim_stats : `dict` A dict with statistics for the night. Returns an empty dict when no statistics are availble. """ if day_obs is None: day_obs = (date.today() - timedelta(days=1)).isoformat() else: day_obs = dayobs_to_date(day_obs).isoformat() sim = find_latest_prenight_sim_for_nights( day_obs, tags=tags, telescope=telescope, max_simulation_age=max_simulation_age, get_prenight_index_kwargs={ "host": host, "user": user, "database": database, "schema": schema, "prenight_index_path": prenight_index_path, }, ) if "stats" in sim: stats_maybe = sim["stats"] else: LOGGER.info( f"Querying the metadata database (host {host}, user {user}, schema {schema})" f" for stats on {day_obs}" ) metadata_db_kwargs = {} if host is not None: metadata_db_kwargs["host"] = host if user is not None: metadata_db_kwargs["user"] = user if database is not None: metadata_db_kwargs["database"] = database try: vseq_metadata = VisitSequenceArchiveMetadata( metadata_db_kwargs=metadata_db_kwargs, metadata_db_schema=schema ) sims_with_stats = vseq_metadata.sims_on_night_with_stats( day_obs, tags=tags, telescope=telescope, max_simulation_age=max_simulation_age ).set_index("visitseq_uuid") try: stats_maybe = sims_with_stats.loc[sim["visitseq_uuid"], "stats"] except KeyError: stats_maybe = None if stats_maybe is None: LOGGER.info("Stats not found in metadata database.") except psycopg2.OperationalError: LOGGER.info("Cannot connect to metadata database.") stats_maybe = None stats = cast(dict[str, Any], stats_maybe) if isinstance(stats_maybe, dict) else {} if len(stats) == 0 and isinstance(sim["visitseq_url"], str): # We still got nothin'. # Get the visits themselves and compute the stats # from them. LOGGER.info("Computing stats from visits.") visits = get_visits(sim["visitseq_url"]) columns = [c for c in visits.columns if is_numeric_dtype(visits[c])] stats_df = compute_nightly_stats(visits, columns=tuple(columns)) dayobs_stats_df = stats_df.set_index("day_obs").loc[date.fromisoformat(day_obs), :] stats_maybe = dayobs_stats_df.set_index("value_name").to_dict(orient="index") stats = cast(dict[str, Any], stats_maybe) if isinstance(stats_maybe, dict) else {} # We need to report number of nominal visits. # This is not a directly recorded statistic, # but every statistic records the number of # values from which its statitics were calculated, # which is what we want. # Go through the values we have, and get the first # with counts reported. if "nominal_visits" not in stats: for value_name in stats.keys(): value_stats = stats[value_name] if isinstance(value_stats, dict): if "count" in stats[value_name]: stats["nominal_visits"] = int(stats[value_name]["count"]) break # If we still do not have a count of visits, get # the actual visits and count them if "nominal_visits" not in stats: if isinstance(sim["visitseq_url"], str): visits = get_visits(sim["visitseq_url"], stackers=[DayObsISOStacker()]) visits = visits.loc[visits.day_obs_iso8601 == day_obs, :] stats["nominal_visits"] = len(visits) return stats