Source code for visualization.plot_incident_angles

#!/usr/bin/python3
"""Plot incident angle histograms for focal, primary, and secondary mirrors.

Plots the primary-mirror hit radius if available.
"""

import logging
from pathlib import Path

import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np

__all__ = ["plot_incident_angles"]

Y_AXIS_BIN_COUNT_LABEL = "Density"


def _gather_angle_arrays(results_by_offset, column, log):
    arrays = []
    for off, tab in results_by_offset.items():
        if tab is None or len(tab) == 0:
            if column == "angle_incidence_focal":
                log.warning(f"Empty results for off-axis={off}")
            continue
        if column not in tab.colnames:
            continue
        arrays.append(tab[column].to(u.deg).value)
    return arrays


def _gather_radius_arrays(results_by_offset, column, log):
    arrays = []
    for off, tab in results_by_offset.items():
        if tab is None or len(tab) == 0 or column not in tab.colnames:
            continue
        try:
            arrays.append(tab[column].to(u.m).value)
        except (AttributeError, ValueError, TypeError):
            log.warning("Skipping radius values for off-axis=%s due to unit/format issue", off)
    return arrays


def _plot_radius_vs_angle(
    results_by_offset,
    radius_col,
    angle_col,
    title,
    out_path,
    log,
):
    any_points = False
    fig, ax = plt.subplots(1, 1, figsize=(7, 5))
    for off in sorted(results_by_offset.keys()):
        tab = results_by_offset[off]
        if tab is None or len(tab) == 0:
            continue
        if radius_col not in tab.colnames or angle_col not in tab.colnames:
            continue
        r = tab[radius_col].to(u.m).value
        a = tab[angle_col].to(u.deg).value
        mask = np.isfinite(r) & np.isfinite(a)
        r, a = r[mask], a[mask]
        if r.size == 0 or a.size == 0:
            continue
        any_points = True
        ax.scatter(r, a, s=4, alpha=0.25, label=f"off-axis {off:g} deg")
    if not any_points:
        plt.close(fig)
        log.warning("No valid data to plot for %s", title)
        return
    ax.set_xlabel("Hit radius (m)")
    ax.set_ylabel("Angle of incidence (deg)")
    ax.set_title(title)
    ax.grid(True, alpha=0.3)
    ax.legend(markerscale=3)
    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close(fig)


def _plot_xy_heatmap(
    results_by_offset,
    x_col,
    y_col,
    title,
    out_path,
    log,
    bins=400,
):
    any_points = False
    fig, ax = plt.subplots(1, 1, figsize=(6, 5))
    h = None
    for _off, x, y in _iter_xy_valid_points(results_by_offset, x_col, y_col):
        any_points = True
        h = ax.hist2d(x, y, bins=bins, cmap="viridis", norm=None)
    if not any_points:
        plt.close(fig)
        log.warning("No valid data to plot for %s", title)
        return
    ax.set_xlabel("X hit (m)")
    ax.set_ylabel("Y hit (m)")
    ax.set_title(title)
    ax.grid(False)
    cb = plt.colorbar(h[3], ax=ax)
    cb.set_label("Counts per bin")
    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close(fig)


def _plot_xy_heatmaps_per_offset(
    results_by_offset,
    x_col,
    y_col,
    title_prefix,
    file_stem,
    out_dir,
    label,
    bins=400,
):
    for off, x, y in _iter_xy_valid_points(results_by_offset, x_col, y_col):
        fig, ax = plt.subplots(1, 1, figsize=(6, 5))
        h = ax.hist2d(x, y, bins=bins, cmap="viridis", norm=None)
        ax.set_xlabel("X hit (m)")
        ax.set_ylabel("Y hit (m)")
        ax.set_aspect("equal", adjustable="box")
        ax.set_title(f"{title_prefix} (off-axis {off:g} deg)")
        cb = plt.colorbar(h[3], ax=ax)
        cb.set_label("Counts per bin")
        plt.tight_layout()
        out_path = out_dir / f"{file_stem}{off:g}_{label}.png"
        plt.savefig(out_path, dpi=300)
        plt.close(fig)


def _iter_xy_valid_points(results_by_offset, x_col, y_col):
    """Yield (off, x, y) arrays for valid entries with finite X/Y in meters.

    Filters out None/empty tables, missing columns, and non-finite rows.
    Offsets are iterated in sorted order.
    """
    for off in sorted(results_by_offset.keys()):
        tab = results_by_offset[off]
        if tab is None or len(tab) == 0:
            continue
        if x_col not in tab.colnames or y_col not in tab.colnames:
            continue
        x = tab[x_col].to(u.m).value
        y = tab[y_col].to(u.m).value
        mask = np.isfinite(x) & np.isfinite(y)
        x, y = x[mask], y[mask]
        if x.size == 0:
            continue
        yield off, x, y


def _compute_bins(all_vals, bin_width, log, context):
    finite_mask = np.isfinite(all_vals)
    if not np.any(finite_mask):
        if context == "focal":
            log.warning("No focal-surface incidence angle values to plot for this telescope type")
        else:
            log.warning("No %s values to plot for this telescope type", context)
        return None
    vals = all_vals[finite_mask]
    vmin = float(np.floor(vals.min() / bin_width) * bin_width)
    vmax = float(np.ceil(vals.max() / bin_width) * bin_width)
    if not np.isfinite(vmin) or not np.isfinite(vmax):
        log.warning("Invalid bin edges for %s: vmin=%s vmax=%s", context, vmin, vmax)
        return None
    if vmax <= vmin:
        vmax = vmin + bin_width
    return np.arange(vmin, vmax + bin_width * 0.5, bin_width)


def _plot_radius_histograms(
    results_by_offset,
    radius_col,
    title,
    xlabel,
    out_path,
    bin_width_m,
    log,
):
    arrays = _gather_radius_arrays(results_by_offset, radius_col, log)
    if not arrays:
        return
    all_vals = np.concatenate(arrays)
    bins_m = _compute_bins(all_vals, bin_width=bin_width_m, log=log, context=f"{radius_col}_m")
    if bins_m is None:
        return
    fig, ax = plt.subplots(1, 1, figsize=(7, 5))
    for off in sorted(results_by_offset.keys()):
        tab = results_by_offset[off]
        if tab is None or len(tab) == 0 or radius_col not in tab.colnames:
            continue
        data = tab[radius_col].to(u.m).value
        data = data[np.isfinite(data)]
        if data.size == 0:
            continue
        _, _, patches = ax.hist(
            data,
            bins=bins_m,
            density=True,
            stacked=True,
            histtype="step",
            linewidth=0.5,
            label=f"off-axis {off:g} deg",
            zorder=3,
        )
        color = patches[0].get_edgecolor() if patches else None
        ax.hist(
            data,
            bins=bins_m,
            density=True,
            stacked=True,
            histtype="stepfilled",
            alpha=0.15,
            color=color,
            edgecolor="none",
            label="_nolegend_",
            zorder=1,
        )
        ax.hist(
            data,
            bins=bins_m,
            density=True,
            stacked=True,
            histtype="step",
            linewidth=0.5,
            color=color,
            label="_nolegend_",
            zorder=4,
        )
    ax.set_xlabel(xlabel)
    ax.set_ylabel(Y_AXIS_BIN_COUNT_LABEL)
    ax.set_title(title)
    ax.grid(True, alpha=0.3)
    ax.legend()
    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close(fig)


def _plot_debug_plots(results_by_offset, out_dir, label, radius_bin_width_m, log):
    _plot_radius_histograms(
        results_by_offset,
        radius_col="primary_hit_radius",
        title="Primary mirror hit radius vs off-axis angle",
        xlabel="Primary-hit radius on M1 (m)",
        out_path=out_dir / f"incident_radius_primary_multi_{label}.png",
        bin_width_m=radius_bin_width_m,
        log=log,
    )
    _plot_radius_histograms(
        results_by_offset,
        radius_col="secondary_hit_radius",
        title="Secondary mirror hit radius vs off-axis angle",
        xlabel="Secondary-hit radius on M2 (m)",
        out_path=out_dir / f"incident_radius_secondary_multi_{label}.png",
        bin_width_m=radius_bin_width_m,
        log=log,
    )

    _plot_radius_vs_angle(
        results_by_offset,
        radius_col="primary_hit_radius",
        angle_col="angle_incidence_primary",
        title="Primary mirror: hit radius vs incidence angle",
        out_path=out_dir / f"incident_primary_radius_vs_angle_multi_{label}.png",
        log=log,
    )
    _plot_radius_vs_angle(
        results_by_offset,
        radius_col="secondary_hit_radius",
        angle_col="angle_incidence_secondary",
        title="Secondary mirror: hit radius vs incidence angle",
        out_path=out_dir / f"incident_secondary_radius_vs_angle_multi_{label}.png",
        log=log,
    )

    _plot_xy_heatmaps_per_offset(
        results_by_offset,
        x_col="primary_hit_x",
        y_col="primary_hit_y",
        title_prefix="Primary mirror: X-Y hit distribution",
        file_stem="incident_primary_xy_heatmap_off",
        out_dir=out_dir,
        label=label,
    )
    _plot_xy_heatmaps_per_offset(
        results_by_offset,
        x_col="secondary_hit_x",
        y_col="secondary_hit_y",
        title_prefix="Secondary mirror: X-Y hit distribution",
        file_stem="incident_secondary_xy_heatmap_off",
        out_dir=out_dir,
        label=label,
    )


def _plot_overlay_angles(results_by_offset, column, bins, ax, use_zorder):
    for off in sorted(results_by_offset.keys()):
        tab = results_by_offset[off]
        if tab is None or len(tab) == 0 or column not in tab.colnames:
            continue
        data = tab[column].to(u.deg).value
        data = data[np.isfinite(data)]
        if data.size == 0:
            continue
        z1, z2, z3 = (3, 1, 4) if use_zorder else (None, None, None)
        _, _, patches = ax.hist(
            data,
            bins=bins,
            density=True,
            stacked=True,
            histtype="step",
            linewidth=0.5,
            label=f"off-axis {off:g} deg",
            zorder=z1,
        )
        color = patches[0].get_edgecolor() if patches else None
        ax.hist(
            data,
            bins=bins,
            density=True,
            stacked=True,
            histtype="stepfilled",
            alpha=0.15,
            color=color,
            edgecolor="none",
            label="_nolegend_",
            zorder=z2,
        )
        ax.hist(
            data,
            bins=bins,
            density=True,
            stacked=True,
            histtype="step",
            linewidth=0.5,
            color=color,
            label="_nolegend_",
            zorder=z3,
        )


def _plot_component_angles(
    results_by_offset,
    column,
    title_suffix,
    out_path,
    bin_width_deg,
    log,
):
    arrays = _gather_angle_arrays(results_by_offset, column, log)
    if not arrays:
        return
    bins = _compute_bins(np.concatenate(arrays), bin_width_deg, log, context=column)
    if bins is None:
        return
    fig, ax = plt.subplots(1, 1, figsize=(7, 5))
    _plot_overlay_angles(results_by_offset, column, bins, ax, use_zorder=False)
    ax.set_xlabel("Angle of incidence (deg)")
    ax.set_ylabel(Y_AXIS_BIN_COUNT_LABEL)
    ax.set_title(f"Incident angle {title_suffix} vs off-axis angle")
    ax.grid(True, alpha=0.3)
    ax.legend()
    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close(fig)


[docs] def plot_incident_angles( results_by_offset, output_dir, label, bin_width_deg=0.1, radius_bin_width_m=0.01, debug_plots=False, logger=None, ): """Plot overlaid histograms of focal, primary, secondary angles, and primary hit radius.""" log = logger or logging.getLogger(__name__) if not results_by_offset: log.warning("No results provided for multi-offset plot") return out_dir = Path(output_dir) / "plots" out_dir.mkdir(parents=True, exist_ok=True) # Focal-surface angles arrays = _gather_angle_arrays(results_by_offset, "angle_incidence_focal", log) if arrays: bins = _compute_bins(np.concatenate(arrays), bin_width_deg, log, context="focal") if bins is not None: fig, ax = plt.subplots(1, 1, figsize=(7, 5)) _plot_overlay_angles( results_by_offset, "angle_incidence_focal", bins, ax, use_zorder=True ) ax.set_xlabel("Angle of incidence at focal surface (deg) w.r.t. optical axis") ax.set_ylabel(Y_AXIS_BIN_COUNT_LABEL) ax.set_title("Incident angle distribution vs off-axis angle") ax.grid(True, alpha=0.3) ax.legend() plt.tight_layout() plt.savefig(out_dir / f"incident_angles_multi_{label}.png", dpi=300) plt.close(fig) # Primary and secondary mirror angles _plot_component_angles( results_by_offset=results_by_offset, column="angle_incidence_primary", title_suffix="on primary mirror (w.r.t. normal)", out_path=out_dir / f"incident_angles_primary_multi_{label}.png", bin_width_deg=bin_width_deg, log=log, ) _plot_component_angles( results_by_offset=results_by_offset, column="angle_incidence_secondary", title_suffix="on secondary mirror (w.r.t. normal)", out_path=out_dir / f"incident_angles_secondary_multi_{label}.png", bin_width_deg=bin_width_deg, log=log, ) # Debug plots if debug_plots: _plot_debug_plots(results_by_offset, out_dir, label, radius_bin_width_m, log)