diff --git a/schedview/__init__.py b/schedview/__init__.py index bb3d7d17..eac573f0 100644 --- a/schedview/__init__.py +++ b/schedview/__init__.py @@ -8,4 +8,4 @@ from .dayobs import DayObs from .sphere import * -from .util import band_column +from .util import DECL_COL, POINTING_COL, RA_COL, band_column diff --git a/schedview/collect/multisim.py b/schedview/collect/multisim.py index 8623f52f..04cdfc9d 100644 --- a/schedview/collect/multisim.py +++ b/schedview/collect/multisim.py @@ -40,7 +40,31 @@ def read_multiple_prenights( Returns ------- visits : `pandas.DataFrame` - Data on the visits. + Data on the visits, with columns generated according to the opsim + schema (see `rubin_scheduler.scheduler.utils.SchemaConverter`), plus + several additional ones providing data on each simulation: + + visitseq_label : + The text label assigned to the simulation when it was added to the + visit sequence archive. + config_url : + The URL of the configuration file used to run the simulation. + scheduler_version : + The version of the scheduler used to run the simulation. + sim_creation_day_obs : + The date (in dayobs timezone) on which the simulation was added + to the simulation archive. + tags : + The list of tags assigned to the simulation in the archive + metadata. + sim_index : + An integer index identifying which simulation the visit destribed + in the row came from. + + All visits associated with all returned simulations are included in + the same ``visits`` ``pd.DataFrame``. To get the visits from only + one simulation of interest, the user needs to filter by the desired + ``sim_index`` value. """ assert HAVE_SIM_ARCHIVE, "Missing optional module " + MISSING_MODULE_ERROR.msg sim_date = DayObs.from_date(sim_date) diff --git a/schedview/compute/__init__.py b/schedview/compute/__init__.py index 185ebcba..15a250eb 100644 --- a/schedview/compute/__init__.py +++ b/schedview/compute/__init__.py @@ -16,15 +16,27 @@ "compute_scalar_metric_at_one_mjd", "compute_scalar_metric_at_mjds", "compute_mixed_scalar_metric", + "compute_obs_sim_offsets", + "compute_offset_stats", + "offsets_of_coord_band", "often_repeated_fields", "count_visits_by_sim", "match_visits_across_sims", "compute_matched_visit_delta_statistics", "munge_sim_archive_metadata", + "find_nearest_pointing_ids", + "combine_completed_with_sims", ] from .astro import compute_sun_moon_positions, convert_evening_date_to_night_of_survey, night_events from .camera import LsstCameraFootprintPerimeter +from .comparesim import ( + combine_completed_with_sims, + compute_obs_sim_offsets, + compute_offset_stats, + find_nearest_pointing_ids, + offsets_of_coord_band, +) from .multisim import ( compute_matched_visit_delta_statistics, count_visits_by_sim, diff --git a/schedview/compute/comparesim.py b/schedview/compute/comparesim.py new file mode 100644 index 00000000..7778143f --- /dev/null +++ b/schedview/compute/comparesim.py @@ -0,0 +1,289 @@ +from functools import partial +from typing import Optional, Tuple + +import astropy.units as u +import numpy as np +import numpy.typing as npt +import pandas as pd +from astropy.coordinates import Angle, SkyCoord + +from .. import DECL_COL, POINTING_COL, RA_COL +from .multisim import match_visits_across_sims + +# Static type checks can get confused by astropy units following +# the u.myunit idiom. Using u.Unit helps them. +DEG: u.Unit = u.Unit("deg") + + +def find_nearest_pointing_ids( + ra: npt.NDArray[np.floating], + decl: npt.NDArray[np.floating], + pointing_ids: npt.NDArray[np.integer], + pointing_ras: npt.NDArray[np.floating], + pointing_decls: npt.NDArray[np.floating], +) -> Tuple[npt.NDArray[np.integer], npt.NDArray[np.floating]]: + """Given arrays of input coordinates and associations of reference + coordinates with ids, return the nearest ids to each input coordinate and + their distances. + + Parameters + ---------- + ra : `numpy.ndarray` of `float` + Right ascension values of input coordinates in degrees. + decl : `numpy.ndarray` of `float` + Declination values of input coordinates in degrees. + pointing_ids : `numpy.ndarray` of `int` + Array of pointing IDs corresponding to the reference pointings. + pointing_ras : `numpy.ndarray` of `float` + Right ascension values of reference pointings in degrees. + pointing_decls : `numpy.ndarray` of `float` + Declination values of reference pointings in degrees. + + Returns + ------- + matched_ids : `numpy.ndarray` of `int` + Array of pointing IDs corresponding to the nearest pointings. + match_separation : `numpy.ndarray` of `float` + Array of angular separations in degrees between input coordinates + and their nearest reference pointings. + """ + + input_coordinates = SkyCoord(ra, decl, unit="deg", frame="icrs") + reference_coords = SkyCoord(pointing_ras, pointing_decls, unit="deg", frame="icrs") + match_index, match_sep, _ = input_coordinates.match_to_catalog_sky(reference_coords) + matched_ids = pointing_ids[match_index] + + match_sep_deg = match_sep.to(DEG).value + return matched_ids, match_sep_deg + + +def combine_completed_with_sims( + simulated_visits: pd.DataFrame, + completed_visits: pd.DataFrame, + scheduler_version: str, + reference_pointings: pd.DataFrame | None = None, + pointing_tolerance: float = 0.002, +) -> pd.DataFrame: + """Combine a DataFrame of simulated visits with one of completed visits. + + Parameters + ---------- + simulated_visits : `pd.DataFrame` + DataFrame containing simulated visits, using the schema returned by + `schedview.collect.multisim.read_multiple_prenights`. This schema + includes columns mapped from the ``opsim`` outputs database, plus + several additional columns including ``sim_index``, which identifies + from which simulations each visit came. + completed_visits : `pd.DataFrame` + DataFrame containing completed (observed) visits, using the schema + returned by `schedview.collect.visits.read_visits`. If reference + pointings are used, columns must include columns designating R.A. and + declination in decimal degrees, and named by `schedview.RA_COL` and + `schedview.DECL_COL`. + scheduler_version : `str` + Version string of the scheduler used to generate the visits. + reference_pointings : `pd.DataFrame` or `None`, optional + DataFrame containing reference pointing coordinates for matching. + If provided, completed visits will be matched to the nearest reference + pointing. Default is None. + pointing_tolerance : `float`, optional + Tolerance in degrees for matching completed visits to reference + pointings. Default is 0.002 degrees. + + Returns + ------- + visits : `pd.DataFrame` + Combined DataFrame of simulated and completed visits with most columns + copied directly from their respective `pd.DataFrame` s of origin. + The rows for the completed visits will be assigned (possibly dummy) + values for ``sim_creation_day_obs``, ``config_url``, and + ``sim_runner_kwargs``. ``label`` will be set to ``Completed`` for + completed visits, and ``sim_index`` to 0. If reference pointings are + provided, the column with the coordinate ID (specified by + `schedview.POINTING_ID`) will be set to the closest available in + the provided ``reference_pointings`` ``pd.DataFrame`` if there are + any within ``pointing_tolerance``. + (Otherwise, they are left unchanged.) + """ + + if 0 in simulated_visits.sim_index.values: + raise ValueError( + "Simulated visits must not include a sim_index of 0, " + "because completed visits will be assign sim_index=0" + ) + + if len(completed_visits) > 0: + completed_visits = completed_visits.copy() + completed_visits["start_date"] = pd.to_datetime( + completed_visits["start_date"], format="ISO8601" + ).dt.tz_localize("UTC") + completed_visits["filter"] = completed_visits["band"] + completed_visits["sim_creation_day_obs"] = None + completed_visits["sim_index"] = 0 + completed_visits["label"] = "Completed" + completed_visits["config_url"] = "" + completed_visits["scheduler_version"] = scheduler_version + completed_visits["sim_runner_kwargs"] = {} + completed_visits.loc[:, "tags"] = len(completed_visits) * [["completed"]] + + if reference_pointings is not None: + nearest_pointing_id, match_separation = find_nearest_pointing_ids( + completed_visits.loc[:, RA_COL].to_numpy(), + completed_visits.loc[:, DECL_COL].to_numpy(), + reference_pointings.index.to_numpy(), + reference_pointings.loc[:, RA_COL].to_numpy(), + reference_pointings.loc[:, DECL_COL].to_numpy(), + ) + match_mask = match_separation < pointing_tolerance + completed_visits.loc[match_mask, POINTING_COL] = nearest_pointing_id[match_mask] + + visits = pd.concat([completed_visits, simulated_visits]) + else: + visits = simulated_visits.copy() + + return visits + + +def offsets_of_coord_band(sim_index: int, visits: pd.DataFrame, obs_index: int = 0) -> pd.DataFrame: + """ + Compute the time offset between a set of observations and a + single simulated visit ``sim_index`` for a given pointing id, band + coordinate pair. + + Parameters + ---------- + sim_index : `int` + The simulation index to compare against the observation (index 0). + visits : `pd.DataFrame` + Table of visits that contains at least the columns ``sim_index`` and + ``start_timestamp``. + + Returns + ------- + offsets: `pd.DataFrame` + A DataFrame with columns ``obs_time``, ``sim_time`` and ``delta`` and + an index level ``sim_index``. + """ + # This function is intended to be run on a DataFrame on a single + # field/band combination, not on the whole set of visits, e.g. for a night. + for col in ("band", POINTING_COL): + if col in visits.columns: + assert len(visits[col].unique()) == 1 + + offsets = match_visits_across_sims(visits.set_index("sim_index").start_timestamp, (obs_index, sim_index)) + + # Normalize the order and sense of the offset + if offsets.columns[0] == obs_index: + offsets["delta"] = -1 * offsets["delta"] + else: + offsets = offsets[[obs_index, sim_index, "delta"]] + + assert offsets.columns[0] == obs_index + assert offsets.columns[1] == sim_index + assert offsets.columns[2] == "delta" + assert len(offsets.columns) == 3 + offsets.columns = pd.Index(data=["obs_time", "sim_time", "delta"]) + offsets["sim_index"] = sim_index + offsets = offsets.set_index("sim_index") + + return offsets + + +def compute_obs_sim_offsets( + visits: pd.DataFrame, + obs_index: int = 0, +) -> pd.DataFrame: + """ + Build a table of offsets for all simulated/completed pairs of visits. + + Parameters + ---------- + visits : `pd.DataFrame` + Table of visits with at least the columns ``sim_index``, + the value of `schedview.POINTING_COL` and ``band``. + obs_index : `int`, optional + ``sim_index`` value for completed (observed) visits (default = 0). + + Returns + ------- + sim_offsets : `pd.DataFrame` + Offsets for every simulated visit, indexed by ``sim_index``, + the value of `schedview.POINTING_COL` and ``band``. + """ + sim_indexes = visits.sim_index.unique() + sim_indexes = sim_indexes[sim_indexes != obs_index] + + sim_offsets = [] + for i in sim_indexes: + sim_offsets.append( + visits.set_index([POINTING_COL, "band"]) + .groupby([POINTING_COL, "band"]) + .apply(partial(offsets_of_coord_band, i)) + .reset_index() + ) + + offsets = pd.concat(sim_offsets, ignore_index=True).set_index(["sim_index", POINTING_COL, "band"]) + + return offsets + + +def compute_offset_stats( + offsets: pd.DataFrame, + visits: Optional[pd.DataFrame] = None, + hhmmss: bool = False, +) -> pd.DataFrame: + """ + Produce a summary table of time offsets between completed and simulated + visits. + + Parameters + ---------- + offsets : `pd.DataFrame` + Result of :func:`compute_obs_sim_offsets`. Must contain a ``delta`` + column and a ``sim_index`` level in the index. + visits : `pd.DataFrame`, optional + The original visits table, required only if observation counts or + labels should be included in the returned ``DataFrame``. + hhmmss : `bool`, optional + If ``True``, convert the numeric statistics (mean, std, etc.) from + seconds to an ``HH:MM:SS`` string representation. + + Returns + ------- + offset_stats : `pd.DataFrame` + A table where each row corresponds to a ``sim_index`` and columns + include match counts, MAD, and the usual descriptive statistics. + """ + abs_delta = offsets["delta"].abs() + abs_delta.name = "abs_delta" + + offset_stats = offsets.groupby("sim_index")["delta"].describe() + offset_stats.insert(0, "match count", offset_stats["count"].astype(int)) + offset_stats.insert(1, "MAD", abs_delta.to_frame().groupby("sim_index")["abs_delta"].median()) + + if visits is not None: + visit_counts = visits.groupby("sim_index").agg({"label": "count"}).rename(columns={"label": "counts"}) + offset_stats.insert( + 0, + "obs count", + pd.Series( + np.full_like(offset_stats["count"], visit_counts.loc[0]).astype(int), index=offset_stats.index + ), + ) + offset_stats.insert(1, "sim count", visit_counts["counts"]) + offset_stats.insert(3, "#match/#obs", (offset_stats["count"] / offset_stats["obs count"]).round(2)) + offset_stats.insert(4, "#match/#sim", (offset_stats["count"] / offset_stats["sim count"]).round(2)) + + offset_stats.drop(columns="count", inplace=True) + + if hhmmss: + for column in ["MAD", "mean", "std", "min", "25%", "50%", "75%", "max"]: + raw_values = offset_stats.loc[:, column].to_numpy() + offset_stats.loc[:, column] = Angle((raw_values.astype(np.int64) / 3600) * u.hour).to_string( + unit=u.hour, sep=":" + ) + + if visits is not None and "label" in visits.columns: + offset_stats.insert(0, "label", visits.groupby("sim_index")["label"].first().to_frame()["label"]) + + return offset_stats diff --git a/schedview/plot/__init__.py b/schedview/plot/__init__.py index 60025125..fc37d39f 100644 --- a/schedview/plot/__init__.py +++ b/schedview/plot/__init__.py @@ -3,6 +3,7 @@ "plot_infeasible", "plot_airmass_vs_time", "plot_alt_vs_time", + "plot_obs_vs_sim_time", "plot_polar_alt_az", "plot_survey_rewards", "create_survey_reward_plot", @@ -42,6 +43,7 @@ from .cadence import create_cadence_plot from .colors import LIGHT_EXTRA_COLORS, LIGHT_PLOT_BAND_COLORS, PLOT_BAND_COLORS, make_band_cmap +from .comparesim import plot_obs_vs_sim_time from .multisim import generate_sim_indicators, overplot_kernel_density_estimates from .nightbf import plot_infeasible, plot_rewards from .nightly import plot_airmass_vs_time, plot_alt_vs_time, plot_polar_alt_az diff --git a/schedview/plot/comparesim.py b/schedview/plot/comparesim.py new file mode 100644 index 00000000..c4115df7 --- /dev/null +++ b/schedview/plot/comparesim.py @@ -0,0 +1,87 @@ +from typing import List, Optional, Tuple + +import bokeh +import bokeh.layouts +import bokeh.models +import bokeh.plotting +import numpy as np +import pandas as pd + +from schedview.plot.colors import make_band_cmap + + +def plot_obs_vs_sim_time( + offsets: pd.DataFrame, tooltips: List[Tuple[str, str]], plot: Optional[bokeh.plotting.figure] = None +) -> bokeh.models.UIElement: + """ + Plot observation times vs simulation times for visits. + + Parameters + ---------- + offsets : `pd.DataFrame` + DataFrame containing visit timing information with columns including + 'obs_time', 'sim_time', 'sim_index', and 'label' as well as any + columns needed for the tooltips. + tooltips : `List[Tuple[str, str]]` + List of tuples defining the tooltip content for hover interactions. + plot : `bokeh.plotting.figure`, optional + Existing bokeh figure to use. If ``None``, a new figure is created. + + Returns + ------- + obs_vs_sim_time_plot : `bokeh.models.UIElement` + A bokeh UI element containing the simulation selector and the plot. + """ + if plot is None: + plot = bokeh.plotting.figure(frame_width=1024, frame_height=512) + + offsets_plot_df = offsets.reset_index().set_index("sim_index") + offsets_plot_df.reset_index(inplace=True) + default_sim_id = offsets_plot_df.sim_index.min() + offsets_plot_df["sim_alpha"] = np.where(offsets_plot_df.sim_index == default_sim_id, 1, 0) + + source = bokeh.models.ColumnDataSource(offsets_plot_df) + + matching_line = bokeh.models.Slope(gradient=1, y_intercept=0, line_color="gray", line_width=1) + plot.add_layout(matching_line) + + scatter_renderer = plot.scatter( + "sim_time", "obs_time", color=make_band_cmap("band"), alpha="sim_alpha", source=source + ) + plot.xaxis[0].formatter = bokeh.models.DatetimeTickFormatter(hours="%H:%M") + plot.xaxis[0].axis_label = "visit start time in simulation" + plot.yaxis[0].formatter = bokeh.models.DatetimeTickFormatter(hours="%H:%M") + plot.yaxis[0].axis_label = "visit start time as completed" + + hover_tool = bokeh.models.HoverTool( + renderers=[scatter_renderer], + tooltips=tooltips, + formatters={"@obs_time": "datetime", "@sim_time": "datetime"}, + ) + plot.add_tools(hover_tool) + + if "label" not in source.column_names: + raise ValueError("A sim selector needs the label column") + sim_labels = offsets.groupby("sim_index")["label"].first().to_frame() + default_sim = sim_labels.loc[default_sim_id, "label"] + sim_selector = bokeh.models.Select( + value=default_sim, options=sim_labels.label.to_list(), name="simselect" + ) + + sim_selector_callback = bokeh.models.CustomJS( + args={"source": source}, + code=""" + for (let i = 0; i < source.data['label'].length; i++) { + if (['All', source.data['label'][i]].includes(this.value)) { + source.data['sim_alpha'][i] = 1.0; + } else { + source.data['sim_alpha'][i] = 0.0; + } + } + source.change.emit() + """, + ) + sim_selector.js_on_change("value", sim_selector_callback) + + ui_element = bokeh.layouts.column([sim_selector, plot]) + return ui_element diff --git a/schedview/plot/visits.py b/schedview/plot/visits.py index 19eb0b7f..671aa052 100644 --- a/schedview/plot/visits.py +++ b/schedview/plot/visits.py @@ -176,6 +176,7 @@ def plot_visit_param_vs_time( offered_columns: Iterable[str] = tuple(), num_plots: int = 1, user_figure_kwargs: dict | None = None, + default_sim: str = "All", **kwargs, ) -> bokeh.models.ui.ui_element.UIElement: """Plot a column in the visit table vs. time. @@ -200,8 +201,10 @@ def plot_visit_param_vs_time( A list of columns to be offered in the dropdown selector. num_plots: int Number of parallel timelines.. - user_figure_kwargs: dict on None + user_figure_kwargs: dict or None Arguments passed along to bokeh.plotting.figure + default_sim: str + Label of default sim to show (defaults to ``All``) **kwargs Additional keyword arguments to be passed to `bokeh.plotting.figure.scatter`. @@ -251,7 +254,13 @@ def plot_visit_param_vs_time( source.data["label"] = np.full_like(source.data["start_timestamp"], "") if "sim_alpha" not in source.data: - source.data["sim_alpha"] = np.full_like(source.data["start_timestamp"], 1.0, dtype=np.float64) + if default_sim == "All": + source.data["sim_alpha"] = np.full_like(source.data["start_timestamp"], 0.2, dtype=np.float64) + else: + source.data["sim_alpha"] = np.full_like(source.data["start_timestamp"], 0.0, dtype=np.float64) + source.data["sim_alpha"][source.data["label"] == default_sim] = 0.2 + + source.data["sim_alpha"][source.data["label"] == "Completed"] = 1.0 scatter_kwargs = {"fill_alpha": 0.0, "line_alpha": "sim_alpha", "name": "timeline"} @@ -403,7 +412,8 @@ def plot_visit_param_vs_time( if "label" not in source.column_names: raise ValueError("A sim selector needs the label column") options = ["All"] + list(o for o in np.unique(source.data["label"]) if o != "Completed") - default_sim = "All" + if default_sim not in options: + raise ValueError(f"Default sim must be one of {options}") sim_selector = bokeh.models.Select(value=default_sim, options=options, name="simselect") sim_selector_callback = bokeh.models.CustomJS( @@ -413,7 +423,7 @@ def plot_visit_param_vs_time( if (source.data['label'][i] === 'Completed') { source.data['sim_alpha'][i] = 1.0; } else if (['All', source.data['label'][i]].includes(this.value)) { - source.data['sim_alpha'][i] = 0.8; + source.data['sim_alpha'][i] = 0.2; } else { source.data['sim_alpha'][i] = 0.0; } diff --git a/schedview/util.py b/schedview/util.py index 8ab959b4..461fefce 100644 --- a/schedview/util.py +++ b/schedview/util.py @@ -1,10 +1,14 @@ import logging import os +RA_COL = "fieldRA" +DECL_COL = "fieldDec" +POINTING_COL = "pointing_id" + BAND_COLUMN_NAME_CANDIDATES = ("band", "filter") -def band_column(visits): +def band_column(visits=None): """Guess the name of the column with band inforamition. Parameters @@ -17,9 +21,10 @@ def band_column(visits): column_name : `str` The name of the column that contains the band information. """ - for band in BAND_COLUMN_NAME_CANDIDATES: - if band in visits.columns: - return band + if visits is not None: + for band in BAND_COLUMN_NAME_CANDIDATES: + if band in visits.columns: + return band return BAND_COLUMN_NAME_CANDIDATES[0] diff --git a/tests/test_compute_comparesim.py b/tests/test_compute_comparesim.py new file mode 100644 index 00000000..0ececd8d --- /dev/null +++ b/tests/test_compute_comparesim.py @@ -0,0 +1,309 @@ +import unittest +from typing import Tuple + +import healpy as hp +import numpy as np +import numpy.typing as npt +import pandas as pd + +from schedview import DECL_COL, POINTING_COL, RA_COL +from schedview.compute.comparesim import ( + combine_completed_with_sims, + compute_obs_sim_offsets, + compute_offset_stats, + find_nearest_pointing_ids, + offsets_of_coord_band, +) + +RANDOM_NUMBER_GENERATOR = np.random.default_rng(6563) + + +class TestOffsetOfCoordBand(unittest.TestCase): + + def setUp(self): + self.num_test_visits = 10 + + # generate a set of self.num_test_visits times separated by 500 to 600 + # seconds, and generate a dataframe self.sim_visits with those times. + start_time = pd.Timestamp("2027-01-01 00:00:00", tz="UTC") + time_diffs = np.random.uniform(500, 600, self.num_test_visits) + times = [ + start_time + pd.Timedelta(seconds=np.sum(time_diffs[:i])) for i in range(self.num_test_visits) + ] + self.sim_visits = pd.DataFrame( + { + "start_timestamp": times, + RA_COL: [10.0] * self.num_test_visits, + DECL_COL: [0.0] * self.num_test_visits, + "band": ["r"] * self.num_test_visits, + } + ) + + # randomly but repeatably generate a set of self.num_test_visits + # offsets between -60 and 60 seconds, self.offsets, and apply these to + # self.sim_visits to get self.obs_visits + self.offsets = RANDOM_NUMBER_GENERATOR.uniform(-60, 60, self.num_test_visits) + self.obs_visits = self.sim_visits.copy() + self.obs_visits["start_timestamp"] = self.obs_visits["start_timestamp"] + pd.to_timedelta( + self.offsets, unit="s" + ) + self.obs_visits["sim_index"] = [0] * self.num_test_visits + + def test_equal_numbers(self): + visits = pd.concat( + [self.sim_visits.assign(sim_index=0), self.obs_visits.assign(sim_index=1)], ignore_index=True + ) + + # Verify that we can recover the time offsets + result = offsets_of_coord_band(0, visits, 1) + np.testing.assert_array_almost_equal(result["delta"].to_numpy(), self.offsets, decimal=4) + + # Verify that we can reverse the sign + result = offsets_of_coord_band(1, visits, 0) + np.testing.assert_array_almost_equal(result["delta"].to_numpy(), -1 * self.offsets, decimal=4) + + def test_missing_some(self): + dropped_indexes = RANDOM_NUMBER_GENERATOR.choice(len(self.sim_visits), 3, replace=False) + kept_indexes = np.setdiff1d(np.arange(len(self.sim_visits)), dropped_indexes) + remaining_offsets = self.offsets[kept_indexes] + + reduced_sim_visits = self.sim_visits.drop(dropped_indexes).reset_index(drop=True) + + visits = pd.concat( + [reduced_sim_visits.assign(sim_index=0), self.obs_visits.assign(sim_index=1)], ignore_index=True + ) + + # Visits that weren't dropped should be recoverable + result = offsets_of_coord_band(0, visits, 1) + np.testing.assert_array_almost_equal(result["delta"].to_numpy(), remaining_offsets, decimal=4) + + # Reverse sim and obs, and see if it still works + result = offsets_of_coord_band(1, visits, 0) + np.testing.assert_array_almost_equal(result["delta"].to_numpy(), -1 * remaining_offsets, decimal=4) + + +def _generate_test_visits( + num_visits_per_hpid_band, num_hpid_band, num_sims +) -> Tuple[pd.DataFrame, npt.NDArray, pd.DataFrame]: + """Generate visits DataFrame for testing.""" + + # Generate a pandas.DataFrame, hpid_band_df, of length + # num_hpid_band, + # with fieldRA, fieldDec, band, and fieldHpid corresponding to + # fieldRA and fieldDec with healpy nside 32 + # fieldRA and fieldDec should be randomly but repeatably generate. + # band should be randomly but repeatably selected from u, g, r, i, z, y + nside = 32 + hpid_band_df = pd.DataFrame() + hpid_band_df[POINTING_COL] = RANDOM_NUMBER_GENERATOR.integers(0, hp.nside2npix(nside), size=num_hpid_band) + hpid_band_df[RA_COL], hpid_band_df[DECL_COL] = hp.pix2ang(nside, hpid_band_df[POINTING_COL], lonlat=True) + + # Generate random bands from u, g, r, i, z, y + bands = ["u", "g", "r", "i", "z", "y"] + hpid_band_df["band"] = RANDOM_NUMBER_GENERATOR.choice(bands, size=num_hpid_band) + + # Generate a set of simulations + sim_dfs = [] + start_time = pd.Timestamp("2027-01-01 00:00:00", tz="UTC") + for sim_index in range(1, num_sims + 1): + sim_df = pd.DataFrame() + visit_data = [] + start_timestamp = start_time + + # in this simulation, observe each hpid_band + for _, hpid_band in hpid_band_df.iterrows(): + for time_diff in RANDOM_NUMBER_GENERATOR.uniform(500, 600, num_visits_per_hpid_band): + start_timestamp = start_timestamp + pd.Timedelta(seconds=time_diff) + visit_data.append( + { + "sim_index": sim_index, + "start_timestamp": start_timestamp, + RA_COL: hpid_band[RA_COL], + DECL_COL: hpid_band[DECL_COL], + "band": hpid_band["band"], + POINTING_COL: hpid_band[POINTING_COL], + "label": f"Sim {sim_index}", + } + ) + + sim_df = pd.DataFrame(visit_data) + sim_dfs.append(sim_df) + + # Generate an obs_df with is similar to the first sim, but with offsets + obs_df = sim_dfs[0].copy() + offsets = RANDOM_NUMBER_GENERATOR.uniform(-6, 6, len(obs_df)) + obs_df["start_timestamp"] = obs_df["start_timestamp"] + pd.to_timedelta(offsets, unit="s") + obs_df["sim_index"] = 0 + obs_df["label"] = "Completed" + + visits = pd.concat([obs_df] + sim_dfs, ignore_index=True) + return visits, offsets, hpid_band_df + + +class TestComputeObsSimOffsets(unittest.TestCase): + + def setUp(self): + self.num_visits_per_hpid_band = 5 + self.num_hpid_band = 3 + self.num_sims = 3 + self.visits, self.offsets, self.hpid_band_df = _generate_test_visits( + self.num_visits_per_hpid_band, self.num_hpid_band, self.num_sims + ) + + def test_compute_obs_sim_offsets_basic(self): + # Test basic functionality of compute_obs_sim_offsets + result = compute_obs_sim_offsets(self.visits, obs_index=0) + + self.assertIsNotNone(result) + self.assertIn("delta", result.columns) + + # Test that we recover offsets + sim_1_result = result.loc[1, :] + assert isinstance(sim_1_result, pd.DataFrame) + self.assertEqual(len(self.visits.query("sim_index == 1")), len(sim_1_result)) + np.testing.assert_array_almost_equal( + sim_1_result.sort_values(by=["sim_time"]).loc[:, "delta"].to_numpy(), + self.offsets, + decimal=4, + ) + + self.assertTrue(isinstance(result.index, pd.MultiIndex)) + self.assertEqual(result.index.names, ["sim_index", POINTING_COL, "band"]) + + self.assertEqual(tuple(result.columns), ("obs_time", "sim_time", "delta")) + self.assertEqual(str(result.obs_time.dtype), "datetime64[ns, UTC]") + self.assertEqual(str(result.sim_time.dtype), "datetime64[ns, UTC]") + self.assertEqual(str(result.delta.dtype), "float64") + + # Should have at least one entry for each simulation + sim_indexes = result.index.get_level_values("sim_index").unique() + assert set(sim_indexes) == set(range(self.num_sims + 1)) - set([0]) + + +class TestComputeOffsetStats(unittest.TestCase): + + def setUp(self): + self.num_visits_per_hpid_band = 5 + self.num_hpid_band = 3 + self.num_sims = 3 + self.visits, self.offset_dt, self.hpid_band_df = _generate_test_visits( + self.num_visits_per_hpid_band, self.num_hpid_band, self.num_sims + ) + self.offsets = compute_obs_sim_offsets(self.visits, obs_index=0) + + def test_compute_obs_sim_offsets_basic(self): + offset_stats = compute_offset_stats(self.offsets, self.visits) + assert len(offset_stats) == self.num_sims + assert offset_stats.loc[1, "sim count"] == self.num_visits_per_hpid_band * self.num_hpid_band + offset_dt_stats = pd.Series(self.offset_dt).describe() + for col in offset_dt_stats.index[1:]: + self.assertAlmostEqual(offset_dt_stats[col], offset_stats.loc[1, col], 5) + + +class TestFindNearestPointingIds(unittest.TestCase): + + def test_find_nearest_pointing_ids(self): + pointing_ids = np.arange(3) + pointing_ras = np.array([0.0, 10.0, 20.0]) + pointing_decls = np.array([0.0, 5.0, 10.0]) + + # Reference pointing coordinates (ids, ra, dec) - with some offset + ra = pointing_ras.copy() + decl = pointing_decls + 0.1 + + # Call the function + matched_ids, match_separation = find_nearest_pointing_ids( + ra, decl, pointing_ids, pointing_ras, pointing_decls + ) + + # Verify results - should match to nearest point + expected_ids = np.arange(3) + expected_separation = np.array([0.1, 0.1, 0.1]) + + np.testing.assert_array_equal(matched_ids, expected_ids) + np.testing.assert_array_almost_equal(match_separation, expected_separation, decimal=10) + + +class TestComputeCommonFractions(unittest.TestCase): + + def setUp(self): + # Create a simple test visit counts DataFrame + # This simulates what would come from count_visits_by_sim + + self.visit_counts = pd.DataFrame( + { + POINTING_COL: [100, 101, 102, 103, 100, 101, 102, 102], + "band": ["u", "u", "u", "u", "g", "g", "g", "g"], + 0: [1, 1, 1, 1, 2, 2, 0, 0], + 1: [1, 1, 1, 1, 2, 2, 0, 0], + 2: [2, 2, 1, 1, 2, 2, 0, 0], + 3: [1, 1, 1, 1, 1, 1, 1, 1], + } + ).set_index([POINTING_COL, "band"]) + + self.num_sims = len(self.visit_counts.columns) - 1 + + # Create sim_labels Series + self.sim_labels = pd.Series( + ["Completed", "Sim 1", "Sim 2", "Sim 3"], index=[0, 1, 2, 3], name="label" + ) + + +class TestCombineCompletedWithSims(unittest.TestCase): + + def setUp(self): + self.simulated_visits = pd.DataFrame( + { + "start_timestamp": [ + pd.Timestamp("2027-01-01 00:00:00", tz="UTC"), + pd.Timestamp("2027-01-01 01:00:00", tz="UTC"), + ], + RA_COL: [10.0, 20.0], + DECL_COL: [0.0, 5.0], + "band": ["r", "g"], + "sim_index": [1, 2], + } + ) + + self.completed_visits = pd.DataFrame( + { + "start_date": ["2027-01-01 00:30:00", "2027-01-01 02:00:00"], + "band": ["r", "g"], + RA_COL: [10.1, 20.1], + DECL_COL: [0.1, 5.1], + } + ) + + def test_combine_completed_with_sims_basic(self): + + result = combine_completed_with_sims( + simulated_visits=self.simulated_visits, + completed_visits=self.completed_visits, + scheduler_version="test_version", + ) + + # Check that we have the right number of rows + self.assertEqual(len(result), 4) # 2 simulated + 2 completed + + # Check that completed visits have correct values + completed_rows = result.loc[result["sim_index"] == 0, :] + self.assertEqual(len(completed_rows), 2) + self.assertTrue(np.all(completed_rows["label"] == "Completed")) + self.assertTrue(np.all(completed_rows["scheduler_version"] == "test_version")) + self.assertTrue(np.all(completed_rows["config_url"] == "")) + + # Check that simulated visits are preserved + simulated_rows = result.loc[result["sim_index"] > 0, :] + self.assertEqual(len(simulated_rows), 2) + self.assertTrue(np.all(simulated_rows["sim_index"].isin([1, 2]))) + + def test_combine_completed_with_sims_no_completed(self): + + result = combine_completed_with_sims( + simulated_visits=self.simulated_visits, + completed_visits=pd.DataFrame(), + scheduler_version="test_version", + ) + + # Should just return simulated visits + self.assertEqual(len(result), len(self.simulated_visits)) diff --git a/tests/test_plot_comparesim.py b/tests/test_plot_comparesim.py new file mode 100644 index 00000000..99732f23 --- /dev/null +++ b/tests/test_plot_comparesim.py @@ -0,0 +1,55 @@ +from datetime import datetime +from unittest import TestCase + +import bokeh.models +import bokeh.plotting +import pandas as pd + +import schedview.plot.comparesim + + +class TestPlotCompareSim(TestCase): + + def setUp(self): + # Create test data that mimics what would come from + # compute_obs_sim_offsets + # Do not worry about making delta correspond to obs and sim times, + # because it does not matter here. + obs_times = [datetime(2030, 1, 1, hr, 0, 0) for hr in range(6)] + sim_times = [datetime(2030, 1, 1, hr, 15, 0) for hr in range(6)] + self.test_offsets = pd.DataFrame( + { + "obs_time": obs_times, + "sim_time": sim_times, + "sim_index": [0, 0, 0, 1, 1, 1], + "label": ["Completed", "Completed", "Completed", "Sim 1", "Sim 1", "Sim 1"], + "fieldRA": [10.0, 20.0, 30.0, 10.0, 20.0, 30.0], + "fieldDec": [0.0, 10.0, 20.0, 0.0, 10.0, 20.0], + "band": ["u", "g", "r", "u", "g", "r"], + "delta": [0, 0, 0, 0, 0, 0], + } + ).set_index(["sim_index", "fieldRA", "fieldDec"]) + + def test_plot_obs_vs_sim_time_basic(self): + """Test basic functionality of plot_obs_vs_sim_time.""" + tooltips = [ + ("Observation Time", "@obs_time{%F %H:%M}"), + ("Simulation Time", "@sim_time{%F %H:%M}"), + ("Band", "@band"), + ] + + result = schedview.plot.comparesim.plot_obs_vs_sim_time(self.test_offsets, tooltips) + + # Check that first child is a Select widget (sim selector) + self.assertIsInstance(result.children[0], bokeh.models.Select) + + # Check that second child is a bokeh figure + plot = result.children[1] + self.assertIsInstance(plot, bokeh.plotting.figure) + + # Check that tooltips were properly set up + self.assertGreater(len(plot.tools), 0) + + # Check that there are data points + source = plot.renderers[0].data_source + self.assertGreater(len(source.data["obs_time"]), 0)