Source code for data_model.table_asset

"""Read and validate ECSV assets referenced by model parameters."""

from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.table import QTable, unique


[docs] def resolve_asset_path(value, parameter_file): """Resolve a model-parameter asset path. Assets are relative to their parameter JSON file. Absolute paths and path traversal are rejected. """ value_path = Path(value) if value_path.is_absolute(): raise ValueError(f"Model asset path must be relative: {value}") root = Path(parameter_file).parent.resolve() candidate = (root / value_path).resolve() if not candidate.is_relative_to(root): raise ValueError(f"Model asset path escapes parameter directory: {value}") return candidate
[docs] def read_ecsv_asset(path, schema_entry=None, parameter_data=None): """Read and validate one ECSV model asset. Parameters ---------- path : str or pathlib.Path ECSV asset path. schema_entry : dict, optional ``data`` entry from the model-parameter schema. Its ``table_columns`` are validated when present. parameter_data : dict, optional JSON parameter record used to validate identity metadata. Returns ------- astropy.table.QTable Validated table. """ path = Path(path) if path.suffix.lower() != ".ecsv": raise ValueError(f"Model table asset must use ECSV format: {path}") table = QTable.read(path, format="ascii.ecsv") validate_table_asset(table, schema_entry=schema_entry, parameter_data=parameter_data) return table
[docs] def validate_table_asset(table, schema_entry=None, parameter_data=None): """Validate ECSV columns, units, ranges, and identity metadata in place.""" if not isinstance(table, QTable): table = QTable(table) table_columns = (schema_entry or {}).get("table_columns", []) _validate_table_columns( table, table_columns, allow_extra_columns=(schema_entry or {}).get("allow_extra_columns", False), ) _validate_processing(table, table_columns) _validate_metadata(table, (schema_entry or {}).get("table_metadata", [])) _validate_identity(table, parameter_data) return table
[docs] def get_simtel_serialization(schema_dict): """Return the sim_telarray table serialization contract from a schema. Parameters ---------- schema_dict : dict Complete model-parameter schema. Returns ------- dict The declared serialization contract. Raises ------ ValueError If the parameter has a sim_telarray table format without a contract. """ software = next( ( entry for entry in schema_dict.get("simulation_software", []) if entry.get("name") == "sim_telarray" ), None, ) if software is None: software = next( ( entry for entry in schema_dict.get("simulation_software", []) if entry.get("serialization") ), None, ) if software is None or software.get("table_format") is None: raise ValueError( f"Parameter '{schema_dict.get('name', '<unknown>')}' has no sim_telarray " "table serialization" ) contract = software.get("serialization") if contract is None: raise ValueError( f"sim_telarray format '{software['table_format']}' has no serialization contract" ) result = dict(contract) result["table_format"] = software["table_format"] result["units"] = { column["name"]: column.get("unit", "dimensionless") for entry in schema_dict.get("data", []) for column in entry.get("table_columns", []) if column["name"] in set(result.get("allowed_columns", result.get("columns", []))) } _validate_serialization_contract(result) return result
def _validate_serialization_contract(contract): """Validate references and invariants in a simulator serialization contract.""" required = { "table_format", "columns", "row_sort_keys", "float_format", "write_comments", "units", } missing = sorted(required - set(contract)) if missing: raise ValueError(f"Serialization contract is incomplete: {missing}") columns = contract["columns"] if not columns or len(columns) != len(set(columns)): raise ValueError("Serialization contract columns must be unique and non-empty") optional = set(contract.get("optional_columns", [])) allowed = set(contract.get("allowed_columns", columns)) if not set(columns) <= allowed | optional: raise ValueError("Serialization contract allowed_columns must include columns") declared = allowed | optional sort_keys = set(contract.get("row_sort_keys", contract.get("sort_keys", []))) if not sort_keys <= declared: raise ValueError("Serialization contract sort keys must reference declared columns") matrix_axes = contract.get("matrix_axes", []) if len(matrix_axes) not in (0, 2) or len(matrix_axes) != len(set(matrix_axes)): raise ValueError("Serialization contract matrix_axes must contain exactly two unique axes") if matrix_axes and not set(matrix_axes) <= declared: raise ValueError("Serialization contract matrix axes must reference declared columns") value_column = contract.get("value_column") if value_column is not None and value_column not in declared: raise ValueError("Serialization contract value_column must reference a declared column") def _validate_table_columns(table, table_columns, allow_extra_columns=False): """Validate table columns against schema declarations.""" descriptions = {entry["name"]: entry for entry in table_columns} required = {entry["name"] for entry in table_columns if entry.get("required", False)} missing = sorted(required - set(table.colnames)) if missing: raise ValueError(f"Missing required table columns: {missing}") if descriptions and not allow_extra_columns: unexpected = sorted(set(table.colnames) - set(descriptions)) if unexpected: raise ValueError(f"Unexpected table columns: {unexpected}") for name in table.colnames: _validate_column(name, table[name], descriptions.get(name)) def _validate_column(name, column, entry): """Validate one column and its numeric constraints.""" if entry is None: if not column.info.description: raise ValueError(f"Table column '{name}' has no description") return if not column.info.description: raise ValueError(f"Table column '{name}' has no description") _validate_unit(name, column, entry.get("unit")) _validate_dtype(name, column, entry.get("type")) if np.issubdtype(column.dtype, np.number): _validate_numeric_column(name, column, entry) def _validate_unit(name, column, expected_unit): """Require the canonical schema unit for a physical column.""" if not expected_unit or expected_unit == "dimensionless": return if getattr(column, "unit", None) is None: raise ValueError(f"Table column '{name}' is missing unit '{expected_unit}'") try: matches = u.Unit(column.unit) == u.Unit(expected_unit) except (TypeError, ValueError, u.UnitsError) as exc: raise ValueError( f"Table column '{name}' has unit {column.unit}; expected {expected_unit}" ) from exc if not matches: raise ValueError(f"Table column '{name}' has unit {column.unit}; expected {expected_unit}") def _validate_numeric_column(name, column, entry): """Validate finite values and declared numeric ranges.""" values = np.asarray(column) allow_nan = "allow_nan" in entry.get("input_processing", []) if not np.all(np.isfinite(values)) and not allow_nan: raise ValueError(f"Table column '{name}' contains NaN or infinite values") _validate_range(name, values, entry.get("allowed_range"), "allowed_range") _validate_range(name, values, entry.get("required_range"), "required_range") def _validate_dtype(name, column, expected): """Validate a NumPy-compatible schema dtype.""" if not expected or expected in ("file", "data_table"): return if expected == "string": if column.dtype.kind not in "OUS": raise ValueError(f"Table column '{name}' must contain strings") return try: expected_dtype = np.dtype(expected) except TypeError: return if ( not np.can_cast(column.dtype, expected_dtype, casting="safe") and column.dtype != expected_dtype ): raise ValueError(f"Table column '{name}' has dtype {column.dtype}; expected {expected}") def _validate_range(name, values, value_range, range_name): """Validate a numeric range declaration.""" if not value_range: return if "min" in value_range and np.nanmin(values) < value_range["min"]: raise ValueError(f"Table column '{name}' violates {range_name}.min") if "max" in value_range and np.nanmax(values) > value_range["max"]: raise ValueError(f"Table column '{name}' violates {range_name}.max") def _validate_processing(table, table_columns): """Validate uniqueness and ordering declarations.""" sort_columns = [ entry["name"] for entry in table_columns if "sort" in entry.get("input_processing", []) ] if sort_columns: values = np.asarray(table[sort_columns[0]]) if np.any(values[1:] < values[:-1]): raise ValueError(f"Table is not sorted by '{sort_columns[0]}'") unique_columns = [ entry["name"] for entry in table_columns if "remove_duplicates" in entry.get("input_processing", []) ] if unique_columns and len(unique(table, keys=unique_columns)) != len(table): raise ValueError(f"Duplicate values found for table key columns {unique_columns}") def _validate_identity(table, parameter_data): """Validate ECSV identity metadata when a parameter record is supplied.""" if parameter_data is None: return for metadata_key, parameter_key in ( ("parameter_name", "parameter"), ("parameter_version", "parameter_version"), ("instrument", "instrument"), ("site", "site"), ): expected = parameter_data.get(parameter_key) if metadata_key not in table.meta: raise ValueError(f"Missing required ECSV metadata: {metadata_key}") if table.meta[metadata_key] != expected: raise ValueError( f"ECSV metadata '{metadata_key}' does not match model parameter: " f" expected: '{expected}', actual: '{table.meta[metadata_key]}'." ) def _validate_metadata(table, metadata_entries): """Validate declared table metadata keys and basic JSON-compatible types.""" for entry in metadata_entries: name = entry["name"] if name not in table.meta: if entry.get("required", False): raise ValueError(f"Missing required table metadata: {name}") continue value = table.meta[name] expected = entry.get("type") if not _metadata_matches_type(name, value, expected): raise ValueError(f"Table metadata '{name}' is not of type {expected}") def _metadata_matches_type(name, value, expected): """Return whether a metadata value has its declared JSON-compatible type.""" if name == "site" and expected == "string": return isinstance(value, str) or ( isinstance(value, list) and all(isinstance(item, str) for item in value) ) return { "string": isinstance(value, str), "number": isinstance(value, (int, float)) and not isinstance(value, bool), "boolean": isinstance(value, bool), "array": isinstance(value, list), "object": isinstance(value, dict), "null": value is None, }.get(expected, True)