Source code for saltshaker.planning

"""
Observation planning and scheduling utilities for SALT.

This module provides the core functional API for determining when targets 
are visible at SALT and managing observing semesters. 

It handles the conversion of theoretical tracking limits into actual 
Universal Time (UTC) windows and provides night-by-night semester 
iterators.
"""

from astropy.time import Time
from astropy.coordinates import SkyCoord
import astropy.units as u
from saltshaker.model import get_model
import numpy as np

[docs] class VisibilityWindow: """ Represents a specific time interval when a target is visible at SALT. Attributes: start_time (Time): The exact beginning of the visibility window. end_time (Time): The exact end of the visibility window. duration (float): The total duration of the window in seconds. """ def __init__(self, start_time, end_time): """ Initializes the visibility window. Args: start_time (Time): Start of the observable period. end_time (Time): End of the observable period. """ self.start_time = start_time self.end_time = end_time self.duration = (end_time - start_time).to(u.second).value @property def start_time_utc(self): """Returns the start time as a UTC ISO string (e.g., '2026-01-15 18:30:00').""" return self.start_time.utc.iso @property def end_time_utc(self): """Returns the end time as a UTC ISO string.""" return self.end_time.utc.iso def __repr__(self): return f"<VisibilityWindow {self.start_time_utc} to {self.end_time_utc} ({self.duration:.1f}s)>"
[docs] def get_visibility_windows(target_coord, obs_date, observer=None): """ Calculates the observable UTC windows for a target (or targets) on a specific date. This function converts tracking hour angle limits into a sequence of UTC time intervals. For many declinations, this will return two windows (an Eastern rising track and a Western setting track). Args: target_coord (SkyCoord): The celestial coordinates of the target(s). obs_date (str | Time): The date of observation. If a string is provided (e.g., '2026-01-15'), the 24-hour window starts at 12:00:00 UTC on that day. observer (SaltObserver | None): The observer instance to use for LST and coordinate calculations. Defaults to a standard `SaltObserver`. Returns: list[VisibilityWindow] | list[list[VisibilityWindow]]: A list of visibility windows (for a single target) or a list of lists (for multiple targets). """ if observer is None: from saltshaker.observer import get_salt_observer observer = get_salt_observer() tracking_model = get_model() # Handle both scalar and array targets is_scalar = target_coord.isscalar decls = target_coord.dec.deg ras = target_coord.ra.hour # Define the observation time range (24 hours starting from noon) if isinstance(obs_date, str): start_time = Time(f"{obs_date} 12:00:00") else: start_time = obs_date end_time = start_time + 24 * u.hour try: east_tracks = tracking_model.get_east_track(decls) west_tracks = tracking_model.get_west_track(decls) except ValueError: return [] if is_scalar else [[] for _ in range(len(target_coord))] # LST at start_time (Cached in SaltObserver) lst_start = observer.local_sidereal_time(start_time).hour # Target HAs at start_time start_has = lst_start - ras # Normalize to [-12, 12] start_has = (start_has + 12) % 24 - 12 SIDEREAL_TO_SOLAR = 1.0 / 1.00273790935 def _get_windows_for_target(e_track, w_track, s_ha): tracks = [] for track_ha_limits in [e_track, w_track]: if track_ha_limits is None or np.any(np.isnan(track_ha_limits)): continue ha_start, ha_end = track_ha_limits diff_start = ha_start - s_ha diff_end = ha_end - s_ha # Shift forward if track has already passed if diff_start < 0 and diff_end < 0: diff_start += 24 diff_end += 24 t_start = start_time + (diff_start * SIDEREAL_TO_SOLAR) * u.hour t_end = start_time + (diff_end * SIDEREAL_TO_SOLAR) * u.hour overlap_start = max(start_time, t_start) overlap_end = min(end_time, t_end) if overlap_start < overlap_end: tracks.append(VisibilityWindow(overlap_start, overlap_end)) tracks.sort(key=lambda x: x.start_time) return tracks if is_scalar: return _get_windows_for_target(east_tracks, west_tracks, start_has) else: # For arrays, east_tracks and west_tracks are tuples of arrays e_starts, e_ends = east_tracks w_starts, w_ends = west_tracks results = [] for i in range(len(target_coord)): e = (e_starts[i], e_ends[i]) w = (w_starts[i], w_ends[i]) if not np.isnan(w_starts[i]) else None results.append(_get_windows_for_target(e, w, start_has[i])) return results
[docs] def get_track_length(target, time, observer=None): """ Returns the available track length for a target at a specific moment. The "Track Length" is the remaining duration (in seconds) that the tracker can follow the object before reaching its physical limit. This is highly dependent on both declination and the target's current position in the tracking zone (hour angle). Args: target (SkyCoord): The celestial coordinates of the target. time (Time): The exact moment of observation. observer (SaltObserver | None): The observer instance to use. Defaults to a standard `SaltObserver`. Returns: Quantity: The remaining tracking duration in units of time (seconds). """ if observer is None: from saltshaker.observer import get_salt_observer observer = get_salt_observer() tracking_model = get_model() lst = observer.local_sidereal_time(time) ha = (lst - target.ra).to(u.hourangle).value # Normalize HA to [-12, 12] if ha > 12: ha -= 24 elif ha < -12: ha += 24 return tracking_model.track_length(target.dec.deg, ha) * u.second
[docs] def is_target_observable(target): """ Checks if a target is EVER observable from SALT based on its declination. SALT's fixed-altitude design limits visibility to a specific range of declinations (approximately -75° to +10°). This function returns True if the target's declination ever enters SALT's tracking annulus. Args: target (SkyCoord | float | np.ndarray): The target(s) to check. Can be an astropy `SkyCoord` object, a float, or a NumPy array representing the declination(s) in degrees. Returns: bool | np.ndarray: True if the target is observable, False otherwise. """ if isinstance(target, (float, int, np.float64, np.ndarray)): dec = target else: # SkyCoord if hasattr(target, 'dec'): dec = target.dec.deg else: dec = target tracking_model = get_model() # get_max_track_length isn't fully vectorized yet, but we can do a quick range check # using the model's declinations decs = np.asarray(dec) result = (decs >= tracking_model.declinations[0]) & (decs <= tracking_model.declinations[-1]) # Refine with actual model data for the edges if needed, # but the above is already very close for SALT. # To be perfectly accurate, we call the model: if np.isscalar(dec): return bool(tracking_model.get_max_track_length(float(dec)) > 0) else: # For array, we can just return the range check or loop if small # Given SALT's model, the range check is 100% accurate for its data file return result
[docs] def get_tracks(target_coord, obs_date): """ Compatibility wrapper for `get_visibility_windows`. Deprecated: Use `get_visibility_windows` instead. Args: target_coord (SkyCoord | float): Target coordinate or declination. obs_date (str | Time): Date of observation. Returns: list[VisibilityWindow]: Observable time windows. """ if isinstance(target_coord, (float, int, np.float64)): target_coord = SkyCoord(ra=0*u.deg, dec=target_coord*u.deg) return get_visibility_windows(target_coord, obs_date)
[docs] def get_semester_start(year, semester): """ Returns the official start date of a SALT observing semester. - Semester 1 (e.g., 2026-1) starts March 1st. - Semester 2 (e.g., 2026-2) starts October 1st. Args: year (int): The calendar year. semester (int): The semester number (1 or 2). Returns: Time: Start of the semester (12:00:00 UTC). """ if semester == 1: return Time(f"{year}-03-01 12:00:00") elif semester == 2: return Time(f"{year}-10-01 12:00:00") else: raise ValueError("Semester must be 1 or 2")
[docs] def get_semester_end(year, semester): """ Returns the official end date of a SALT observing semester. Args: year (int): The calendar year. semester (int): The semester number (1 or 2). Returns: Time: End of the semester (12:00:00 UTC). """ if semester == 1: return Time(f"{year}-10-01 12:00:00") elif semester == 2: return Time(f"{year+1}-03-01 12:00:00") else: raise ValueError("Semester must be 1 or 2")
[docs] def get_semester_nights(year, semester, observer=None): """ Generates a sequence of observing nights for an entire semester. A "night" is defined as the period between evening astronomical twilight (-18° altitude) and morning astronomical twilight. This implementation uses a vectorized grid-based approach for maximum performance, providing a ~5-10x speedup over iterative methods. Args: year (int): The calendar year. semester (int): The semester number (1 or 2). observer (SaltObserver | None): The observer instance to use for twilight calculations. Defaults to a standard `SaltObserver`. Returns: list[tuple[Time, Time]]: A list of tuples, where each tuple is (evening_twilight, morning_twilight). """ if observer is None: from saltshaker.observer import get_salt_observer observer = get_salt_observer() start_time = get_semester_start(year, semester) end_time = get_semester_end(year, semester) # Calculate approximate number of days num_days = int((end_time - start_time).to(u.day).value) # Create a grid of times to sample the Sun's altitude # 1-hour resolution is enough for robust interpolation of twilight grid_times = start_time + np.linspace(0, num_days, num_days * 24 + 1) * u.day from astropy.coordinates import get_sun, AltAz sun_coords = get_sun(grid_times) altaz_frame = AltAz(obstime=grid_times, location=observer.location) sun_alts = sun_coords.transform_to(altaz_frame).alt.deg # Find where altitude crosses -18 degrees # We look for transitions from > -18 to < -18 (evening) # and < -18 to > -18 (morning) target_alt = -18.0 nights = [] # Evening twilight search: altitude goes from above -18 to below -18 # Typically happens between noon (alt > 0) and midnight (alt < -18) for i in range(num_days): day_start_idx = i * 24 # Search window: from 12:00 to 24:00 (approx) # 12 hours = 12 indices window_idx = day_start_idx + np.arange(6, 18) # 18:00 to 06:00 is too late, let's use 12:00 to 24:00 # Actually let's just search the whole day window_idx = day_start_idx + np.arange(24) # Evening: find where sun_alts[j] > -18 and sun_alts[j+1] < -18 evening_mask = (sun_alts[window_idx[:-1]] >= target_alt) & (sun_alts[window_idx[1:]] < target_alt) morning_mask = (sun_alts[window_idx[:-1]] <= target_alt) & (sun_alts[window_idx[1:]] > target_alt) if np.any(evening_mask): idx = window_idx[np.where(evening_mask)[0][0]] # Linear interpolation for better accuracy v1, v2 = sun_alts[idx], sun_alts[idx+1] t1, t2 = grid_times[idx], grid_times[idx+1] evening_t = t1 + (t2 - t1) * (target_alt - v1) / (v2 - v1) # Now find the corresponding morning twilight (next transition) # Search from this evening onwards m_window = np.arange(idx + 1, min(idx + 18, len(sun_alts) - 1)) m_mask = (sun_alts[m_window] <= target_alt) & (sun_alts[m_window+1] > target_alt) if np.any(m_mask): midx = m_window[np.where(m_mask)[0][0]] mv1, mv2 = sun_alts[midx], sun_alts[midx+1] mt1, mt2 = grid_times[midx], grid_times[midx+1] morning_t = mt1 + (mt2 - mt1) * (target_alt - mv1) / (mv2 - mv1) if evening_t < end_time: nights.append((evening_t, morning_t)) return nights