Source code for model_repository.source

"""Read simulation models through a storage-neutral source."""

import io
import logging
import shutil
import threading
from copy import deepcopy
from pathlib import Path, PurePosixPath
from time import perf_counter

from astropy.table import Table
from packaging.version import Version

from simtools.data_model import parameter_catalog as catalog
from simtools.data_model import schema
from simtools.data_model.table_asset import validate_table_asset
from simtools.io import ascii_handler
from simtools.model_repository import files
from simtools.model_repository.asset_names import (
    SOURCE_VALUE_KEY,
    qualify_parameter_file_name,
)
from simtools.model_repository.parsing import normalize_model_parameter

logger = logging.getLogger(__name__)
_PRODUCTIONS_PATH = PurePosixPath("simulation-models/productions")
_PARAMETERS_PATH = PurePosixPath("simulation-models/model_parameters")


[docs] class ModelSource: """Read-only access to model data through a storage implementation.""" def __init__(self, storage): """Initialize the source with model storage.""" self.storage = storage self._model_versions = None self._snapshots = {} self._production_tables = {} self._parameters = {} self._lock = threading.RLock() @property def source_name(self): """Return the storage's user-facing identity.""" return self.storage.name @property def source_config(self): """Return the serializable storage configuration.""" return self.storage.config
[docs] def is_configured(self): """Return whether the source is ready for reads.""" return True
[docs] def get_model_versions(self, collection_name="telescopes"): """Return semantically sorted production versions.""" del collection_name if self._model_versions is None: prefix = _PRODUCTIONS_PATH.as_posix() versions = { PurePosixPath(path).relative_to(_PRODUCTIONS_PATH).parts[0] for path in self.storage.iter_files(prefix) if len(PurePosixPath(path).relative_to(_PRODUCTIONS_PATH).parts) > 1 } self._model_versions = sorted(versions, key=Version) return list(self._model_versions)
[docs] def get_production_descriptions(self): """Return model-version descriptions from production info files.""" prefix = _PRODUCTIONS_PATH.as_posix() info_files = [ PurePosixPath(path) for path in self.storage.iter_files(prefix) if PurePosixPath(path).parent.parent == _PRODUCTIONS_PATH and PurePosixPath(path).name in {"info.yaml", "info.yml"} ] def read_info(path): return ascii_handler.collect_data_from_bytes( self.storage.read_bytes(path.as_posix()), path ) return files.collect_production_descriptions(info_files, read_info)
def _warm_model_version(self, model_version): """Load production and referenced parameter data for one version.""" model_version = str(model_version) with self._lock: if model_version in self._snapshots: return started = perf_counter() documents = self._production_documents(model_version) tables = files.read_production_tables_from_documents(model_version, documents) parameter_paths = self._parameter_paths(tables) self._read_parameter_paths(parameter_paths) for collection, table in tables.items(): self._production_tables[(model_version, collection)] = table self._snapshots[model_version] = True logger.debug( "Model warm-up source=%s model_version=%s production_files=%d " "parameter_files=%d duration=%.3fs", self.source_name, model_version, len(documents), len(parameter_paths), perf_counter() - started, ) def _production_documents(self, model_version): """Read production documents for a version and its patch history.""" documents = [] for model_name in self._model_history(model_version): paths = self._iter_files_or_empty(_PRODUCTIONS_PATH / model_name) if not paths and model_name != model_version: raise ValueError( f"Model version {model_name} in the patch history of {model_version} " f"not found in {self.source_name}" ) documents.extend( (model_name, path, self._read_document(path)) for path in paths if path.endswith(".json") ) if not documents: raise ValueError(f"Model version {model_version} not found in {self.source_name}") return documents def _model_history(self, model_version): """Return the version and, for patch updates, its history in version order.""" model_names = [model_version] model_prefix = _PRODUCTIONS_PATH / model_version paths = self._iter_files_or_empty(model_prefix) for info_name in ("info.yml", "info.yaml"): info_path = (model_prefix / info_name).as_posix() if info_path in paths: info = self._read_document(info_path) if info.get("model_update") == "patch_update": model_names.extend(info.get("model_version_history", [])) break return sorted(set(model_names), key=Version) def _iter_files_or_empty(self, prefix): """List files below a prefix; an absent prefix has no files.""" try: return self.storage.iter_files(prefix.as_posix()) except FileNotFoundError: return [] def _read_document(self, path): """Read and parse one JSON or YAML repository document.""" return ascii_handler.collect_data_from_bytes(self.storage.read_bytes(path), path) @staticmethod def _parameter_paths(tables): """Return unique parameter paths referenced by production tables.""" paths = {} for collection, table in tables.items(): parameters = table.get("parameters", {}) parameter_sets = ( {"global": parameters} if collection in ("configuration_corsika", "configuration_sim_telarray") else parameters ) for instrument, values in parameter_sets.items(): if not isinstance(values, dict): continue for parameter, version in values.items(): if isinstance(version, str): path = ModelSource._parameter_path( collection, instrument, parameter, version ) paths[path] = (parameter, version) return paths @staticmethod def _parameter_path(collection_name, instrument, parameter, parameter_version): """Return a repository-relative parameter document path.""" scope = catalog.get_model_parameter_scope(collection_name, instrument, parameter) return ( _PARAMETERS_PATH / scope / parameter / f"{parameter}-{parameter_version}.json" ).as_posix() def _read_parameter_paths(self, paths): """Read and cache parameter documents by repository path.""" for path in paths: if path in self._parameters: continue try: data = ascii_handler.collect_data_from_bytes(self.storage.read_bytes(path), path) except FileNotFoundError: continue self._parameters[path] = normalize_model_parameter(data)
[docs] def read_production_table(self, collection_name, model_version): """Return an aggregated production table for a collection and version.""" model_version = str(model_version) self._warm_model_version(model_version) try: return deepcopy(self._production_tables[(model_version, collection_name)]) except KeyError as exc: raise ValueError( f"No production table for {collection_name} in model version {model_version}" ) from exc
[docs] def read_parameters(self, parameter_versions, collection_name, instrument=None, site=None): """Read and filter normalized parameter documents.""" instrument = self._get_parameter_instrument( {"instrument": instrument, "site": site}, collection_name ) paths = { self._parameter_path(collection_name, instrument, parameter, version): parameter for parameter, version in parameter_versions.items() } self._read_parameter_paths(paths) result = [] for path, parameter in paths.items(): if path not in self._parameters: continue scope = catalog.get_model_parameter_scope(collection_name, instrument, parameter) data = self._parameters[path] if self._matches_filters(data, scope, site): result.append(deepcopy(data)) if not result: raise ValueError(f"No parameters found for {collection_name}: {parameter_versions}") return result
@staticmethod def _get_parameter_instrument(query, collection_name): """Resolve the instrument scope used for a parameter lookup.""" instrument = query.get("instrument") if instrument: return instrument if collection_name == "sites" and query.get("site"): return f"OBS-{query['site']}" if collection_name in ("configuration_corsika", "configuration_sim_telarray"): return "global" raise ValueError( f"Model lookup for collection {collection_name} requires an array element name" ) @staticmethod def _matches_filters(parameter_data, instrument, site): """Return whether parameter metadata matches source filters.""" if instrument == "global": instrument = None if instrument and parameter_data.get("instrument") != instrument: return False parameter_sites = parameter_data.get("site") if site and isinstance(parameter_sites, list): return site in parameter_sites if not site or parameter_sites == site: return True return instrument is None and parameter_sites is None
[docs] @staticmethod def resolve_parameter_asset(parameter_data): """Resolve a file-valued parameter relative to its parameter directory.""" value = parameter_data.get(SOURCE_VALUE_KEY, parameter_data.get("value")) parameter = parameter_data.get("parameter") version = parameter_data.get("parameter_version") instrument = parameter_data.get("instrument") or "global" if not all(isinstance(part, str) for part in (value, parameter, version)): raise ValueError( "Model file access requires parameter, parameter_version, and value metadata." ) value_path = PurePosixPath(value) if value_path.is_absolute() or ".." in value_path.parts: raise ValueError(f"Model asset path escapes parameter directory: {value!r}") return (_PARAMETERS_PATH / instrument / parameter / value_path).as_posix()
[docs] def get_parameter_table(self, parameter_data): """Read and validate an ECSV table referenced by a parameter record.""" value = parameter_data.get("value") if not isinstance(value, str) or not value.lower().endswith(".ecsv"): raise ValueError("Parameter does not reference an ECSV table") path = self.resolve_parameter_asset(parameter_data) model_schema = schema.get_model_parameter_schema( parameter_data.get("parameter"), parameter_data.get("model_parameter_schema_version") ) entries = [entry for entry in model_schema.get("data", []) if entry.get("type") == "file"] try: raw = self.storage.read_bytes(path) except FileNotFoundError as exc: raise FileNotFoundError(f"Model file not found in {self.source_name}: {path}") from exc return validate_table_asset( Table.read(io.BytesIO(raw), format="ascii.ecsv"), schema_entry=entries[0] if entries else None, parameter_data=parameter_data, )
[docs] def export_model_files(self, parameters=None, file_names=None, dest=None): """Copy referenced model files to a destination directory.""" if dest is None: raise ValueError("Destination path is required to export model files.") if parameters is None and file_names: raise ValueError("Model file export requires parameter metadata") selected = [ p for p in (parameters or {}).values() if isinstance(p, dict) and p.get("file") and p.get("value") ] destination = Path(dest) destination.mkdir(parents=True, exist_ok=True) exported = {} for parameter in selected: target_name, status = self._export_parameter_file(parameter, destination) exported[parameter.get("value", target_name)] = status return exported
def _export_parameter_file(self, parameter, destination): """Export one parameter asset; return the target name and export status. Identical files are skipped. A different ECSV file with the same name raises; other collisions are written with a scope suffix and the parameter value updated. """ source = self.resolve_parameter_asset(parameter) scope = PurePosixPath(source).parent.parent.name target_name = qualify_parameter_file_name(parameter, fallback_instrument=scope) target = destination / target_name if not target.exists(): self._copy(source, target) return target_name, "copied from model source" if self._same_content(source, target): return target_name, "file exists" if Path(source).suffix.lower() == ".ecsv": raise FileExistsError(f"Refusing to overwrite colliding model asset '{target.name}'") original_name = parameter.get(SOURCE_VALUE_KEY, parameter.get("value", target_name)) target_name = self._get_collision_file_name(original_name, source) parameter.setdefault(SOURCE_VALUE_KEY, original_name) parameter["value"] = target_name target = destination / target_name if not target.exists(): self._copy(source, target) return target_name, "copied from model source" if not self._same_content(source, target): raise FileExistsError(f"Refusing to overwrite colliding model asset '{target.name}'") return target_name, "file exists" def _same_content(self, source, target): """Return whether a stored asset and a local file have equal content.""" with self.storage.open(source) as stream: return self._matches_file(stream, target) @staticmethod def _get_collision_file_name(file_name, source): """Return a deterministic filename qualified by the model scope.""" path = Path(file_name) scope = PurePosixPath(source).parent.parent.name return f"{path.stem}-{scope}{path.suffix}" @staticmethod def _matches_file(stream, target): """Compare a streamed storage asset with a local file in chunks.""" with target.open("rb") as existing: while True: chunk = stream.read(1024 * 1024) if chunk != existing.read(1024 * 1024): return False if not chunk: return True def _copy(self, source, target): """Copy a stored asset to a local destination.""" target.parent.mkdir(parents=True, exist_ok=True) with self.storage.open(source) as input_file, target.open("wb") as output_file: shutil.copyfileobj(input_file, output_file)