"""Plot production-grid points on sky coordinate projections."""
import logging
from pathlib import Path
import numpy as np
from astropy import units as u
from astropy.table import Table
from simtools.production_configuration.jobs_io import JOB_GRID_SCHEMA
from simtools.visualization.matplotlib_backend import pyplot as plt
from simtools.visualization.visualize import save_figure
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_FILE_STEM = "production_grid_sky_projection"
AZIMUTH_ANGLE_COLUMN = "azimuth_angle"
ZENITH_ANGLE_COLUMN = "zenith_angle"
PLOT_VALUE_KEYS = tuple(
key
for key in JOB_GRID_SCHEMA.column_units
if key
not in (
AZIMUTH_ANGLE_COLUMN,
ZENITH_ANGLE_COLUMN,
"ha",
"dec",
"view_cone_min",
"nsb_rate",
"corsika_hadronic_transition_energy",
)
)
DEFAULT_OUTPUT_FILE_EXTENSION = "png"
DEFAULT_MARKER_SIZE = 8
SHOWERS_PER_RUN_COLUMN = "showers_per_run"
TOTAL_SHOWERS_LABEL = "Total showers per pointing"
def _azimuth_zenith_output_file_stem(value_key):
"""Return output stem for azimuth/zenith color-scale plots."""
return f"production_grid_altaz_{value_key}"
def _zenith_profile_output_file_stem(value_key):
"""Return output stem for zenith profile plots."""
return f"production_grid_zenith_profile_{value_key}"
[docs]
class ProductionGridPlotter:
"""
Plot production grid points on sky coordinate projections.
Parameters
----------
grid_points_file : str or Path
Path to the ECSV file containing grid points.
output_path : str or Path
Path to save output plots.
"""
def __init__(
self,
grid_points_file,
output_path,
):
"""Initialize the ProductionGridPlotter."""
self.grid_points_file = Path(grid_points_file)
self.output_path = Path(output_path)
self.output_path.mkdir(parents=True, exist_ok=True)
self.grid_metadata = {}
self.grid_columns = []
self.has_hadec_columns = False
self.grid_points = self._load_grid_points()
logger.info(f"Loaded {len(self.grid_points)} grid points from {self.grid_points_file}")
def _load_grid_points(self):
"""
Load grid points from ECSV file.
Returns
-------
list
List of grid point dictionaries.
Raises
------
FileNotFoundError
If the grid points file does not exist.
ValueError
If the grid points file is not ECSV.
"""
if not self.grid_points_file.exists():
msg = f"Grid points file not found: {self.grid_points_file}"
logger.error(msg)
raise FileNotFoundError(msg)
if self.grid_points_file.suffix.lower() != ".ecsv":
msg = f"Grid points file must be ECSV: {self.grid_points_file}"
logger.error(msg)
raise ValueError(msg)
grid_table = Table.read(self.grid_points_file, format="ascii.ecsv")
self.grid_metadata = dict(grid_table.meta)
self.grid_columns = list(grid_table.colnames)
self.has_hadec_columns = {"ha", "dec"}.issubset(self.grid_columns)
return self._convert_ecsv_table_to_grid_points(grid_table)
@classmethod
def _convert_ecsv_table_to_grid_points(cls, grid_table):
"""Convert an ECSV grid-point table to plain dictionaries."""
return [cls._convert_ecsv_row_to_grid_point(row, grid_table) for row in grid_table]
@staticmethod
def _plain_table_value(value):
"""Return a plain scalar for non-masked table values."""
if np.ma.is_masked(value):
return None
if isinstance(value, np.generic):
return value.item()
return value
@classmethod
def _convert_ecsv_row_to_grid_point(cls, row, grid_table):
"""Convert one ECSV row and merge split value/unit quantity columns."""
point = {}
for column_name in grid_table.colnames:
value = cls._plain_table_value(row[column_name])
if value is not None:
unit = grid_table[column_name].unit
point[column_name] = value * unit if unit is not None else value
return point
@staticmethod
def _extract_quantity_value(point, key):
"""
Extract numeric value from a grid-point entry.
Parameters
----------
point : dict
Grid-point dictionary.
key : str
Key to extract.
Returns
-------
float or None
Extracted numeric value.
"""
value = point.get(key)
if isinstance(value, u.Quantity):
return float(value.value)
if isinstance(value, dict):
if "value" in value:
return float(value["value"])
if "lower" in value and isinstance(value["lower"], dict) and "value" in value["lower"]:
return float(value["lower"]["value"])
return None
if value is None:
return None
return float(value)
@classmethod
def _extract_first_available_quantity_value(cls, point, keys):
"""Extract first available quantity value from a list of candidate keys."""
for key in keys:
value = cls._extract_quantity_value(point, key)
if value is not None:
return value
return None
def _normalize_grid_point(self, point):
"""
Normalize a grid point from available columns.
Parameters
----------
point : dict
Grid-point dictionary.
Returns
-------
dict or None
Normalized point with native frame metadata.
"""
azimuth = self._extract_first_available_quantity_value(
point,
(AZIMUTH_ANGLE_COLUMN,),
)
zenith = self._extract_first_available_quantity_value(
point,
(ZENITH_ANGLE_COLUMN,),
)
hour_angle = self._extract_quantity_value(point, "ha")
declination = self._extract_quantity_value(point, "dec")
value_data = {value_key: point.get(value_key) for value_key in PLOT_VALUE_KEYS}
point_ha = float(hour_angle) if hour_angle is not None else None
point_dec = float(declination) if declination is not None else None
if azimuth is not None and zenith is not None:
return {
"native_frame": "altaz",
"azimuth": azimuth % 360.0,
"zenith": zenith,
"ha": point_ha,
"dec": point_dec,
**value_data,
"visible_in_altaz": True,
}
if hour_angle is not None and declination is not None:
return {
"native_frame": "hadec",
"azimuth": None,
"zenith": None,
"ha": float(hour_angle),
"dec": float(declination),
**value_data,
"visible_in_altaz": None,
}
logger.warning(f"Skipping point without supported coordinates: {point}")
return None
def _normalize_grid_points(self):
"""Normalize all grid points for plotting."""
normalized_points = []
for point in self.grid_points:
normalized_point = self._normalize_grid_point(point)
if normalized_point is not None:
normalized_points.append(normalized_point)
return normalized_points
[docs]
def normalize_grid_points(self):
"""Return normalized grid points for plotting and validation."""
return self._normalize_grid_points()
@staticmethod
def _split_points_by_frame(plot_points, frame_name, require_altaz_visibility=False):
"""Split plot points by native coordinate frame."""
selected_points = []
for point in plot_points:
if point["native_frame"] != frame_name:
continue
if require_altaz_visibility and not point["visible_in_altaz"]:
continue
selected_points.append(point)
return selected_points
@staticmethod
def _resolve_value_unit(plot_points, value_key):
"""Resolve a representative unit string for one plotted value key."""
for point in plot_points:
value = point.get(value_key)
if isinstance(value, u.Quantity):
return str(value.unit)
return None
@staticmethod
def _plot_value(value):
"""Return a plain numeric value for plotting."""
if isinstance(value, u.Quantity):
return float(value.value)
return float(value)
@classmethod
def _format_value_label_with_unit(cls, plot_points, value_key, value_label):
"""Format a plot label with units when available in normalized points."""
unit_value = cls._resolve_value_unit(plot_points, value_key)
if unit_value:
return f"{value_label} [{unit_value}]"
return value_label
@staticmethod
def _has_plottable_hadec_points(plot_points):
"""Return whether normalized points include plottable HA/Dec coordinates."""
return any(point["ha"] is not None and point["dec"] is not None for point in plot_points)
@staticmethod
def _create_projection_axes(show_hadec_panel):
"""Create figure and projection axes based on available coordinate panels."""
figure = plt.figure(figsize=(15, 7) if show_hadec_panel else (8, 7))
if show_hadec_panel:
altaz_axis = figure.add_subplot(1, 2, 1, projection="polar")
hadec_axis = figure.add_subplot(1, 2, 2)
return figure, altaz_axis, hadec_axis
altaz_axis = figure.add_subplot(1, 1, 1, projection="polar")
return figure, altaz_axis, None
@staticmethod
def _add_axis_legend_if_present(axis, location_kwargs):
"""Add a legend to an axis only when visible labels are present."""
_, labels = axis.get_legend_handles_labels()
if any(label and not label.startswith("_") for label in labels):
axis.legend(**location_kwargs)
def _add_panel_legends(self, altaz_axis, hadec_axis, altaz_count, hadec_count):
"""Add legends to Alt/Az and HA/Dec panels when needed."""
if altaz_count > 0:
self._add_axis_legend_if_present(
altaz_axis,
{"loc": "upper left", "bbox_to_anchor": (1.0, 1.15)},
)
if hadec_axis and hadec_count > 0:
self._add_axis_legend_if_present(hadec_axis, {"loc": "upper right"})
def _build_subtitle_lines(self):
"""Build subtitle lines from available grid metadata."""
subtitle_lines = []
if self.grid_metadata.get("site"):
subtitle_lines.append(f"Site: {self.grid_metadata['site']}")
if self.grid_metadata.get("direction_grid_density") is not None:
density_unit = self.grid_metadata.get("direction_grid_density_unit") or "1/deg^2"
subtitle_lines.append(
f"Grid density: {self.grid_metadata['direction_grid_density']} {density_unit}"
)
if self.grid_metadata.get("time_of_observation_utc"):
subtitle_lines.append(
f"Observation time (UTC): {self.grid_metadata['time_of_observation_utc']}"
)
return subtitle_lines
@staticmethod
def _render_subtitle_lines(figure, subtitle_lines):
"""Render subtitle lines below the figure title."""
for index, subtitle_line in enumerate(subtitle_lines):
figure.text(
0.5,
0.95 - index * 0.025,
subtitle_line,
ha="center",
va="top",
fontsize=10,
)
[docs]
def plot_sky_projection(self):
"""Create sky projection plots with Alt/Az and HA/Dec grid points."""
plot_points = self.normalize_grid_points()
show_hadec_panel = self.has_hadec_columns and self._has_plottable_hadec_points(plot_points)
figure, altaz_axis, hadec_axis = self._create_projection_axes(show_hadec_panel)
altaz_count = self._plot_altaz_points(altaz_axis, plot_points)
hadec_count = self._plot_hadec_points(hadec_axis, plot_points) if hadec_axis else 0
self._add_panel_legends(altaz_axis, hadec_axis, altaz_count, hadec_count)
figure.suptitle("Production Grid Points", fontsize=14, y=0.98)
self._render_subtitle_lines(figure, self._build_subtitle_lines())
figure.tight_layout(rect=(0.0, 0.0, 1.0, 0.92))
output_file = self.output_path / (
f"{DEFAULT_OUTPUT_FILE_STEM}.{DEFAULT_OUTPUT_FILE_EXTENSION}"
)
save_figure(figure, output_file.with_suffix(""), figure_format=["png"], dpi=300)
logger.info(f"Saved sky projection plot to {output_file}")
plt.close(figure)
def _configure_altaz_axis(self, axis):
"""Configure the azimuth/zenith polar projection axis."""
axis.set_theta_zero_location("N")
axis.set_theta_direction(-1)
axis.set_rmax(90)
axis.set_rticks(np.arange(10, 91, 10))
axis.grid(True, color="gray", alpha=0.5, linestyle="--")
axis.set_title("Local Azimuth / Zenith", pad=18)
axis.set_xlabel("Azimuth [deg]")
axis.set_ylabel("")
axis.set_axisbelow(False)
def _configure_hadec_axis(self, axis, plot_points):
"""Configure the HA/Dec axis."""
hour_angles = (
np.array([point["ha"] for point in plot_points]) if plot_points else np.array([])
)
declinations = (
np.array([point["dec"] for point in plot_points]) if plot_points else np.array([])
)
axis.grid(True, color="gray", alpha=0.5, linestyle="--")
axis.set_xlabel("Hour angle [deg]")
axis.set_ylabel("Declination [deg]")
axis.set_title("Hour angle / Declination")
if hour_angles.size > 0:
ha_min = np.floor(np.min(hour_angles) / 10.0) * 10.0
ha_max = np.ceil(np.max(hour_angles) / 10.0) * 10.0
if np.isclose(ha_min, ha_max):
ha_min -= 5.0
ha_max += 5.0
axis.set_xlim(ha_min, ha_max)
if declinations.size > 0:
dec_min = np.floor(np.min(declinations) / 5.0) * 5.0
dec_max = np.ceil(np.max(declinations) / 5.0) * 5.0
if np.isclose(dec_min, dec_max):
dec_min -= 5.0
dec_max += 5.0
axis.set_ylim(dec_min, dec_max)
@staticmethod
def _scatter_group(axis, plot_points, color, x_key, y_key, x_transform=None):
"""Scatter a group of points with configurable coordinates."""
if not plot_points:
return 0
x_values = [point[x_key] for point in plot_points]
y_values = [point[y_key] for point in plot_points]
if x_transform is not None:
x_values = x_transform(x_values)
axis.scatter(
x_values,
y_values,
s=DEFAULT_MARKER_SIZE,
linewidths=1.0,
edgecolors=color,
facecolors="none",
zorder=2.2,
)
return len(plot_points)
def _plot_frame_points(
self,
axis,
plot_points,
primary_frame,
secondary_frame,
primary_color,
secondary_color,
x_key,
y_key,
panel_name,
require_altaz_visibility=False,
x_transform=None,
):
"""Plot primary and transformed point groups for a given panel."""
primary_points = self._split_points_by_frame(
plot_points,
primary_frame,
require_altaz_visibility=require_altaz_visibility,
)
secondary_points = self._split_points_by_frame(
plot_points,
secondary_frame,
require_altaz_visibility=require_altaz_visibility,
)
plotted_points = 0
plotted_points += self._scatter_group(
axis,
primary_points,
color=primary_color,
x_key=x_key,
y_key=y_key,
x_transform=x_transform,
)
plotted_points += self._scatter_group(
axis,
secondary_points,
color=secondary_color,
x_key=x_key,
y_key=y_key,
x_transform=x_transform,
)
if plotted_points == 0:
logger.warning(f"No valid grid points found for {panel_name} plotting")
return 0
logger.info(f"Plotted {plotted_points} grid points in the {panel_name} panel")
return plotted_points
def _plot_altaz_points(self, axis, plot_points):
"""
Plot grid points on the Alt/Az projection.
Parameters
----------
axis : matplotlib.axes.Axes
Polar projection axes.
plot_points : list of dict
Normalized plot points.
"""
self._configure_altaz_axis(axis)
plotted_points = self._plot_frame_points(
axis=axis,
plot_points=plot_points,
primary_frame="altaz",
secondary_frame="hadec",
primary_color="tab:blue",
secondary_color="tab:orange",
x_key="azimuth",
y_key="zenith",
panel_name="Azimuth/Zenith",
require_altaz_visibility=True,
x_transform=np.radians,
)
hidden_points = sum(point["visible_in_altaz"] is False for point in plot_points)
if hidden_points > 0:
logger.info(
f"Skipping {hidden_points} HA/Dec points below the horizon in Azimuth/Zenith panel"
)
return plotted_points
def _plot_hadec_points(self, axis, plot_points):
"""
Plot grid points on the HA/Dec projection.
Parameters
----------
axis : matplotlib.axes.Axes
Cartesian axes.
plot_points : list of dict
Normalized plot points.
"""
self._configure_hadec_axis(axis, plot_points)
return self._plot_frame_points(
axis=axis,
plot_points=plot_points,
primary_frame="hadec",
secondary_frame="altaz",
primary_color="tab:orange",
secondary_color="tab:blue",
x_key="ha",
y_key="dec",
panel_name="HA/Dec",
)
@staticmethod
def _select_altaz_points_with_value(plot_points, value_key):
"""Select native Alt/Az points that have valid coordinates and requested values."""
return [
point
for point in plot_points
if point.get("native_frame") == "altaz"
and point.get("azimuth") is not None
and point.get("zenith") is not None
and point.get(value_key) is not None
]
[docs]
def plot_azimuth_zenith_projection_with_color_scale(
self, value_key, value_label, output_file_stem, plot_points=None
):
"""Plot azimuth/zenith points with a color scale for one value column."""
plot_points = self.normalize_grid_points() if plot_points is None else plot_points
value_label_with_unit = self._format_value_label_with_unit(
plot_points,
value_key,
value_label,
)
colored_points = self._select_altaz_points_with_value(plot_points, value_key)
if not colored_points:
logger.warning(f"No azimuth/zenith points with '{value_key}' available for plotting")
return
figure = plt.figure(figsize=(8, 7))
axis = figure.add_subplot(1, 1, 1, projection="polar")
self._configure_altaz_axis(axis)
azimuth_values = np.radians([point["azimuth"] for point in colored_points])
zenith_values = [point["zenith"] for point in colored_points]
color_values = [self._plot_value(point[value_key]) for point in colored_points]
scatter = axis.scatter(
azimuth_values,
zenith_values,
c=color_values,
cmap="viridis",
s=DEFAULT_MARKER_SIZE * 3,
edgecolors="black",
linewidths=0.3,
)
colorbar = figure.colorbar(scatter, ax=axis, pad=0.12)
colorbar.set_label(value_label_with_unit)
axis.set_title(f"Local Azimuth / Zenith colored by {value_label_with_unit}", pad=18)
figure.tight_layout(rect=(0.0, 0.0, 1.0, 0.95))
output_file = self.output_path / f"{output_file_stem}.{DEFAULT_OUTPUT_FILE_EXTENSION}"
save_figure(figure, output_file.with_suffix(""), figure_format=["png"], dpi=300)
logger.info(f"Saved azimuth/zenith color-scale plot to {output_file}")
plt.close(figure)
@staticmethod
def _available_plot_value_keys(plot_points):
"""Return value keys present in at least one normalized Alt/Az point."""
return [
value_key
for value_key in PLOT_VALUE_KEYS
if any(
point["native_frame"] == "altaz" and point.get(value_key) is not None
for point in plot_points
)
]
[docs]
def plot_limit_projections(self):
"""Plot all supported production-grid limit values."""
plot_points = self.normalize_grid_points()
for value_key in self._available_plot_value_keys(plot_points):
self.plot_azimuth_zenith_projection_with_color_scale(
value_key=value_key,
value_label=value_key,
output_file_stem=_azimuth_zenith_output_file_stem(value_key),
plot_points=plot_points,
)
self.plot_zenith_limits_for_azimuths(
value_key=value_key,
value_label=value_key,
output_file_stem=_zenith_profile_output_file_stem(value_key),
plot_points=plot_points,
)
@staticmethod
def _circular_azimuth_difference_degrees(first_azimuth, second_azimuth):
"""Return minimal absolute difference between two azimuth angles."""
return abs(((first_azimuth - second_azimuth + 180.0) % 360.0) - 180.0)
[docs]
def plot_zenith_limits_for_azimuths(
self,
value_key,
value_label,
output_file_stem,
azimuth_targets=(0.0, 180.0),
azimuth_tolerance_deg=1e-6,
plot_points=None,
):
"""Plot value versus zenith for selected azimuth pointings."""
plot_points = self.normalize_grid_points() if plot_points is None else plot_points
value_label_with_unit = self._format_value_label_with_unit(
plot_points,
value_key,
value_label,
)
altaz_points = self._select_altaz_points_with_value(plot_points, value_key)
if not altaz_points:
logger.warning(
f"No azimuth/zenith points with '{value_key}' available for zenith profiling"
)
return
figure, axis = plt.subplots(figsize=(8, 5))
plotted_series = 0
for azimuth_target in azimuth_targets:
selected_points = [
point
for point in altaz_points
if self._circular_azimuth_difference_degrees(point["azimuth"], azimuth_target)
<= azimuth_tolerance_deg
]
if not selected_points:
continue
selected_points = sorted(selected_points, key=lambda point: point["zenith"])
zenith_values = [point["zenith"] for point in selected_points]
value_values = [self._plot_value(point[value_key]) for point in selected_points]
axis.plot(zenith_values, value_values, marker="o", label=f"az={azimuth_target:.0f} deg")
plotted_series += 1
if plotted_series == 0:
logger.warning(
f"No zenith profile points for '{value_key}' at azimuths {azimuth_targets}"
)
plt.close(figure)
return
axis.set_xlabel("Zenith angle [deg]")
axis.set_ylabel(value_label_with_unit)
axis.set_title(f"{value_label_with_unit} vs zenith angle")
axis.grid(True, color="gray", alpha=0.5, linestyle="--")
axis.legend(loc="best")
figure.tight_layout()
output_file = self.output_path / f"{output_file_stem}.{DEFAULT_OUTPUT_FILE_EXTENSION}"
save_figure(figure, output_file.with_suffix(""), figure_format=["png"], dpi=300)
logger.info(f"Saved zenith profile plot to {output_file}")
plt.close(figure)
def _plot_shower_statistics(self, points, output_stem, title, y_label):
"""Plot shower counts versus zenith with azimuth encoded by color."""
zenith_values, azimuth_values, shower_values = zip(*points)
figure, axis = plt.subplots(figsize=(9, 5.5), constrained_layout=True)
scatter = axis.scatter(
zenith_values,
shower_values,
c=azimuth_values,
cmap="viridis",
s=24,
alpha=0.8,
linewidths=0,
)
axis.set_xlabel("Zenith angle (deg)")
axis.set_ylabel(y_label)
axis.set_title(title)
axis.grid(True, alpha=0.25)
figure.colorbar(scatter, ax=axis).set_label("Azimuth angle (deg)")
output_file = self.output_path / f"{output_stem}.{DEFAULT_OUTPUT_FILE_EXTENSION}"
save_figure(figure, output_file.with_suffix(""), figure_format=["png"], dpi=180)
plt.close(figure)
logger.info("Saved shower statistics plot to %s", output_file)
[docs]
def plot_shower_statistics_vs_zenith(self, azimuth_center=180.0, azimuth_width=1.0):
"""Plot total and per-run showers against zenith angle.
Parameters
----------
azimuth_center : float, optional
Center of the azimuth interval used for the selected-azimuth plot.
azimuth_width : float, optional
Width in degrees of the selected azimuth interval.
Raises
------
ValueError
If ``azimuth_width`` is not positive.
"""
if azimuth_width <= 0:
raise ValueError("azimuth_width must be positive")
grouped_rows = {}
for point in self.grid_points:
azimuth = self._extract_quantity_value(point, AZIMUTH_ANGLE_COLUMN)
zenith = self._extract_quantity_value(point, ZENITH_ANGLE_COLUMN)
showers = point.get(SHOWERS_PER_RUN_COLUMN)
if azimuth is not None and zenith is not None and showers is not None:
grouped_rows.setdefault((azimuth, zenith), []).append(int(showers))
if not grouped_rows:
logger.warning("No Alt/Az shower statistics available for zenith plotting")
return
total_points = [
(zenith, azimuth, sum(showers)) for (azimuth, zenith), showers in grouped_rows.items()
]
single_run_points = [
(zenith, azimuth, showers[0]) for (azimuth, zenith), showers in grouped_rows.items()
]
selected_points = [
point
for point in total_points
if self._circular_azimuth_difference_degrees(point[1], azimuth_center)
<= azimuth_width / 2
]
if not selected_points:
logger.warning(
"No shower statistics within %s deg of azimuth %s deg",
azimuth_width / 2,
azimuth_center,
)
else:
self._plot_shower_statistics(
selected_points,
f"showers_vs_zenith_az{azimuth_center:g}_width{azimuth_width:g}deg",
f"Total showers per pointing, azimuth {azimuth_center:g} +/- "
f"{azimuth_width / 2:g} deg",
TOTAL_SHOWERS_LABEL,
)
self._plot_shower_statistics(
total_points,
"showers_vs_zenith_all_azimuths",
"Total showers per pointing, all azimuths",
TOTAL_SHOWERS_LABEL,
)
self._plot_shower_statistics(
single_run_points,
"showers_per_run_vs_zenith_single_row",
"Showers in one row per pointing, all azimuths",
"Showers per run (one row)",
)