"""Build and load trigger-histogram products from reduced event data."""
import logging
import os
import re
from collections import Counter
from pathlib import Path
import astropy.units as u
import h5py
import numpy as np
from astropy.io.registry import IORegistryError
from astropy.table import Table, vstack
from simtools.context import RunContext
from simtools.histogram.hdf5 import read_histograms, write_histograms
from simtools.histogram.histogram import Histogram
from simtools.io import table_handler
from simtools.io.ascii_handler import write_data_to_file
from simtools.io.file_type import product_value, validate_file_type
from simtools.job_execution.execution import map_ordered, options_from_args, submit_jobs
from simtools.job_execution.job import JobSpec
from simtools.production_configuration.production_event_data_helpers import (
accumulate_histograms_by_telescope_config,
normalize_telescope_configs,
resolve_telescope_configs,
)
from simtools.production_configuration.production_file_selection import (
ProductionManifest,
check_manifest,
discover_product_manifests,
get_manifest_schema_metadata,
normalize_for_comparison,
select_file_groups,
selection_summary,
stable_configuration_hash,
values_match,
)
from simtools.sim_events.histograms import EventDataHistograms
from simtools.sim_events.metadata import build_standard_metadata
from simtools.utils import value_conversion
_RUN_NUMBERS = "run_numbers"
_CONFIGURATION_KEY = "configuration"
_FILES_KEY = "files"
_GROUPS_KEY = "groups"
_HISTOGRAM_SETTINGS_KEY = "histogram_settings"
_INPUT_FILES_KEY = "input_files"
_INPUT_RUN_NUMBERS_KEY = "input_run_numbers"
_ENERGY_BINS_PER_DECADE = "energy_bins_per_decade"
_ANGULAR_DISTANCE_BIN_WIDTH = "angular_distance_bin_width"
_CORE_DISTANCE_BIN_WIDTH = "core_distance_bin_width"
_MINIMUM_TRIGGERED_TELESCOPES = "minimum_triggered_telescopes"
_PRODUCTION_PATH_KEY = "production_path"
_REDUCED_EVENT_DATA = "reduced_event_data"
_SELECT_KEY = "select"
_TRIGGER_HISTOGRAMS = "trigger_histograms"
_DEFAULT_HISTOGRAM_SETTINGS = {
_ENERGY_BINS_PER_DECADE: 10,
_ANGULAR_DISTANCE_BIN_WIDTH: 0.5 * u.deg,
_CORE_DISTANCE_BIN_WIDTH: 20.0 * u.m,
_MINIMUM_TRIGGERED_TELESCOPES: 2,
}
_logger = logging.getLogger(__name__)
TRIGGER_HISTOGRAM_METADATA_TABLE = "TRIGGER_REFERENCE_METADATA"
TRIGGER_HISTOGRAM_BINS_TABLE = "TRIGGER_REFERENCE_BINS"
TRIGGER_TOPOLOGY_COUNTS_TABLE = "TRIGGER_TOPOLOGY_COUNTS"
TRIGGER_SUBSET_HISTOGRAMS_TABLE = "TRIGGER_SUBSET_HISTOGRAMS"
TRIGGER_HISTOGRAM_DENSE_GROUP = "TRIGGER_HISTOGRAM_DENSE"
_DENSE_HISTOGRAM_FORMAT_VERSION = 2
_DERIVED_HISTOGRAM_SUFFIXES = ("_eff", "_cumulative")
_REDUCED_EVENT_DATA_SUFFIX = product_value("sim_telarray_event_data", "default_suffix")
_PART_SUFFIX_PATTERN = re.compile(r"\.part\d+$")
def _get_plot_directory_name(array_name, telescope_ids):
"""Return a readable directory name for plot output."""
if array_name != "array_element_list":
return array_name
if not telescope_ids:
return array_name
return "_".join(str(telescope_id) for telescope_id in telescope_ids)
def _use_readable_inline_array_names(telescope_configs):
"""Return telescope configs with readable names for inline array-element lists."""
readable_configs = []
for config in telescope_configs:
array_name = _get_plot_directory_name(config["array_name"], config["telescope_ids"])
readable_configs.append(config | {"array_name": array_name})
return readable_configs
def _get_angular_distance_bin_width(histograms):
"""Return the configured angular-distance bin width for metadata."""
if histograms.angular_distance_bin_width is not None:
return histograms.angular_distance_bin_width
return np.diff(histograms.view_cone_bins)[0] * u.deg
def _quantity_value(value, unit):
"""Return a scalar value converted to unit, preserving missing values."""
if hasattr(value, "to"):
return value.to(unit)
return value
def _data_range_value(histograms, name, index):
"""Return a stored data-range value, or NaN when unavailable."""
data_range = getattr(histograms, "data_ranges", {}).get(name)
if data_range is None:
return np.nan
return float(data_range[index])
def _create_histogram_tables(reference_specs):
"""Convert accumulated histogram products into metadata and bin tables."""
metadata_tables = [
_create_metadata_table(
reference_id=spec["reference_id"],
production_index=spec["production_index"],
site=spec["site"],
array_name=spec["array_name"],
telescope_ids=spec["telescope_ids"],
histograms=spec["histograms"],
)
for spec in reference_specs
]
bin_tables = [
_create_bin_table(
reference_id=spec["reference_id"],
production_index=spec["production_index"],
array_name=spec["array_name"],
histograms=spec["histograms"],
)
for spec in reference_specs
]
return (
vstack(metadata_tables, metadata_conflicts="silent"),
vstack(bin_tables, metadata_conflicts="silent"),
)
def _iter_persisted_histograms(histograms):
"""Yield persisted histogram names and definitions."""
for histogram_name, histogram in histograms.histograms.items():
if _is_persisted_histogram(histogram_name, histogram):
yield histogram_name, histogram
def _create_trigger_topology_count_table(reference_specs):
"""Create count table for trigger multiplicities, combinations, and telescope participation."""
rows = []
for spec in reference_specs:
topology = spec.get("trigger_topology") or {}
for count_type in (
"trigger_multiplicity",
"trigger_combinations",
"telescope_participation",
):
for key, count in topology.get(count_type, {}).items():
rows.append(
{
"reference_id": spec["reference_id"],
"count_type": count_type,
"subset": "",
"key": str(key),
"count": int(count),
}
)
for subset_name, multiplicity_counts in topology.get("subset_multiplicity", {}).items():
for multiplicity, count in multiplicity_counts.items():
rows.append(
{
"reference_id": spec["reference_id"],
"count_type": "subset_multiplicity",
"subset": str(subset_name),
"key": str(multiplicity),
"count": int(count),
}
)
table = Table(rows=rows, names=["reference_id", "count_type", "subset", "key", "count"])
table.meta["EXTNAME"] = TRIGGER_TOPOLOGY_COUNTS_TABLE
return table
def _quantity_bin_edges(histograms, quantity_name):
"""Return bin edges for a named event quantity."""
if quantity_name == "energy":
return histograms.energy_bins
if quantity_name == "core_distance":
return histograms.core_distance_bins
if quantity_name == "angular_distance":
return histograms.view_cone_bins
raise ValueError(f"Unsupported quantity: {quantity_name}")
def _create_trigger_subset_histogram_table(reference_specs):
"""Create per-trigger-subset histograms for event-production comparisons."""
rows = []
for spec in reference_specs:
topology = spec.get("trigger_topology") or {}
subset_values = topology.get("subset_values", {})
for quantity_name, values_by_subset in subset_values.items():
bin_edges = _quantity_bin_edges(spec["histograms"], quantity_name)
for subset_name, values in values_by_subset.items():
counts, _ = np.histogram(values, bins=bin_edges)
for bin_index, count in enumerate(counts):
rows.append(
{
"reference_id": spec["reference_id"],
"subset": str(subset_name),
"quantity": quantity_name,
"bin_index": bin_index,
"bin_low": float(bin_edges[bin_index]),
"bin_high": float(bin_edges[bin_index + 1]),
"count": int(count),
}
)
table = Table(
rows=rows,
names=["reference_id", "subset", "quantity", "bin_index", "bin_low", "bin_high", "count"],
)
table.meta["EXTNAME"] = TRIGGER_SUBSET_HISTOGRAMS_TABLE
return table
def _create_metadata_table(
reference_id,
production_index,
site,
array_name,
telescope_ids,
histograms,
):
"""Create the one-row metadata table for trigger histograms."""
file_info = histograms.file_info
metadata = Table(
rows=[
{
"reference_id": reference_id,
"production_index": production_index,
"site": site or "",
"array_name": array_name,
"telescope_ids": ",".join(telescope_ids) if telescope_ids else "",
"primary_particle": file_info.get("primary_particle", ""),
"zenith": file_info.get("zenith", 0.0 * u.deg),
"azimuth": file_info.get("azimuth", 0.0 * u.deg),
"nsb_level": file_info.get("nsb_level", 0.0),
"spectral_index": file_info.get("spectral_index", np.nan),
"energy_min": file_info.get("energy_min", 0.0 * u.TeV),
"energy_max": file_info.get("energy_max", 0.0 * u.TeV),
"viewcone_min": file_info.get("viewcone_min", 0.0 * u.deg),
"viewcone_max": file_info.get("viewcone_max", 0.0 * u.deg),
"core_scatter_min": file_info.get("core_scatter_min", 0.0 * u.m),
"core_scatter_max": file_info.get("core_scatter_max", 0.0 * u.m),
"scatter_area": file_info.get("scatter_area", 0.0 * u.cm**2),
"solid_angle": file_info.get("solid_angle", 0.0 * u.sr),
"angular_distance_min": _data_range_value(histograms, "angular_distance", 0)
* u.deg,
"angular_distance_max": _data_range_value(histograms, "angular_distance", 1)
* u.deg,
"energy_bins_per_decade": histograms.energy_bins_per_decade,
"angular_distance_bin_width": _get_angular_distance_bin_width(histograms),
"angular_distance_bin_count": len(histograms.view_cone_bins) - 1,
"core_distance_bin_count": len(histograms.core_distance_bins) - 1,
"total_simulated_events": int(
np.sum(
histograms.histograms[
"angular_distance_vs_energy_vs_core_distance_mc"
].values
)
),
"total_triggered_events": int(
np.sum(
histograms.histograms["angular_distance_vs_energy_vs_core_distance"].values
)
),
}
]
)
metadata.meta["EXTNAME"] = TRIGGER_HISTOGRAM_METADATA_TABLE
return metadata
def _is_persisted_histogram(name, histogram):
"""Return True for base histograms that should be serialized."""
if name.endswith(_DERIVED_HISTOGRAM_SUFFIXES):
return False
return isinstance(histogram, Histogram)
def _write_dense_histogram_payload(reference_specs, output_file):
"""Write persisted histogram arrays and bin edges as dense HDF5 datasets."""
with h5py.File(output_file, "a") as hdf5_file:
dense_group = hdf5_file.require_group(TRIGGER_HISTOGRAM_DENSE_GROUP)
dense_group.attrs["format_version"] = _DENSE_HISTOGRAM_FORMAT_VERSION
for spec in reference_specs:
reference_group = dense_group.create_group(str(spec["reference_id"]))
write_histograms(
reference_group,
dict(_iter_persisted_histograms(spec["histograms"])),
)
[docs]
def load_dense_histogram_payloads(reference_file):
"""Load dense histograms grouped by reference id."""
with h5py.File(reference_file, "r") as hdf5_file:
if TRIGGER_HISTOGRAM_DENSE_GROUP not in hdf5_file:
return {}
dense_group = hdf5_file[TRIGGER_HISTOGRAM_DENSE_GROUP]
version = dense_group.attrs.get("format_version")
if version != _DENSE_HISTOGRAM_FORMAT_VERSION:
raise ValueError(
f"Unsupported trigger histogram format in '{reference_file}': found "
f"{version}, supported version is {_DENSE_HISTOGRAM_FORMAT_VERSION}."
)
return {
reference_id: read_histograms(reference_group)
for reference_id, reference_group in dense_group.items()
}
[docs]
def inspect_trigger_histogram_file(file_path, format_report=True):
"""
Return trigger-histogram-specific consistency checks when applicable.
Parameters
----------
file_path : str or Path
Path to the file to inspect.
format_report : bool, optional
Whether to format the report as human-readable text. Default is True.
Returns
-------
str or dict or None
Formatted report string, structured report dictionary, or None if the file
does not contain trigger-histogram products.
"""
with h5py.File(file_path, "r") as hdf5_file:
has_metadata = TRIGGER_HISTOGRAM_METADATA_TABLE in hdf5_file
has_dense_group = TRIGGER_HISTOGRAM_DENSE_GROUP in hdf5_file
if not has_metadata and not has_dense_group:
return None
dense_reference_ids = (
sorted(
str(reference_id)
for reference_id in hdf5_file[TRIGGER_HISTOGRAM_DENSE_GROUP].keys()
)
if has_dense_group
else []
)
metadata_reference_ids = []
if has_metadata:
metadata = table_handler.read_tables(
file_path,
[TRIGGER_HISTOGRAM_METADATA_TABLE],
file_type="HDF5",
)[TRIGGER_HISTOGRAM_METADATA_TABLE]
metadata_reference_ids = sorted(str(row["reference_id"]) for row in metadata)
metadata_reference_id_set = set(metadata_reference_ids)
dense_reference_id_set = set(dense_reference_ids)
report = {
"inspector": "trigger_histogram",
"metadata_reference_count": len(metadata_reference_ids),
"dense_reference_count": len(dense_reference_ids),
"missing_dense_reference_ids": sorted(metadata_reference_id_set - dense_reference_id_set),
"orphan_dense_reference_ids": sorted(dense_reference_id_set - metadata_reference_id_set),
}
return _format_trigger_histogram_inspection(report) if format_report else report
def _format_trigger_histogram_inspection(report):
"""Format trigger-histogram inspection results for console output."""
lines = ["Trigger histogram consistency:"]
lines.append(f"- metadata reference ids: {report['metadata_reference_count']}")
lines.append(f"- dense payload reference ids: {report['dense_reference_count']}")
if report["missing_dense_reference_ids"]:
lines.append(
"- missing dense payloads for metadata ids: "
+ ", ".join(report["missing_dense_reference_ids"])
)
else:
lines.append("- missing dense payloads for metadata ids: none")
if report["orphan_dense_reference_ids"]:
lines.append(
"- orphan dense payload ids without metadata rows: "
+ ", ".join(report["orphan_dense_reference_ids"])
)
else:
lines.append("- orphan dense payload ids without metadata rows: none")
return "\n".join(lines)
def _create_bin_table(reference_id, production_index, array_name, histograms):
"""Create the flattened per-bin histogram table."""
triggered_hist = histograms.histograms["angular_distance_vs_energy_vs_core_distance"].values
simulated_hist = histograms.histograms["angular_distance_vs_energy_vs_core_distance_mc"].values
efficiency_hist = histograms.histograms[
"angular_distance_vs_energy_vs_core_distance_eff"
].values
energy_edges = histograms.energy_bins
angular_edges = histograms.view_cone_bins
core_edges = histograms.core_distance_bins
rows = []
for angular_index in range(len(angular_edges) - 1):
for energy_index in range(len(energy_edges) - 1):
for core_index in range(len(core_edges) - 1):
rows.append(
{
"reference_id": reference_id,
"production_index": production_index,
"array_name": array_name,
"angular_distance_bin_index": angular_index,
"energy_bin_index": energy_index,
"core_distance_bin_index": core_index,
"angular_distance_low": angular_edges[angular_index] * u.deg,
"angular_distance_high": angular_edges[angular_index + 1] * u.deg,
"energy_low": energy_edges[energy_index] * u.TeV,
"energy_high": energy_edges[energy_index + 1] * u.TeV,
"core_distance_low": core_edges[core_index] * u.m,
"core_distance_high": core_edges[core_index + 1] * u.m,
"simulated_count": int(
simulated_hist[angular_index, energy_index, core_index]
),
"triggered_count": int(
triggered_hist[angular_index, energy_index, core_index]
),
"trigger_efficiency": float(
efficiency_hist[angular_index, energy_index, core_index]
),
}
)
bin_table = Table(rows=rows)
bin_table.meta["EXTNAME"] = TRIGGER_HISTOGRAM_BINS_TABLE
return bin_table
def _process_production(
file_path,
telescope_configs,
energy_bins_per_decade,
angular_distance_bin_width,
core_distance_bin_width=None,
skip_invalid_event_data_files=False,
minimum_triggered_telescopes=2,
):
"""Read one production once and build trigger histograms for all telescope configurations."""
finalized_histograms = accumulate_histograms_by_telescope_config(
file_path,
telescope_configs,
energy_bins_per_decade=energy_bins_per_decade,
angular_distance_bin_width=angular_distance_bin_width,
core_distance_bin_width=core_distance_bin_width,
skip_invalid_event_data_files=skip_invalid_event_data_files,
fill_efficiency_histogram=True,
collect_trigger_topology=True,
minimum_triggered_telescopes=minimum_triggered_telescopes,
)
return [(histograms, topology) for _, histograms, topology in finalized_histograms]
def _execute_production_job(job_spec):
"""Execute one production-pattern histogram job in a worker process."""
histogram_topology_pairs = _process_production(
job_spec["production_pattern"],
job_spec["telescope_configs"],
energy_bins_per_decade=job_spec["energy_bins_per_decade"],
angular_distance_bin_width=job_spec["angular_distance_bin_width"],
core_distance_bin_width=job_spec.get("core_distance_bin_width"),
skip_invalid_event_data_files=job_spec["skip_invalid_event_data_files"],
minimum_triggered_telescopes=job_spec.get("minimum_triggered_telescopes", 2),
)
return [
{
"production_index": job_spec["production_index"],
"site": job_spec["site"],
"array_name": config["array_name"],
"telescope_ids": config["telescope_ids"],
"histograms": histograms,
"trigger_topology": trigger_topology,
}
for config, (histograms, trigger_topology) in zip(
job_spec["telescope_configs"], histogram_topology_pairs
)
]
[docs]
def discover_event_data_groups(event_data_directory):
"""
Discover reduced event-data files and group partitioned products by filename.
Parameters
----------
event_data_directory : str or pathlib.Path
Directory containing direct-child reduced event-data HDF5 files.
Returns
-------
list[tuple[str, list[pathlib.Path]]]
Sorted ``(group_name, files)`` pairs. A group name is the file stem with a
trailing ``.part<digits>`` suffix removed.
Raises
------
FileNotFoundError
If the input directory does not exist or is not a directory.
ValueError
If the directory does not contain matching reduced event-data files.
"""
directory = Path(event_data_directory)
if not directory.is_dir():
raise FileNotFoundError(f"Event-data directory not found: {directory}")
groups = {}
for file_path in sorted(directory.glob(f"*{_REDUCED_EVENT_DATA_SUFFIX}")):
if not file_path.is_file():
continue
file_stem = file_path.name.removesuffix(_REDUCED_EVENT_DATA_SUFFIX)
group_name = _PART_SUFFIX_PATTERN.sub("", file_stem)
groups.setdefault(group_name, []).append(file_path)
if not groups:
raise ValueError(
"No reduced event-data files matching "
f"'*{_REDUCED_EVENT_DATA_SUFFIX}' found in {directory}."
)
return [(group_name, groups[group_name]) for group_name in sorted(groups)]
def _write_trigger_histogram_product(
args_dict,
production_patterns,
output_file,
product_metadata=None,
telescope_configs=None,
context=None,
):
"""Build and write one trigger-histogram product from production sources."""
telescope_configs = telescope_configs or _resolve_telescope_configs(args_dict, context)
output_file = validate_file_type(output_file, file_type="hdf5")
reference_specs = []
job_specs = [
{
"production_index": production_index,
"production_pattern": pattern,
"site": args_dict.get("site"),
"telescope_configs": telescope_configs,
"energy_bins_per_decade": args_dict["energy_bins_per_decade"],
"angular_distance_bin_width": args_dict["angular_distance_bin_width"],
"core_distance_bin_width": args_dict.get("core_distance_bin_width"),
"skip_invalid_event_data_files": args_dict.get("skip_invalid_event_data_files", False),
"minimum_triggered_telescopes": args_dict.get("minimum_triggered_telescopes", 2),
}
for production_index, pattern in enumerate(production_patterns)
]
_logger.info(
"Processing %d trigger-histogram production pattern(s) with max_workers=%s",
len(job_specs),
args_dict.get("max_workers", 1),
)
for production_result in map_ordered(
_execute_production_job,
job_specs,
backend=args_dict.get("backend", "local"),
max_workers=args_dict.get("max_workers", 1),
backend_config=args_dict.get("backend_config"),
context=context,
):
for spec in production_result:
reference_specs.append(spec | {"reference_id": f"reference_{len(reference_specs)}"})
metadata_table, bin_table = _create_histogram_tables(reference_specs)
topology_count_table = _create_trigger_topology_count_table(reference_specs)
subset_histogram_table = _create_trigger_subset_histogram_table(reference_specs)
table_handler.write_tables(
[
metadata_table,
bin_table,
topology_count_table,
subset_histogram_table,
],
output_file,
overwrite_existing=True,
file_type="HDF5",
metadata_documents={
"METADATA": build_standard_metadata(
args_dict,
output_file,
product_data_name=_TRIGGER_HISTOGRAMS,
context=context,
),
},
)
_write_dense_histogram_payload(reference_specs, output_file)
if product_metadata is not None:
_write_trigger_histogram_metadata(args_dict, output_file, product_metadata)
return metadata_table, bin_table
def _write_trigger_histogram_metadata(args_dict, output_file, product_metadata):
"""Write a neighboring YAML manifest for a trigger-histogram product."""
output_file = Path(output_file)
metadata_file = output_file.with_suffix(".yml")
manifest_schema = get_manifest_schema_metadata(_TRIGGER_HISTOGRAMS)
manifest = {
**manifest_schema,
"product_type": _TRIGGER_HISTOGRAMS,
"status": "complete",
_CONFIGURATION_KEY: product_metadata[_CONFIGURATION_KEY],
_HISTOGRAM_SETTINGS_KEY: _histogram_settings(args_dict),
"array_selection": product_metadata["array_selection"],
_INPUT_RUN_NUMBERS_KEY: product_metadata[_RUN_NUMBERS],
_INPUT_FILES_KEY: {
_REDUCED_EVENT_DATA: [
_relative_to_directory(path, metadata_file.parent)
for path in product_metadata[_INPUT_FILES_KEY]
],
},
_FILES_KEY: {
_TRIGGER_HISTOGRAMS: [_relative_to_directory(output_file, metadata_file.parent)],
},
}
check_manifest(ProductionManifest(path=metadata_file, data=manifest))
write_data_to_file(manifest, metadata_file)
def _write_directory_group_job(job_spec, context=None):
"""Build one directory-discovered trigger-histogram product."""
args_dict = job_spec["args_dict"] | {
"event_data_files": job_spec["event_data_files"],
"backend": "local",
"backend_config": None,
"max_workers": 1,
}
_write_trigger_histogram_product(
args_dict,
[job_spec["event_data_files"]],
job_spec["output_file"],
product_metadata=job_spec.get("product_metadata"),
telescope_configs=job_spec.get("telescope_configs"),
context=context,
)
return str(job_spec["output_file"])
def _write_directory_products(args_dict, context=None):
"""Submit or execute one independent trigger-histogram product per directory group."""
context = context or RunContext.from_environment()
output_directory = context.paths.get_output_directory()
event_data_directory = Path(args_dict["event_data_directory"]).resolve()
groups = discover_event_data_groups(event_data_directory)
jobs = []
for index, (group_name, event_data_files) in enumerate(groups):
output_file = validate_file_type(
output_directory / f"{group_name}.trigger_histograms.hdf5",
file_type="hdf5",
)
jobs.append(
JobSpec(
job_id=f"trigger-histograms-{index:06d}",
index=index,
function=_write_directory_group_job,
item={
"args_dict": args_dict,
"event_data_files": [str(file_path) for file_path in event_data_files],
"output_file": output_file,
},
context_spec=context.to_spec()
if args_dict.get("backend", "local") != "local"
else None,
mount_paths=(event_data_directory,),
output_paths=(output_file,),
)
)
_logger.info("Submitting %d trigger-histogram directory group(s)", len(jobs))
return submit_jobs(jobs, options_from_args(args_dict, max_workers=args_dict.get("max_workers")))
def _write_production_selection_products(args_dict, context=None):
"""Submit one trigger-histogram product per selected production configuration group."""
context = context or RunContext.from_environment()
if args_dict.get("file_type", _REDUCED_EVENT_DATA) != _REDUCED_EVENT_DATA:
raise ValueError("Production metadata input requires file_type='reduced_event_data'.")
output_directory = context.paths.get_output_directory()
selection_result = select_file_groups(
args_dict[_PRODUCTION_PATH_KEY],
selections=args_dict.get(_SELECT_KEY),
file_type=_REDUCED_EVENT_DATA,
require_complete_runs=args_dict.get("require_complete_runs", False),
)
_logger.info("\n%s", selection_summary(selection_result))
if not selection_result[_GROUPS_KEY]:
raise ValueError("Production metadata selection did not match any files.")
production_path = Path(args_dict[_PRODUCTION_PATH_KEY]).resolve()
products = []
for group in selection_result[_GROUPS_KEY]:
telescope_configs = _resolve_group_telescope_configs(
args_dict, group.configuration, context
)
product_identity = {
_HISTOGRAM_SETTINGS_KEY: _histogram_settings(args_dict),
"array_selection": telescope_configs,
}
output_stem = _group_output_stem(
group, product_identity, selections=args_dict.get(_SELECT_KEY), include_hash=False
)
products.append((group, telescope_configs, product_identity, output_stem))
stem_counts = Counter(product[-1] for product in products)
jobs = []
for index, (group, telescope_configs, product_identity, output_stem) in enumerate(products):
if stem_counts[output_stem] > 1:
output_stem = _group_output_stem(
group,
product_identity,
selections=args_dict.get(_SELECT_KEY),
include_hash=True,
)
output_file = validate_file_type(
output_directory / f"{output_stem}.trigger_histograms.hdf5",
file_type="hdf5",
)
metadata_file = output_file.with_suffix(".yml")
if output_file.exists() or metadata_file.exists():
raise FileExistsError(f"Trigger-histogram output already exists: {output_file}")
jobs.append(
JobSpec(
job_id=f"trigger-histograms-{index:06d}",
index=index,
function=_write_directory_group_job,
item={
"args_dict": args_dict,
"event_data_files": [str(file_path) for file_path in group.file_paths],
"output_file": output_file,
"product_metadata": {
_CONFIGURATION_KEY: group.configuration,
_RUN_NUMBERS: group.run_numbers,
_INPUT_FILES_KEY: group.file_paths,
"array_selection": telescope_configs,
},
"telescope_configs": telescope_configs,
},
context_spec=context.to_spec()
if args_dict.get("backend", "local") != "local"
else None,
mount_paths=(production_path,),
output_paths=(output_file, metadata_file),
)
)
_logger.info("Submitting %d trigger-histogram production metadata group(s)", len(jobs))
return submit_jobs(jobs, options_from_args(args_dict, max_workers=args_dict.get("max_workers")))
[docs]
def write_trigger_histograms(args_dict, context=None):
"""
Build trigger histograms and write them to file.
Parameters
----------
args_dict : dict
Application arguments.
Returns
-------
tuple[astropy.table.Table, astropy.table.Table] or simtools.job_execution.job.SubmissionHandle
Metadata and per-bin histogram tables for explicit input patterns, or the submission
handle for directory-mode jobs.
Raises
------
ValueError
If the output file does not use an HDF5 suffix or no supported telescope
selection is provided.
"""
context = context or RunContext.from_environment()
if args_dict.get("event_data_directory"):
return _write_directory_products(args_dict, context)
if args_dict.get(_PRODUCTION_PATH_KEY):
return _write_production_selection_products(args_dict, context)
production_patterns = value_conversion.ensure_string_lists(args_dict["event_data_files"])
output_file = context.paths.get_output_file(args_dict["output_file"])
return _write_trigger_histogram_product(
args_dict, production_patterns, output_file, context=context
)
def _group_output_stem(group, product_identity=None, selections=None, include_hash=False):
"""Return a descriptive output stem for a selected production group.
Parameters
----------
group : ProductionFileGroup
Selected production configuration group.
product_identity : dict, optional
Additional settings that define the product.
selections : sequence of str, optional
Command-line selection expressions to include in the filename.
include_hash : bool, optional
Append a stable identity hash when the descriptive filename is ambiguous.
The production-selection workflow only enables this for colliding names.
"""
configuration = group.configuration
parts = [
_safe_stem_part(configuration.get("primary", "production")),
*_energy_stem_parts(configuration),
_angle_stem_part("za", configuration.get("zenith_angle")),
_angle_stem_part("azm", configuration.get("azimuth_angle")),
_safe_stem_part(configuration.get("corsika_he_interaction")),
]
parts = [part for part in parts if part]
parts.extend(_selection_stem_parts(selections))
layout_part = _safe_stem_part(configuration.get("array_layout_name"))
if layout_part:
parts.append(layout_part)
if include_hash:
parts.append(
stable_configuration_hash(
{
_CONFIGURATION_KEY: configuration,
**(product_identity or {}),
}
)
)
return "-".join(parts)
def _selection_stem_parts(selections):
"""Return filename components for command-line selection expressions."""
parts = []
for selection in selections or []:
if "=" not in selection:
continue
key, value = selection.split("=", maxsplit=1)
key = key.rsplit(".", maxsplit=1)[-1]
if key in {
"array_layout_name",
"primary",
"zenith_angle",
"azimuth_angle",
"energy_min",
"energy_max",
}:
continue
value = value.strip().strip("\"'")
key_part = _safe_stem_part(key)
value_part = _safe_stem_part(value)
if key_part and value_part:
parts.append(f"{key_part}-{value_part}")
return parts
def _angle_stem_part(prefix, value):
"""Return an angle value formatted for an output file stem."""
if isinstance(value, dict) and set(value) == {"value", "unit"}:
quantity = float(value["value"]) * u.Unit(value["unit"])
return f"{prefix}{quantity.to_value(u.deg):g}deg"
return None
def _energy_stem_parts(configuration):
"""Return compact energy components for an output file stem."""
minimum = _stem_quantity(configuration.get("energy_min"))
maximum = _stem_quantity(configuration.get("energy_max"))
if minimum is None or maximum is None:
return []
minimum = minimum.to(u.GeV)
maximum = maximum.to(u.GeV)
if np.isclose(minimum.value, maximum.value):
return [_compact_energy_stem_part("e", minimum)]
return [
_compact_energy_stem_part("emin", minimum),
_compact_energy_stem_part("emax", maximum),
]
def _compact_energy_stem_part(prefix, quantity):
"""Return an energy using GeV below 1 TeV and TeV otherwise."""
if abs(quantity.to_value(u.TeV)) >= 1:
quantity = quantity.to(u.TeV)
return f"{prefix}{quantity.value:g}{str(quantity.unit).lower()}"
def _stem_quantity(value):
"""Return a quantity from a manifest value or an existing quantity."""
if isinstance(value, dict) and set(value) == {"value", "unit"}:
return float(value["value"]) * u.Unit(value["unit"])
return value if hasattr(value, "to") else None
def _safe_stem_part(value):
"""Return a filesystem-safe output stem component."""
if value is None:
return None
if isinstance(value, list | tuple):
value = "-".join(map(str, value))
return re.sub(r"[^A-Za-z0-9]+", "-", str(value)).strip("-").lower()
def _relative_to_directory(path, directory):
"""Return a portable path relative to a metadata directory."""
path = Path(path).resolve()
directory = Path(directory).resolve()
return Path(os.path.relpath(path, directory)).as_posix()
def _resolve_telescope_configs(args_dict, context):
"""Return normalized resolved telescope configurations."""
return _use_readable_inline_array_names(
normalize_telescope_configs(resolve_telescope_configs(args_dict, context=context))
)
def _resolve_group_telescope_configs(args_dict, configuration, context):
"""Resolve telescope configurations from one selected production configuration."""
group_args = dict(args_dict)
_set_group_argument(group_args, configuration, "site")
_set_group_argument(group_args, configuration, "model_version", list_value=True)
if group_args.get("array_element_list"):
return _resolve_telescope_configs(group_args, context)
_set_group_argument(group_args, configuration, "array_layout_name", list_value=True)
return _resolve_telescope_configs(group_args, context)
def _set_group_argument(args_dict, configuration, key, list_value=False):
"""Set one telescope-resolution argument from metadata or reject an incompatible override."""
if key not in configuration:
raise ValueError(f"Selected production metadata is missing configuration.{key}.")
metadata_value = configuration[key]
expected = _as_argument_value(metadata_value, list_value)
provided = args_dict.get(key)
if provided is not None and provided != expected:
raise ValueError(
f"Explicit --{key}={provided} does not match selected production metadata {expected}."
)
args_dict[key] = expected
def _as_argument_value(value, list_value):
"""Return manifest values in the form accepted by the command-line resolver."""
if not list_value:
return value
return value if isinstance(value, list) else [value]
def _histogram_settings(args_dict):
"""Return settings that define a trigger-histogram product."""
return {
_ENERGY_BINS_PER_DECADE: args_dict[_ENERGY_BINS_PER_DECADE],
_ANGULAR_DISTANCE_BIN_WIDTH: args_dict[_ANGULAR_DISTANCE_BIN_WIDTH],
_CORE_DISTANCE_BIN_WIDTH: args_dict.get(_CORE_DISTANCE_BIN_WIDTH),
_MINIMUM_TRIGGERED_TELESCOPES: args_dict.get(_MINIMUM_TRIGGERED_TELESCOPES, 2),
}
[docs]
def load_trigger_histograms(reference_file):
"""
Load trigger-histogram metadata and bin tables from HDF5.
Parameters
----------
reference_file : str or pathlib.Path
Path to the trigger-histogram HDF5 file.
Returns
-------
tuple[astropy.table.Table, astropy.table.Table]
Metadata table and per-bin histogram table read from the input file.
"""
tables = table_handler.read_tables(
reference_file,
[TRIGGER_HISTOGRAM_METADATA_TABLE, TRIGGER_HISTOGRAM_BINS_TABLE],
file_type="HDF5",
)
return tables[TRIGGER_HISTOGRAM_METADATA_TABLE], tables[TRIGGER_HISTOGRAM_BINS_TABLE]
def _row_value(row, name):
"""Return a scalar row value, preserving astropy quantities."""
value = row[name]
unit = getattr(value, "unit", None)
table = getattr(row, "_table", None)
if unit is None and table is not None and name in table.colnames:
unit = getattr(table[name], "unit", None)
if unit is not None:
return value * unit
return value
def _metadata_file_info(row):
"""Build EventDataHistograms.file_info from a metadata row."""
return {
"primary_particle": row["primary_particle"],
"zenith": _row_value(row, "zenith"),
"azimuth": _row_value(row, "azimuth"),
"nsb_level": row["nsb_level"],
"spectral_index": row["spectral_index"] if "spectral_index" in row.colnames else np.nan,
"energy_min": _row_value(row, "energy_min"),
"energy_max": _row_value(row, "energy_max"),
"viewcone_min": _row_value(row, "viewcone_min"),
"viewcone_max": _row_value(row, "viewcone_max"),
"core_scatter_min": _row_value(row, "core_scatter_min"),
"core_scatter_max": _row_value(row, "core_scatter_max"),
"scatter_area": _row_value(row, "scatter_area"),
"solid_angle": _row_value(row, "solid_angle"),
}
def _metadata_data_ranges(row):
"""Build data_ranges from metadata values if present."""
if "angular_distance_min" not in row.colnames or "angular_distance_max" not in row.colnames:
return {}
angular_min = _quantity_value(_row_value(row, "angular_distance_min"), u.deg)
angular_max = _quantity_value(_row_value(row, "angular_distance_max"), u.deg)
angular_min = angular_min.value if hasattr(angular_min, "value") else float(angular_min)
angular_max = angular_max.value if hasattr(angular_max, "value") else float(angular_max)
if np.isnan(angular_min) or np.isnan(angular_max):
return {}
return {"angular_distance": (float(angular_min), float(angular_max))}
def _apply_loaded_histograms(loaded_histograms):
"""Attach loaded histograms and exact bin edges to a histogram object."""
loaded = dict(loaded_histograms)
loaded_bin_edges = {}
for histogram_name, edges_key in (
("energy", "energy"),
("core_distance", "core_distance"),
("angular_distance", "viewcone"),
):
if histogram_name in loaded_histograms:
loaded_bin_edges[edges_key] = loaded_histograms[histogram_name].edges[0]
return loaded, loaded_bin_edges
def _histograms_from_reference_row_dense(row, loaded_histograms):
"""Reconstruct finalized EventDataHistograms from dense HDF5 payloads."""
histograms = EventDataHistograms.create_accumulator(
array_name=row["array_name"],
telescope_list=list(filter(None, str(row["telescope_ids"]).split(","))),
energy_bins_per_decade=int(row["energy_bins_per_decade"]),
angular_distance_bin_count=int(row["angular_distance_bin_count"]) + 1,
angular_distance_bin_width=_row_value(row, "angular_distance_bin_width"),
core_distance_bin_count=int(row["core_distance_bin_count"]) + 1,
)
loaded_histograms, loaded_bin_edges = _apply_loaded_histograms(loaded_histograms)
histograms.set_loaded_histograms(
loaded_histograms,
file_info=_metadata_file_info(row),
data_ranges=_metadata_data_ranges(row),
loaded_bin_edges=loaded_bin_edges,
contains_triggered_data=True,
)
histograms.calculate_efficiency_data()
histograms.calculate_cumulative_data()
return histograms
[docs]
def load_event_data_histograms(reference_file, array_names=None, production_indices=None):
"""
Load finalized EventDataHistograms objects from a trigger-histogram HDF5 file.
Parameters
----------
reference_file : str or pathlib.Path
Path to the trigger-histogram HDF5 file.
array_names : list[str], optional
Restrict loaded histograms to these array names.
production_indices : list[int], optional
Restrict loaded histograms to these production indices.
Returns
-------
list[tuple[astropy.table.Row, EventDataHistograms]]
Metadata row and reconstructed histogram object for each reference.
"""
dense_payloads = load_dense_histogram_payloads(reference_file)
metadata = table_handler.read_tables(
reference_file,
[TRIGGER_HISTOGRAM_METADATA_TABLE],
file_type="HDF5",
)[TRIGGER_HISTOGRAM_METADATA_TABLE]
if array_names:
metadata = metadata[np.isin(metadata["array_name"].astype(str), array_names)]
if production_indices is not None:
metadata = metadata[np.isin(metadata["production_index"], production_indices)]
loaded = []
for row in metadata:
reference_id = str(row["reference_id"])
loaded.append(
(
row,
_histograms_from_reference_row_dense(row, dense_payloads[reference_id]),
)
)
return loaded
[docs]
def validate_derived_workflow(applications):
"""Check trigger products owned by histogram-writer workflow steps."""
for application in applications:
configuration = application.get(_CONFIGURATION_KEY, {})
if (
application.get("application") == "simtools-write-trigger-histograms"
and _PRODUCTION_PATH_KEY in configuration
):
validate_derived_products(configuration)
[docs]
def validate_derived_products(configuration):
"""Check histogram metadata, run coverage, and source freshness without publishing it."""
groups = select_file_groups(
configuration[_PRODUCTION_PATH_KEY],
selections=configuration.get(_SELECT_KEY),
require_complete_runs=True,
)[_GROUPS_KEY]
products = discover_product_manifests(configuration["output_path"], _TRIGGER_HISTOGRAMS)
by_key = {}
for product in products:
key = normalize_for_comparison(product.data[_CONFIGURATION_KEY])
if key in by_key:
raise ValueError("Duplicate derived configuration")
by_key[key] = product
if set(by_key) != {normalize_for_comparison(group.configuration) for group in groups}:
raise ValueError("Derived configuration coverage differs from production")
for group in groups:
product = by_key[normalize_for_comparison(group.configuration)]
_validate_derived_group(group, product, configuration)
def _validate_derived_group(group, product, configuration):
"""Require a readable histogram built from the current input runs and settings."""
data = product.data
if sorted(data[_INPUT_RUN_NUMBERS_KEY]) != sorted(group.run_numbers):
raise ValueError("Derived run coverage differs from production")
sources = [
(product.directory / path).resolve() for path in data[_INPUT_FILES_KEY][_REDUCED_EVENT_DATA]
]
if set(sources) != {path.resolve() for path in group.file_paths}:
raise ValueError("Derived input files differ from production")
output_files = data[_FILES_KEY][_TRIGGER_HISTOGRAMS]
if len(output_files) != 1:
raise ValueError("Expected one histogram per configuration")
output = product.directory / output_files[0]
try:
metadata, bins = load_trigger_histograms(output)
except (KeyError, OSError, ValueError, IORegistryError) as exc:
raise ValueError(f"Unreadable trigger histogram: {output}") from exc
if not metadata or not bins:
raise ValueError("Empty trigger histogram")
if any(path.stat().st_mtime_ns > output.stat().st_mtime_ns for path in group.file_paths):
raise ValueError("Production inputs changed after derivation")
for key, value in data[_HISTOGRAM_SETTINGS_KEY].items():
expected = configuration.get(key, _DEFAULT_HISTOGRAM_SETTINGS.get(key))
if not values_match(value, str(expected)):
raise ValueError(f"Histogram setting changed: {key}")