Source code for simtel.multi_illuminator_simulator

"""Multi-illuminator simulation orchestration with parallel execution."""

import logging
from pathlib import Path

from simtools.context import RunContext
from simtools.job_execution.execution import map_ordered
from simtools.model.illuminator_visibility import IlluminatorTelescopeVisibility
from simtools.simtel.simulator_light_emission import SimulatorLightEmission
from simtools.utils import value_conversion

_logger = logging.getLogger(__name__)

_NO_RESULTS_MSG = "No simulations have been run yet. Call simulate() first."


def _get_job_model_directory(config, illuminator, telescope, wavelength):
    """Return the isolated generated-model directory for one simulation job."""
    output_path = config.get("output_path")
    if output_path is None:
        return None
    wavelength_nm = wavelength.to_value("nm")
    job_name = f"{illuminator}_{telescope}_{wavelength_nm:g}nm"
    return Path(output_path) / "model" / str(config["model_version"]) / job_name


def _simulate_illuminator_telescope_pair(job_spec):
    """
    Simulate a single illuminator-telescope pair.

    This function is executed in a separate process.

    Parameters
    ----------
    job_spec : dict
        Job specification containing:
        - illuminator: str - illuminator name
        - telescope: str - telescope name
        - site: str - site name (North/South)
        - label: str - base label for the simulation
        - config: dict - light emission configuration
        - model_source: dict - serializable model-source selection

    Returns
    -------
    dict
        Result dictionary with simulation outcome:
        - illuminator: str
        - telescope: str
        - success: bool
        - error: str or None
    """
    illuminator = job_spec["illuminator"]
    telescope = job_spec["telescope"]
    config = job_spec["config"].copy()
    try:
        context = RunContext.from_spec(job_spec["context"])
        model_reader = context.model_reader

        # Update configuration for this specific pair
        config["telescope"] = telescope
        config["light_source"] = illuminator

        label = job_spec["label"]
        _logger.info(f"Starting simulation for {illuminator} -> {telescope}")

        simulator = SimulatorLightEmission(
            light_emission_config=config,
            telescope=telescope,
            label=label,
            model_reader=model_reader,
            context=context,
        )

        simulator.simulate()
        simulator.validate_simulations()

        _logger.info(f"Completed simulation for {illuminator} -> {telescope}")

        return {
            "illuminator": illuminator,
            "telescope": telescope,
            "wavelength": job_spec.get("wavelength"),
            "success": True,
            "error": None,
        }

    except Exception as exc:  # pylint: disable=broad-except
        _logger.warning(f"Failed simulation for {illuminator} -> {telescope}: {exc}")
        return {
            "illuminator": illuminator,
            "telescope": telescope,
            "wavelength": job_spec.get("wavelength"),
            "success": False,
            "error": str(exc),
        }


[docs] class MultiIlluminatorSimulator: """ Orchestrate parallel simulations of multiple illuminator-telescope pairs. This class manages simulations for all valid illuminator-telescope combinations defined in a visibility table, running them in parallel across multiple CPU cores. Parameters ---------- config : dict Base configuration for light emission simulations. Will be updated for each illuminator-telescope pair. Should contain: - site: str (North/South) - model_version: str - number_of_events: int - other SimulatorLightEmission parameters visibility_data : list of dict, optional Structured visibility records with ``illuminator_id``, ``telescope_id``, and ``visible`` keys. If not provided, the visibility table is retrieved from the site model using the site and model_version from config. label : str, optional Base label for all simulations. Pair-specific labels will be appended. max_workers : int or None, optional Maximum number of parallel worker processes. If None, uses 60% of CPU cores. If <= 0, uses all available cores. """ def __init__( self, config, visibility_data=None, label=None, max_workers=None, backend="local", backend_config=None, model_reader=None, context=None, ): """Initialize the multi-illuminator simulator.""" self._logger = logging.getLogger(__name__) self.context = context or RunContext.from_environment() self.model_reader = model_reader or self.context.model_reader if self.model_reader is None: raise ValueError("A simulation model reader is required for illuminator simulations.") self.context = self.context.replace(model_reader=self.model_reader) # Load visibility table from provided data or from the site model if visibility_data is None: visibility_data = self._load_visibility_from_site_model( config, self.model_reader, context=self.context ) self.visibility = IlluminatorTelescopeVisibility(visibility_data) self.base_config = config self.label = label or "multi_illuminator" self.max_workers = max_workers self.backend = backend self.backend_config = backend_config self.results = None self._logger.info("Using execution backend %s", self.backend) @staticmethod def _load_visibility_from_site_model(config, model_reader=None, *, context): """ Load visibility data from the model repository. Parameters ---------- config : dict Configuration containing "site" and "model_version" keys. Returns ------- list of dict Structured visibility records. """ # Import here to avoid loading SiteModel when visibility_data is provided directly from simtools.model.site_model import SiteModel # pylint: disable=import-outside-toplevel site_model = SiteModel( site=config["site"], model_version=config["model_version"], model_reader=model_reader, context=context, ) return site_model.get_parameter_value("illuminator_telescope_visibility")
[docs] def simulate(self, wavelengths=None, illuminators=None, telescopes=None): """ Run simulations for all valid illuminator-telescope pairs in parallel. Optionally simulate across multiple wavelengths. If wavelengths are not specified and not in base_config, all model wavelengths will be used. Parameters ---------- wavelengths : list of astropy.units.Quantity, optional List of wavelengths to simulate. If None, uses wavelength from base_config if present, otherwise all model wavelengths. illuminators : list of str, optional Restrict simulations to specific illuminators. If None, simulate all. telescopes : list of str, optional Restrict simulations to specific telescopes. If None, simulate all. Returns ------- list of dict List of simulation results. Each dict contains: - illuminator: str - telescope: str - wavelength: astropy.units.Quantity (if wavelength simulation) - success: bool - error: str or None """ # Get all valid pairs all_pairs = self.visibility.get_valid_pairs() # Apply filters first (before using a pair to fetch wavelengths) if illuminators is not None: all_pairs = [(ill, tel) for ill, tel in all_pairs if ill in illuminators] if telescopes is not None: all_pairs = [(ill, tel) for ill, tel in all_pairs if tel in telescopes] if not all_pairs: self._logger.warning("No valid pairs to simulate after filtering") self.results = [] return self.results # Determine wavelengths: explicit param > config > model if wavelengths is not None: wavelengths = value_conversion.ensure_list(wavelengths) elif "wavelength" in self.base_config and self.base_config["wavelength"] is not None: wavelengths = value_conversion.ensure_list(self.base_config["wavelength"]) else: # Fetch from model using a sample pair self._logger.info("No wavelength specified, fetching from model") sample_illuminator, sample_telescope = all_pairs[0] config_for_query = self.base_config.copy() config_for_query["telescope"] = sample_telescope config_for_query["light_source"] = sample_illuminator wavelengths = SimulatorLightEmission.get_available_wavelengths_from_config( config_for_query, model_reader=self.model_reader, context=self.context ) self._logger.info( f"Using {len(wavelengths)} wavelengths from model: " f"{[w.value for w in wavelengths]} nm" ) # Build job specs for all wavelength-pair combinations job_specs = [] model_source = self.model_reader.source_config if not isinstance(model_source, dict): model_source = None for wavelength in wavelengths: for illuminator, telescope in all_pairs: # Create config with wavelength config_with_wl = self.base_config.copy() config_with_wl["wavelength"] = wavelength model_directory = _get_job_model_directory( self.base_config, illuminator, telescope, wavelength ) if model_directory is not None: config_with_wl["model_directory"] = model_directory job_spec = { "illuminator": illuminator, "telescope": telescope, "wavelength": wavelength, "site": self.base_config.get("site"), "label": self.label, "config": config_with_wl, "context": self.context.to_spec(), } job_specs.append(job_spec) self._logger.info( f"Simulating {len(all_pairs)} pair(s) x {len(wavelengths)} wavelength(s) " f"= {len(job_specs)} jobs with {self.max_workers} workers" ) # Execute in parallel self.results = map_ordered( _simulate_illuminator_telescope_pair, job_specs, backend=self.backend, max_workers=self.max_workers, backend_config=self.backend_config, context=self.context, ) # Log summary successful = sum(1 for r in self.results if r["success"]) failed = len(self.results) - successful self._logger.info( f"Simulation complete: {successful} successful, {failed} failed " f"across {len(wavelengths)} wavelength(s)" ) return self.results