"""Camera efficiency simulations and analysis."""
import logging
from collections import defaultdict
from pathlib import Path
import astropy.units as u
import numpy as np
from astropy.table import Table
import simtools.data_model.model_data_writer as writer
from simtools.atmosphere.atmosphere import AtmosphereProfile
from simtools.camera.camera_efficiency_calculator import (
RESULT_COLUMN_DESCRIPTIONS,
CameraEfficiencyCalculator,
)
from simtools.context import RunContext
from simtools.io import ascii_handler
from simtools.model.model_utils import initialize_simulation_models
from simtools.model_repository.asset_names import get_simtel_table_file_name
from simtools.simulation.configuration import get_model_writer
from simtools.utils import names
from simtools.visualization import visualize
ECSV_SUFFIX = ".ecsv"
_PREPARED_RESOURCE_PLACEHOLDER = "${prepared:"
_SPECTRAL_STAGE_DESCRIPTIONS = {
"cherenkov_at_ground": (
"Cherenkov spectrum at ground, relative to the emission spectrum at 400 nm."
),
"cherenkov_after_mirrors": "Cherenkov spectrum after mirror reflection and shadowing.",
"cherenkov_at_photodetector": "Cherenkov spectrum after the camera filter and lightguide.",
"cherenkov_detected": "Cherenkov spectrum after all optical and quantum efficiencies.",
"cherenkov_detected_without_mirrors": "Detected Cherenkov spectrum excluding mirror losses.",
"nsb_before_correction": "NSB photon spectrum before altitude and airmass corrections.",
"nsb_after_mirrors": "NSB spectrum after mirror reflection and shadowing.",
"nsb_at_photodetector": "NSB spectrum after mirrors, camera filter, and lightguide.",
"nsb_detected": "NSB spectrum after all optical and quantum efficiencies.",
"nsb_detected_without_mirrors": "NSB spectrum excluding mirror losses.",
}
[docs]
class CameraEfficiency:
"""
Camera efficiency simulations and analysis.
Parameters
----------
label: str
Instance label, optional.
config_data: dict.
Dict containing the configurable parameters.
efficiency_type: str
The type of efficiency to simulate (e.g., 'Shower', 'Muon', or 'NSB').
telescope_model: TelescopeModel, optional
Reuse an initialized telescope model.
site_model: SiteModel, optional
Reuse an initialized site model.
"""
def __init__(
self,
label,
config_data,
efficiency_type,
telescope_model=None,
site_model=None,
*,
context=None,
):
"""Initialize the CameraEfficiency class."""
self._logger = logging.getLogger(__name__)
self.context = context or RunContext.from_environment()
self.label = label
self.io_handler = self.context.paths
if telescope_model is None or site_model is None:
initialized_telescope_model, initialized_site_model, _ = initialize_simulation_models(
label=self.label,
model_version=config_data["model_version"],
site=config_data["site"],
telescope_name=config_data["telescope"],
model_reader=self.context.model_reader,
context=self.context,
)
self.telescope_model = (
initialized_telescope_model if telescope_model is None else telescope_model
)
self.site_model = initialized_site_model if site_model is None else site_model
else:
self.telescope_model = telescope_model
self.site_model = site_model
self.output_dir = self.io_handler.get_output_directory()
self._results = None
self._calculated_results = None
self._has_results = False
self.efficiency_type = efficiency_type.lower()
self.config = self._configuration(config_data)
self._file = self._load_files()
self.nsb_pixel_pe_per_ns = None
self.nsb_rate_ref_conditions = None
def __repr__(self):
"""Return string representation of the CameraEfficiency instance."""
return f"CameraEfficiency(label={self.label})\n"
def _configuration(self, config_data):
"""
Extract configuration data from command line and class parameters.
Parameters
----------
config_data: dict
Dict containing the configurable parameters.
Returns
-------
dict
Configuration data.
"""
nsb_spectrum = config_data.get("nsb_spectrum")
if isinstance(nsb_spectrum, str) and nsb_spectrum.startswith(
_PREPARED_RESOURCE_PLACEHOLDER
):
self._logger.info(
"Prepared NSB spectrum is unavailable in direct application mode; "
"using the site model reference spectrum."
)
nsb_spectrum = None
return {
"zenith_angle": config_data["zenith_angle"].to("deg").value,
"azimuth_angle": config_data["azimuth_angle"].to("deg").value,
"nsb_spectrum": nsb_spectrum,
"skip_correction_to_nsb_spectrum": config_data.get(
"skip_correction_to_nsb_spectrum", False
),
"efficiency_type": self.efficiency_type,
}
def _load_files(self):
"""Define the camera-efficiency result file name."""
file_name = names.generate_file_name(
file_type="telescope_throughput",
suffix=ECSV_SUFFIX,
site=self.telescope_model.site,
telescope_model_name=self.telescope_model.name,
zenith_angle=self.config["zenith_angle"],
azimuth_angle=self.config["azimuth_angle"],
label=self.efficiency_type,
)
return {"results": self.io_handler.get_output_directory().joinpath(file_name)}
[docs]
def simulate(self):
"""Calculate camera efficiency using the in-process ECSV calculator."""
self._logger.info("Simulating CameraEfficiency")
if not self.config.get("skip_correction_to_nsb_spectrum", False):
get_model_writer(
self.telescope_model
).export_nsb_spectrum_to_telescope_altitude_correction_file(
model_directory=self.telescope_model.config_file_directory
)
calculator = CameraEfficiencyCalculator(
telescope_model=self.telescope_model,
site_model=self.site_model,
zenith_angle=self.config["zenith_angle"],
x_max=self._get_x_max_for_efficiency_type(),
nsb_spectrum=self.config["nsb_spectrum"],
skip_correction_to_nsb_spectrum=self.config.get(
"skip_correction_to_nsb_spectrum", False
),
)
self._calculated_results = calculator.calculate()
[docs]
def get_nsb_pixel_rate(self, reference_conditions=False):
"""
Return the expected NSB pixel rate for each camera pixel.
This is an approximation because the calculator evaluates the on-axis pixel only.
Returns
-------
list
Expected NSB pixel rate in p.e./ns for the provided NSB spectrum.
"""
base_rate = (
self.nsb_rate_ref_conditions if reference_conditions else self.nsb_pixel_pe_per_ns
)
# Accept either a plain float (assumed already in GHz) or an astropy Quantity
if isinstance(base_rate, u.Quantity):
base_rate_ghz = base_rate.to(u.GHz).value
else:
base_rate_ghz = float(base_rate)
n_pixels = int(self.telescope_model.get_parameter_value("camera_pixels"))
return u.Quantity(np.full(n_pixels, base_rate_ghz), u.GHz)
[docs]
def analyze(self, export=True, force=False):
"""
Analyze camera efficiency output file and store the results in _results.
Parameters
----------
export: bool
If True, results will be exported to a file automatically. Alternatively, export_results
function can be used.
force: bool
If True, existing results files will be removed and analysis will be done again.
"""
self._logger.info("Analyzing CameraEfficiency")
if "results" in self._file and not force:
self._logger.info("Results file exists and force=False - skipping analyze")
self._read_results()
return
# List of parameters to be calculated and stored
eff_pars = [
"wl",
"eff",
"eff_atm",
"qe",
"ref",
"masts",
"filt",
"pixel",
"atm_trans",
"cher",
"nsb",
"atm_corr",
"nsb_site",
"nsb_site_eff",
"nsb_be",
"nsb_be_eff",
]
_results = defaultdict(list)
if self._calculated_results is None:
raise RuntimeError("Camera efficiency must be simulated before it can be analyzed.")
for row in self._calculated_results:
for name in eff_pars:
_results[name].append(row[name])
mirror_throughput = row["ref"] * row["masts"]
camera_throughput = row["filt"] * row["pixel"]
quantum_efficiency = row["qe"]
cherenkov_at_ground = row["atm_trans"] * (400 / row["wl"]) ** 2
cherenkov_after_mirrors = cherenkov_at_ground * mirror_throughput
cherenkov_at_photodetector = cherenkov_after_mirrors * camera_throughput
cherenkov_detected = cherenkov_at_photodetector * quantum_efficiency
_results["cherenkov_at_ground"].append(cherenkov_at_ground)
_results["cherenkov_after_mirrors"].append(cherenkov_after_mirrors)
_results["cherenkov_at_photodetector"].append(cherenkov_at_photodetector)
_results["cherenkov_detected"].append(cherenkov_detected)
_results["cherenkov_detected_without_mirrors"].append(
cherenkov_at_ground * quantum_efficiency * camera_throughput
)
nsb_before_correction = row["nsb_be"]
nsb_after_mirrors = nsb_before_correction * mirror_throughput
nsb_at_photodetector = nsb_after_mirrors * camera_throughput
nsb_detected = nsb_at_photodetector * quantum_efficiency
_results["nsb_before_correction"].append(nsb_before_correction)
_results["nsb_after_mirrors"].append(nsb_after_mirrors)
_results["nsb_at_photodetector"].append(nsb_at_photodetector)
_results["nsb_detected"].append(nsb_detected)
_results["nsb_detected_without_mirrors"].append(
nsb_before_correction * quantum_efficiency * camera_throughput
)
self._results = Table(_results)
descriptions = RESULT_COLUMN_DESCRIPTIONS | _SPECTRAL_STAGE_DESCRIPTIONS
for name in self._results.colnames:
self._results[name].description = descriptions[name]
self._has_results = True
self.nsb_pixel_pe_per_ns, self.nsb_rate_ref_conditions = self.calc_nsb_rate()
summary = self.results_summary()
self._logger.info(f"\n{summary}")
if self.efficiency_type == "shower" and all(
key in summary for key in ("cam_eff", "tot_sens")
):
camera_efficiency = summary["cam_eff"]["value"]
self._logger.info(
"Photon detection efficiency (300-550 nm; weighted by Cherenkov spectrum): "
f"{camera_efficiency:.1%}"
)
self._logger.info(
"Signal-to-noise efficiency (Cherenkov / sqrt(reference night-sky spectrum; "
f"albedo excluded)): {summary['tot_sens']['value']:.4f}"
)
if export:
self.export_results()
[docs]
def results_summary(self):
"""
Fill a dictionary with summary of the results.
Include a header for the zenith/azimuth settings and the NSB spectrum file which was used.
The summary includes the various CTAO requirements and the final expected NSB pixel rate.
Returns
-------
dict
Summary of the results.
"""
nsb_spectrum = self.config["nsb_spectrum"]
if nsb_spectrum:
nsb_spectrum = Path(nsb_spectrum).name
meta = {
"meta": {
"tel": self.telescope_model.name,
"model_version": self.telescope_model.model_version,
"zen": self.config["zenith_angle"],
"az": self.config["azimuth_angle"],
"nsb": nsb_spectrum or "default sim_telarray spectrum",
}
}
metrics = {}
if self.efficiency_type == "shower":
metrics |= {
"reflectivity": {
"value": self.calc_reflectivity(),
"description": "Spectrum weighted reflectivity",
},
"cam_eff": {
"value": self.calc_camera_efficiency(),
"description": "Camera nominal efficiency with gaps (B-TEL-1170)",
},
"tel_eff": {
"value": self.calc_tel_efficiency(),
"description": "Telescope total efficiency with gaps (was A-PERF-2020)",
},
"tot_sens": {
"value": self.calc_tot_efficiency(self.calc_tel_efficiency()),
"description": (
"Telescope total Cherenkov light efficiency / sqrt(total NSB efficiency) "
"(A-PERF-2025/B-TEL-0090)"
),
},
}
elif self.efficiency_type == "nsb":
metrics |= {
"nsb_rate": {
"value": self.nsb_pixel_pe_per_ns,
"description": "Expected NSB pixel rate for the provided NSB spectrum",
},
"nsb_ref": {
"value": self.nsb_rate_ref_conditions,
"description": "Expected NSB pixel rate for the reference NSB",
},
}
elif self.efficiency_type == "muon":
metrics |= {
"muon_frac": {
"value": self.calc_partial_efficiency(lambda_min=200.0, lambda_max=290.0),
"description": (
"Fraction of light (from muons) in the wavelength range 200-290 nm "
"(B-TEL-0095)"
),
},
}
return meta | metrics
[docs]
def export_results(self):
"""Export results to a ecsv file."""
if not self._has_results:
self._logger.error("Cannot export results because they do not exist")
else:
self._logger.info(f"Exporting camera efficiency table to {self._file['results']}")
self._results.write(self._file["results"], format=f"ascii{ECSV_SUFFIX}", overwrite=True)
_results_summary_file = str(self._file["results"]).replace(ECSV_SUFFIX, "_summary.yml")
self._logger.info(f"Exporting summary results to {_results_summary_file}")
ascii_handler.write_data_to_file(self.results_summary(), Path(_results_summary_file))
def _read_results(self):
"""Read existing results file and store it in _results."""
self._results = Table.read(self._file["results"], format=f"ascii{ECSV_SUFFIX}")
self._has_results = True
[docs]
def calc_tel_efficiency(self):
"""
Calculate the telescope total efficiency including gaps (as defined in A-PERF-2020).
Returns
-------
tel_efficiency: float
Telescope efficiency
"""
# Sum(cherenkov_at_ground) from 300 - 550 nm:
cherenkov_at_ground_band = self._results["cherenkov_at_ground"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
cherenkov_at_ground_sum = np.sum(cherenkov_at_ground_band)
# Sum(cherenkov_detected) from 200 - 999 nm:
cherenkov_detected_sum = np.sum(self._results["cherenkov_detected"])
masts_factor = self._results["masts"][0]
fill_factor = self.telescope_model.camera.get_camera_fill_factor()
return fill_factor * (cherenkov_detected_sum / (masts_factor * cherenkov_at_ground_sum))
[docs]
def calc_camera_efficiency(self):
"""
Calculate the camera nominal efficiency including gaps (as defined in B-TEL-1170).
Returns
-------
cam_efficiency: float
Wavelength-averaged camera efficiency
"""
# Sum(cherenkov_at_ground) from 300 - 550 nm:
cherenkov_at_ground_band = self._results["cherenkov_at_ground"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
cherenkov_at_ground_sum = np.sum(cherenkov_at_ground_band)
# Sum(cherenkov_detected_without_mirrors) from 300 - 550 nm:
cherenkov_without_mirrors_band = self._results["cherenkov_detected_without_mirrors"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
cherenkov_without_mirrors_sum = np.sum(cherenkov_without_mirrors_band)
fill_factor = self.telescope_model.camera.get_camera_fill_factor()
cam_efficiency_no_gaps = cherenkov_without_mirrors_sum / cherenkov_at_ground_sum
return cam_efficiency_no_gaps * fill_factor
[docs]
def calc_tot_efficiency(self, tel_efficiency):
"""
Calculate the telescope total efficiency including gaps (as defined in A-PERF-2020).
Parameters
----------
tel_efficiency: float
The telescope efficiency as calculated by calc_tel_efficiency()
Returns
-------
Float
Telescope total efficiency including gaps
"""
# Sum(nsb_before_correction) from 300 - 550 nm:
nsb_before_correction_band = self._results["nsb_before_correction"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
nsb_before_correction_sum = np.sum(nsb_before_correction_band)
# Sum(nsb_detected) from 200 - 999 nm:
nsb_detected_sum = np.sum(self._results["nsb_detected"])
masts_factor = self._results["masts"][0]
fill_factor = self.telescope_model.camera.get_camera_fill_factor()
tel_efficiency_nsb = fill_factor * (
nsb_detected_sum / (masts_factor * nsb_before_correction_sum)
)
return tel_efficiency / np.sqrt(tel_efficiency_nsb)
[docs]
def calc_partial_efficiency(self, lambda_min=200.0, lambda_max=290.0):
"""
Compare efficiency in a given wavelength range with total efficiency.
Parameters
----------
lambda_min: float
Minimum wavelength in nm.
lambda_max: float
Maximum wavelength in nm.
Returns
-------
Float
Fraction of light in the given wavelength range compared to total efficiency.
"""
# Sum(cherenkov_detected) from lamba_min to lambda_max nm:
cherenkov_detected_band = self._results["cherenkov_detected"][
[lambda_min < wl_now < lambda_max for wl_now in self._results["wl"]]
]
cherenkov_detected_sum = np.sum(cherenkov_detected_band)
# Sum(cherenkov_detected) from 200 - 999 nm:
cherenkov_detected_total = np.sum(self._results["cherenkov_detected"])
# (no need to apply masts or fill factors as in calc_tel_efficiency, they cancel out)
return cherenkov_detected_sum / cherenkov_detected_total
[docs]
def calc_reflectivity(self):
"""
Calculate the Cherenkov spectrum weighted reflectivity in the range 300-550 nm.
Returns
-------
Float
Cherenkov spectrum weighted reflectivity (300-550 nm)
"""
# Sum(cherenkov_at_ground) from 300 - 550 nm:
cherenkov_at_ground_band = self._results["cherenkov_at_ground"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
cherenkov_at_ground_sum = np.sum(cherenkov_at_ground_band)
# Sum(cherenkov_after_mirrors) from 300 - 550 nm:
cherenkov_after_mirrors_band = self._results["cherenkov_after_mirrors"][
[299 < wl_now < 551 for wl_now in self._results["wl"]]
]
cherenkov_after_mirrors_sum = np.sum(cherenkov_after_mirrors_band)
return cherenkov_after_mirrors_sum / cherenkov_at_ground_sum / self._results["masts"][0]
[docs]
def calc_nsb_rate(self, wavelength_range=(300 * u.nm, 650 * u.nm)):
"""
Calculate the NSB rate.
CTAO reference wavelength range is 300-650 nm.
Parameters
----------
wavelength_range: tuple
Wavelength range used for the NSB rate calculation (default: (300 nm, 650 nm)).
Returns
-------
nsb_rate_provided_spectrum: float
NSB pixel rate in p.e./ns for the provided NSB spectrum
nsb_rate_ref_conditions: float
NSB pixel rate in p.e./ns for reference conditions
(https://jama.cta-observatory.org/perspective.req#/items/26694?projectId=11)
"""
self.nsb_pixel_pe_per_ns = (
np.sum(self._results["nsb_detected"])
* self.telescope_model.camera.get_pixel_active_solid_angle()
* self.telescope_model.get_on_axis_eff_optical_area().to("m2").value
/ self.telescope_model.get_parameter_value("telescope_transmission")[0]
)
wavelength_range = (
wavelength_range[0].to("nm").value,
wavelength_range[1].to("nm").value,
)
# (integral is in ph./(m^2 ns sr) ) over wavelength_range
nsb_before_correction_band = self._results["nsb_before_correction"][
[wavelength_range[0] <= wl_now <= wavelength_range[1] for wl_now in self._results["wl"]]
]
nsb_before_correction_sum = np.sum(nsb_before_correction_band)
nsb_before_correction_edges = self._results["nsb_before_correction"][
[wl_now in [wavelength_range[0], wavelength_range[1]] for wl_now in self._results["wl"]]
]
nsb_before_correction_edges_sum = np.sum(nsb_before_correction_edges)
nsb_integral = 0.0001 * (nsb_before_correction_sum - 0.5 * nsb_before_correction_edges_sum)
self.nsb_rate_ref_conditions = (
self.nsb_pixel_pe_per_ns
* self.site_model.get_parameter_value("nsb_reference_value")
/ nsb_integral
)
return self.nsb_pixel_pe_per_ns * u.GHz, self.nsb_rate_ref_conditions * u.GHz
[docs]
def plot_efficiency(self, save_fig=False):
"""
Plot efficiency vs wavelength.
Parameters
----------
save_fig: bool
If True, the figure will be saved to a file.
Returns
-------
fig
The figure instance of pyplot
"""
if self.efficiency_type in ("shower", "muon"):
spectrum = "cherenkov"
initial_column = "cherenkov_at_ground"
else:
spectrum = "nsb"
initial_column = "nsb_before_correction"
column_titles = {
"wl": "Wavelength [nm]",
initial_column: initial_column.replace("_", " "),
f"{spectrum}_after_mirrors": "After mirrors",
f"{spectrum}_at_photodetector": "At photodetector",
f"{spectrum}_detected": "Detected",
f"{spectrum}_detected_without_mirrors": "Detected without mirrors",
}
table_to_plot = Table([self._results[col_now] for col_now in column_titles])
for column_now, column_title in column_titles.items():
table_to_plot.rename_column(column_now, column_title)
y_title = f"{self.efficiency_type} light efficiency"
if self.efficiency_type == "nsb":
y_title = r"Diff. ph. rate [$10^{9} \times $ph/(nm s m$^2$ sr)]"
plot = visualize.plot_table(
table_to_plot,
y_title=y_title,
title=f"{self.telescope_model.name} response to {self.efficiency_type} light",
no_markers=True,
)
if self.efficiency_type == "nsb":
plot.gca().set_yscale("log")
ylim = plot.gca().get_ylim()
plot.gca().set_ylim(1e-3, ylim[1])
if save_fig:
self._save_plot(plot, self.efficiency_type)
return plot
def _save_plot(self, fig, plot_title):
"""
Save plot to pdf and png file.
Parameters
----------
fig
The figure instance of pyplot
plot_title: str
The title of the plot
"""
plot_file = self.output_dir.joinpath(
self.label + "_" + self.telescope_model.name + "_" + plot_title
)
visualize.save_figure(fig, plot_file, log_title=f"{plot_title} efficiency")
[docs]
def dump_nsb_pixel_rate(self):
"""Write NSB pixel rate parameter file."""
cfg = self.context.args
writer.ModelDataWriter.write_model_parameter(
parameter_name="nsb_pixel_rate",
value=self.get_nsb_pixel_rate(
reference_conditions=self.context.args.get(
"write_reference_nsb_rate_as_parameter", False
)
),
instrument=cfg.get("telescope"),
parameter_version=cfg.get("parameter_version") or "0.0.0",
output_file=Path(f"nsb_pixel_rate-{cfg.get('parameter_version', '0.0.0')}.json"),
output_path=self.output_dir / cfg.get("telescope") / "nsb_pixel_rate",
context=self.context,
)
def _get_x_max_for_efficiency_type(self):
"""
Get X max value in g/cm2 depending on the efficiency type.
Returns
-------
float
max value in g/cm2
"""
# typical value for shower X-max around 10 km (not relevant for NSB type)
x_max = 300.0
obs_level = self.site_model.get_parameter_value_with_unit("corsika_observation_level")
if self.efficiency_type == "muon":
atmospheric_profile = self.site_model.get_parameter_value("atmospheric_profile")
if str(atmospheric_profile).lower().endswith(ECSV_SUFFIX):
parameter_data = self.site_model.parameters.get("atmospheric_profile", {})
atmospheric_profile = get_simtel_table_file_name(parameter_data) or (
f"atmospheric_profile-{Path(self.telescope_model.config_file_path).stem}.dat"
)
get_model_writer(self.site_model).export_model_parameter_as_simtel_file(
"atmospheric_profile",
self.telescope_model.config_file_directory,
table_format="plain",
output_name=atmospheric_profile,
)
atmo = AtmosphereProfile(self.site_model.config_file_directory / atmospheric_profile)
alt = obs_level.to(u.km) + 0.1 * u.km
x_max = atmo.interpolate(altitude=alt, column="thick")
self._logger.info(
f"Using X-max for {self.efficiency_type} efficiency: {x_max:.2f} g/cm2"
f" (at observation level: {obs_level:.2f})"
)
return x_max