Source code for radiotools.visibility.visibility

"""Shows the source visibility at a given location and time."""

import datetime
from collections import namedtuple
from typing import Literal

import astropy.units as u
import dateutil.parser
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from astropy.coordinates import AltAz, BaseCoordinateFrame, EarthLocation, SkyCoord
from astropy.time import Time
from rich.console import Console
from rich.table import Table

from radiotools.layouts import Layout
from radiotools.utils import get_array_names

COLORS = [
    "#fd7f6f",
    "#7eb0d5",
    "#b2e061",
    "#bd7ebe",
    "#ffb55a",
    "#ffee65",
    "#beb9db",
    "#fdcce5",
    "#8bd3c7",
    "#b3d4ff",
]

PYVISGEN_LAYOUTS = "https://raw.githubusercontent.com/radionets-project/"
PYVISGEN_LAYOUTS += "pyvisgen/refs/heads/main/resources/layouts/"
PYVISGEN = "https://github.com/radionets-project/pyvisgen/tree/main/resources/layouts"

LOCATIONS = [
    "alma",
    "alma_dsharp",
    "dsa2000W",
    "dsa2000_31b",
    "eht",
    "meerkat",
    "vla",
    "vlba",
]


[docs] class SourceVisibility: """Plots the source visibility for a given location and time range. Parameters ---------- target : tuple or str Tuple of RA/Dec coordinates or string of valid target name (ICRS). date : str or list[str] Date or start and end points of a range of dates. location : str or :class:`astropy.coordinates.EarthLocation`, optional Name of an existing array layout included in pyvisgen, a location, or :class:`astropy.coordinates.EarthLocation` object of an observatory or telescope. Default: ``None`` obs_length: float, optional Observation length in hours. Default: ``None`` frame : str or :class:`astropy.coordinates.BaseCoordinateFrame`, optional Type of coordinate frame the source sky coordinates should represent. Defaults: ``'icrs'`` print_optimal_date : bool, optional Prints the optimal date to observe. Default: False """ def __init__( self, target: tuple | str, date: str | list[str], location: str | EarthLocation = None, obs_length: float = 4.0, frame: str | BaseCoordinateFrame = "icrs", min_alt: float = 15.0, max_alt: float = 85.0, print_optimal_date: bool = False, ) -> None: """Initializes the class with source and observation information. Parameters ---------- target : tuple or str Tuple of RA/Dec coordinates or string of valid target name (ICRS). date : str or list[str] Date or start and end points of a range of dates. location : str or :class:`astropy.coordinates.EarthLocation`, optional Name of an existing array layout included in pyvisgen, a location, or :class:`astropy.coordinates.EarthLocation` object of an observatory or telescope. Default: ``None`` obs_length: float, optional Observation length in hours. Default: ``None`` frame : str or :class:`astropy.coordinates.BaseCoordinateFrame`, optional Type of coordinate frame the source sky coordinates should represent. Defaults: 'icrs' min_alt : float, optional The minimum altitude of the source (in degrees) above the horizon to be determined as visible. Default: 15.0 max_alt : float, optional The maximum altitude of the source (in degrees) above the horizon to be determined as visible. Default: 85.0 print_optimal_date: bool, optional If `True` prints the optimal date for the observation. Default: ``False`` """ if isinstance(target, tuple) or ( isinstance(target, np.ndarray) and target.size == 2 ): self.ra = u.Quantity(target[0], unit=u.deg) self.dec = u.Quantity(target[1], unit=u.deg) self.source = SkyCoord(self.ra, self.dec, frame=frame) self.target_name = None elif isinstance(target, str): self.source = SkyCoord.from_name(target, frame=frame) self.ra = self.source.ra self.dec = self.source.dec self.target_name = target else: raise TypeError( "Please either provide a valid target name or a RA/Dec tuple!" ) if isinstance(location, str) and ( (location in get_array_names(PYVISGEN)) or (location in LOCATIONS) ): self.name = location self.array = Layout.from_url(PYVISGEN_LAYOUTS + location + ".txt") self.location = EarthLocation.from_geocentric( self.array.x, self.array.y, self.array.z, unit=u.m ) elif isinstance(location, str): self.name = location self.location = EarthLocation.of_address(location) elif isinstance(location, EarthLocation): self.name = location self.location = location elif isinstance(location, tuple) and list(map(type, location)) == [ str, pd.DataFrame, ]: self.name = location[0] self.location = EarthLocation.from_geocentric( location[1].X, location[1].Y, location[1].Z, unit=u.m, ) else: raise ValueError("Please provide a valid location!") self.date = date self.obs_length = obs_length if min_alt > max_alt: raise ValueError( "Expected 'min_alt' to be smaller than 'max_alt', but got " f"min_alt = {min_alt} and max_alt = {max_alt}." ) if min_alt < 0 or min_alt > 90 or max_alt < 0 or max_alt > 90: raise ValueError( "Expected 'min_alt' and 'max_alt' to be between 0 and 90 degrees." ) self.min_alt = min_alt self.max_alt = max_alt self._get_dates() self._get_pos() self.get_optimal_date(print_optimal_date) def _get_dates(self) -> None: """Creates a date range from the dates given when initializing this class. """ if isinstance(self.date, str): start_date = dateutil.parser.parse(self.date) end_date = start_date + datetime.timedelta(1) else: start_date = dateutil.parser.parse(self.date[0]) end_date = dateutil.parser.parse(self.date[1]) self.dates = pd.date_range(start_date, end_date, periods=1000).strftime( "%Y-%m-%d %H:%M:%S" ) self.dates = pd.to_datetime(self.dates) def _get_pos(self) -> None: """Creates the sky coordinates of the source throughout the time range for a given frame (default is ICRS) and calculates the altitude and azimuth in the AltAz frame. """ self.source_pos = dict() if self.location.size == 1: altaz_frame = AltAz(obstime=Time(self.dates), location=self.location) self.source_pos[0] = self.source.transform_to(altaz_frame) elif self.location.size > 10: altaz_frame = AltAz(obstime=Time(self.dates), location=self.location[0]) self.source_pos[0] = self.source.transform_to(altaz_frame) else: for i, loc in enumerate(self.location): altaz_frame = AltAz(obstime=Time(self.dates), location=loc) self.source_pos[i] = self.source.transform_to(altaz_frame) def _plot_config(self, ax) -> None: """Settings for the plot. Parameters ---------- ax : matplotlib.axes.Axes.axis Axis object of the current figure. """ locale_label_strings = { "EN": dict(xlabel="Time / hours", ylabel="Altitude / deg"), "DE": dict(xlabel="Zeit / Stunden", ylabel="Höhenwinkel / Grad"), } locale_legend_strings = { "EN": dict(title="Telescope ID"), "DE": dict(title="Teleskop-Nummer"), } # axis ticks ax["A"].set_xticks( ax["A"].get_xticks(), ax["A"].get_xticklabels(), rotation=45, ha="right" ) # further axis settings ax["A"].set( **locale_label_strings[self.plot_locale], ylim=(0, 90), title=self.name, ) ax["A"].xaxis.set_major_formatter( mdates.ConciseDateFormatter(ax["A"].xaxis.get_major_locator()) ) ax["B"].axis("off") # legend is plotted on axis "B" if self.legend: handles, labels = ax["A"].get_legend_handles_labels() ax["B"].legend( ncols=2, handles=handles, labels=labels, loc="upper left", handlelength=4, **locale_legend_strings[self.plot_locale], ) # Text is drawn on axis "B" text_anchor = ax["B"].get_window_extent() locale_text = { "EN": "Solid lines indicate that the source\n" + "is visible. The visibility window is\n" + f"limited to a range between {self.min_alt} deg\n" + f"and {self.max_alt} deg.", "DE": "Durchgezogene Linien zeigen, dass die Quelle\n" + "sichtbar ist. Das Sichtbarkeitsfenster ist\n" + f"zwischen {self.min_alt}° und {self.max_alt}°" + "limitiert.", } locale_text_large_array = { "EN": " For array layouts with\n" + "more than 10 stations, only the first\n" + "station (ID 0) is shown.", "DE": " Für Anordnungen mit mehr als\n" + "10 Stationen wird nur die erste Station\n" + "(Index 0) gezeigt.", } if self.descr_text: text = locale_text[self.plot_locale] if self.location.size > 10: text += locale_text_large_array[self.plot_locale] ax["B"].annotate( text, (0.025, 0.025), xycoords=text_anchor, fontsize=12, va="bottom", ha="left", color="#41424C", ) ax["B"].annotate( f"RA:\t{self.ra:.5f}\nDec:\t{self.dec:.5f}".expandtabs(), (0.025, 0.4), xycoords=text_anchor, fontsize=12, va="bottom", ha="left", fontfamily="monospace", color="#41424C", ) if self.target_name: ax["B"].annotate( self.target_name, (0.025, 0.5), xycoords=text_anchor, fontsize=13, va="bottom", ha="left", fontfamily="monospace", color="#232023", )
[docs] def plot( self, figsize: tuple[int, int] = (10, 5), colors: list = COLORS, *, descr_text: bool = True, legend: bool = True, locale: Literal["EN", "DE"] = "EN", ) -> tuple: """Plots the visibility of the source at the given time range. Also plots the positions of the sun and moon if set to ``True``. Parameters ---------- figsize : tuple[int, int], optional Figure size. Width, height in inches. Default: (10, 5) colors : list, optional List of colors. If nothing provided a default list of colors is used. Default: None descr_text : bool, optional Whether to put a descriptive text next to the plot. Default: True legend : bool, optional If True, show legend in the plot. Default: True Returns ------- tuple Figure and axis objects. """ self.descr_text = descr_text self.legend = legend self.plot_locale = locale colors = iter(colors) fig, ax = plt.subplot_mosaic( "AB", layout="constrained", figsize=figsize, width_ratios=[3, 2], ) for i, source_pos in enumerate(self.source_pos.values()): color = next(colors) mask = np.logical_and( source_pos.alt > self.min_alt * u.deg, self.max_alt * u.deg > source_pos.alt, ) visible = source_pos.alt.copy() visible.value[~mask] = np.nan ax["A"].plot( self.dates, source_pos.alt, linestyle=":", color=color, ) ax["A"].plot( self.dates, visible, lw=4, color=color, label=f"{i}", ) ax["A"].plot() self._plot_config(ax) return fig, ax
def _time_delta(self, r1, r2): latest_start = max(r1.start, r2.start) earliest_end = min(r1.end, r2.end) delta = earliest_end - latest_start return max(0, delta)
[docs] def get_optimal_date(self, print_result: bool = False) -> list: """Computes the best date to observe the target source. Returns a list of three :class:`~pandas.Timestamp` where the first and last are the best date :math:`\pm` `obs_length / 2`. Parameters ---------- print_result : bool, optional If `True`, also prints the result. Default: `False` Returns ------- result : list List of :class:`~pandas.Timestamp`. """ times = dict() t_range = namedtuple("t_range", ["start", "end"]) for key, val in self.source_pos.items(): maximum = np.max(val.alt) if maximum > self.max_alt * u.deg or maximum < self.min_alt * u.deg: continue idx_max = np.argmax(val.alt) delta = datetime.timedelta(hours=self.obs_length / 2) times[key] = [ self.dates[idx_max] - delta, self.dates[idx_max], self.dates[idx_max] + delta, ] # Reindex times in case a key was skipped due to alt check. # This would be the case if, e.g. key 0 and 3 did not pass # the check resulting in times = {1: ..., 2: ..., 4: ...}. # This reindexing then creates times = {0: ..., 1: ..., 2: ...}. times = dict(enumerate(times.values())) dt = np.zeros([len(times), len(times)]) for i, key_i in enumerate(times): for j, key_j in enumerate(times): r1 = t_range( start=times[key_i][0].to_datetime64(), end=times[key_i][-1].to_datetime64(), ) r2 = t_range( start=times[key_j][0].to_datetime64(), end=times[key_j][-1].to_datetime64(), ) dt[i, j] = self._time_delta(r1, r2) if dt.sum(axis=0).size == 0: raise ValueError( "The source is not visible with the chosen parameters. " "Radiotools was not able to determine the optimal " "observation date!" ) result = times[int(np.argmax(dt.sum(axis=0)))] if print_result: print("") tab = Table(title="*** Best observation time ***") tab.add_column("Station ID", justify="right", style="cyan") tab.add_column("Obs. time start") tab.add_column("Obs. time midpoint") tab.add_column("Obs. time end") tab.add_row( f"{np.argmax(dt.sum(axis=0))}", result[0].strftime("%Y-%m-%d %H:%M:%S"), result[1].strftime("%Y-%m-%d %H:%M:%S"), result[2].strftime("%Y-%m-%d %H:%M:%S"), ) console = Console() console.print(tab) print("") return result