Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion schedview/clientsite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions schedview/collect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"read_visits",
"sample_pickle",
"sync_query_efd_topic_for_night",
"get_observatory_status_summary",
]


Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions schedview/collect/observatory_state.py
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions tests/test_observatory_state.py
Original file line number Diff line number Diff line change
@@ -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()
Loading