Source code for simtel.bias_curve_generator

"""Generate bias curves from NSB and proton trigger rates."""

import logging
from pathlib import Path

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

from simtools.context import RunContext
from simtools.data_model import model_data_writer
from simtools.io.file_type import product_value
from simtools.model.telescope_model import TelescopeModel
from simtools.simtel.nsb_trigger_calculator import (
    derive_nsb_triggers,
    extract_run_number,
    extract_threshold,
)
from simtools.telescope_trigger_rates import telescope_trigger_rates
from simtools.visualization import plot_tables

_logger = logging.getLogger(__name__)

_REDUCED_EVENT_DATA_SUFFIX = product_value("sim_telarray_event_data", "default_suffix")


[docs] def generate_bias_curves(args, context=None): """Generate bias curves from NSB/proton reduced event-data HDF5 files.""" time_window = _calculate_time_window(args, context) _logger.info(f"Calculated time window: {time_window * 1e9:.2f} ns") _logger.info("Extracting NSB trigger rates from HDF5 files...") nsb_stats = _extract_nsb_rates(args, time_window) if not nsb_stats: raise FileNotFoundError(f"No NSB input files found in {args['event_data_directory']}") _logger.info("Calculating proton trigger rates...") proton_stats = _extract_proton_rates(args, context) if not proton_stats: raise FileNotFoundError(f"No proton input files found in {args['event_data_directory']}") if args.get("proton_table_file"): _write_proton_ecsv(proton_stats, args["proton_table_file"]) _logger.info(f"Proton table written to {args['proton_table_file']}") plot_output_path = plot_tables.resolve_plot_output_path(args["figure_file"]) bias_curve_table_output = plot_output_path.with_suffix(".ecsv") trigger_threshold = _calculate_trigger_threshold(args, nsb_stats, proton_stats) # Log the data points _logger.info("Trigger threshold calculation data:") thresholds = sorted(set(nsb_stats.keys()) | set(proton_stats.keys())) scaling_factor = args.get("scaling_factor", 1.35) for thresh in thresholds: nsb_rate = nsb_stats[thresh]["rate_hz"] if thresh in nsb_stats else None proton_rate = proton_stats[thresh]["rate_hz"] if thresh in proton_stats else None scaled_proton = ( scaling_factor * plot_tables.PROTON_INTERSECTION_FRACTION * proton_rate if proton_rate is not None else None ) _logger.info( f" Threshold {thresh}: NSB={nsb_rate} Hz, " f"Proton={proton_rate} Hz, Scaled={scaled_proton} Hz" ) _logger.info("Plotting bias curves...") plot_tables.plot_bias_curves(nsb_stats, proton_stats, args, plot_output_path, trigger_threshold) _write_bias_curve_ecsv(nsb_stats, proton_stats, bias_curve_table_output) _export_trigger_threshold_as_model_parameter(args, trigger_threshold, context=context) _logger.info(f"Bias curve plot written to {plot_output_path}") _logger.info(f"Bias curve table written to {bias_curve_table_output}") nsb_table_file = args.get("nsb_table_file") if nsb_table_file and Path(nsb_table_file).exists(): _logger.info(f"NSB table written to {nsb_table_file}")
def _calculate_time_window(args, context): """ Calculate time window from telescope parameters. Gets telescope from args and retrieves its parameters. time_window = disc_bins / (fadc_mhz x 1e6) Parameters ---------- args : dict Arguments with telescope configuration. Returns ------- float Time window in seconds. Raises ------ ValueError If telescope name cannot be determined or parameters cannot be retrieved. """ telescope_name = args.get("telescope") if not telescope_name: raise ValueError("telescope must be provided for telescope configuration") telescope_model = TelescopeModel( site=args["site"], telescope_name=telescope_name, model_version=args["model_version"], context=context, ) disc_bins = telescope_model.get_parameter_value("disc_bins") fadc_mhz = telescope_model.get_parameter_value("fadc_mhz") time_window = disc_bins / (fadc_mhz * u.MHz) _logger.info( f"Telescope {telescope_name}: disc_bins={disc_bins}, " f"fadc_mhz={fadc_mhz} MHz, time_window={time_window.to_value(u.s):.2f} s" ) return time_window.to_value(u.s) def _extract_nsb_rates(args, time_window): """Extract NSB trigger rates from gamma reduced event-data HDF5 files.""" data_dir = Path(args["event_data_directory"]) gamma_hdf5_files = list(data_dir.rglob(f"gamma*{_REDUCED_EVENT_DATA_SUFFIX}")) if gamma_hdf5_files: _logger.info(f"Found {len(gamma_hdf5_files)} gamma HDF5 file(s)") return _run_nsb_trigger_derivation(data_dir, args, time_window) raise FileNotFoundError(f"No gamma*{_REDUCED_EVENT_DATA_SUFFIX} files found in {data_dir}") def _run_nsb_trigger_derivation(root_dir, args, time_window): """Run NSB trigger derivation on gamma reduced event-data HDF5 files.""" nsb_args = { "root_dir": root_dir, "pattern": f"gamma*{_REDUCED_EVENT_DATA_SUFFIX}", "output": args.get("nsb_table_file"), "time_window": time_window, "verbose": False, } return derive_nsb_triggers(nsb_args) def _extract_proton_rates(args, context): """ Extract proton trigger rates from HDF5 files. Thresholds and run numbers are extracted from file names. """ data_dir = Path(args["event_data_directory"]) proton_files = _group_hdf5_files_by_threshold_and_run(data_dir) if not proton_files: raise FileNotFoundError(f"No proton HDF5 files with threshold labels found in {data_dir}") _logger.info(f"Found proton HDF5 files for {len(proton_files)} thresholds") proton_stats = {} for threshold, run_files in sorted(proton_files.items()): _logger.info(f"Processing threshold {threshold}: {len(run_files)} HDF5 file(s)") proton_stats[threshold] = _calculate_proton_statistics_for_threshold( run_files, args, context ) return proton_stats def _group_hdf5_files_by_threshold_and_run(proton_dir): """Group proton HDF5 files by threshold and run extracted from file names.""" threshold_files = {} for hdf5_file in sorted(proton_dir.rglob(f"*{_REDUCED_EVENT_DATA_SUFFIX}")): if "proton" not in hdf5_file.name.lower(): continue threshold = extract_threshold(hdf5_file) run = extract_run_number(hdf5_file) if threshold is None or run is None: _logger.warning( f"Skipping proton file with missing threshold or run: " f"{hdf5_file} (threshold={threshold}, run={run})" ) continue run_files = threshold_files.setdefault(threshold, {}) if run in run_files: raise ValueError( f"Duplicate proton input for threshold {threshold}, run {run}: " f"{run_files[run]} and {hdf5_file}" ) run_files[run] = hdf5_file return threshold_files def _calculate_proton_statistics_for_threshold(run_files, args, context): """Calculate mean proton trigger rate and error for one threshold.""" run_rates = {} for run, hdf5_file in sorted(run_files.items()): rate = _calculate_proton_rate_for_file(hdf5_file, args, context) if rate is not None: run_rates[run] = rate rates = list(run_rates.values()) if not rates: return { "runs": {}, "rate_hz": np.nan, "error_hz": np.nan, "num_runs": 0, } error_hz = 0 if len(rates) > 1: error_hz = np.std(rates, ddof=1) / np.sqrt(len(rates)) return { "runs": run_rates, "rate_hz": float(np.mean(rates)), "error_hz": float(error_hz), "num_runs": len(rates), } def _calculate_proton_rate_for_file(hdf5_file, args, context): """ Calculate proton trigger rate for a single HDF5 file. Parameters ---------- hdf5_file : Path Path to HDF5 file. args : dict Arguments with telescope configuration. Returns ------- float Trigger rate in Hz, or None if calculation fails. """ if not args.get("telescope"): _logger.warning("No telescope configuration provided") return None trigger_args = {key: args[key] for key in ("telescope", "site", "model_version")} trigger_args.update({"event_data_file": str(hdf5_file), "plot_histograms": False}) try: results = telescope_trigger_rates(trigger_args, context=context) if results: array_name = next(iter(results.keys())) rate_with_units = results[array_name] return rate_with_units.to(u.Hz).value return None except TypeError as e: if "NoneType" in str(e): _logger.info(f"No triggered event data found in {hdf5_file}; using 0 Hz") return 0.0 _logger.debug(f"Error calculating rate for {hdf5_file}: {e}") return None except (OSError, KeyError, ValueError, AttributeError) as e: _logger.debug(f"Error calculating rate for {hdf5_file}: {e}") return None def _write_proton_ecsv(proton_stats, output_file): """Write runwise proton trigger rates to an ECSV table.""" output_file = Path(output_file) error_hz_column = "Error (Hz)" rate_hz_column = "Rate (Hz)" if not proton_stats: raise ValueError("No proton statistics to write") all_runs = sorted( {run for threshold_stats in proton_stats.values() for run in threshold_stats["runs"].keys()} ) threshold_col = [] run_cols = {run: [] for run in all_runs} rate_hz_col = [] error_hz_col = [] num_runs_col = [] for threshold in sorted(proton_stats.keys()): stats = proton_stats[threshold] threshold_col.append(threshold) for run in all_runs: run_cols[run].append(stats["runs"].get(run, np.nan)) rate_hz_col.append(stats["rate_hz"]) error_hz_col.append(stats["error_hz"]) num_runs_col.append(stats["num_runs"]) table_data = {"threshold": threshold_col} for run in all_runs: table_data[f"run{run}"] = run_cols[run] table_data[rate_hz_column] = rate_hz_col table_data[error_hz_column] = error_hz_col table_data["Num runs"] = num_runs_col table = Table(table_data) table.meta["comments"] = ["Run columns contain proton trigger rates in Hz."] table[rate_hz_column] = np.round(table[rate_hz_column], 2) table[error_hz_column] = np.round(table[error_hz_column], 2) table[rate_hz_column].format = ".2f" table[error_hz_column].format = ".2f" output_file.parent.mkdir(parents=True, exist_ok=True) table.write(output_file, format="ascii.ecsv", overwrite=True) def _write_bias_curve_ecsv(nsb_stats, proton_stats, output_file): """Write final plotted bias-curve values to an ECSV table.""" output_file = Path(output_file) thresholds = sorted(set(nsb_stats.keys()) | set(proton_stats.keys())) nsb_error_column = "NSB error (Hz)" nsb_rate_column = "NSB rate (Hz)" proton_error_column = "Proton error (Hz)" proton_rate_column = "Proton rate (Hz)" table = Table( { "threshold": thresholds, nsb_rate_column: [ nsb_stats[threshold]["rate_hz"] if threshold in nsb_stats else np.nan for threshold in thresholds ], nsb_error_column: [ nsb_stats[threshold]["error_hz"] if threshold in nsb_stats else np.nan for threshold in thresholds ], proton_rate_column: [ proton_stats[threshold]["rate_hz"] if threshold in proton_stats else np.nan for threshold in thresholds ], proton_error_column: [ proton_stats[threshold]["error_hz"] if threshold in proton_stats else np.nan for threshold in thresholds ], } ) for column_name in ( nsb_rate_column, nsb_error_column, proton_rate_column, proton_error_column, ): table[column_name] = np.round(table[column_name], 2) table[column_name].format = ".2f" output_file.parent.mkdir(parents=True, exist_ok=True) table.write(output_file, format="ascii.ecsv", overwrite=True) def _calculate_trigger_threshold(args, nsb_stats, proton_stats): """ Calculate trigger threshold from bias curve intersection. Trigger threshold is the intersection of log-linear fits to the NSB and proton rates (scaled by the scaling factor and then multiplied by 0.1), within their shared threshold range. Parameters ---------- args : dict Dictionary with configuration parameters. nsb_stats : dict NSB statistics by threshold. proton_stats : dict Proton statistics by threshold. Returns ------- float The calculated trigger threshold. Raises ------ ValueError If no valid threshold points exist or intersection cannot be found. """ scaling_factor = args.get("scaling_factor", 1.35) fits = [] for stats, scale in ( (nsb_stats, 1.0), (proton_stats, scaling_factor * plot_tables.PROTON_INTERSECTION_FRACTION), ): thresholds = sorted(stats) fit = plot_tables.fit_bias_curve( thresholds, [scale * stats[threshold]["rate_hz"] for threshold in thresholds] ) if fit is None: raise ValueError("At least two finite positive rates per curve are required.") fits.append(fit) _, nsb_fit = fits[0] _, proton_fit = fits[1] slope_difference = nsb_fit[0] - proton_fit[0] if np.isclose(slope_difference, 0): raise ValueError("Could not find intersection point between parallel fitted curves.") trigger_threshold = (proton_fit[1] - nsb_fit[1]) / slope_difference _logger.info(f"Calculated trigger threshold: {trigger_threshold}") return float(trigger_threshold) def _export_trigger_threshold_as_model_parameter(args, trigger_threshold, context=None): """ Export trigger threshold as a model parameter. Determines whether to use asum_threshold, dsum_threshold, or discriminator_threshold based on the telescope's default_trigger parameter. Parameters ---------- args : dict Dictionary with configuration parameters. trigger_threshold : float The calculated trigger threshold value. """ try: # Get telescope name from args telescope_name = args.get("telescope") if not telescope_name: _logger.warning("No telescope name provided. Using 'unknown' as telescope name.") telescope_name = "unknown" parameter_version = args["parameter_version"] # Determine which threshold parameter to use based on default_trigger telescope_model = TelescopeModel( site=args["site"], telescope_name=telescope_name, model_version=args["model_version"], context=context, ) default_trigger = telescope_model.get_parameter_value("default_trigger") if default_trigger == "AnalogSum": parameter_name = "asum_threshold" # asum_threshold expects float64 in mV value = round(trigger_threshold, 2) unit = "mV" elif default_trigger == "DigitalSum": parameter_name = "dsum_threshold" # dsum_threshold expects int64 in counts value = round(trigger_threshold) unit = "count" elif default_trigger == "Majority": parameter_name = "discriminator_threshold" # discriminator_threshold expects float64 in mV value = round(trigger_threshold, 2) unit = "mV" else: _logger.warning( f"Unknown default_trigger '{default_trigger}' for telescope {telescope_name}. " "Cannot export trigger threshold." ) return context = context or RunContext.from_environment() output_path = context.paths.get_output_directory() output_file = f"{parameter_name}-{parameter_version}.json" model_data_writer.ModelDataWriter.write_model_parameter( parameter_name=parameter_name, value=value, instrument=telescope_name, parameter_version=parameter_version, output_file=output_file, output_path=output_path / telescope_name / parameter_name, metadata_input_dict={**args, "source": "bias_curve_analysis"}, unit=unit, check_for_existing_parameter=False, context=context, ) _logger.info( f"Exported trigger threshold as {parameter_name} for {telescope_name}: {value}" ) except (OSError, ValueError, KeyError) as exc: _logger.warning(f"Failed to export trigger threshold as model parameter: {exc}")