from __future__ import annotations
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Any
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib as mpl
import matplotlib.animation as animation
import matplotlib.pyplot as plt
import numpy as np
from astropy import units
from astropy.coordinates import ITRS, SkyCoord
from astropy.time import Time
from cartopy.feature.nightshade import Nightshade
from mergedeep import merge
from radiotools.layouts import Layout
from tqdm.auto import tqdm
if TYPE_CHECKING:
from pyvisgrid.core.gridder import GridData, GridDataSeries, Gridder
from pyvisgrid.plotting.plotting import (
_apply_crop,
_configure_axes,
_configure_colorbar,
_get_norm,
)
__all__ = ["plot_earth_layout", "plot_observation_state", "animate_observation"]
_default_colors = mpl.colormaps["inferno"].resampled(10).colors
def _is_value_in(value: Any, lst: list) -> bool:
"""Checks whether a given value is in a nested list.
Parameters
----------
value : object
The value to search for.
lst : list
The list to search the value in. May contain other lists.
Returns
-------
bool :
Whether the value is in the list.
"""
return value in np.ravel(lst)
def _times2hours(times: np.ndarray) -> np.ndarray:
"""Converts the given MJD times to relative hours.
The first returned element will therefore be 0.
Parameters
----------
times : numpy.ndarray
The array containing the times in MJD.
The times should be in ascending order, meaning the
first element should be the starting time.
Returns
-------
np.ndarray :
An array containing the relative time in hours since the first
given time.
"""
times = Time(times, format="mjd")
times = times.unix
times -= times[0]
times /= 3600
return times
[docs]
def plot_earth_layout(
layout: Layout | str,
src_ra: float,
src_dec: float,
current_time: Time,
show_legend: bool = True,
legend_args: dict | None = None,
legend_fontsize: str | int = "x-small",
show_title: bool = True,
title_fontsize: str | int = "small",
coastline_width: float = 0.7,
show_terrain_texture: bool = True,
show_grid_lines: bool = True,
show_night_shade: bool = True,
marker_sizes: dict | None = None,
plot_colors: dict | None = None,
save_to: str | PathLike | None = None,
save_args: dict | None = None,
fig_args: dict | None = None,
fig: mpl.figure.Figure | None = None,
mosaic_axes: dict[mpl.axes.Axes] | None = None,
mosaic_axes_key: str = "earth",
ax: mpl.axes.Axes | None = None,
) -> tuple[mpl.figure.Figure, mpl.axes.Axes, dict[mpl.axes.Axes] | None]:
"""Plots the a projected source position and the given interferometer layout
on an earth visualization at a specific time.
Parameters
----------
layout : radiotools.layouts.Layout | str
The interferometer layout to plot. If a string is provided, a valid ``pyvisgen``
interferometer layout will be searched at this location. The string therefore
has to be a valid path.
src_ra : float
The Right Ascension (RA) of the source in degrees.
src_dec : float
The Declination (DEC) of the source in degrees.
current_time : astropy.time.Time
The time at which to plot the positions. The view positions
is then determined by the projected position of the source at this time.
show_legend : bool, optional
Whether to show a plot legend for the layout and source markers.
Default is ``True``.
legend_args : dict | None, optional
The arguments to pass to the legend.
Default is
.. code-block:: python
legend_args = {
"loc": "center",
"bbox_to_anchor": (0.5, -0.12),
"fontsize": legend_fontsize,
"borderaxespad": 0,
}
legend_fontsize : str | int, optional
The font size of the legend's text.
Default is ``'x-small'``.
show_title : bool, optional
Whether to show the current time in the title.
Default is ``True``.
legend_fontsize : str | int, optional
The font size of the title.
Default is ``'small'``.
coastline_width : float, optional
The width of the coastlines.
Default is ``0.7``.
show_terrain_texture : bool, optional
Whether to display terrain textures.
Default is ``True``.
show_grid_lines : bool, optional
Whether to show gridlines on the globe.
Default is ``True``.
show_night_shade : bool, optional
Whether to show the night shade of the earth.
Default is ``True``.
marker_sizes : dict | None, optional
The marker sizes for the antenna (dict-key: ``'antennas'``)
and source (dict-key: ``'source'``) positions.
Default is
.. code-block:: python
marker_sizes = {
"antennas": 13,
"source": 150,
}
plot_colors : dict | None, optional
The colors for the antenna (dict-key: ``'antennas'``)
and source (dict-key: ``'source'``) positions and the
connection vectors (dict-key: ``'connections'``) between
the antennas.
The default colors are given by the inferno colormap which
resampled into 10 colors.
Default is
.. code-block:: python
colors = {
"antennas": _default_colors[5],
"source": _default_colors[-2],
"connections": _default_colors[0],
}
save_to : str | PathLike | None, optional
The location to save the plot to. If set to ``None``, the plot won't
be saved. Default is ``None``.
save_args : dict | None, optional
Additional arguments to provide to the ``Figure.savefig`` method.
Default is ``{"bbox_inches": "tight"}``.
fig_args : dict | None, optional
The arguments to pass to the figure. If a custom figure is given,
this has no effect.
Default is ``None``.
fig : matplotlib.figure.Figure | None, optional
The figure to add the plot to. If not given, a new figure will be
created.
Default is ``None``.
mosaic_axes : dict[mpl.axes.Axes] | None, optional
The axes for a mosaic subplot.
Default is ``None``.
mosaic_axes_key : str, optional
The key of the axes of the mosaic subplot to plot the earth on.
If no ``mosaic_axes`` was given, this has no effect.
Default is ``'earth'``.
ax : matplotlib.axes.Axes | None, optional
The axes to add the plot to. If not given, a new axis will be created
or if a ``mosaic_axes`` and a ``mosaic_axes_key`` are given, the mosaic
axes will be used.
Default is ``None``.
Returns
-------
matplotlib.figure.Figure:
The figure object.
matplotlib.axes.Axes:
The axes object.
dict[matplotlib.axes.Axes] | None:
The mosaic subplot axes if the ``mosaic_axes`` parameter was given.
Otherwise this is ``None``.
"""
fig, ax = _configure_axes(fig=fig, ax=ax, fig_args=fig_args)
if marker_sizes is None:
marker_sizes = {
"antennas": 13,
"source": 150,
}
if plot_colors is None:
colors = {
"antennas": _default_colors[5],
"source": _default_colors[-2],
"connections": _default_colors[0],
}
if isinstance(layout, str):
layout = Layout.from_pyvisgen(layout)
src_pos = SkyCoord(
ra=src_ra,
dec=src_dec,
unit=(units.deg, units.deg),
)
src_pos_itrs = src_pos.transform_to(ITRS(obstime=current_time)).spherical
projection = ccrs.NearsidePerspective(
central_longitude=src_pos_itrs.lon,
central_latitude=src_pos_itrs.lat,
satellite_height=1e10,
)
if mosaic_axes is not None:
gs = ax.get_subplotspec()
fig.delaxes(ax)
ax = fig.add_subplot(gs, projection=projection)
mosaic_axes[mosaic_axes_key] = ax
threshold_original = projection._threshold
if show_title:
ax.set_title(current_time.iso[:-4], fontsize=title_fontsize)
ax.add_feature(cfeature.OCEAN, zorder=0)
ax.add_feature(cfeature.LAND, zorder=0, edgecolor="black")
if show_night_shade:
ax.add_feature(Nightshade(date=current_time.to_datetime(), alpha=0.2))
ax.set_global()
if show_grid_lines:
ax.gridlines()
if show_terrain_texture:
ax.stock_img()
ax.coastlines(linewidth=coastline_width)
transform = ccrs.Geodetic()
antennas = layout.get_antenna_positions()
connection_vecs = layout.get_station_combinations()
ax.scatter(
x=antennas.lon,
y=antennas.lat,
transform=transform,
color=colors["antennas"],
s=marker_sizes["antennas"],
label="Antenna positions",
zorder=2,
)
ax.scatter(
x=src_pos_itrs.lon.deg,
y=src_pos_itrs.lat.deg,
color=colors["source"],
s=marker_sizes["source"],
label="Projected source position",
zorder=3,
marker="*",
)
if show_legend:
if legend_args is None:
legend_args = {
"loc": "center",
"bbox_to_anchor": (0.5, -0.12),
"fontsize": legend_fontsize,
"borderaxespad": 0,
}
ax.legend(**legend_args)
projection._threshold *= 100
ax.plot(
connection_vecs.lon,
connection_vecs.lat,
transform=transform,
color=colors["connections"],
linewidth=0.5,
zorder=1,
)
projection._threshold = threshold_original
if save_to is not None:
save_to = Path(save_to)
save_args = {"bbox_inches": "tight"} if save_args is None else save_args
fig.savefig(save_to, **save_args)
return fig, ax, mosaic_axes
[docs]
def plot_observation_state(
gridder: Gridder,
vis_data: GridData,
u: np.ndarray,
v: np.ndarray,
times: np.ndarray,
max_values: tuple[GridData, np.ndarray, np.ndarray, np.ndarray] | None = None,
uv_max_extension: float = 0.2,
plot_positions: list[list[str]] | None = None,
dirty_image_mode: str = "real",
dirty_image_crop: tuple[list[float | None]] = ([None, None], [None, None]),
mask_mode: str = "amp_phase",
swap_masks: bool = False,
mask_crop: tuple[list[float | None]] = ([None, None], [None, None]),
axes_options: dict | None = None,
save_to: str | PathLike | None = None,
save_args: dict | None = None,
) -> tuple[mpl.figure.Figure, dict[mpl.axes.Axes], dict[mpl.artist.Artist], dict]:
"""Plot several visualizations for a given state of an observation.
Parameters
----------
gridder : Gridder
The ``Gridder`` with which the series was gridded.
vis_data : GridData
The grid data returned by the Gridder.
u : numpy.ndarray
The ungridded :math:`u` coordinates.
v : numpy.ndarray
The ungridded :math:`v` coordinates.
times : numpy.ndarray
The MJD timestamps of the :math:`(u,v)` points.
max_values : tuple[GridData, np.ndarray, np.ndarray, np.ndarray] | None, optional
The maximum values of the gridded and ungridded data and the timestamps.
These values are used to configure the maximum scale for the plot axes
and colorbars. The values have to be in the following order:
``[GridData, ungridded u, ungridded v, times]``.
Default is ``None``.
uv_max_extension : float, optional
The fractional extension of the uv plot's axes limits.
A value of e.g. ``0.5`` would correspond to 50% larger u and v axes.
Default is ``0.2``.
plot_positions : dict[str] | None, optional
The mosaic layout of the plot. The following keys are available:
- ``uv``: Refers to the ungridded :math:(u,v) coordinates plot.
- ``di``: Refers to the dirty image plot.
- ``earth``: Refers to the plot of the source position and the
antennas on the earth's surface.
- ``mask_hi``: Refers to the upper plot of the gridded visibility masks.
- ``mask_lo``: Refers to the lower plot of the gridded visibility masks.
Note the layout should be valid to be used in a
``matplotlib.pyplot.subplot_mosaic`` call.
Default is
.. code-block:: python
[["mask_hi", "earth", "uv"], ["mask_lo", "earth", "di"]]
dirty_image_mode : str, optional
The mode specifying which values of the dirty image should be plotted.
Possible values are:
- ``real``: Plots the real part of the dirty image.
- ``imag``: Plots the imaginary part of the dirty image.
- ``abs`` / ``amp``: Plot the absolute value of the dirty image.
Default is ``real``.
dirty_image_crop : tuple[list[float | None]], optional
The crop of the dirty image. This has to have the format
``([x_left, x_right], [y_left, y_right])``, where the left and right
values for each axis are the upper and lower limits of the axes which
should be shown.
Default is `([None, None], [None, None])`
mask_mode : str, optional
The mode specifying which representation of the visibility masks should be used.
Possible values are:
- ``amp_phase``: Plots the amplitude and phase of the complex numbers.
- ``real_imag``: Plots the real and imaginary parts of the complex numbers.
Default is ``amp_phase``.
swap_masks : bool, optional
Whether to swap the mask order which is used to determine which mask part is
used as ``mask_hi`` and which as ``mask_lo``.
By default the order is: ``mask_hi = amplitude | real``
and ``mask_lo = phase | imaginary``.
Default is ``False``.
mask_crop : tuple[list[float | None]], optional
The crop of the masks. This has to have the format
``([x_left, x_right], [y_left, y_right])``, where the left and right
values for each axis are the upper and lower limits of the axes which
should be shown.
Default is `([None, None], [None, None])`
axes_options : dict | None, optional
Options for the different subplots of the mosaic plot.
The given dictionary will be merged with the default option dictionary.
This means that options which are given in this parameter overwrite the
option in the default configuration.
The default configuration is:
.. code-block:: python
{
"uv": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": True,
"axes_fontsize": "x-small",
"show_times": True,
"cmap": "magma",
"show_cbar": True,
"cbar_ticks": True,
"cbar_label": True,
"cbar_fontsize": "small",
"color": _default_colors[4],
"aspect": "equal",
},
"di": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": "inferno",
"norm": "sqrt",
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
"mode_in_label": True,
},
"mask_hi": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[0],
"label": mask_labels[0],
"norm": mask_norms[0],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"mask_lo": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[1],
"label": mask_labels[1],
"norm": mask_norms[1],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"earth": {
"show_title": True,
"title_fontsize": "small",
"show_legend": True,
"legend_args": None,
"legend_fontsize": "x-small",
"coastline_width": 0.7,
"show_terrain_texture": True,
"show_grid_lines": True,
"show_night_shade": True,
"marker_sizes": None,
"plot_colors": None,
},
}
save_to : str | PathLike | None, optional
The location to save the plot to. If set to ``None``, the plot won't
be saved. Default is ``None``.
save_args : dict | None, optional
Additional arguments to provide to the ``Figure.savefig`` method.
Default is ``{"bbox_inches":"tight"}``.
Returns
-------
tuple[mpl.figure.Figure, mpl.axes.Axes, dict[mpl.artist.Artist], dict]:
The figure object, mosaic subplot axes and dictionary of the
artists of the plots. The last dict contains all plot options.
"""
if axes_options is None:
axes_options = {}
if plot_positions is None:
plot_positions = [["mask_hi", "earth", "uv"], ["mask_lo", "earth", "di"]]
if mask_mode == "amp_phase":
mask_cmaps = ("viridis", "RdBu")
mask_labels = ("Visibility Amplitude / a.u.", "Phase / rad")
mask_norms = ("log", None)
elif mask_mode == "real_imag":
mask_cmaps = ("PiYG", "PuOr")
mask_labels = ("Real part / a.u.", "Imaginary part / a.u.")
mask_norms = ("centered", "centered")
else:
raise ValueError("Possible mask_modes: 'amp_phase' or 'real_imag'")
if swap_masks:
mask_cmaps = mask_cmaps[::-1]
mask_labels = mask_labels[::-1]
mask_norms = mask_norms[::-1]
default_axes_options = {
"uv": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": True,
"axes_fontsize": "x-small",
"show_times": True,
"cmap": "magma",
"show_cbar": True,
"cbar_ticks": True,
"cbar_label": True,
"cbar_fontsize": "small",
"color": _default_colors[4],
"aspect": "equal",
},
"di": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": "inferno",
"norm": "sqrt",
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
"mode_in_label": True,
},
"mask_hi": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[0],
"label": mask_labels[0],
"norm": mask_norms[0],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"mask_lo": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[1],
"label": mask_labels[1],
"norm": mask_norms[1],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"earth": {
"show_title": True,
"title_fontsize": "small",
"show_legend": True,
"legend_args": None,
"legend_fontsize": "x-small",
"coastline_width": 0.7,
"show_terrain_texture": True,
"show_grid_lines": True,
"show_night_shade": True,
"marker_sizes": None,
"plot_colors": None,
},
}
axes_options = merge({}, default_axes_options, axes_options)
fig, ax = plt.subplot_mosaic(plot_positions)
# Set initial values
u = np.append(-u, u)
v = np.append(-v, v)
# Set maximum values: max_values = [vis_data, u, v, times]
if max_values is not None and len(max_values) != 4:
raise ValueError(
"The 'max_values' parameter has to have the form "
"max_values = [vis_data, u, v, times]."
)
if max_values is not None:
vis_data_max = max_values[0]
u_max = max_values[1]
v_max = max_values[2]
times_max = max_values[3]
else:
vis_data_max = vis_data
u_max = u
v_max = v
times_max = times
u_max = np.abs(u_max).max() * (1 + uv_max_extension)
v_max = np.abs(v_max).max() * (1 + uv_max_extension)
# Set mask values
if mask_mode == "amp_phase":
mask_imgs = vis_data.get_mask_abs_phase()
mask_imgs_max = vis_data_max.get_mask_abs_phase()
elif mask_mode == "real_imag":
mask_imgs = vis_data.get_mask_real_imag()
mask_imgs_max = vis_data_max.get_mask_real_imag()
if swap_masks:
mask_imgs = mask_imgs[::-1]
mask_imgs_max = mask_imgs_max[::-1]
# Plot subplots
if _is_value_in("uv", plot_positions):
time_hours = _times2hours(times=times)
time_hours -= time_hours.min()
time_hours_max = _times2hours(times=times_max)
time_hours_max -= time_hours_max.min()
if axes_options["uv"]["show_times"]:
uv_scat = ax["uv"].scatter(
x=u,
y=v,
c=np.tile(time_hours, reps=2),
s=0.5,
cmap=axes_options["uv"]["cmap"],
vmin=time_hours_max.min(),
vmax=time_hours_max.max(),
)
if axes_options["uv"]["show_cbar"]:
_configure_colorbar(
mappable=uv_scat,
ax=ax["uv"],
fig=fig,
label="Time / h" if axes_options["uv"]["cbar_label"] else None,
show_ticks=axes_options["uv"]["cbar_ticks"],
fontsize=axes_options["uv"]["cbar_fontsize"],
)
else:
uv_scat = ax["uv"].scatter(
x=u, y=v, s=0.5, color=axes_options["uv"]["color"]
)
ax["uv"].set_xlim(-u_max, u_max)
ax["uv"].set_ylim(-v_max, v_max)
ax["uv"].set_aspect(axes_options["uv"]["aspect"])
if axes_options["uv"]["show_title"]:
ax["uv"].set_title(
"Ungridded $(u,v)$", fontsize=axes_options["uv"]["title_fontsize"]
)
if axes_options["uv"]["axes_labels"]:
ax["uv"].set_xlabel(
"$u$ / $\\lambda$", fontsize=axes_options["uv"]["axes_fontsize"]
)
ax["uv"].set_ylabel(
"$v$ / $\\lambda$", fontsize=axes_options["uv"]["axes_fontsize"]
)
if not axes_options["uv"]["axes_ticks"]:
ax["uv"].set_xticks([])
ax["uv"].set_yticks([])
else:
ax["uv"].xaxis.set_tick_params(
labelsize=axes_options["uv"]["axes_fontsize"]
)
ax["uv"].yaxis.set_tick_params(
labelsize=axes_options["uv"]["axes_fontsize"]
)
else:
uv_scat = None
if _is_value_in("di", plot_positions):
match dirty_image_mode:
case "real":
dirty_image = vis_data.dirty_image.real
case "imag":
dirty_image = vis_data.dirty_image.imag
case "abs" | "amp":
dirty_image = np.abs(vis_data.dirty_image)
case _:
raise ValueError(
"The given dirty image mode does not exist! "
"Valid modes are: real, imag, abs / amp"
)
di_im = ax["di"].imshow(
X=dirty_image,
cmap=axes_options["di"]["cmap"],
norm=_get_norm(
axes_options["di"]["norm"],
vmin=vis_data_max.dirty_image.real[
vis_data_max.dirty_image.real > 0
].min(),
vmax=vis_data_max.dirty_image.real.max(),
),
origin="lower",
interpolation="none",
)
if axes_options["di"]["show_cbar"]:
mode_str = (
"" if axes_options["di"]["mode_in_label"] else f" {dirty_image_mode}"
)
_configure_colorbar(
mappable=di_im,
ax=ax["di"],
fig=fig,
label=f"Flux density{mode_str} / Jy/pix"
if axes_options["di"]["cbar_label"]
else None,
show_ticks=axes_options["di"]["cbar_ticks"],
fontsize=axes_options["di"]["cbar_fontsize"],
)
if axes_options["di"]["show_title"]:
ax["di"].set_title(
"Dirty Image", fontsize=axes_options["di"]["title_fontsize"]
)
if axes_options["di"]["axes_labels"]:
ax["di"].set_xlabel("Pixels", fontsize=axes_options["di"]["axes_fontsize"])
ax["di"].set_ylabel("Pixels", fontsize=axes_options["di"]["axes_fontsize"])
if not axes_options["di"]["axes_ticks"]:
ax["di"].set_xticks([])
ax["di"].set_yticks([])
else:
ax["di"].xaxis.set_tick_params(
labelsize=axes_options["di"]["axes_fontsize"]
)
ax["di"].yaxis.set_tick_params(
labelsize=axes_options["di"]["axes_fontsize"]
)
_apply_crop(ax=ax["di"], crop=dirty_image_crop)
else:
di_im = None
if _is_value_in("earth", plot_positions):
plot_earth_layout(
layout=gridder.antenna_layout,
src_ra=gridder.src_ra,
src_dec=gridder.src_dec,
current_time=Time(times[-1], format="mjd"),
show_legend=axes_options["earth"]["show_legend"],
legend_fontsize=axes_options["earth"]["legend_fontsize"],
show_title=axes_options["earth"]["show_title"],
title_fontsize=axes_options["earth"]["title_fontsize"],
coastline_width=axes_options["earth"]["coastline_width"],
show_terrain_texture=axes_options["earth"]["show_terrain_texture"],
show_grid_lines=axes_options["earth"]["show_grid_lines"],
show_night_shade=axes_options["earth"]["show_night_shade"],
plot_colors=axes_options["earth"]["plot_colors"],
fig=fig,
mosaic_axes=ax,
mosaic_axes_key="earth",
ax=ax["earth"],
)
def _plot_mask(mask_img, mask_img_max, mask_key):
if (
mask_key == "mask_hi"
or (mask_key == "mask_lo" and not _is_value_in("mask_hi", plot_positions))
) and axes_options[mask_key]["show_title"]:
ax[mask_key].set_title(
"Gridded Visibilities",
fontsize=axes_options[mask_key]["title_fontsize"],
)
mask = ax[mask_key].imshow(
X=mask_img,
cmap=axes_options[mask_key]["cmap"],
norm=_get_norm(
axes_options[mask_key]["norm"],
vmin=mask_img_max[mask_img_max > 0].min(),
vmax=mask_img_max.max(),
),
origin="lower",
interpolation="none",
)
if axes_options[mask_key]["show_cbar"]:
_configure_colorbar(
mappable=mask,
ax=ax[mask_key],
fig=fig,
label=axes_options[mask_key]["label"]
if axes_options[mask_key]["cbar_label"]
else None,
show_ticks=axes_options[mask_key]["cbar_ticks"],
fontsize=axes_options[mask_key]["cbar_fontsize"],
)
if axes_options[mask_key]["axes_labels"]:
ax[mask_key].set_xlabel(
"Frequels", fontsize=axes_options[mask_key]["axes_fontsize"]
)
ax[mask_key].set_ylabel(
"Frequels", fontsize=axes_options[mask_key]["axes_fontsize"]
)
if not axes_options[mask_key]["axes_ticks"]:
ax[mask_key].set_xticks([])
ax[mask_key].set_yticks([])
else:
ax[mask_key].xaxis.set_tick_params(
labelsize=axes_options[mask_key]["axes_fontsize"]
)
ax[mask_key].yaxis.set_tick_params(
labelsize=axes_options[mask_key]["axes_fontsize"]
)
_apply_crop(ax=ax[mask_key], crop=mask_crop)
return mask
plots = {
"uv": uv_scat,
"di": di_im,
"earth": _is_value_in("earth", plot_positions),
"mask_hi": None,
"mask_lo": None,
}
for mask_key, mask_img, mask_img_max in zip(
["mask_hi", "mask_lo"], mask_imgs, mask_imgs_max
):
if _is_value_in(mask_key, plot_positions):
mask = _plot_mask(
mask_img=mask_img, mask_img_max=mask_img_max, mask_key=mask_key
)
plots[mask_key] = mask
if save_to is not None:
save_to = Path(save_to)
save_args = {"bbox_inches": "tight"} if save_args is None else save_args
fig.savefig(save_to, **save_args)
return fig, ax, plots, axes_options
[docs]
def animate_observation(
gridder: Gridder,
series: GridDataSeries,
fps: float,
save_to: str | PathLike,
max_values: tuple[GridData, np.ndarray, np.ndarray, np.ndarray] | None = None,
uv_max_extension: float = 0.2,
plot_positions: list[list[str]] | None = None,
dirty_image_mode: str = "real",
mask_mode: str = "amp_phase",
swap_masks: bool = False,
mask_crop: tuple[list[float | None]] = ([None, None], [None, None]),
axes_options: dict | None = None,
show_progress: bool = True,
dpi: int | str = "figure",
) -> None:
"""Creates an animation from the given GridDataSeries.
Parameters
----------
gridder : Gridder
The ``Gridder`` with which the series was gridded.
series : GridDataSeries
The series of gridded observations.
fps : float
The frame rate for the animation in frames per second.
save_to : str | PathLike
The path to save the animation to. This has to include the filename and
extensions.
max_values : tuple[GridData, np.ndarray, np.ndarray, np.ndarray] | None, optional
The maximum values of the gridded and ungridded data and the timestamps.
These values are used to configure the maximum scale for the plot axes
and colorbars. The values have to be in the following order:
``[GridData, ungridded u, ungridded v, times]``.
If set to ``None``, the maximum will be taken from the last element of the
``series``.
Default is ``None``.
uv_max_extension : float, optional
The fractional extension of the uv plot's axes limits.
A value of e.g. ``0.5`` would correspond to 50% larger u and v axes.
Default is ``0.2``.
plot_positions : dict[str] | None, optional
The mosaic layout of the plot. The following keys are available:
- ``uv``: Refers to the ungridded :math:(u,v) coordinates plot.
- ``di``: Refers to the dirty image plot.
- ``earth``: Refers to the plot of the source position and the
antennas on the earth's surface.
- ``mask_hi``: Refers to the upper plot of the gridded visibility masks.
- ``mask_lo``: Refers to the lower plot of the gridded visibility masks.
Note the layout should be valid to be used in a
``matplotlib.pyplot.subplot_mosaic`` call.
Default is
.. code-block:: python
[["mask_hi", "earth", "uv"], ["mask_lo", "earth", "di"]]
dirty_image_mode : str, optional
The mode specifying which values of the dirty image should be plotted.
Possible values are:
- ``real``: Plots the real part of the dirty image.
- ``imag``: Plots the imaginary part of the dirty image.
- ``abs`` / ``amp``: Plot the absolute value of the dirty image.
Default is ``real``.
mask_mode : str, optional
The mode specifying which representation of the visibility masks should be used.
Possible values are:
- ``amp_phase``: Plots the amplitude and phase of the complex numbers.
- ``real_imag``: Plots the real and imaginary parts of the complex numbers.
Default is ``amp_phase``.
swap_masks : bool, optional
Whether to swap the mask order which is used to determine which mask part is
used as ``mask_hi`` and which as ``mask_lo``.
By default the order is: ``mask_hi = amplitude | real`` and
``mask_lo = phase | imaginary``.
Default is ``False``.
mask_crop : tuple[list[float | None]], optional
The crop of the masks. This has to have the format
``([x_left, x_right], [y_left, y_right])``, where the left and right
values for each axis are the upper and lower limits of the axes which
should be shown.
Default is `([None, None], [None, None])`
axes_options : dict | None, optional
Options for the different subplots of the mosaic plot. The given dictionary will
be merged with the default option dictionary. This means that options which
are given in this parameter overwrite the option in the default configuration.
The default configuration is:
.. code-block:: python
{
"uv": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": True,
"axes_fontsize": "x-small",
"show_times": True,
"cmap": "magma",
"show_cbar": True,
"cbar_ticks": True,
"cbar_label": True,
"cbar_fontsize": "small",
"color": _default_colors[4],
"aspect": "equal",
},
"di": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": "inferno",
"norm": "sqrt",
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
"mode_in_label": True,
},
"mask_hi": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[0],
"label": mask_labels[0],
"norm": mask_norms[0],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"mask_lo": {
"show_title": True,
"title_fontsize": "medium",
"axes_ticks": False,
"axes_labels": False,
"axes_fontsize": "x-small",
"cmap": mask_cmaps[1],
"label": mask_labels[1],
"norm": mask_norms[1],
"show_cbar": True,
"cbar_ticks": False,
"cbar_label": True,
"cbar_fontsize": "small",
},
"earth": {
"show_title": True,
"title_fontsize": "small",
"show_legend": True,
"legend_args": None,
"legend_fontsize": "x-small",
"coastline_width": 0.7,
"show_terrain_texture": True,
"show_grid_lines": True,
"show_night_shade": True,
"marker_sizes": None,
"plot_colors": None,
},
}
show_progress : bool, optional
Whether to show the progress of saving the animation.
Default is ``True``.
dpi : int | str, optional
The DPI (Dots Per Inch) of the animation.
Default is ``'figure'``.
"""
def _progress_func(_i, _n):
progress_bar.update(1)
frames = len(series)
# Format: GridDataSeries[i] = [grid_data, u, v, times]
init_data = series[1]
last_data = series[-1]
fig, ax, plots, axes_options = plot_observation_state(
gridder=gridder,
vis_data=init_data[0],
u=init_data[1],
v=init_data[2],
times=init_data[3],
max_values=[last_data[0], last_data[1], last_data[2], last_data[3]],
uv_max_extension=uv_max_extension,
plot_positions=plot_positions,
mask_mode=mask_mode,
swap_masks=swap_masks,
mask_crop=mask_crop,
axes_options=axes_options,
)
def update(frame):
return_vals = []
for val in plots.values():
if val is not None and not isinstance(val, bool):
return_vals.append(val)
if frame == 0:
return return_vals
vis_data, u, v, times = series[frame]
# Update uv plot
uv_scat = plots["uv"]
if uv_scat is not None:
u = np.append(-u, u)
v = np.append(-v, v)
uv_scat.set_offsets(np.stack([u, v]).T)
uv_scat.set_array(np.tile(_times2hours(times=times), reps=2))
# Update dirty image
di_im = plots["di"]
if di_im is not None:
match dirty_image_mode:
case "real":
dirty_image = vis_data.dirty_image.real
case "imag":
dirty_image = vis_data.dirty_image.imag
case "abs" | "amp":
dirty_image = np.abs(vis_data.dirty_image)
di_im.set_data(dirty_image)
# Update masks
if mask_mode == "amp_phase":
mask_imgs = vis_data.get_mask_abs_phase()
elif mask_mode == "real_imag":
mask_imgs = vis_data.get_mask_real_imag()
if swap_masks:
mask_imgs = mask_imgs[::-1]
mask_hi = plots["mask_hi"]
if mask_hi is not None:
mask_hi.set_data(mask_imgs[0])
mask_lo = plots["mask_lo"]
if mask_lo is not None:
mask_lo.set_data(mask_imgs[1])
# Update earth
if plots["earth"]:
current_time = Time(times[-1], format="mjd")
plot_earth_layout(
layout=gridder.antenna_layout,
src_ra=gridder.src_ra,
src_dec=gridder.src_dec,
current_time=current_time,
show_legend=axes_options["earth"]["show_legend"],
show_title=axes_options["earth"]["show_title"],
coastline_width=axes_options["earth"]["coastline_width"],
show_terrain_texture=axes_options["earth"]["show_terrain_texture"],
show_grid_lines=axes_options["earth"]["show_grid_lines"],
show_night_shade=axes_options["earth"]["show_night_shade"],
plot_colors=axes_options["earth"]["plot_colors"],
fig=fig,
mosaic_axes=ax,
mosaic_axes_key="earth",
ax=ax["earth"],
)
return return_vals
save_to = Path(save_to)
writer = None
if save_to.suffix.lower() == ".gif":
writer = animation.PillowWriter(
fps=fps,
bitrate=-1,
)
writer.setup(fig=fig, outfile=save_to, dpi=dpi)
ani = animation.FuncAnimation(
fig=fig, func=update, frames=frames, blit=False, interval=1e3 / fps
)
with tqdm(
total=frames, desc="Saving animation", disable=not show_progress
) as progress_bar:
if writer is None:
ani.save(save_to, progress_callback=_progress_func, dpi=dpi)
else:
ani.save(save_to, progress_callback=_progress_func, writer=writer, dpi=dpi)