Source code for data_model.model_data_writer

"""Model data writer module."""

import logging
from pathlib import Path

import packaging.version
from astropy.io.registry.base import IORegistryError

import simtools.utils.general as gen
from simtools.context import RunContext
from simtools.data_model import schema, validate_data
from simtools.data_model.json_validation import validate_finite_json_values
from simtools.data_model.metadata_collector import MetadataCollector
from simtools.io import ascii_handler
from simtools.io.output_paths import OutputPaths
from simtools.utils import names, value_conversion


[docs] class ModelDataWriter: """ Writer for simulation model data and metadata. Parameters ---------- output_file: str Name of output file. output_file_format: str Format of output file. output_path: str or Path Path to output file. """ def __init__( self, output_file=None, output_file_format=None, output_path=None, model_reader=None, *, context=None, ): """Initialize model data writer.""" self._logger = logging.getLogger(__name__) self.context = context or RunContext.from_environment() self.model_reader = model_reader or self.context.model_reader self.schema_dict = {} self.output_label = "default" selected_output_path = output_path or self.context.args.get("output_path") if selected_output_path is not None: paths = OutputPaths( output_path=selected_output_path, model_path=self.context.paths.model_path, ) self.context = self.context.replace(paths=paths) self.io_handler = self.context.paths try: self.output_file = ( self.io_handler.get_output_file( file_name=output_file, output_path_label=self.output_label ) if self.output_label in self.io_handler.output_path else None ) except TypeError: self.output_file = None self.output_file_format = self._derive_data_format(output_file_format, self.output_file)
[docs] @staticmethod def write_product_data( output_file=None, metadata=None, product_data=None, output_file_format="ascii.ecsv", validate_schema_file=None, metadata_output_file=None, context=None, ): """ Write model data and metadata (as static method). Parameters ---------- output_file: string or Path Name of output file (args["output_file"] is used if this parameter is not set). metadata: MetadataCollector object Metadata to be written. product_data: astropy Table Model data to be written output_file_format: str Format of output file. validate_schema_file: str Schema file used in validation of output data. metadata_output_file: str or Path, optional Separate output path for application metadata. If omitted, metadata is written next to the product data. """ writer = ModelDataWriter( output_file=output_file, output_file_format=output_file_format, context=context, ) skip_output_validation = writer.context.args.get("skip_output_validation", True) if validate_schema_file is not None and not skip_output_validation: product_data = writer.validate_and_transform( product_data_table=product_data, validate_schema_file=validate_schema_file, ) writer.write_data( metadata=metadata, product_data=product_data, metadata_output_file=metadata_output_file, )
[docs] @staticmethod def write_model_parameter( parameter_name, value, instrument, parameter_version, output_file, output_path=None, metadata_input_dict=None, unit=None, model_parameter_schema_version=None, check_for_existing_parameter=True, model_reader=None, context=None, ): """ Generate model repository-style model parameter dict and write it to json file. Parameters ---------- parameter_name: str Name of the parameter. value: any Value of the parameter. instrument: str Name of the instrument. parameter_version: str Version of the parameter. output_file: str Name of output file. output_path: str or Path Path to output file. metadata_input_dict: dict Input to metadata collector. unit: str Unit of the parameter value (if applicable and value is not of type astropy Quantity). model_parameter_schema_version: str, None Version of the model parameter schema (if None, use schema version from schema dict). check_for_existing_parameter: bool If True, check whether the parameter version exists before writing. model_reader: object, optional Reader used for the existing-parameter check. Returns ------- dict Validated parameter dictionary. """ writer = ModelDataWriter( output_file=output_file, output_file_format="json", output_path=output_path, model_reader=model_reader, context=context, ) if check_for_existing_parameter and not writer.context.args.get( "ignore_existing_parameter_version", False ): writer.check_for_existing_parameter(parameter_name, instrument, parameter_version) output_file = writer.io_handler.get_output_file( output_file, output_path_label=writer.output_label ) unique_id = None metadata = None if metadata_input_dict is not None: metadata_input_dict = dict(metadata_input_dict) metadata_input_dict["output_file"] = Path(output_file).name metadata_input_dict["output_file_format"] = Path(output_file).suffix.lstrip(".") if metadata_input_dict.get("activity_id") is None: activity_id = gen.extract_uuid7_from_path(output_file) if activity_id is not None: metadata_input_dict["activity_id"] = activity_id metadata = MetadataCollector(args_dict=metadata_input_dict, context=context) unique_id = ( metadata.get_top_level_metadata().get("cta", {}).get("product", {}).get("id") ) _json_dict = writer.get_validated_parameter_dict( parameter_name, value, instrument, parameter_version, unique_id, model_parameter_schema_version=model_parameter_schema_version, unit=unit, ) output_file = writer.write_model_parameter_dict_json(output_file, _json_dict) if metadata is not None: metadata.write(output_file) return _json_dict
[docs] def check_for_existing_parameter(self, parameter_name, instrument, parameter_version): """ Check if a parameter with the same version exists in the model repository. Parameters ---------- parameter_name: str Name of the parameter. instrument: str Name of the instrument. parameter_version: str Version of the parameter. Raises ------ ValueError If parameter with the same version exists in the model repository. """ model_reader = self.model_reader or self.context.model_reader if model_reader is None: raise RuntimeError("No simulation model reader is configured.") if not model_reader.is_configured(): return try: model_reader.get_model_parameter( parameter=parameter_name, parameter_version=parameter_version, site=names.get_site_from_array_element_name(instrument), array_element_name=instrument, ) except ValueError: pass # parameter does not exist - expected behavior else: raise ValueError( f"Parameter {parameter_name} with version {parameter_version} already exists." )
[docs] def get_validated_parameter_dict( self, parameter_name, value, instrument, parameter_version, unique_id=None, schema_version=None, unit=None, model_parameter_schema_version=None, ): """ Get validated parameter dictionary. Parameters ---------- parameter_name: str Name of the parameter. value: any Value of the parameter. instrument: str Name of the instrument. parameter_version: str Version of the parameter. schema_version: str Version of the schema. unique_id: str Unique ID of the parameter set (from metadata). unit: str Unit of the parameter value (if applicable and value is not an astropy Quantity). model_parameter_schema_version: str, None Version of the model parameter schema (if None, use schema version from schema dict). Returns ------- dict Validated parameter dictionary. """ self._logger.debug(f"Getting validated parameter dictionary for {instrument}") self.schema_dict, schema_file = self._read_schema_dict( parameter_name, model_parameter_schema_version ) if unit is None: value, unit = value_conversion.split_value_and_unit(value) validate_finite_json_values(value) data_dict = { "schema_version": schema.get_model_parameter_schema_version(schema_version), "parameter": parameter_name, "instrument": instrument, "site": names.get_site_from_array_element_name(instrument), "parameter_version": parameter_version, "unique_id": unique_id, "value": value, "unit": unit, "type": self._get_parameter_type(), "file": self._parameter_is_a_file(), "model_parameter_schema_version": model_parameter_schema_version or self.schema_dict.get("schema_version", "0.1.0"), } return self.validate_and_transform( product_data_dict=data_dict, validate_schema_file=schema_file, is_model_parameter=True, )
def _read_schema_dict(self, parameter_name, schema_version): """ Read schema dict for given parameter name and version. Use newest schema version if schema_version is None. Parameters ---------- parameter_name: str Name of the parameter. schema_version: str Schema version. Returns ------- dict Schema dictionary. """ schema_file = schema.get_model_parameter_schema_file(parameter_name) schemas = ascii_handler.collect_data_from_file(schema_file) if isinstance(schemas, list): if schema_version is None: return self._find_highest_schema_version(schemas), schema_file for entry in schemas: if entry.get("schema_version") == schema_version: return entry, schema_file else: return schemas, schema_file raise ValueError(f"Schema version {schema_version} not found for {parameter_name}") def _find_highest_schema_version(self, schema_list): """ Find entry with highest schema_version in a list of schema dicts. Parameters ---------- schema_list: list List of schema dictionaries. Returns ------- dict Schema dictionary with highest schema_version. """ try: valid_entries = [entry for entry in schema_list if "schema_version" in entry] except TypeError as exc: raise TypeError("No valid schema versions found in the list.") from exc return max(valid_entries, key=lambda e: packaging.version.Version(e["schema_version"]))
[docs] def get_parameter_type_for_schema(self, parameter_name, model_parameter_schema_version=None): """ Return parameter type(s) from a selected model-parameter schema. This method loads the schema for ``parameter_name`` and an optional ``model_parameter_schema_version``, then extracts the ``type`` field from each data entry. If all entries share the same type, a single string is returned; otherwise a list of types is returned. Parameters ---------- parameter_name: str Name of the model parameter. model_parameter_schema_version: str or None Explicit model-parameter schema version to use. If None, the newest available schema version is selected. Returns ------- str or list[str] Parameter type, reduced to a single value when all schema entries have the same type. """ schema_dict, _ = self._read_schema_dict(parameter_name, model_parameter_schema_version) parameter_types = [data["type"] for data in schema_dict["data"]] return ( parameter_types[0] if all(data_type == parameter_types[0] for data_type in parameter_types) else parameter_types )
def _get_parameter_type(self): """ Return parameter type(s) from the currently loaded schema. This helper reads ``self.schema_dict`` (expected to be populated by ``_read_schema_dict`` beforehand), extracts ``type`` values from its data entries, and reduces the result to a single string when all types are identical. Returns ------- str or list[str] Parameter type derived from ``self.schema_dict``. """ _parameter_type = [data["type"] for data in self.schema_dict["data"]] return ( _parameter_type[0] if all(t == _parameter_type[0] for t in _parameter_type) else _parameter_type ) def _parameter_is_a_file(self): """ Check if parameter is a file. Returns ------- bool True if parameter is a file. """ try: return self.schema_dict["data"][0]["type"] == "file" except KeyError, IndexError: pass return False
[docs] def validate_and_transform( self, product_data_table=None, product_data_dict=None, validate_schema_file=None, is_model_parameter=False, ): """ Validate product data using jsonschema given in metadata. If necessary, transform product data to match schema. Parameters ---------- product_data_table: astropy Table Model data to be validated. product_data_dict: dict Model data to be validated. validate_schema_file: str Schema file used in validation of output data. is_model_parameter: bool True if data describes a model parameter. """ _validator = validate_data.DataValidator( schema_file=validate_schema_file, data_table=product_data_table, data_dict=product_data_dict, check_exact_data_type=False, ) validated = _validator.validate_and_transform(is_model_parameter) if is_model_parameter and isinstance(validated, dict): validated["unit"] = value_conversion.normalize_dimensionless_unit(validated.get("unit")) return validated
[docs] def write_data(self, product_data=None, metadata=None, metadata_output_file=None): """ Write model data and metadata. Parameters ---------- product_data: astropy Table Model data to be written metadata: MetadataCollector object Metadata to be written. metadata_output_file: str or Path, optional Separate output path for application metadata. If omitted, metadata is written next to the product data. Raises ------ FileNotFoundError if data writing was not successful. """ if product_data is None: return if metadata is not None: product_data.meta.update( value_conversion.change_dict_keys_case(metadata.get_top_level_metadata(), True) ) self._logger.info(f"Writing data to {self.output_file}") if isinstance(product_data, dict) and Path(self.output_file).suffix == ".json": self.write_model_parameter_dict_json(self.output_file, product_data) return try: product_data.write(self.output_file, format=self.output_file_format, overwrite=True) except IORegistryError: self._logger.error(f"Error writing model data to {self.output_file}.") raise if metadata is not None: metadata.write( metadata_output_file or self.output_file, add_activity_name=True, )
[docs] def write_model_parameter_dict_json(self, file_name, data_dict): """ Write dictionary to model-parameter-style json file. Parameters ---------- file_name : str Name of output file. data_dict : dict Data dictionary. Raises ------ FileNotFoundError if data writing was not successful. Returns ------- Path Resolved output file path. """ data_dict = ModelDataWriter.prepare_data_dict_for_writing(data_dict) output_file = self.io_handler.get_output_file( file_name, output_path_label=self.output_label ) self._logger.info(f"Writing data to {output_file}") ModelDataWriter.write_model_parameter_json(data_dict, output_file) return output_file
[docs] @staticmethod def write_model_parameter_json(data_dict, output_file): """ Write model parameter dictionary to JSON file. Centralizes the JSON serialization options (sort_keys, numpy_types, compact_numeric_lists) so all callers use consistent settings. Parameters ---------- data_dict : dict Data dictionary to write. output_file : str or Path Path of the output JSON file. """ ascii_handler.write_data_to_file( data=data_dict, output_file=output_file, sort_keys=True, numpy_types=True, compact_numeric_lists=isinstance(data_dict.get("value"), dict), )
[docs] @staticmethod def prepare_data_dict_for_writing(data_dict): """ Prepare data dictionary for writing to json file. Ensure sim_telarray style lists as strings 'type' and 'unit' entries. Replace "None" with "null" for unit field. Replace list of equal units with single unit string. Parameters ---------- data_dict: dict Dictionary with lists. Returns ------- dict Dictionary with lists converted to strings. """ try: data_dict["unit"] = value_conversion.normalize_dimensionless_unit(data_dict["unit"]) if isinstance(data_dict["unit"], str): data_dict["unit"] = data_dict["unit"].replace("None", "null") elif isinstance(data_dict["unit"], list): data_dict["unit"] = [ unit.replace("None", "null") if isinstance(unit, str) else unit for unit in data_dict["unit"] ] if not data_dict["unit"]: data_dict["unit"] = None elif all(u == data_dict["unit"][0] for u in data_dict["unit"]): data_dict["unit"] = data_dict["unit"][0] except KeyError: pass return data_dict
@staticmethod def _derive_data_format(product_data_format, output_file=None): """ Derive data format and ensure conformance with astropy data format naming. If product_data_format is None and output_file is given, derive format from output_file suffix. Parameters ---------- product_data_format: string format identifier """ if product_data_format is None and output_file is not None: product_data_format = Path(output_file).suffix.lstrip(".") return "ascii.ecsv" if product_data_format == "ecsv" else product_data_format