Source code for testing.output_validation.reference

"""Reference-file comparison validators."""

import difflib
import logging
from pathlib import Path

import numpy as np
from astropy.table import Table

from simtools.io import ascii_handler
from simtools.utils import value_conversion

_logger = logging.getLogger(__name__)
_ECSV_FORMAT = "ascii.ecsv"
_ECSV_SUFFIX = ".ecsv"


[docs] def resolve_path(path): """Resolve a repository-relative reference path.""" reference = Path(path) if reference.is_absolute(): return reference for parent in Path(__file__).resolve().parents: if (parent / "pyproject.toml").exists(): return parent / reference return Path.cwd() / reference
def _resource_suffixes(value): if not isinstance(value, str) or "/" not in value: return set() parts = [part for part in Path(value).as_posix().split("/") if part not in ("", ".")] markers = ("integration_tests", "tests", "static", "generated", "downloaded") return {tuple(parts[index:]) for index, part in enumerate(parts) if part in markers} def _compare_mappings(first, second, tolerance): """Compare nested mappings, applying tolerance to model-parameter values.""" return first.keys() == second.keys() and all( compare_values(first[key], second[key], tolerance, key == "value") for key in first ) def _compare_sequences(first, second, tolerance, value_field): """Compare ordered nested sequences.""" return len(first) == len(second) and all( compare_values(left, right, tolerance, value_field) for left, right in zip(first, second) ) def _compare_model_values(first, second, tolerance): """Compare possibly string-encoded numerical model-parameter values.""" try: left = value_conversion.convert_string_to_list(first) if isinstance(first, str) else first right = ( value_conversion.convert_string_to_list(second) if isinstance(second, str) else second ) return np.allclose(np.atleast_1d(left), np.atleast_1d(right), rtol=tolerance) except TypeError, ValueError: return first == second
[docs] def compare_values(first, second, tolerance, value_field=False): """Compare values recursively, including numerically tolerant model values.""" if isinstance(first, dict) and isinstance(second, dict): return _compare_mappings(first, second, tolerance) if isinstance(first, (list, tuple)) and isinstance(second, (list, tuple)): return _compare_sequences(first, second, tolerance, value_field) if value_field: return _compare_model_values(first, second, tolerance) if isinstance(first, str) and isinstance(second, str): return first == second or bool(_resource_suffixes(first) & _resource_suffixes(second)) return first == second
[docs] def compare_json_or_yaml_files(first_file, second_file, tolerance=1.0e-2): """Compare JSON or YAML documents, tolerating numerical model values.""" first = ascii_handler.collect_data_from_file(first_file) second = ascii_handler.collect_data_from_file(second_file) if isinstance(first, dict): first.pop("schema_version", None) if isinstance(second, dict): second.pop("schema_version", None) return compare_values(first, second, tolerance)
def _filter_mask(table, rule): """Return a boolean mask for one typed reference-table filter.""" values = np.asarray(table[rule["column"]]) expected = rule["value"] operators = { "equal": lambda: values == expected, "not_equal": lambda: values != expected, "less": lambda: values < expected, "less_equal": lambda: values <= expected, "greater": lambda: values > expected, "greater_equal": lambda: values >= expected, "in": lambda: np.isin(values, expected), "not_in": lambda: ~np.isin(values, expected), } try: return operators[rule["operator"]]() except KeyError as exc: raise ValueError(f"Unknown reference filter operator '{rule['operator']}'.") from exc def _prepare_table(table, filters, key_columns): """Apply typed filters and optional deterministic key ordering.""" prepared = table for rule in filters or []: prepared = prepared[_filter_mask(prepared, rule)] if key_columns: keys = zip(*(np.asarray(prepared[name]) for name in key_columns)) if len(set(keys)) != len(prepared): raise ValueError(f"Reference key columns are not unique: {key_columns}") prepared = prepared.copy() prepared.sort(key_columns) return prepared def _compare_column(left, right, tolerance): """Compare two ECSV columns, including their dtype and unit.""" if left.dtype != right.dtype or left.unit != right.unit: return False if np.issubdtype(left.dtype, np.number): return np.allclose(left, right, rtol=tolerance, equal_nan=True) return np.array_equal(np.asarray(left), np.asarray(right)) def _compare_selected_columns(first, second, selected, tolerance): """Compare selected columns from two ECSV tables.""" return all(_compare_column(first[name], second[name], tolerance) for name in selected)
[docs] def compare_ecsv_files( first_file, second_file, tolerance=1.0e-5, columns=None, metadata=False, filters=None, key_columns=None, ): """Compare ECSV rows, selected columns, units, and optional metadata.""" first = _prepare_table(Table.read(first_file, format=_ECSV_FORMAT), filters, key_columns) second = _prepare_table(Table.read(second_file, format=_ECSV_FORMAT), filters, key_columns) selected = columns or first.colnames if columns is None and first.colnames != second.colnames: return False if len(first) != len(second) or any( name not in first.colnames or name not in second.colnames for name in selected ): return False return _compare_selected_columns(first, second, selected, tolerance) and ( not metadata or first.meta == second.meta )
[docs] def compare_files( first_file, second_file, tolerance=1.0e-5, columns=None, metadata=False, filters=None, key_columns=None, ): """Compare supported structured files.""" first_suffix = Path(first_file).suffix.lower() if first_suffix != Path(second_file).suffix.lower(): raise ValueError(f"File suffixes do not match: {first_file} and {second_file}") if first_suffix == _ECSV_SUFFIX: return compare_ecsv_files( first_file, second_file, tolerance, columns, metadata, filters, key_columns, ) if first_suffix in (".json", ".yaml", ".yml"): return compare_json_or_yaml_files(first_file, second_file, tolerance) _logger.warning(f"Unknown file type for files: {first_file} and {second_file}") return False
def _format_mismatch_rows(reference_values, generated_values, mismatch_indices): """Format a small sample of mismatching rows.""" return [ f" row {index}: reference={reference_values[index]!r}, " f"generated={generated_values[index]!r}" for index in mismatch_indices[:5] ] def _numeric_ecsv_column_differences(column, reference_values, generated_values, tolerance): """Return differences for one numeric ECSV column.""" equal = np.isclose( reference_values, generated_values, rtol=tolerance, equal_nan=True, ) mismatch_indices = np.flatnonzero(~equal) if mismatch_indices.size == 0: return [] absolute_differences = np.abs( reference_values[mismatch_indices] - generated_values[mismatch_indices] ) valid_differences = absolute_differences[~np.isnan(absolute_differences)] maximum = np.max(valid_differences) if valid_differences.size else float("nan") return [ ( f"{column}: {len(mismatch_indices)} differing row(s), " f"maximum absolute difference={maximum:g}" ), *_format_mismatch_rows(reference_values, generated_values, mismatch_indices), ] def _text_ecsv_column_differences(column, reference_values, generated_values): """Return differences for one non-numeric ECSV column.""" mismatch_indices = np.flatnonzero(reference_values != generated_values) if mismatch_indices.size == 0: return [] return [ f"{column}: {len(mismatch_indices)} differing row(s)", *_format_mismatch_rows(reference_values, generated_values, mismatch_indices), ] def _ecsv_column_differences(reference, generated, column, tolerance): """Return differences for one selected ECSV column.""" if column not in reference.colnames or column not in generated.colnames: return [f"{column}: missing from one of the tables"] reference_column = reference[column] generated_column = generated[column] if reference_column.dtype != generated_column.dtype: return [f"{column}: dtype differs ({reference_column.dtype} vs {generated_column.dtype})"] if reference_column.unit != generated_column.unit: return [f"{column}: unit differs ({reference_column.unit} vs {generated_column.unit})"] count = min(len(reference_column), len(generated_column)) reference_values = np.asarray(reference_column[:count]) generated_values = np.asarray(generated_column[:count]) if np.issubdtype(reference_column.dtype, np.number): return _numeric_ecsv_column_differences( column, reference_values, generated_values, tolerance ) return _text_ecsv_column_differences(column, reference_values, generated_values) def _ecsv_difference_report( reference_file, output_file, tolerance=1.0e-5, columns=None, metadata=False, filters=None, key_columns=None, ): """Return a compact comparison report for ECSV files.""" reference = _prepare_table( Table.read(reference_file, format=_ECSV_FORMAT), filters, key_columns ) generated = _prepare_table(Table.read(output_file, format=_ECSV_FORMAT), filters, key_columns) selected = columns or reference.colnames report = [ f"reference: {reference_file} (rows={len(reference)}, columns={reference.colnames})", f"generated: {output_file} (rows={len(generated)}, columns={generated.colnames})", ] if len(reference) != len(generated): report.append(f"row count differs: reference={len(reference)}, generated={len(generated)}") differences = [] if columns is None and reference.colnames != generated.colnames: differences.append( f"columns differ: reference={reference.colnames}, generated={generated.colnames}" ) differences.extend( difference for column in selected for difference in _ecsv_column_differences(reference, generated, column, tolerance) ) if metadata and reference.meta != generated.meta: differences.append("metadata differs") report.append("differences:") report.extend(f" {difference}" for difference in differences or ["none detected"]) report.append("generated preview:") if selected and all(column in generated.colnames for column in selected): report.extend(f" {line}" for line in generated[selected][:3].pformat(max_width=-1)) return "\n".join(report)
[docs] def difference_report( reference_file, output_file, tolerance=1.0e-5, columns=None, metadata=False, filters=None, key_columns=None, ): """Return a readable difference report for a reference and generated file.""" if ( Path(reference_file).suffix.lower() == _ECSV_SUFFIX and Path(output_file).suffix.lower() == _ECSV_SUFFIX ): try: return _ecsv_difference_report( reference_file, output_file, tolerance, columns, metadata, filters, key_columns, ) except (OSError, TypeError, ValueError) as exc: return f"(Unable to read ECSV files for comparison: {exc})" try: reference_lines = ( Path(reference_file) .read_text(encoding="utf-8", errors="replace") .splitlines(keepends=True) ) output_lines = ( Path(output_file) .read_text(encoding="utf-8", errors="replace") .splitlines(keepends=True) ) except OSError as exc: return f"(Unable to read files for diff: {exc})" return "".join( difflib.unified_diff( reference_lines, output_lines, fromfile=f"reference: {reference_file}", tofile=f"generated: {output_file}", ) )