Source code for simtel.table_serializers

"""Contract-driven serializers for sim_telarray tabular inputs."""

from itertools import product
from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.table import Table


[docs] class SimtelTableWriter: """Write validated model tables using a declared sim_telarray contract.""" _writers = {}
[docs] @classmethod def register(cls, format_name): """Register a table-format adapter.""" def decorator(writer): cls._writers[format_name] = writer return writer return decorator
[docs] @classmethod def write(cls, table, destination, contract, output_name): """Validate and serialize one table using its schema contract.""" if not isinstance(table, Table): raise TypeError( f"sim_telarray table writer requires an Astropy table, got {type(table)}" ) _validate_contract_definition(contract) validate_simtel_serialization(table, contract) output_path = Path(destination) / output_name if output_path.name != output_name: raise ValueError(f"Unsafe sim_telarray output filename: {output_name}") output_path.parent.mkdir(parents=True, exist_ok=True) try: writer = cls._writers[contract["table_format"]] except KeyError as exc: raise ValueError( f"Unknown sim_telarray table format: {contract['table_format']}" ) from exc writer.write(table, output_path, contract) return output_path.name
[docs] def write_simtel_table(table, destination, contract, output_name=None): """Write a table with its required schema serialization contract.""" if contract is None: raise ValueError("sim_telarray table writing requires a serialization contract") if not isinstance(table, Table): raise TypeError(f"sim_telarray table writer requires an Astropy table, got {type(table)}") output_name = output_name or f"{table.meta.get('parameter_name', 'table')}.dat" return SimtelTableWriter.write(table, destination, dict(contract), output_name)
[docs] def validate_simtel_serialization(table, contract): """Validate positional, unit, and matrix invariants before serialization.""" _validate_contract_columns(table, contract) _validate_contract_units(table, contract) _validate_contract_matrix(table, contract)
def _validate_contract_definition(contract): """Validate a complete schema-declared serialization contract.""" required = { "table_format", "columns", "row_sort_keys", "float_format", "write_comments", "units", } missing = sorted(required - set(contract)) if missing: raise ValueError(f"Incomplete sim_telarray serialization contract: {missing}") columns = contract["columns"] if not columns or len(columns) != len(set(columns)): raise ValueError("sim_telarray serialization columns must be unique and non-empty") allowed = set(contract.get("allowed_columns", columns)) | set( contract.get("optional_columns", []) ) if not set(columns) <= allowed: raise ValueError("sim_telarray serialization allowed columns must include columns") if not set(contract["row_sort_keys"]) <= allowed: raise ValueError("sim_telarray serialization sort keys must reference declared columns") axes = contract.get("matrix_axes", []) if len(axes) not in (0, 2) or len(axes) != len(set(axes)) or not set(axes) <= allowed: raise ValueError("sim_telarray serialization matrix_axes must contain two declared axes") if axes and contract.get("value_column") not in allowed: raise ValueError("sim_telarray serialization matrix requires a declared value_column") if contract.get("incomplete_grid_policy") == "opaque" and "missing_value" not in contract: raise ValueError("Opaque incomplete-grid policy requires a missing_value") def _validate_contract_columns(table, contract): """Validate table columns against its positional contract.""" missing = sorted(set(contract["columns"]) - set(table.colnames)) if missing: raise ValueError(f"sim_telarray serialization is missing columns: {missing}") allowed = set(contract.get("allowed_columns", contract["columns"])) allowed.update(contract.get("optional_columns", [])) unexpected = sorted(set(table.colnames) - allowed) if unexpected: raise ValueError(f"sim_telarray serialization has undeclared columns: {unexpected}") if contract.get("matrix_axes"): required = set(contract["matrix_axes"]) | {contract["value_column"]} missing = sorted(required - set(table.colnames)) one_dimensional_rpol = contract["table_format"] == "rpol_matrix" and missing == [ contract["matrix_axes"][1] ] if missing and not one_dimensional_rpol: raise ValueError(f"sim_telarray serialization is missing columns: {missing}") def _validate_contract_units(table, contract): """Validate canonical units required by the schema contract.""" for name, expected in contract["units"].items(): if name not in table.colnames: continue actual = getattr(table[name], "unit", None) if not _unit_matches(actual, expected): raise ValueError(f"sim_telarray column '{name}' has unit {actual}; expected {expected}") def _unit_matches(actual, expected): """Return whether an actual column unit satisfies a contract unit.""" if actual is None: return expected == "dimensionless" try: actual_unit = u.Unit(actual) if expected == "dimensionless": return actual_unit.is_equivalent(u.dimensionless_unscaled) return actual_unit == u.Unit(expected) except (TypeError, ValueError, u.UnitsError) as exc: raise ValueError(f"Invalid table unit {actual}; expected {expected}") from exc def _validate_contract_matrix(table, contract): """Validate a complete unique Cartesian matrix when its axes are present.""" axes = contract.get("matrix_axes", []) if not axes or axes[1] not in table.colnames: return keys = list(zip(*(_raw_values(table[axis]) for axis in axes), strict=True)) if len(keys) != len(set(keys)): raise ValueError("sim_telarray matrix contains duplicate axis combinations") expected = set(product(*(sorted(set(_raw_values(table[axis]))) for axis in axes))) missing = expected - set(keys) if missing and "missing_value" not in contract: raise ValueError("sim_telarray matrix must contain a complete Cartesian grid") def _ordered_table(table, contract): """Select schema-declared columns and canonical row order without mutation.""" columns = list(contract["columns"]) columns.extend(name for name in contract.get("optional_columns", []) if name in table.colnames) result = table[columns].copy(copy_data=True) sort_keys = [name for name in contract["row_sort_keys"] if name in result.colnames] sort_keys.extend(name for name in result.colnames if name not in sort_keys) if sort_keys: result.sort(sort_keys) return result def _format(value, contract): """Format a scalar with the schema-declared numeric format.""" value = getattr(value, "value", value) if isinstance(value, (float, np.floating)): return format(float(value), contract["float_format"]) return str(value) def _raw_values(values): """Return scalar values from an Astropy column.""" return getattr(values, "value", values)
[docs] @SimtelTableWriter.register("plain") @SimtelTableWriter.register("pulse") @SimtelTableWriter.register("mirror_list") class PlainTableWriter: """Serialize positional whitespace-separated tables."""
[docs] @staticmethod def write(table, output_path, contract): """Write the selected sorted rows.""" ordered = _ordered_table(table, contract) columns = [_raw_values(ordered[name]) for name in ordered.colnames] with output_path.open("w", encoding="utf-8") as file: for row in zip(*columns, strict=True): file.write(" ".join(_format(value, contract) for value in row) + "\n")
[docs] @SimtelTableWriter.register("rpol_matrix") class RpolMatrixWriter: """Serialize wavelength and incidence-angle response matrices."""
[docs] @staticmethod def write(table, output_path, contract): """Write a one-dimensional response or sim_telarray's RPOL matrix dialect. Two-dimensional output starts with ``#@RPOL@[ANGLE=] 2`` and an ``ANGLE=`` row. sim_telarray reads these mirror/filter tables with degree-valued angle coordinates and its ``yscale=deg2rad`` option; one-dimensional input remains a normal wavelength-response table. """ axis_0, axis_1 = contract["matrix_axes"] if axis_1 not in table.colnames: PlainTableWriter.write(table, output_path, contract) return value_column = contract["value_column"] axis_0_values = _raw_values(table[axis_0]) axis_1_values = _raw_values(table[axis_1]) values = dict( zip( zip(axis_0_values, axis_1_values, strict=True), _raw_values(table[value_column]), strict=True, ) ) axis_0_values = sorted(set(axis_0_values)) axis_1_values = sorted(set(axis_1_values)) with output_path.open("w", encoding="utf-8") as file: file.write("#@RPOL@[ANGLE=] 2\n") file.write( "ANGLE= " + " ".join(_format(value, contract) for value in axis_1_values) + "\n" ) for axis_0_value in axis_0_values: fields = [_format(axis_0_value, contract)] fields.extend( _format(values[(axis_0_value, axis_1_value)], contract) for axis_1_value in axis_1_values ) file.write(" ".join(fields) + "\n")
[docs] @SimtelTableWriter.register("atmospheric_transmission") class AtmosphericTransmissionWriter: """Serialize atmospheric transmission matrices."""
[docs] @staticmethod def write(table, output_path, contract): """Write an altitude header and wavelength matrix. Missing wavelength-altitude cells are written with the contract's explicit opaque optical-depth sentinel. sim_telarray interprets every matrix entry as an optical depth, so this policy is deterministic but must only be used for cells known to represent zero transmission. """ axis_0, axis_1 = contract["matrix_axes"] value_column = contract["value_column"] axis_0_values = _raw_values(table[axis_0]) axis_1_values = _raw_values(table[axis_1]) values = dict( zip( zip(axis_0_values, axis_1_values, strict=True), _raw_values(table[value_column]), strict=True, ) ) axis_0_values = sorted(set(axis_0_values)) axis_1_values = sorted(set(axis_1_values)) level = _scalar(table.meta.get("observatory_level")) with output_path.open("w", encoding="utf-8") as file: header = "# H1= " + " ".join(_format(value, contract) for value in axis_1_values) file.write( f"# H2= {_format(level, contract)}, {header[2:]}\n" if level is not None else header + "\n" ) for axis_0_value in axis_0_values: fields = [_format(axis_0_value, contract)] fields.extend( _format( values.get((axis_0_value, axis_1_value), contract.get("missing_value")), contract, ) for axis_1_value in axis_1_values ) file.write(" ".join(fields) + "\n")
def _scalar(value): """Return a scalar value from an Astropy quantity.""" return getattr(value, "value", value)