From 36ac60d5a9271b54732bf16626e2d25740378841 Mon Sep 17 00:00:00 2001 From: Lynne Jones Date: Thu, 25 Jun 2026 01:59:29 -0700 Subject: [PATCH 1/2] Add function to fetch dayobs-downtime summary --- schedview/collect/__init__.py | 2 + schedview/collect/observatory_state.py | 48 +++++++++ tests/test_observatory_state.py | 134 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 schedview/collect/observatory_state.py create mode 100644 tests/test_observatory_state.py diff --git a/schedview/collect/__init__.py b/schedview/collect/__init__.py index 84c8aae5..fa92918d 100644 --- a/schedview/collect/__init__.py +++ b/schedview/collect/__init__.py @@ -19,6 +19,7 @@ "read_visits", "sample_pickle", "sync_query_efd_topic_for_night", + "get_observatory_status_summary", ] @@ -67,6 +68,7 @@ def read_multiple_prenights(*args, **kwargs): from .nightreport import get_night_narrative, get_night_report +from .observatory_state import get_observatory_status_summary from .opsim import read_ddf_visits, read_opsim from .resources import find_file_resources from .rewards import read_rewards diff --git a/schedview/collect/observatory_state.py b/schedview/collect/observatory_state.py new file mode 100644 index 00000000..94cfcfa0 --- /dev/null +++ b/schedview/collect/observatory_state.py @@ -0,0 +1,48 @@ +import pandas as pd +import rubin_nights.dayobs_utils as rn_dayobs +import rubin_nights.observatory_status as obs_status +from astropy.time import TimeDelta +from rubin_nights.influx_query import InfluxQueryClient + +import schedview.clientsite + +from .auth import get_auth + + +def get_observatory_status_summary(day_obs: int, day_obs_max: int | None) -> pd.DataFrame: + """Return a summary of observatory status (WEATHER, DOWNTIME, etc). + + Parameters + ---------- + day_obs : `int` + Day_obs (YYYYMMDD) to start query. + day_obs_max : `int` or `None` + Day_obs (YYYYMMDD) to end query. If None, query only the first day_obs. + + Returns + ------- + obs_status_summary : `pd.DataFrame` + Dataframe with index `day_obs` and columns including `weather_down` + indicating the hours of weather downtime during the night. + """ + + # Make some assumptions that ought to work in an RSP environment, + # or user's env variables match schedview's expectations, + # e.g. tokenfile set in env variable ACCESS_TOKEN_FILE. + site = schedview.clientsite.EFD_NAME.replace("_efd", "") + auth = get_auth() + + # Remind me to come back and make this context manager to ensure close. + efd_client = InfluxQueryClient(site=site, db_name="efd", repertoire_site=site, auth=auth) + + t_start = rn_dayobs.day_obs_to_time(day_obs) + if day_obs_max is not None: + t_end = rn_dayobs.day_obs_to_time(day_obs_max) + TimeDelta(1, format="jd") + else: + t_end = t_start + TimeDelta(1, format="jd") + obs_status_times = obs_status.get_observatory_state_times(t_start, t_end, efd_client=efd_client) + obs_status_times, obs_status_summary = obs_status.count_observatory_states( + obs_status_times, dome_open=None + ) + + return obs_status_summary diff --git a/tests/test_observatory_state.py b/tests/test_observatory_state.py new file mode 100644 index 00000000..7daa4a89 --- /dev/null +++ b/tests/test_observatory_state.py @@ -0,0 +1,134 @@ +import os +import unittest +from unittest import mock + +import pandas as pd + +from schedview.collect import get_observatory_status_summary + +# Set TEST_WITH_EFD=T (or TRUE/1) to run the test against the live EFD web +# service. It is skipped by default so automated CI does not need EFD access. +USE_EFD = os.environ.get("TEST_WITH_EFD", "F").upper() in ("T", "TRUE", "1") + + +class TestGetObservatoryStatusSummary(unittest.TestCase): + test_day_obs = 20260604 + test_day_obs_max = 20260605 + + def test_get_observatory_status_summary_mocked(self): + """Unit test with the EFD web service fully mocked out.""" + + # The summary that count_observatory_states would return: one row per + # night, indexed by day_obs, with a breakdown of downtime hours. + expected_summary = pd.DataFrame( + { + "sunset12": pd.to_datetime(["2026-06-04 22:45:42.147656"]), + "sunrise12": pd.to_datetime(["2026-06-05 10:37:19.272813"]), + "night_hours": [11.860312543809414], + "weather_down": [1.9343212781941594], + "downtime_down": [0.0], + "fault_down": [0.03295169055555558], + "operational_hours": [9.893039575059698], + "idle_down": [1.9343212781941594], + "unknown_down": [0.0], + }, + index=pd.Index([20260604], name="day_obs"), + ) + # get_observatory_state_times returns a table of state intervals, one + # row per (night, state) with start/end times and duration in hours. + # count_observatory_states is also mocked, so these contents only need + # to be shape-realistic. + fake_state_times = pd.DataFrame( + { + "day_obs": [20260604, 20260604, 20260604, 20260604], + "sunset12": pd.to_datetime(["2026-06-04 22:45:42.147656"] * 4), + "sunrise12": pd.to_datetime(["2026-06-05 10:37:19.272813"] * 4), + "start": pd.to_datetime( + [ + "2026-06-04 22:45:42.147656", + "2026-06-05 08:39:17.090126", + "2026-06-05 08:41:15.716212", + "2026-06-05 08:41:15.716212", + ] + ), + "end": pd.to_datetime( + [ + "2026-06-05 08:39:17.090126", + "2026-06-05 08:41:15.716212", + "2026-06-05 10:37:19.272813", + "2026-06-05 10:37:19.272813", + ] + ), + "hours": [9.893039575059698, 0.03295169055555558, 1.9343212781941594, 1.9343212781941594], + "type": ["OPERATIONAL", "FAULT", "WEATHER", "IDLE"], + } + ) + + module = "schedview.collect.observatory_state" + with ( + mock.patch(f"{module}.get_auth", return_value=("user", "fake-token")), + mock.patch(f"{module}.InfluxQueryClient") as mock_client, + mock.patch( + f"{module}.obs_status.get_observatory_state_times", + return_value=fake_state_times, + ) as mock_get_times, + mock.patch( + f"{module}.obs_status.count_observatory_states", + return_value=(fake_state_times, expected_summary), + ) as mock_count, + ): + result = get_observatory_status_summary(self.test_day_obs, self.test_day_obs_max) + + # No real web service was contacted: the client was constructed but its + # network methods were never called directly by our function. + mock_client.assert_called_once() + mock_get_times.assert_called_once() + mock_count.assert_called_once() + + # efd_client passed to get_observatory_state_times is the mocked one. + _, get_times_kwargs = mock_get_times.call_args + self.assertIs(get_times_kwargs["efd_client"], mock_client.return_value) + + self.assertIsInstance(result, pd.DataFrame) + self.assertIn("weather_down", result.columns) + pd.testing.assert_frame_equal(result, expected_summary) + + def test_single_day_obs_mocked(self): + """day_obs_max=None should query a single night.""" + expected_summary = pd.DataFrame( + { + "night_hours": [11.860312543809414], + "weather_down": [1.9343212781941594], + "downtime_down": [0.0], + "fault_down": [0.03295169055555558], + "operational_hours": [9.893039575059698], + "idle_down": [1.9343212781941594], + "unknown_down": [0.0], + }, + index=pd.Index([20260604], name="day_obs"), + ) + module = "schedview.collect.observatory_state" + with ( + mock.patch(f"{module}.get_auth", return_value=("user", "fake-token")), + mock.patch(f"{module}.InfluxQueryClient"), + mock.patch(f"{module}.obs_status.get_observatory_state_times", return_value=pd.DataFrame()), + mock.patch( + f"{module}.obs_status.count_observatory_states", + return_value=(pd.DataFrame(), expected_summary), + ) as mock_count, + ): + result = get_observatory_status_summary(self.test_day_obs, None) + + mock_count.assert_called_once() + self.assertIsInstance(result, pd.DataFrame) + + @unittest.skipUnless(USE_EFD, "Skipping test that requires live EFD access") + def test_get_observatory_status_summary_live(self): + """Run against the real EFD web service (skipped in automated CI).""" + result = get_observatory_status_summary(self.test_day_obs, self.test_day_obs_max) + self.assertIsInstance(result, pd.DataFrame) + self.assertIn("weather_down", result.columns) + + +if __name__ == "__main__": + unittest.main() From 27d849307723f7df5704d2d65a43d107d84b7ccc Mon Sep 17 00:00:00 2001 From: Lynne Jones Date: Wed, 1 Jul 2026 02:56:45 -0700 Subject: [PATCH 2/2] Fix typo in clientsite warning message --- schedview/clientsite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schedview/clientsite.py b/schedview/clientsite.py index afba73b9..adadd6e4 100644 --- a/schedview/clientsite.py +++ b/schedview/clientsite.py @@ -47,7 +47,7 @@ def guess_site() -> str: elif hostname == "htcondor.cp.lsst.org": site = "summit" else: - warn(f"Could not deterime site from HOSTNAME {hostname}.") + warn(f"Could not determine site from HOSTNAME {hostname}.") if site is None: site = "local"