Source code for production_configuration.monte_carlo_statistics_estimator

"""Estimate required Monte Carlo statistics from trigger histogram products."""

import logging

import astropy.units as u
import numpy as np
from astropy.table import Table

from simtools.context import RunContext
from simtools.io.file_type import validate_file_type
from simtools.production_configuration.derive_corsika_limits import FILE_INFO_COLUMNS
from simtools.production_configuration.histogram_output_metadata import (
    extract_histogram_output_metadata,
)
from simtools.production_configuration.trigger_histograms import (
    load_trigger_histograms,
)
from simtools.visualization.plot_simtel_event_histograms import (
    plot_monte_carlo_statistics_diagnostics,
)

_logger = logging.getLogger(__name__)


def _get_metadata_quantity(metadata_row, column_name, default_unit):
    """Return a quantity-valued metadata field from an astropy table row.

    Parameters
    ----------
    metadata_row : astropy.table.row.Row
        Metadata row selected from the trigger-histogram metadata table.
    column_name : str
        Column name to read from the row.
    default_unit : astropy.units.UnitBase or str
        Unit to apply when the source column has no unit metadata.

    Returns
    -------
    astropy.units.Quantity
        Quantity reconstructed from the row scalar and the table-column unit.
    """
    value = metadata_row[column_name]
    column = metadata_row.table[column_name]
    unit = getattr(column, "unit", None) or default_unit
    return u.Quantity(value, unit)


def _resolve_effective_upper_bound(original_value, override_value, unit, label):
    """Return an effective upper bound, validating an optional reduced override."""
    original_value = u.Quantity(original_value).to(unit)
    if original_value.value <= 0.0:
        raise ValueError(f"Original {label} must be positive.")
    if override_value is None:
        return original_value

    override_value = u.Quantity(override_value).to(unit)
    if override_value.value <= 0.0:
        raise ValueError(f"Reduced {label} must be positive.")
    if override_value > original_value:
        raise ValueError(f"Reduced {label} cannot exceed the original simulated {label}.")
    return override_value


def _resolve_effective_throw_radius(original_radius, radius_override=None):
    """Return the effective throw radius, validating optional overrides."""
    return _resolve_effective_upper_bound(
        original_radius,
        radius_override,
        u.m,
        "core scatter radius",
    )


def _resolve_effective_view_cone_radius(original_radius, radius_override=None):
    """Return the effective maximum view-cone radius, validating optional overrides."""
    return _resolve_effective_upper_bound(
        original_radius,
        radius_override,
        u.deg,
        "view cone radius",
    )


def _power_law_bin_integrals(bin_lows, bin_highs, spectral_index):
    """Integrate a power law across energy bins."""
    exponent = spectral_index + 1.0
    bin_lows = np.asarray(bin_lows, dtype=float)
    bin_highs = np.asarray(bin_highs, dtype=float)
    if np.isclose(exponent, 0.0):
        return np.log(bin_highs / bin_lows)
    return (np.power(bin_highs, exponent) - np.power(bin_lows, exponent)) / exponent


def _compute_spectral_bin_weights(energy_edges, spectral_index, br_energy):
    """Return unnormalized spectral weights per energy bin."""
    energy_edges = np.asarray(energy_edges, dtype=float)
    br_energy_min = u.Quantity(br_energy[0]).to_value(u.TeV)
    br_energy_max = u.Quantity(br_energy[1]).to_value(u.TeV)
    if br_energy_min <= 0.0 or br_energy_max <= 0.0:
        raise ValueError("Thrown energy bounds must be positive.")
    if br_energy_min >= br_energy_max:
        raise ValueError("Thrown energy minimum must be smaller than the maximum.")

    lows = energy_edges[:-1]
    highs = energy_edges[1:]
    clipped_lows = np.maximum(lows, br_energy_min)
    clipped_highs = np.minimum(highs, br_energy_max)
    overlaps = clipped_highs > clipped_lows

    weights = np.zeros_like(lows, dtype=float)
    if np.any(overlaps):
        weights[overlaps] = _power_law_bin_integrals(
            clipped_lows[overlaps],
            clipped_highs[overlaps],
            spectral_index,
        )
    return weights


def _compute_energy_bin_probabilities(
    energy_edges,
    energy_totals,
    source_spectral_index,
    target_spectral_index,
    br_energy,
):
    """Return empirical per-bin probabilities reweighted from source to target spectrum."""
    energy_totals = np.asarray(energy_totals, dtype=float)
    total_simulated = np.sum(energy_totals)
    if total_simulated <= 0.0:
        raise ValueError("No simulated events available in reference energy bins.")

    if np.isclose(source_spectral_index, target_spectral_index):
        return energy_totals / total_simulated

    source_weights = _compute_spectral_bin_weights(energy_edges, source_spectral_index, br_energy)
    target_weights = _compute_spectral_bin_weights(energy_edges, target_spectral_index, br_energy)
    reweighting = np.divide(
        target_weights,
        source_weights,
        out=np.zeros_like(target_weights, dtype=float),
        where=source_weights > 0.0,
    )
    weighted_totals = energy_totals * reweighting
    total = np.sum(weighted_totals)
    if total <= 0.0:
        raise ValueError("Thrown energy range does not overlap any reference energy bins.")
    return weighted_totals / total


def _compute_expected_trigger_matrix(simulated_counts, triggered_counts, energy_probabilities):
    """Return the expected triggered counts per one thrown event."""
    simulated_counts = np.asarray(simulated_counts, dtype=float)
    triggered_counts = np.asarray(triggered_counts, dtype=float)
    energy_probabilities = np.asarray(energy_probabilities, dtype=float)

    energy_totals = simulated_counts.sum(axis=0)
    trigger_probabilities = np.divide(
        triggered_counts,
        energy_totals[np.newaxis, :],
        out=np.zeros_like(simulated_counts, dtype=float),
        where=energy_totals[np.newaxis, :] > 0,
    )
    return trigger_probabilities * energy_probabilities[np.newaxis, :]


def _compute_expected_counts(expected_triggers_per_event, required_total_events):
    """Return expected triggered counts for the solved total thrown events."""
    if not np.isfinite(required_total_events):
        return np.full_like(expected_triggers_per_event, np.nan, dtype=float)
    return np.asarray(expected_triggers_per_event, dtype=float) * float(required_total_events)


def _ceil_required_total_events(required_total_events):
    """Round the required total number of events up to the next integer."""
    if not np.isfinite(required_total_events):
        return required_total_events
    return int(np.ceil(required_total_events))


def _compute_relative_uncertainty(expected_counts):
    """Return Poisson relative uncertainty for expected counts."""
    expected_counts = np.asarray(expected_counts, dtype=float)
    return np.divide(
        1.0,
        np.sqrt(expected_counts),
        out=np.full_like(expected_counts, np.inf, dtype=float),
        where=expected_counts > 0.0,
    )


def _get_reference_matrix(reference_rows, column_name):
    """Return one per-bin matrix reconstructed from the flattened table."""
    rows = reference_rows.copy()
    if "core_distance_bin_index" in rows.colnames:
        rows.sort(["angular_distance_bin_index", "energy_bin_index", "core_distance_bin_index"])
        angular_indices = np.unique(rows["angular_distance_bin_index"])
        energy_indices = np.unique(rows["energy_bin_index"])
        core_indices = np.unique(rows["core_distance_bin_index"])
        return np.asarray(rows[column_name]).reshape(
            len(angular_indices), len(energy_indices), len(core_indices)
        )
    rows.sort(["angular_distance_bin_index", "energy_bin_index"])
    angular_indices = np.unique(rows["angular_distance_bin_index"])
    energy_indices = np.unique(rows["energy_bin_index"])
    return np.asarray(rows[column_name]).reshape(len(angular_indices), len(energy_indices))


def _get_reference_bin_edges(reference_rows):
    """Return the bin edges for energy and angular distance histograms."""
    axis_units = {
        "energy": u.TeV,
        "angular_distance": u.deg,
    }
    edges = []
    for axis, unit in axis_units.items():
        rows = reference_rows.copy()
        rows.sort(f"{axis}_bin_index")
        lows = np.unique(rows[f"{axis}_low"].quantity.to_value(unit))
        highs = np.unique(rows[f"{axis}_high"].quantity.to_value(unit))
        edges.append(np.concatenate([lows[:1], highs]))
    return edges[0], edges[1]


def _get_reference_core_edges(reference_rows):
    """Return core-distance bin edges for core-distance-binned trigger histograms."""
    if "core_distance_bin_index" not in reference_rows.colnames:
        raise ValueError(
            "Core-distance-binned trigger histograms are required to use reduced_core_radius. "
            "Rebuild trigger histograms with simtools-write-trigger-histograms."
        )
    rows = reference_rows.copy()
    rows.sort("core_distance_bin_index")
    lows = np.unique(rows["core_distance_low"].quantity.to_value(u.m))
    highs = np.unique(rows["core_distance_high"].quantity.to_value(u.m))
    return np.concatenate([lows[:1], highs])


def _compute_core_distance_weights(core_edges, effective_radius):
    """Return area-fraction weights for integrating core-distance bins."""
    effective_radius = u.Quantity(effective_radius).to_value(u.m)
    if effective_radius <= 0.0:
        raise ValueError("Effective core scatter radius must be positive.")

    core_edges = np.asarray(core_edges, dtype=float)
    lows = core_edges[:-1]
    highs = core_edges[1:]
    denominator = highs**2 - lows**2
    clipped_highs = np.minimum(highs, effective_radius)
    numerator = np.maximum(clipped_highs**2 - lows**2, 0.0)
    return np.divide(
        numerator,
        denominator,
        out=np.zeros_like(numerator, dtype=float),
        where=denominator > 0.0,
    ).clip(0.0, 1.0)


def _compute_view_cone_weights(angular_edges, effective_radius):
    """Return solid-angle-fraction weights for integrating angular-distance bins.

    Weights are computed from the spherical solid angle covered by each bin, not from
    the linear angular width. Bins intersected by the reduced view-cone radius receive
    a partial weight corresponding to the clipped solid-angle fraction.
    """
    effective_radius = u.Quantity(effective_radius).to_value(u.deg)
    if effective_radius <= 0.0:
        raise ValueError("Effective view cone radius must be positive.")

    angular_edges = np.asarray(angular_edges, dtype=float)
    lows = np.deg2rad(angular_edges[:-1])
    highs = np.deg2rad(angular_edges[1:])
    clipped_highs = np.deg2rad(np.minimum(angular_edges[1:], effective_radius))
    denominator = np.cos(lows) - np.cos(highs)
    numerator = np.maximum(np.cos(lows) - np.cos(clipped_highs), 0.0)
    return np.divide(
        numerator,
        denominator,
        out=np.zeros_like(numerator, dtype=float),
        where=denominator > 0.0,
    ).clip(0.0, 1.0)


def _collapse_core_distance_counts(
    simulated_counts, triggered_counts, core_edges, effective_radius
):
    """Collapse 3D count matrices over core distance using area-fraction weights."""
    weights = _compute_core_distance_weights(core_edges, effective_radius)
    simulated_counts = np.asarray(simulated_counts, dtype=float)
    triggered_counts = np.asarray(triggered_counts, dtype=float)
    return (
        np.sum(simulated_counts * weights[np.newaxis, np.newaxis, :], axis=2),
        np.sum(triggered_counts * weights[np.newaxis, np.newaxis, :], axis=2),
    )


def _restrict_view_cone_counts(simulated_counts, triggered_counts, angular_edges, effective_radius):
    """Scale angular-distance bins to a reduced maximum view-cone radius."""
    weights = _compute_view_cone_weights(angular_edges, effective_radius)
    simulated_counts = np.asarray(simulated_counts, dtype=float)
    triggered_counts = np.asarray(triggered_counts, dtype=float)
    return (
        simulated_counts * weights[:, np.newaxis],
        triggered_counts * weights[:, np.newaxis],
    )


def _compute_trigger_efficiency(triggered_counts, simulated_counts):
    """Return trigger efficiency from triggered and simulated counts."""
    triggered_counts = np.asarray(triggered_counts, dtype=float)
    simulated_counts = np.asarray(simulated_counts, dtype=float)
    return np.divide(
        triggered_counts,
        simulated_counts,
        out=np.zeros_like(triggered_counts, dtype=float),
        where=simulated_counts > 0.0,
    )


def _compute_overall_trigger_probability(expected_triggers_per_event, energy_mask):
    """Return the weighted trigger probability in the selected optimization range."""
    return float(np.sum(np.asarray(expected_triggers_per_event, dtype=float)[:, energy_mask]))


def _log_reference_validation_summary(metadata_row, reference_rows):
    """Log a compact validation summary for one trigger-histogram reference."""
    total_simulated = int(np.sum(np.asarray(reference_rows["simulated_count"], dtype=float)))
    total_triggered = int(np.sum(np.asarray(reference_rows["triggered_count"], dtype=float)))
    overall_efficiency = (
        float(total_triggered / total_simulated) if total_simulated > 0 else float("nan")
    )
    zenith = _get_metadata_quantity(metadata_row, "zenith", u.deg).to_value(u.deg)
    azimuth = _get_metadata_quantity(metadata_row, "azimuth", u.deg).to_value(u.deg)
    nsb_level = metadata_row["nsb_level"]
    _logger.info(
        "Using trigger histogram for array_layout=%s "
        "(zenith=%.3f deg, azimuth=%.3f deg, nsb_level=%s): "
        "simulated_events=%d triggered_events=%d overall_trigger_efficiency=%.6g",
        metadata_row["array_name"],
        zenith,
        azimuth,
        nsb_level,
        total_simulated,
        total_triggered,
        overall_efficiency,
    )


def _log_overall_trigger_probability(metadata_row, overall_trigger_probability):
    """Log the overall trigger probability used for overall-target estimation."""
    _logger.info(
        "Overall trigger probability in selected optimization range for array_layout=%s "
        "(zenith=%.3f deg, azimuth=%.3f deg, nsb_level=%s): %.6g",
        metadata_row["array_name"],
        _get_metadata_quantity(metadata_row, "zenith", u.deg).to_value(u.deg),
        _get_metadata_quantity(metadata_row, "azimuth", u.deg).to_value(u.deg),
        metadata_row["nsb_level"],
        overall_trigger_probability,
    )


def _select_reference_rows(metadata_table, array_names):
    """Filter reference metadata rows according to optional user-facing selectors."""
    selected = metadata_table
    if array_names:
        selected = selected[np.isin(selected["array_name"].astype(str), array_names)]
    if len(selected) == 0:
        raise ValueError("No trigger histograms matched the requested selection.")
    return selected


def _optimization_energy_mask(energy_edges, optimization_energy):
    """Return the energy-bin mask used for the optimization criterion."""
    centers = 0.5 * (np.asarray(energy_edges[:-1]) + np.asarray(energy_edges[1:]))
    optimization_energy_min = u.Quantity(optimization_energy[0]).to_value(u.TeV)
    optimization_energy_max = u.Quantity(optimization_energy[1]).to_value(u.TeV)
    if optimization_energy_min >= optimization_energy_max:
        raise ValueError("Optimization energy minimum must be smaller than the maximum.")
    mask = (centers >= optimization_energy_min) & (centers <= optimization_energy_max)
    if not np.any(mask):
        raise ValueError("Optimization energy range does not overlap any reference energy bins.")
    return mask


def _estimate_required_events(
    expected_triggers_per_event,
    energy_mask,
    target_relative_uncertainty=None,
    target_triggered_events=None,
    overall_trigger_probability=None,
):
    """Solve for required total thrown events from estimable trigger bins."""
    if target_relative_uncertainty is not None and target_triggered_events is not None:
        raise ValueError(
            "Target relative uncertainty and target triggered events are mutually exclusive."
        )
    candidate_matrix, positive_mask = _prepare_estimation_candidates(
        expected_triggers_per_event, energy_mask
    )

    if target_relative_uncertainty is not None:
        return _estimate_required_events_from_uncertainty(
            candidate_matrix,
            positive_mask,
            target_relative_uncertainty,
        )

    if target_triggered_events is not None:
        return _estimate_required_events_from_total_trigger_target(
            target_triggered_events,
            overall_trigger_probability,
        )

    raise ValueError(
        "Either target relative uncertainty or target triggered events must be provided."
    )


def _prepare_estimation_candidates(expected_triggers_per_event, energy_mask):
    """Return candidate bins and masks restricted to the optimization energy range."""
    candidate_matrix = np.asarray(expected_triggers_per_event, dtype=float)[:, energy_mask]
    positive_mask = np.isfinite(candidate_matrix) & (candidate_matrix > 0.0)
    return candidate_matrix, positive_mask


def _estimate_required_events_from_uncertainty(
    candidate_matrix,
    positive_mask,
    target_relative_uncertainty,
):
    """Solve the per-bin uncertainty target using the worst relevant bin."""
    if target_relative_uncertainty <= 0.0:
        raise ValueError("Target relative uncertainty must be positive.")
    if not np.any(positive_mask):
        return np.inf

    required_trigger_count = 1.0 / float(target_relative_uncertainty) ** 2
    required_totals = np.full_like(candidate_matrix, -np.inf, dtype=float)
    required_totals[positive_mask] = required_trigger_count / candidate_matrix[positive_mask]
    return float(np.max(required_totals))


def _estimate_required_events_from_total_trigger_target(
    target_triggered_events,
    overall_trigger_probability,
):
    """Solve the overall triggered-event target using the selected-range trigger probability."""
    if target_triggered_events <= 0:
        raise ValueError("Target triggered events must be positive.")
    if overall_trigger_probability is None:
        raise ValueError("Overall trigger probability must be provided.")
    if overall_trigger_probability <= 0.0:
        return np.inf

    return float(target_triggered_events) / float(overall_trigger_probability)


def _resolve_energy_ranges(metadata_row, optimization_energy):
    """
    Resolve thrown and optimization energy ranges for one histogram row.

    Parameters
    ----------
    metadata_row : astropy.table.row.Row
        Metadata row selected from the trigger-histogram metadata table.
    optimization_energy : tuple of astropy.units.Quantity or None
        Optional user-specified optimization energy range.

    """
    br_energy = (
        _get_metadata_quantity(metadata_row, "energy_min", u.TeV),
        _get_metadata_quantity(metadata_row, "energy_max", u.TeV),
    )
    optimization_energy_min = None
    optimization_energy_max = None
    if optimization_energy is not None:
        optimization_energy_min, optimization_energy_max = optimization_energy
    resolved_optimization_energy = (
        br_energy[0] if optimization_energy_min is None else optimization_energy_min,
        br_energy[1] if optimization_energy_max is None else optimization_energy_max,
    )
    return br_energy, resolved_optimization_energy


def _extract_diagnostic_file_info(metadata_row):
    """Return observational metadata used to disambiguate diagnostic plot filenames."""
    return {
        "zenith": _get_metadata_quantity(metadata_row, "zenith", u.deg),
        "azimuth": _get_metadata_quantity(metadata_row, "azimuth", u.deg),
        "nsb_level": metadata_row["nsb_level"],
    }


def _build_result_metadata(
    metadata_row,
):
    """Build the metadata fields shared by all estimator result rows."""
    return extract_histogram_output_metadata(
        metadata_row,
        FILE_INFO_COLUMNS,
        include_array_name=True,
    )


def _build_table_metadata(args_dict):
    """Build table-level metadata for configuration values shared by all rows."""
    metadata = {
        "spectral_index": args_dict.get("spectral_index"),
        "target_relative_uncertainty": args_dict.get("target_relative_uncertainty"),
        "target_triggered_events": args_dict.get("target_triggered_events"),
        "optimization_energy_min": args_dict.get("optimization_energy_min"),
        "optimization_energy_max": args_dict.get("optimization_energy_max"),
        "reduced_core_radius": args_dict.get("reduced_core_radius"),
        "reduced_view_cone_radius": args_dict.get("reduced_view_cone_radius"),
    }
    return {key: value for key, value in metadata.items() if value is not None}


def _resolve_source_spectral_index(metadata_row):
    """Return the original simulated spectral index stored in histogram metadata."""
    if "spectral_index" in metadata_row.colnames:
        spectral_index = float(metadata_row["spectral_index"])
        if np.isfinite(spectral_index):
            return spectral_index

    _logger.warning(
        "No spectral index found in trigger histogram metadata for array_layout=%s; assuming -2.0.",
        metadata_row["array_name"],
    )
    return -2.0


def _resolve_reference_spectral_index(
    metadata_row, spectral_index_override, source_spectral_index=None
):
    """Return the target spectral index to use for one reference row."""
    if source_spectral_index is None:
        source_spectral_index = _resolve_source_spectral_index(metadata_row)
    if spectral_index_override is not None:
        spectral_index = float(spectral_index_override)
        _logger.info(
            "Using user-provided target spectral index %.6g for array_layout=%s "
            "(source spectral index %.6g from trigger histogram metadata).",
            spectral_index,
            metadata_row["array_name"],
            source_spectral_index,
        )
        return spectral_index

    _logger.info(
        "Using spectral index %.6g from trigger histogram metadata for array_layout=%s.",
        source_spectral_index,
        metadata_row["array_name"],
    )
    return source_spectral_index


def _resolved_table_spectral_index(selected_references, spectral_index_override):
    """Return the table-level spectral index metadata value."""
    if spectral_index_override is not None:
        return float(spectral_index_override)

    if "spectral_index" in selected_references.colnames:
        spectral_indices = np.asarray(selected_references["spectral_index"], dtype=float)
        spectral_indices = spectral_indices[np.isfinite(spectral_indices)]
        if spectral_indices.size == 0:
            return -2.0
        unique_indices = np.unique(np.round(spectral_indices, decimals=12))
        if unique_indices.size == 1:
            return float(unique_indices[0])
    return -2.0


def _prepare_reference_estimation_inputs(
    metadata_row,
    reference_rows,
    reduced_core_radius,
    reduced_view_cone_radius,
    optimization_energy,
    source_spectral_index,
    target_spectral_index,
):
    """Prepare per-reference matrices, ranges, and derived probabilities for estimation."""
    energy_edges, angular_edges = _get_reference_bin_edges(reference_rows)
    simulated_counts = _get_reference_matrix(reference_rows, "simulated_count")
    triggered_counts = _get_reference_matrix(reference_rows, "triggered_count")

    effective_radius = _resolve_effective_throw_radius(
        _get_metadata_quantity(metadata_row, "core_scatter_max", u.m),
        reduced_core_radius,
    )
    if simulated_counts.ndim == 3:
        core_edges = _get_reference_core_edges(reference_rows)
        simulated_counts, triggered_counts = _collapse_core_distance_counts(
            simulated_counts,
            triggered_counts,
            core_edges,
            effective_radius,
        )
    elif reduced_core_radius is not None:
        raise ValueError(
            "Core-distance-binned trigger histograms are required to use reduced_core_radius. "
            "Rebuild trigger histograms with simtools-write-trigger-histograms."
        )

    effective_view_cone_radius = _resolve_effective_view_cone_radius(
        _get_metadata_quantity(metadata_row, "viewcone_max", u.deg),
        reduced_view_cone_radius,
    )
    simulated_counts, triggered_counts = _restrict_view_cone_counts(
        simulated_counts,
        triggered_counts,
        angular_edges,
        effective_view_cone_radius,
    )
    trigger_efficiency = _compute_trigger_efficiency(triggered_counts, simulated_counts)
    br_energy, optimization_energy = _resolve_energy_ranges(metadata_row, optimization_energy)
    energy_probabilities = _compute_energy_bin_probabilities(
        energy_edges,
        simulated_counts.sum(axis=0),
        source_spectral_index,
        target_spectral_index,
        br_energy,
    )
    expected_triggers_per_event = _compute_expected_trigger_matrix(
        simulated_counts,
        triggered_counts,
        energy_probabilities,
    )
    energy_mask = _optimization_energy_mask(energy_edges, optimization_energy)
    overall_trigger_probability = _compute_overall_trigger_probability(
        expected_triggers_per_event,
        energy_mask,
    )
    return {
        "energy_edges": energy_edges,
        "angular_edges": angular_edges,
        "simulated_counts": simulated_counts,
        "triggered_counts": triggered_counts,
        "effective_radius": effective_radius,
        "effective_view_cone_radius": effective_view_cone_radius,
        "trigger_efficiency": trigger_efficiency,
        "br_energy": br_energy,
        "optimization_energy": optimization_energy,
        "expected_triggers_per_event": expected_triggers_per_event,
        "energy_mask": energy_mask,
        "overall_trigger_probability": overall_trigger_probability,
    }


def _build_result_row(
    metadata_row,
    bin_table,
    target_relative_uncertainty,
    target_triggered_events,
    reduced_core_radius,
    reduced_view_cone_radius,
    optimization_energy,
    spectral_index_override,
    plot_diagnostics,
    context=None,
):
    """Build one result row for a selected trigger histogram."""
    reference_id = metadata_row["reference_id"]
    reference_rows = bin_table[bin_table["reference_id"] == reference_id]
    source_spectral_index = _resolve_source_spectral_index(metadata_row)
    spectral_index = _resolve_reference_spectral_index(
        metadata_row,
        spectral_index_override,
        source_spectral_index=source_spectral_index,
    )
    _log_reference_validation_summary(metadata_row, reference_rows)
    prepared = _prepare_reference_estimation_inputs(
        metadata_row,
        reference_rows,
        reduced_core_radius,
        reduced_view_cone_radius,
        optimization_energy,
        source_spectral_index,
        spectral_index,
    )
    if target_triggered_events is not None:
        _log_overall_trigger_probability(metadata_row, prepared["overall_trigger_probability"])
    required_total_events = _estimate_required_events(
        prepared["expected_triggers_per_event"],
        prepared["energy_mask"],
        target_relative_uncertainty=target_relative_uncertainty,
        target_triggered_events=target_triggered_events,
        overall_trigger_probability=prepared["overall_trigger_probability"],
    )
    required_total_events = _ceil_required_total_events(required_total_events)
    expected_counts = _compute_expected_counts(
        prepared["expected_triggers_per_event"], required_total_events
    )
    relative_uncertainty = _compute_relative_uncertainty(expected_counts)

    if plot_diagnostics:
        context = context or RunContext.from_environment()
        plot_monte_carlo_statistics_diagnostics(
            context.paths.get_output_directory(),
            metadata_row["array_name"],
            _extract_diagnostic_file_info(metadata_row),
            prepared["energy_edges"],
            prepared["angular_edges"],
            expected_counts,
            relative_uncertainty,
        )

    return _build_result_metadata(
        metadata_row,
    ) | {
        "estimated_total_events": required_total_events,
        "br_energy_min": u.Quantity(prepared["br_energy"][0]).to(u.TeV),
        "br_energy_max": u.Quantity(prepared["br_energy"][1]).to(u.TeV),
    }


[docs] def estimate_monte_carlo_statistics(args_dict=None, metadata=None, context=None): """ Estimate required total thrown events for one or more trigger histograms. Parameters ---------- args_dict : dict, optional Estimator configuration. Uses the active application configuration when omitted. metadata : dict, optional Top-level product metadata to include in the ECSV header. Returns ------- astropy.table.Table Results table containing required thrown-event estimates and limiting-bin diagnostics for each selected trigger histogram. """ context = context or RunContext.from_environment() args_dict = args_dict or context.args metadata_table, bin_table = load_trigger_histograms(args_dict.get("trigger_histogram_file")) selected_references = _select_reference_rows( metadata_table, args_dict.get("array_layout_name"), ) if args_dict.get("plot_diagnostics") and len(selected_references) > 0: _logger.info( "Writing Monte Carlo statistics diagnostic plots to %s", context.paths.get_output_directory(), ) output_rows = [ _build_result_row( metadata_row, bin_table, args_dict.get("target_relative_uncertainty"), args_dict.get("target_triggered_events"), args_dict.get("reduced_core_radius"), args_dict.get("reduced_view_cone_radius"), ( args_dict.get("optimization_energy_min"), args_dict.get("optimization_energy_max"), ), args_dict.get("spectral_index"), args_dict.get("plot_diagnostics"), context=context, ) for metadata_row in selected_references ] results = Table(rows=output_rows) results.meta.update(metadata or {}) results.meta.update(_build_table_metadata(args_dict)) resolved_spectral_index = _resolved_table_spectral_index( selected_references, args_dict.get("spectral_index"), ) if resolved_spectral_index is not None: results.meta["spectral_index"] = resolved_spectral_index output_file = validate_file_type( context.paths.get_output_file(args_dict.get("output_file")), file_type="table" ) results.write(output_file, format="ascii.ecsv", overwrite=True) _logger.info(f"Writing Monte Carlo statistics estimates to {output_file}") return results