import datetime
import functools
import astropy.units as u
import numpy as np
from astroplan import Observer
from astropy.coordinates.errors import UnknownSiteException
from astropy.time import Time, TimeDelta
__all__ = [
"today_day_obs",
"yesterday_day_obs",
"tomorrow_day_obs",
"time_to_day_obs",
"time_to_day_obs_int",
"day_obs_str_to_int",
"day_obs_int_to_str",
"day_obs_to_date",
"day_obs_to_time",
"rubin_observer",
"day_obs_sunset_sunrise",
"estimated_baseline_visit_range",
]
[docs]
def today_day_obs() -> str:
"""Return the day_obs for today, formatted as YYYY-MM-DD."""
return time_to_day_obs(Time.now())
[docs]
def yesterday_day_obs() -> str:
"""Return the day_obs for yesterday, formatted as YYYY-MM-DD."""
return time_to_day_obs(Time.now() - TimeDelta(1, format="jd"))
[docs]
def tomorrow_day_obs() -> str:
"""Return the day_obs for tomorrow, formatted at YYYY-MM-DD."""
return time_to_day_obs(Time.now() + TimeDelta(1, format="jd"))
[docs]
def time_to_day_obs(time: Time) -> str:
"""Return day_obs for astropy Time, formatted as YYYY-MM-DD."""
return Time(int(time.mjd - 0.5), format="mjd", scale="utc").iso[0:10]
[docs]
def time_to_day_obs_int(time: Time) -> int:
"""Return day_obs for astropy Time, integer YYYYMMDD."""
day_obs_str = Time(int(time.mjd - 0.5), format="mjd", scale="utc").iso[0:10]
return day_obs_str_to_int(day_obs_str)
[docs]
def day_obs_int_to_str(day_obs: int) -> str:
"""Day_obs integer YYYYMMDD transformed to string YYYY-MM-DD."""
day_obs_str = str(day_obs)
return f"{day_obs_str[0:4]}-{day_obs_str[4:6]}-{day_obs_str[6:]}"
[docs]
def day_obs_str_to_int(day_obs: str) -> int:
"""Day_obs string YYYY-MM-DD to integer YYYYMMDD."""
return int(day_obs.replace("-", ""))
def day_obs_to_date(day_obs: int | str) -> datetime.date:
day_obs_str = str(day_obs)
if "-" not in day_obs_str:
day_obs_str = day_obs_int_to_str(int(day_obs))
vals = day_obs_str.split("-")
return datetime.date(int(vals[0]), int(vals[1]), int(vals[2]))
[docs]
def day_obs_to_time(day_obs: int | str) -> Time:
"""Day_obs int or string to astropy Time."""
try:
day_obs_int = int(day_obs)
return Time(f"{day_obs_int_to_str(day_obs_int)}T12:00:00", format="isot", scale="tai")
except ValueError:
return Time(f"{day_obs}T12:00:00", format="isot", scale="tai")
@functools.cache
def rubin_observer() -> Observer:
try:
observer = Observer.at_site("Rubin")
except UnknownSiteException:
# Better to use Rubin, but old astropy installs might not have it.
observer = Observer.at_site("Cerro Pachon")
return observer
[docs]
def day_obs_sunset_sunrise(day_obs: str | int, sun_alt: float = -12) -> tuple[Time, Time]:
"""Return the civil sunset and sunrise for day_obs.
Parameters
----------
day_obs
Current day_obs in format YYYY-MM-DD or YYYYMMDD
sun_alt
Altitude (in degrees) of the sun at 'sunrise' and 'sunset'.
Returns
-------
sunset, sunrise : `Time`, `Time`
The time of -6 degree (civil) sunset and sunrise.
Science observations are generally expected from -12 degree twilight.
"""
if isinstance(day_obs, str):
day_obs_str = day_obs
else:
day_obs_str = str(day_obs)
# Sometimes we may be passed YYYYMMDD in a string already
if "-" not in day_obs_str:
day_obs_str = day_obs_int_to_str(int(day_obs))
day_obs_time = Time(f"{day_obs_str}T12:00:00", format="isot", scale="tai")
observer = rubin_observer()
sunset = Time(
observer.sun_set_time(day_obs_time, which="next", horizon=sun_alt * u.deg), format="jd", scale="tai"
)
sunrise = Time(
observer.sun_rise_time(day_obs_time, which="next", horizon=sun_alt * u.deg), format="jd", scale="tai"
)
return (sunset, sunrise)
[docs]
def estimated_baseline_visit_range(day_obs: int, relative_performance: float = 1.0) -> dict[str, int]:
"""Estimate an average and likely upper limit for the number of visits on
a given day_obs.
Parameters
----------
day_obs
The day of interest.
relative_performance
The expected open shutter fraction ratio compared to the baseline
value used in the simulations.
The simulations used to estimate open shutter fraction here are v5.1.
Returns
-------
estimate_visits : `dict` [`str` : `int`]
A dictionary with `n_vis_ave` and `n_vis_high` keys, containing
a range of estimated nvisits values. These estimates are based on
open shutter fraction and night length.
"""
sunset, sunrise = day_obs_sunset_sunrise(day_obs, sun_alt=-12)
night_length = (sunrise - sunset).jd * 24 * 60 * 60 # seconds
night = day_obs_to_time(day_obs)
night_min = Time("2025-12-21T12:00:00")
# The mean open shutter fraction in v5.1 runs is ~0.74
# but this doesn't match average nvisits without modulation
open_shutter_fraction = 0.74 - 0.02 * np.cos((night - night_min).jd / 365 * 2 * np.pi)
estimate_nvis_ave = open_shutter_fraction * night_length / 30 # assume 30s visits
estimate_nvis_ave = int(np.floor(estimate_nvis_ave * relative_performance))
# The upper envelope of nvisits/night over many simulations + years
# matches more closely with 0.78
open_shutter_fraction = 0.78
estimate_nvis_hi = open_shutter_fraction * night_length / 30 # assume 30s visits
estimate_nvis_hi = int(np.floor(estimate_nvis_hi * relative_performance))
return {"n_vis_ave": estimate_nvis_ave, "n_vis_high": estimate_nvis_hi}