#!/usr/bin/python3
"""Base class for simulation model parameters (e.g., for SiteModel or TelescopeModel)."""
import logging
from copy import deepcopy
from pathlib import Path
import astropy.units as u
from simtools.context import RunContext
from simtools.data_model import parameter_catalog as catalog
from simtools.data_model import schema
from simtools.data_model.json_validation import validate_finite_json_values
from simtools.data_model.validate_data import DataValidator
from simtools.utils import names, value_conversion
SIMULATION_SOFTWARE_COLLECTIONS = {
"sim_telarray": "configuration_sim_telarray",
"corsika": "configuration_corsika",
}
_SIM_TELARRAY_COLLECTION = "configuration_sim_telarray"
_METADATA_KEYS = ("unit", "model_parameter_schema_version")
[docs]
class InvalidModelParameterError(Exception):
"""Exception for invalid model parameter."""
def _normalise_overrides(changes):
"""
Normalise model overrides to ``{(collection, element, parameter): entry}``.
Entry is a dict with ``value``, ``version`` and ``metadata`` (unit, schema version or None).
Element is the top-level key of the override mapping (site, array element, design type
or ``global``). Unknown parameter names have collection ``None``.
"""
normalised = {}
for element, parameters in (changes or {}).items():
if not isinstance(parameters, dict):
continue
for par_name, par_value in parameters.items():
normalised[(_collection_or_none(par_name), element, par_name)] = _normalise_entry(
par_value
)
return normalised
def _collection_or_none(par_name):
"""Return the collection name of a parameter, or None for unknown parameter names."""
try:
return catalog.get_collection_name_from_parameter_name(par_name)
except KeyError:
return None
def _normalise_entry(par_value):
"""Return the ``value``, ``version`` and ``metadata`` entry of a single override."""
if isinstance(par_value, dict) and ("value" in par_value or "version" in par_value):
return {
"value": par_value.get("value"),
"version": par_value.get("version"),
"metadata": {k: par_value[k] for k in _METADATA_KEYS if k in par_value} or None,
}
return {"value": par_value, "version": None, "metadata": None}
[docs]
class ModelParameter:
"""
Base class for simulation model parameters.
Provides methods to read and manipulate parameters from the model repository.
Writing of sim_telarray configuration files and model exports is done by the model
writers (see ``simulation.configuration.get_model_writer``).
Parameters
----------
model_version: str
Version of the model (ex. 5.0.0).
site: str
Site name (e.g., South or North).
array_element_name: str
Array element name (e.g., LSTN-01, LSTN-design, ILLN-01).
collection: str
instrument class (e.g. telescopes, calibration_devices)
as stored under collection in the model repository.
label: str
Instance label. Used for output file naming.
overwrite_model_parameter_dict: dict, optional
Dictionary to overwrite model parameters from the model repository with provided values.
ignore_software_version: bool
If True, ignore software version checks for deprecated parameters.
Useful for documentation generation.
model_directory: pathlib.Path or str, optional
Directory for generated model assets and sim_telarray configuration files.
parameter_names: iterable of str, optional
If supplied, load only these model parameters.
load_simulation_software_parameters: bool
If False, do not load CORSIKA and sim_telarray configuration parameters.
"""
def __init__(
self,
model_version,
site=None,
array_element_name=None,
collection="telescopes",
label=None,
overwrite_model_parameter_dict=None,
ignore_software_version=False,
model_reader=None,
model_directory=None,
parameter_names=None,
load_simulation_software_parameters=True,
context=None,
):
self._logger = logging.getLogger(__name__)
self.context = context or RunContext.from_environment()
self.io_handler = self.context.paths
self.model_reader = model_reader or self.context.model_reader
if self.model_reader is None:
raise RuntimeError("No simulation model reader is configured.")
self.parameters = {}
self._simulation_config_parameters = {sw: {} for sw in catalog.simulation_software()}
self.collection = collection
self.label = label
self.model_version = model_version
self.ignore_software_version = ignore_software_version
self._model_loading_options = {
"parameter_names": None if parameter_names is None else frozenset(parameter_names),
"load_simulation_software_parameters": load_simulation_software_parameters,
}
self.site = names.validate_site_name(site) if site is not None else None
self.name = (
names.validate_array_element_name(array_element_name)
if array_element_name is not None
else None
)
self.design_model = (
self.model_reader.get_design_model(
self.model_version, self.name, collection="telescopes"
)
if self.name is not None
else None
)
self._config_file_directory = Path(model_directory) if model_directory is not None else None
self._config_file_path = None
self.overwrite_model_parameter_dict = overwrite_model_parameter_dict
self._overrides = _normalise_overrides(overwrite_model_parameter_dict)
self.model_files_exported = False
self._load_parameters_from_repository()
self.configuration_writers = {}
def _get_parameter_dict(self, par_name):
"""
Get model parameter dictionary for a specific parameter as stored in the model repository.
No conversion to values are applied for the use in simtools
(e.g., no conversion from the string representation of lists
to lists).
Parameters
----------
par_name: str
Name of the parameter.
Returns
-------
dict
Dictionary with complete model repository entry for the given parameter.
Raises
------
InvalidModelParameterError
If par_name does not match any parameter in this model.
"""
try:
return self.parameters[par_name]
except (KeyError, ValueError) as exc:
raise InvalidModelParameterError(
f"Parameter {par_name} was not found in the model {self.name}, {self.site}."
) from exc
[docs]
def get_parameter_value(self, par_name):
"""
Get the value of a model parameter.
List of values stored in strings are returns as lists, so that no knowledge
of the model repository structure is needed when accessing the model parameters.
Parameters
----------
par_name: str
Name of the parameter.
Returns
-------
Value of the parameter.
Raises
------
InvalidModelParameterError
If par_name does not match any parameter in this model.
"""
try:
value = self._get_parameter_dict(par_name)["value"]
except KeyError as exc:
raise InvalidModelParameterError(f"Parameter {par_name} does not have a value") from exc
if isinstance(value, str):
try:
_is_float = self.get_parameter_type(par_name).startswith("float")
except (
InvalidModelParameterError,
TypeError,
AttributeError,
): # float - in case we don't know
_is_float = True
value = value_conversion.convert_string_to_list(value, is_float=_is_float)
if len(value) == 1:
value = value[0]
return value
[docs]
def get_parameter_value_with_unit(self, par_name):
"""
Get the value of an existing parameter of the model as an Astropy Quantity with its unit.
Parameters
----------
par_name: str
Name of the parameter.
Returns
-------
Astropy quantity with the value of the parameter multiplied by its unit.
If no unit is provided in the model, the value is returned without a unit.
"""
_parameter = self._get_parameter_dict(par_name)
_value = self.get_parameter_value(par_name)
try:
if isinstance(_parameter.get("unit"), str):
_unit = [item.strip() for item in _parameter.get("unit").split(",")]
else:
_unit = _parameter.get("unit")
# if there is only one value or the values share one unit
if (isinstance(_value, (int | float))) or (len(_value) > len(_unit)):
return _value * u.Unit(_unit[0])
# Create list of quantities for multiple values with different units
return [
value_conversion.get_value_as_quantity(
_value[i], _unit[i] if i < len(_unit) else None
)
for i in range(len(_value))
]
except (KeyError, TypeError, AttributeError) as exc:
self._logger.debug(
f"{exc} encountered for parameter {par_name}, returning only value without units."
)
return _value # if unit is NoneType
[docs]
def get_parameter_table(self, par_name):
"""Return the validated ECSV table associated with a model parameter."""
parameter_data = self._get_parameter_dict(par_name)
try:
return self.model_reader.get_parameter_table(parameter_data)
except ValueError as exc:
raise InvalidModelParameterError(
f"Parameter {par_name} does not reference a valid ECSV table: {exc}"
) from exc
[docs]
def get_parameter_type(self, par_name):
"""
Get the type of an existing model parameter (the ``type`` field).
Parameters
----------
par_name: str
Name of the parameter.
Returns
-------
str
type of the parameter
"""
try:
return self._get_parameter_dict(par_name)["type"]
except KeyError:
self._logger.debug(f"Parameter {par_name} does not have a type.")
return None
[docs]
def get_parameter_version(self, par_name):
"""
Get version for a given parameter used in the model.
Parameters
----------
par_name: str
Name of the parameter.
Returns
-------
str
parameter version used in the model (eg. '1.0.0')
"""
return self._get_parameter_dict(par_name)["parameter_version"]
[docs]
def get_simulation_software_parameters(self, simulation_software):
"""
Get simulation software parameters.
Parameters
----------
simulation_software: str
Simulation software name.
Returns
-------
dict
Simulation software parameters.
"""
return self._simulation_config_parameters.get(simulation_software)
@property
def config_file_directory(self):
"""Directory for configuration files. Configure if not yet set."""
if self._config_file_directory is None:
self._set_config_file_directory_and_name()
return self._config_file_directory
@property
def config_file_path(self):
"""Path of the config file. Configure, if necessary."""
if self._config_file_path is None:
self._set_config_file_directory_and_name()
return self._config_file_path
def _set_config_file_directory_and_name(self):
"""Set and create the directory and the name of the config file."""
if self.name is None and self.site is None:
return
if self._config_file_directory is None:
self._config_file_directory = self.io_handler.get_model_configuration_directory(
model_version=self.model_version
)
self._config_file_path = self.config_file_directory.joinpath(
names.sim_telarray_config_file_name(site=self.site, telescope_model_name=self.name)
)
def _load_parameters_from_repository(self):
"""Read model parameters, apply overrides, and load simulation software parameters."""
if not (self.name or self.site):
return
self.parameters = deepcopy(
self.model_reader.get_model_parameters(
self.site,
self.name,
self.collection,
self.model_version,
parameter_names=self._model_loading_options["parameter_names"],
)
)
selected_element = self._selected_override_element()
for (collection, element, par_name), entry in self._overrides.items():
if element != selected_element:
continue
if collection == _SIM_TELARRAY_COLLECTION:
self._route_to_sim_telarray(par_name, entry)
elif collection not in SIMULATION_SOFTWARE_COLLECTIONS.values():
if par_name not in self.parameters:
raise ValueError(
f"Parameter {par_name} not found in model {self.name}, cannot overwrite it."
)
self._set_parameter(self.parameters, par_name, **_entry_kwargs(entry))
self._check_model_parameter_versions(self.parameters, self.ignore_software_version)
if self._model_loading_options["load_simulation_software_parameters"]:
self._load_simulation_software_parameters()
def _selected_override_element(self):
"""Return the override element (site, array element or design type) for this model."""
if self.site and not self.name:
return f"OBS-{self.site}"
elements = {element for (_, element, _) in self._overrides}
if self.name in elements:
return self.name
design_model = self.model_reader.get_design_model(
self.model_version, self.name, collection=self.collection
)
return design_model if design_model in elements else None
def _load_simulation_software_parameters(self):
"""Load simulation software parameters and apply the matching overrides."""
for software in self._simulation_config_parameters:
try:
loaded = self.model_reader.get_simulation_configuration_parameters(
site=self.site,
array_element_name=self.name,
model_version=self.model_version,
simulation_software=software,
)
except ValueError:
continue
# overrides routed to sim_telarray before loading take precedence over the repository
if existing := self._simulation_config_parameters[software]:
loaded.update(existing)
self._simulation_config_parameters[software] = loaded
self._apply_software_overrides(software)
def _apply_software_overrides(self, software):
"""Apply overrides for one simulation software (global and matching elements)."""
collection = SIMULATION_SOFTWARE_COLLECTIONS.get(software)
if collection is None:
return
store = self._simulation_config_parameters[software]
for (par_collection, element, par_name), entry in self._overrides.items():
if par_collection != collection:
continue
if software == "sim_telarray" and not (
element == "global"
or names.matches_array_element_name_or_design_type(element, self.name)
):
continue
if software == "sim_telarray":
self._route_to_sim_telarray(par_name, entry)
elif par_name in store:
self._set_parameter(store, par_name, **_entry_kwargs(entry))
else:
self._logger.warning(
f"Skipping overwrite of '{par_name}': not in {software} configuration."
)
def _route_to_sim_telarray(self, par_name, entry):
"""Store an override as a sim_telarray configuration entry (no schema validation)."""
sim_telarray_params = self._simulation_config_parameters.setdefault("sim_telarray", {})
sim_telarray_params[par_name] = {"value": entry["value"], **(entry["metadata"] or {})}
@staticmethod
def _check_model_parameter_versions(parameters, ignore_software_version, software_name=None):
"""
Ensure parameters follow the latest schema and are compatible with installed software.
Compares software versions listed in schema files with the installed software versions
(e.g., sim_telarray, CORSIKA).
Parameters
----------
parameters: dict
Dictionary containing model parameters.
ignore_software_version: bool
If True, ignore software version checks for deprecated parameters.
software_name: str
Name of the software for which the parameters are checked.
"""
for par_name, par_data in parameters.items():
validate_finite_json_values(par_data.get("value"))
if par_name in (parameter_schema := catalog.model_parameters()):
schema.validate_deprecation_and_version(
data=parameter_schema[par_name],
software_name=software_name,
ignore_software_version=ignore_software_version,
)
schema_version = par_data.get("model_parameter_schema_version")
if schema_version is None:
raise ValueError(f"Parameter '{par_name}' has no schema version")
[docs]
def overwrite_model_parameter(self, par_name, value, parameter_version=None, metadata=None):
"""
Overwrite a parameter of this model in memory (the model repository is not modified).
If only the parameter version is given (value None), the parameter is taken from the
model repository for that version.
Parameters
----------
par_name: str
Name of the parameter.
value:
New value for the parameter.
parameter_version: str, optional
New version for the parameter.
metadata: dict, optional
Additional metadata (unit, model_parameter_schema_version).
Raises
------
InvalidModelParameterError
If the parameter to be changed does not exist in this model.
"""
if par_name not in self.parameters:
raise InvalidModelParameterError(f"Parameter {par_name} not in the model")
self._set_parameter(self.parameters, par_name, value, parameter_version, metadata)
if self.parameters[par_name].get("file", False):
self.model_files_exported = False
def _set_parameter(self, store, par_name, value, parameter_version=None, metadata=None):
"""Set one parameter in a store, validated against its schema (or taken from repository)."""
if value is None and parameter_version:
parameter_data = self.model_reader.get_model_parameter(
parameter=par_name,
site=self.site,
array_element_name=self.name,
parameter_version=parameter_version,
)
if parameter_data:
store[par_name] = parameter_data.get(par_name)
return
if isinstance(value, str):
value = value_conversion.convert_string_to_list(value)
try:
DataValidator.validate_model_parameter(
self._update_parameter_dict(store, par_name, value, parameter_version, metadata),
par_name=par_name,
)
except FileNotFoundError as exc:
raise FileNotFoundError(f"Schema file for parameter {par_name} not found.") from exc
def _update_parameter_dict(self, store, par_name, value, parameter_version, metadata):
"""
Update a parameter dictionary with value and metadata, filling schema-derived defaults.
This keeps overwrite behavior robust when metadata dictionaries are incomplete
(e.g. missing type or unit).
"""
par_dict = store[par_name]
par_dict["value"] = value
if parameter_version:
par_dict["parameter_version"] = parameter_version
schema_version = (
metadata.get("model_parameter_schema_version")
if metadata and "model_parameter_schema_version" in metadata
else par_dict.get("model_parameter_schema_version")
) or "latest"
try:
schema_version = schema.get_model_parameter_schema(par_name, schema_version).get(
"schema_version"
)
except FileNotFoundError as exc:
raise FileNotFoundError(f"Schema file for parameter {par_name} not found.") from exc
except ValueError as exc:
raise ValueError(
f"Schema version '{schema_version}' not available for parameter '{par_name}'"
) from exc
par_type = schema.get_parameter_attribute_from_schema(par_name, schema_version, "type")
schema_unit = schema.get_parameter_attribute_from_schema(par_name, schema_version, "unit")
if metadata:
_apply_metadata(par_dict, metadata, par_type)
par_dict["model_parameter_schema_version"] = schema_version
if par_type is not None:
par_dict.setdefault("type", par_type)
par_dict.setdefault("unit", schema_unit)
return par_dict
def _apply_metadata(par_dict, metadata, par_type):
"""Copy known metadata keys into a parameter dictionary."""
for key in _METADATA_KEYS:
if key in metadata:
par_dict[key] = metadata[key]
if "model_parameter_schema_version" in metadata and isinstance(par_type, list):
par_dict["type"] = par_type
def _entry_kwargs(entry):
"""Return keyword arguments for ``ModelParameter._set_parameter`` from an override entry."""
return {
"value": entry["value"],
"parameter_version": entry["version"],
"metadata": entry["metadata"],
}