"""Utilities for managing the simulation models repository.
Verify production tables against model parameters, and generate new production
tables and model parameters from the changes defined in ``info.yml``.
"""
import logging
import urllib.error
import urllib.request
from pathlib import Path
from packaging.version import Version
from packaging.version import parse as parse_version
import simtools.data_model.model_data_writer as writer
from simtools.constants import DEFAULT_SIMULATION_WORKFLOWS
from simtools.data_model import parameter_catalog as catalog
from simtools.data_model.table_asset import resolve_asset_path
from simtools.io import ascii_handler
from simtools.utils import value_conversion
_logger = logging.getLogger(__name__)
_INFO_FILE_NAME = "info.yml"
_MODEL_UPDATE = "model_update"
_MODEL_VERSION_HISTORY = "model_version_history"
_PATCH_UPDATE = "patch_update"
_WORKFLOW_ASSET_TIMEOUT_SECONDS = 30
[docs]
def get_production_directory(simulation_models_path, model_version=None):
"""
Get the production directory for a model version.
Parameters
----------
simulation_models_path : str
Path to the simulation models repository.
model_version : str, optional
Model version. If None, the productions root directory is returned.
Returns
-------
Path
Production directory.
"""
base = Path(simulation_models_path) / "simulation-models/productions"
return base / str(model_version) if model_version else base
[docs]
def initialize_production(
simulation_models_path, model_version, base_model_version, update_type=_PATCH_UPDATE
):
"""
Create an initial production info file based on an existing model version.
Parameters
----------
simulation_models_path : str or Path
Path to the simulation models repository.
model_version : str
Version to initialize.
base_model_version : str
Existing production version used as the starting point.
update_type : str, optional
Production update type to write to the new info file.
Raises
------
FileExistsError
If the target production directory already exists.
FileNotFoundError
If the base production info file does not exist.
ValueError
If the update type is not supported.
"""
if update_type not in {_PATCH_UPDATE, "full_update"}:
raise ValueError(f"Unsupported production update type: {update_type}")
target_directory = get_production_directory(simulation_models_path, model_version)
source_info = get_production_directory(simulation_models_path, base_model_version) / (
_INFO_FILE_NAME
)
if target_directory.exists():
raise FileExistsError(
f"Cannot initialize production {model_version}: target directory already exists: "
f"{target_directory}"
)
if not source_info.is_file():
raise FileNotFoundError(
f"Cannot initialize production {model_version}: base production info file does not "
f"exist: {source_info}"
)
history = [base_model_version]
if update_type == _PATCH_UPDATE:
base_info = ascii_handler.collect_data_from_file(source_info)
if base_info.get(_MODEL_UPDATE) == _PATCH_UPDATE:
history.extend(base_info.get(_MODEL_VERSION_HISTORY, []))
info = {
"model_version": model_version,
_MODEL_UPDATE: update_type,
_MODEL_VERSION_HISTORY: history,
"model_status": "development",
"description": f"Initial configuration based on production model {base_model_version}",
"changes": {},
}
target_directory.mkdir(parents=True)
ascii_handler.write_data_to_file(info, target_directory / _INFO_FILE_NAME)
[docs]
def verify_simulation_model_production_tables(simulation_models_path):
"""
Verify that all model parameters referenced by the production tables exist.
Parameters
----------
simulation_models_path : str
Path to the simulation models repository.
Returns
-------
bool
True if all parameters found, False if any missing.
"""
production_files = list(get_production_directory(simulation_models_path).rglob("*.json"))
_logger.info(
f"Verifying {len(production_files)} simulation model production "
f"tables in {simulation_models_path}"
)
missing_files = []
total_checked = 0
for production_file in production_files:
file_missing, file_checked = _verify_model_parameters_for_production(
simulation_models_path, production_file
)
missing_files.extend(file_missing)
total_checked += file_checked
_logger.info(f"Checked {total_checked} parameters, {len(missing_files)} missing")
if missing_files:
for missing_file in missing_files:
_logger.error(f"Missing: {missing_file}")
return False
_logger.info("Verification passed: All parameters found")
return True
def _verify_model_parameters_for_production(simulation_models_path, production_file):
"""Return (missing_files, total_checked_count) for one production table."""
production_table = ascii_handler.collect_data_from_file(production_file)
missing_files = []
total_checked = 0
for array_element, par_dict in production_table.get("parameters", {}).items():
if isinstance(par_dict, dict):
for param_name, param_version in par_dict.items():
total_checked += 1
parameter_file = get_model_parameter_file_path(
simulation_models_path, array_element, param_name, param_version
)
if parameter_file and not parameter_file.exists():
missing_files.append(str(parameter_file))
return missing_files, total_checked
[docs]
def get_model_parameter_file_path(
simulation_models_path, array_element, parameter_name, parameter_version
):
"""
Get the file path for a model parameter.
Null-instrument parameters are stored below the explicit ``global`` scope.
Parameters
----------
simulation_models_path : str
Path to the simulation models repository.
array_element : str
Name of the array element (e.g., 'telescope'), or ``None`` for a global parameter.
parameter_name : str
Name of the parameter.
parameter_version : str
Version of the parameter.
Returns
-------
Path
The file path to the model parameter JSON file.
"""
instrument = _get_model_parameter_scope(array_element, parameter_name)
return (
Path(simulation_models_path)
/ "simulation-models/model_parameters"
/ instrument
/ parameter_name
/ f"{parameter_name}-{parameter_version}.json"
)
def _get_model_parameter_scope(telescope, parameter_name=None):
"""Return the filesystem scope for a production-table key and parameter."""
if telescope in (None, "global", "configuration_corsika"):
return "global"
if catalog.is_global_sim_telarray_parameter(parameter_name):
return "global"
return telescope
[docs]
def generate_new_production(
model_version, simulation_models_path, setting_workflows_git_tag=None, context=None
):
"""
Generate a new production definition (production tables and model parameters).
Production tables are copied from the base model version and updated with the
changes in 'info.yml' (full or patch update). New model parameter entries are
generated for the changed parameters.
Parameters
----------
model_version: str
Model version to be created or updated.
simulation_models_path: str
Path to the simulation models repository.
setting_workflows_git_tag: str, optional
Branch or tag used to download parameters from the simulation workflow repository.
Overrides ``setting_workflows_git_tag`` from ``info.yml`` if given; defaults to
``"main"`` if neither is set.
context : RunContext, optional
Run configuration and model reader used to write model parameters.
"""
modification_dict = _get_changes_dict(model_version, simulation_models_path)
update_type = modification_dict.get(_MODEL_UPDATE, "full_update")
if setting_workflows_git_tag is None:
setting_workflows_git_tag = modification_dict.get("setting_workflows_git_tag", "main")
setting_workflows_git_repository = modification_dict.get(
"setting_workflows_git_repository", DEFAULT_SIMULATION_WORKFLOWS
)
changes, base_model_version = _get_changes_to_production(
modification_dict, simulation_models_path, update_type
)
_apply_changes_to_production_tables(
changes,
base_model_version,
modification_dict["model_version"],
update_type,
simulation_models_path,
)
_apply_changes_to_model_parameters(
modification_dict.get("changes", {}),
simulation_models_path,
setting_workflows_git_tag,
setting_workflows_git_repository,
context=context,
)
def _apply_changes_to_production_tables(
changes, base_model_version, model_version, update_type, simulation_models_path
):
"""Apply changes to (or create) production tables and write them to the target directory."""
source = get_production_directory(simulation_models_path, base_model_version)
target = get_production_directory(simulation_models_path, model_version)
_logger.info(f"Production tables {update_type} from {source} to {target}")
target.mkdir(parents=True, exist_ok=True)
tables = {}
for file_path in Path(source).rglob("*.json"):
data = ascii_handler.collect_data_from_file(file_path)
if not isinstance(data, dict):
raise TypeError(f"Unsupported data type {type(data)} in {file_path}")
tables[data["production_table_name"]] = data
for table_name in changes:
tables.setdefault(table_name, {})
tables.setdefault("configuration_sim_telarray", {})
for table_name, data in tables.items():
if table_name == "configuration_sim_telarray":
has_changes = _apply_changes_to_sim_telarray_production_table(
data, changes, model_version, update_type == "patch_update"
)
should_write = update_type != "patch_update" or has_changes
else:
should_write = _apply_changes_to_production_table(
table_name, data, changes, model_version, update_type == "patch_update"
)
if should_write:
target_file = target / f"{table_name}.json"
_logger.info(f"Writing updated production table '{target_file}'")
data["production_table_name"] = table_name
ascii_handler.write_data_to_file(data, target_file, sort_keys=True)
def _apply_changes_to_production_table(table_name, data, changes, model_version, patch_update):
"""
Apply changes to a single production table.
Returns True if the table should be written (always for full updates).
"""
data["model_version"] = model_version
if table_name not in changes:
return not patch_update
production_key = "global" if table_name == "configuration_corsika" else table_name
table_parameters = {} if patch_update else data.get("parameters", {}).get(production_key, {})
parameters, deprecated = _update_parameters_dict(table_parameters, changes, table_name)
if patch_update and not parameters.get(production_key) and not deprecated:
return False
data["parameters"] = parameters
if deprecated and patch_update:
data["deprecated_parameters"] = deprecated
return True
def _get_changes_dict(model_version, simulation_models_path):
"""Load the changes dictionary from the 'info.yml' of a production directory."""
return ascii_handler.collect_data_from_file(
get_production_directory(simulation_models_path, model_version) / _INFO_FILE_NAME
)
def _get_changes_to_production(
modification_dict, simulation_models_path, update_type="full_update"
):
"""
Prepare changes applied to production tables.
For full updates, the changes of all versions in the history are combined,
starting from the base version. Returns (changes, base_model_version).
"""
model_version_history = modification_dict.get(_MODEL_VERSION_HISTORY, [])
try:
# oldest version is the base version
base_model_version = min(set(model_version_history), key=Version)
except ValueError:
_logger.debug(f"Base model version not found in {model_version_history}")
return {}, modification_dict.get("model_version")
changes = modification_dict.get("changes", {})
if update_type == _PATCH_UPDATE:
return changes, base_model_version
for version_mod in reversed(model_version_history):
_changes_dict = _get_changes_dict(version_mod, simulation_models_path)
_version_changes, base_model_version = _get_changes_to_production(
_changes_dict, simulation_models_path, update_type="full_update"
)
changes = _update_two_levels_in_changes_dict(_version_changes, changes)
# stop iterative loop after reaching first full version of production tables
if _changes_dict.get(_MODEL_UPDATE, "full_update") == "full_update":
break
return changes, base_model_version
def _update_two_levels_in_changes_dict(d, u):
"""Update changes dict, e.g. {"LSTN-design": { "parameter_name: { ... } } }."""
for k, v in u.items():
if isinstance(v, dict) and isinstance(d.get(k), dict):
d[k].update(v)
else:
d[k] = v
return d
def _apply_global_sim_telarray_changes(global_params, sim_telarray):
"""Apply global configuration_sim_telarray changes; returns (has_changes, deprecated)."""
has_changes = False
deprecated = []
for name, data in sim_telarray:
if not catalog.is_global_sim_telarray_parameter(name):
continue
has_changes = True
if data.get("deprecated", False):
global_params.pop(name, None)
deprecated.append(name)
else:
global_params[name] = data["version"]
return has_changes, deprecated
def _apply_telescope_sim_telarray_changes(parameters, telescope, sim_telarray):
"""Apply telescope sim_telarray changes; returns (has_changes, deprecated)."""
has_changes = False
deprecated = []
telescope_params = parameters.get(telescope)
for name, data in sim_telarray:
if catalog.is_global_sim_telarray_parameter(name):
continue
has_changes = True
if data.get("deprecated", False):
if telescope_params is not None:
telescope_params.pop(name, None)
deprecated.append(name)
continue
if telescope_params is None:
telescope_params = {}
telescope_params[name] = data["version"]
if telescope_params is not None:
parameters[telescope] = telescope_params
return has_changes, deprecated
def _apply_sim_telarray_changes_for_scope(parameters, global_params, telescope, params):
"""
Apply configuration_sim_telarray changes for one scope.
Global parameters go to ``global_params``; the others to ``parameters[telescope]``.
Returns (has_changes, global_deprecated, deprecated).
"""
sim_telarray = [
(name, data)
for name, data in params.items()
if catalog.get_collection_name_from_parameter_name(name) == "configuration_sim_telarray"
]
global_changed, global_deprecated = _apply_global_sim_telarray_changes(
global_params, sim_telarray
)
telescope_changed, deprecated = _apply_telescope_sim_telarray_changes(
parameters, telescope, sim_telarray
)
return global_changed or telescope_changed, global_deprecated, deprecated
def _remove_migrated_global_parameters(parameters):
"""Remove migrated global parameters from all non-global scopes."""
for scope, scope_parameters in parameters.items():
if scope != "global" and isinstance(scope_parameters, dict):
for parameter_name in catalog.SIM_TELARRAY_GLOBAL_PARAMETERS:
scope_parameters.pop(parameter_name, None)
def _record_deprecated_parameters(data, names, patch_update):
"""Record deprecated parameter names in the production table for patch updates."""
if names and patch_update:
data.setdefault("deprecated_parameters", []).extend(names)
def _apply_changes_to_sim_telarray_production_table(data, changes, model_version, patch_update):
"""
Apply configuration_sim_telarray parameter changes to the production table.
Process-wide parameters are stored under ``global``; telescope-dependent ones by design.
Returns True if at least one configuration_sim_telarray parameter was found.
"""
data["model_version"] = model_version
has_changes = False
parameters = data.get("parameters", {})
# migrated global parameters are removed from non-global scopes
_remove_migrated_global_parameters(parameters)
global_params = parameters.setdefault("global", {})
global_deprecated = []
for telescope, params in changes.items():
if not isinstance(params, dict):
continue
changes_found, global_changes, telescope_changes = _apply_sim_telarray_changes_for_scope(
parameters, global_params, telescope, params
)
has_changes = has_changes or changes_found
global_deprecated.extend(global_changes)
_record_deprecated_parameters(data, telescope_changes, patch_update)
_record_deprecated_parameters(data, global_deprecated, patch_update)
if not global_params:
parameters.pop("global", None)
data["parameters"] = parameters
return has_changes
def _update_parameters_dict(table_parameters, changes, table_name):
"""
Create the parameters dictionary for one production table.
Only changes relevant to the table are included; deprecated parameters are
removed and listed. configuration_sim_telarray parameters are skipped (handled
separately). Returns (parameters_dict, deprecated_parameter_names).
"""
new_table_name = "global" if table_name == "configuration_corsika" else table_name
new_params = {new_table_name: table_parameters}
deprecated_params = []
for param, data in changes[table_name].items():
if catalog.get_collection_name_from_parameter_name(param) == "configuration_sim_telarray":
continue
if data.get("deprecated", False):
_logger.info(f"Removing model parameter '{table_name} - {param}'")
deprecated_params.append(param)
new_params[new_table_name].pop(param, None)
else:
version = data["version"]
_logger.info(f"Setting '{table_name} - {param}' to version {version}")
new_params[new_table_name][param] = version
return new_params, deprecated_params
def _raise_with_entry_details(exc, telescope, param, param_data):
"""Re-raise an info.yml processing error with the offending entry details."""
if isinstance(param_data, dict):
entry_details = ", ".join(f"{key}={value!r}" for key, value in param_data.items())
else:
entry_details = f"param_data={param_data!r}"
raise type(exc)(
f"Failed to process info.yml entry '{telescope} -> {param}' ({entry_details}): {exc}"
) from exc
def _apply_model_parameter_entry(
telescope,
param,
param_data,
simulation_models_path,
setting_workflows_git_tag,
setting_workflows_git_repository,
context,
):
"""Create or download one model parameter entry from an info.yml change."""
if param_data.get("activity_id") is not None and param_data.get("value") is not None:
raise ValueError(
f"Both activity_id and value are set for '{telescope} - {param}'. "
"Provide only one source for model parameter content."
)
if param_data.get("activity_id") is not None:
_download_model_parameter_from_workflow(
telescope,
param,
param_data,
simulation_models_path,
setting_workflows_git_tag,
setting_workflows_git_repository,
)
elif param_data.get("value") is not None:
_create_new_model_parameter_entry(
telescope, param, param_data, simulation_models_path, context=context
)
def _apply_changes_to_model_parameters(
changes,
simulation_models_path,
setting_workflows_git_tag="main",
setting_workflows_git_repository=DEFAULT_SIMULATION_WORKFLOWS,
context=None,
):
"""
Apply changes to model parameters by creating new parameter entries.
Raises
------
ValueError
If both ``activity_id`` and ``value`` are provided for the same parameter.
"""
for telescope, parameters in changes.items():
for param, param_data in parameters.items():
try:
_apply_model_parameter_entry(
telescope,
param,
param_data,
simulation_models_path,
setting_workflows_git_tag,
setting_workflows_git_repository,
context,
)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
_raise_with_entry_details(exc, telescope, param, param_data)
def _download_model_parameter_from_workflow(
telescope,
param,
param_data,
simulation_models_path,
setting_workflows_git_tag="main",
setting_workflows_git_repository=DEFAULT_SIMULATION_WORKFLOWS,
):
"""
Download a model parameter entry from the simulation workflow repository.
Raises
------
TypeError
If downloaded content is not a dictionary.
ValueError
If downloaded parameter_version does not match requested version.
"""
target_scope = _get_model_parameter_scope(telescope, param)
target_file = get_model_parameter_file_path(
simulation_models_path, target_scope, param, param_data["version"]
)
source_file = (
f"output/{telescope}/{param}/{param_data['activity_id']}/"
f"{param}/{param}-{param_data['version']}.json"
)
if target_file.exists():
_logger.info(
"Model parameter file already exists for requested version: '%s'.", target_file
)
_copy_model_parameter_asset_from_workflow(
ascii_handler.collect_data_from_file(target_file),
target_file,
source_file,
setting_workflows_git_repository,
setting_workflows_git_tag,
)
return
_logger.info(f"Downloading model parameter '{telescope} - {param}' from '{source_file}'.")
downloaded_data = ascii_handler.collect_data_from_git(
file_name=source_file,
git_repository=setting_workflows_git_repository,
git_branch=setting_workflows_git_tag,
)
if not isinstance(downloaded_data, dict):
raise TypeError(
f"Downloaded model parameter is of type {type(downloaded_data)} "
f"for '{telescope} - {param}'."
)
downloaded_version = downloaded_data.get("parameter_version")
if downloaded_version != param_data["version"]:
raise ValueError(
f"Version mismatch for '{telescope} - {param}': requested "
f"'{param_data['version']}', downloaded '{downloaded_version}'."
)
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_scope == "global":
downloaded_data["instrument"] = None
downloaded_data["site"] = None
writer.ModelDataWriter.write_model_parameter_json(downloaded_data, target_file)
_copy_model_parameter_asset_from_workflow(
downloaded_data,
target_file,
source_file,
setting_workflows_git_repository,
setting_workflows_git_tag,
)
def _copy_model_parameter_asset_from_workflow(
parameter_data, target_file, source_file, git_repository, git_branch
):
"""Copy a file asset referenced by a workflow parameter JSON into the model repository."""
if not parameter_data.get("file"):
return
asset_value = parameter_data.get("value")
if not isinstance(asset_value, str):
raise ValueError(f"File model parameter must reference a relative filename: {asset_value}")
target_asset = resolve_asset_path(asset_value, target_file)
if target_asset.exists():
if target_asset.is_file():
return
raise IsADirectoryError(f"Model parameter asset path is not a file: {target_asset}")
source_asset = (Path(source_file).parent / asset_value).as_posix()
repository_url = str(git_repository).rstrip("/")
if "/-/raw" in repository_url:
asset_url = f"{repository_url}/{git_branch}/{source_asset}"
else:
asset_url = f"{repository_url.removesuffix('.git')}/-/raw/{git_branch}/{source_asset}"
try:
with urllib.request.urlopen(asset_url, timeout=_WORKFLOW_ASSET_TIMEOUT_SECONDS) as response:
asset_content = response.read()
except urllib.error.HTTPError as exc:
exc.close()
raise FileNotFoundError(
f"Failed to download model parameter asset from {asset_url}"
) from exc
target_asset.parent.mkdir(parents=True, exist_ok=True)
target_asset.write_bytes(asset_content)
def _create_new_model_parameter_entry(
telescope, param, param_data, simulation_models_path, context=None
):
"""
Create a new model parameter entry in the model parameters directory.
If a model parameter file exists, the latest version is used as template and
the fields are updated. Otherwise, a new file is generated from the schema.
"""
target_scope = _get_model_parameter_scope(telescope, param)
param_dir = get_model_parameter_file_path(
simulation_models_path, target_scope, param, param_data["version"]
).parent
if not param_dir.exists():
_logger.info(
f"Create directory for model parameter '{telescope} - {param}': '{param_dir}'."
)
param_dir.mkdir(parents=True, exist_ok=True)
try:
latest_file = _get_latest_model_parameter_file(param_dir, param, param_data["version"])
except FileNotFoundError:
latest_file = None
if latest_file is not None:
json_data = ascii_handler.collect_data_from_file(latest_file)
param_data["version"] = _check_for_major_version_jump(
json_data, param_data, param, telescope
)
# important for e.g. nsb_pixel_rate
if isinstance(json_data["value"], list) and not isinstance(param_data["value"], list):
param_data["value"] = [param_data["value"]] * len(json_data["value"])
target_file = param_dir / f"{param}-{param_data['version']}.json"
if target_file.exists():
_validate_existing_model_parameter_file(
target_file,
None if target_scope == "global" else target_scope,
param,
param_data,
)
_logger.info("Model parameter file already matches requested version: '%s'.", target_file)
return
writer.ModelDataWriter.write_model_parameter(
parameter_name=param,
value=param_data["value"],
instrument=None if target_scope == "global" else target_scope,
parameter_version=param_data["version"],
output_file=f"{param}-{param_data['version']}.json",
output_path=param_dir,
unit=param_data.get("unit"),
model_parameter_schema_version=param_data.get("model_parameter_schema_version", None),
context=context,
)
def _validate_existing_model_parameter_file(target_file, instrument, param, param_data):
"""Validate an existing generated model parameter file before reusing it."""
existing_data = ascii_handler.collect_data_from_file(target_file)
expected_data = {
"parameter": param,
"instrument": instrument,
"parameter_version": param_data["version"],
"value": param_data["value"],
"unit": _normalize_units_for_comparison(param_data.get("unit")),
}
existing_data["unit"] = _normalize_units_for_comparison(existing_data.get("unit"))
mismatches = {
key: (existing_data.get(key), value)
for key, value in expected_data.items()
if existing_data.get(key) != value
}
if mismatches:
raise ValueError(
f"Existing model parameter file '{target_file}' does not match the requested "
f"value for '{instrument} - {param}': {mismatches}"
)
def _normalize_units_for_comparison(unit):
"""Normalize equivalent scalar and per-value unit representations."""
unit = value_conversion.normalize_dimensionless_unit(unit)
if isinstance(unit, list):
unit = [_normalize_units_for_comparison(entry) for entry in unit]
if unit and all(entry == unit[0] for entry in unit):
return unit[0]
return unit
return value_conversion.normalize_model_parameter_unit(0, unit)
def _get_latest_model_parameter_file(directory, parameter, max_version):
"""
Get the latest model parameter JSON file with version <= ``max_version``.
Files are named 'parameter-version.json'.
Raises
------
FileNotFoundError
If no matching files are found in the directory.
"""
files = list(Path(directory).glob(f"{parameter}-*.json"))
if not files:
raise FileNotFoundError(
f"No JSON files found for parameter '{parameter}' in directory '{directory}'."
)
def extract_version(path):
return parse_version(path.stem.split("-", 1)[1])
max_ver = parse_version(max_version)
filtered_files = [f for f in files if extract_version(f) <= max_ver]
if not filtered_files:
raise FileNotFoundError(
f"No JSON files found for parameter '{parameter}' with version <= {max_version} "
f"in directory '{directory}'."
)
return str(max(filtered_files, key=extract_version))
def _check_for_major_version_jump(json_data, param_data, param, telescope):
"""
Check for major version jump and print a warning if necessary.
Generally a jump from e.g. '3.1.0' to '5.0.0' should be avoided.
"""
latest_version = parse_version(json_data.get("parameter_version", "0"))
new_version = parse_version(param_data["version"])
if new_version.major > latest_version.major + 1:
_logger.warning(
f"Major version jump from {latest_version} to {new_version} "
f"for parameter '{param}' in telescope '{telescope}'."
)
return param_data["version"]