Source code for science_tests.definitions

"""Load and validate science-test release definitions.

A release directory contains ``release.yml``, ``context.yml`` and optional site overrides.
Test definitions, workflows, site defaults, and acceptance contracts are read from the shared
science-test template. Workflows reference release values through the placeholders defined
in this module.
"""

import hashlib
import json
import logging
import os
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path

from simtools.constants import RUN_TIME_ENVIRONMENT_SCHEMA, SCIENCE_TEST_SCHEMA_PATH
from simtools.data_model import schema
from simtools.io import ascii_handler
from simtools.utils.value_conversion import replace_placeholders_recursively

CANDIDATE_PATH = "__SCIENCE_CANDIDATE_PATH__"
BASELINE_PATH = "__SCIENCE_BASELINE_PATH__"
CANDIDATE_SITE_PATH = "__SCIENCE_CANDIDATE_SITE_PATH__"
BASELINE_SITE_PATH = "__SCIENCE_BASELINE_SITE_PATH__"
PRODUCTION_CONFIGURATION_PATH = "__PRODUCTION_CONFIGURATION_PATH__"
REPORT_PATH = "__SCIENCE_REPORT_PATH__"
RELEASE_LABEL = "__SCIENCE_RELEASE_LABEL__"
BASELINE_LABEL = "__SCIENCE_BASELINE_LABEL__"
TELESCOPE = "__SCIENCE_CAMERA_TELESCOPE__"
CONFIG_DIRECTORY = "__CONFIG_DIRECTORY__"
_BASELINE_PLACEHOLDER = "__SCIENCE_BASELINE"
_UNRESOLVED_PLACEHOLDER = "__SCIENCE_"

MODELS_PATH = "simulation_models_path"
MODELS_GIT_PATH = "simulation_models_git_path"
MODELS_GIT_REVISION = "simulation_models_git_revision"
MODEL_SOURCE_KEYS = (MODELS_PATH, MODELS_GIT_PATH, MODELS_GIT_REVISION)

RELEASE_FILE = "release.yml"
CONTEXT_FILE = "context.yml"
TEMPLATE_DIRECTORY = "science-test-template"
RUNTIME_FILE = "run_time.yml"
VALID_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
SCIENCE_TESTS_ROOT_ENV = "SIMTOOLS_SCIENCE_TESTS_ROOT"
_ENCODING = "utf-8"
_TESTS_KEY = "tests"
_SITES_KEY = "sites"
_RELEASE_LABEL_KEY = "release_label"
_REPLACEMENTS_KEY = "replacements"
_WORKFLOW_KEY = "workflow"
_REQUIRED_TESTS_KEY = "required_tests"

logger = logging.getLogger(__name__)


[docs] @dataclass class ReleaseDefinition: """Validated definitions of one release, as returned by :func:`load_release`.""" release_dir: Path template_dir: Path release: dict catalogue: dict context: dict products: dict rules: dict suites: dict = field(default_factory=dict)
[docs] def load_release(release_dir, template_dir=None, context_file=None): """Load and validate all definitions of one release. Parameters ---------- release_dir : str or pathlib.Path Release science-test directory. template_dir : str or pathlib.Path or None Shared template; discovered above the release directory by default. context_file : str or pathlib.Path or None Context file; ``<release_dir>/context.yml`` by default. Returns ------- ReleaseDefinition Release, catalogue, context, site suites, and acceptance contracts. """ release_dir = Path(release_dir).resolve() template_dir = resolve_template_dir(release_dir, template_dir) release = load_yaml(release_dir / RELEASE_FILE) validate_config(release, "release.schema.yml") if not VALID_NAME.fullmatch(release[_RELEASE_LABEL_KEY]): raise ValueError(f"release_label must be a filename-safe label: {release_dir}") catalogue_path = Path(release.get("catalogue", template_dir / "catalogue.yml")) catalogue = load_yaml(release_dir / catalogue_path) validate_config(catalogue, "catalogue.schema.yml") acceptance = template_dir / "acceptance" products = load_yaml(acceptance / "expected-products.yml") validate_config(products, "expected_products.schema.yml") thresholds = load_yaml(acceptance / "thresholds.yml") validate_config(thresholds, "thresholds.schema.yml") bundle = ReleaseDefinition( release_dir=release_dir, template_dir=template_dir, release=release, catalogue=catalogue[_TESTS_KEY], context=load_context(context_file or release_dir / CONTEXT_FILE), products=products[_TESTS_KEY], rules=thresholds["rules"], ) bundle.suites = { site: _load_suite(bundle, site) for site in dict.fromkeys(release["required_sites"]) } return bundle
[docs] def resolve_template_dir(release_dir, template_dir=None): """Return the given template directory or the nearest one above the release. Parameters ---------- release_dir : pathlib.Path Release directory whose ancestors are searched. template_dir : str or pathlib.Path or None Explicit template directory. Returns ------- pathlib.Path Shared science-test template directory. """ if template_dir is not None: return Path(template_dir).resolve() for parent in Path(release_dir).resolve().parents: if (parent / TEMPLATE_DIRECTORY).is_dir(): return parent / TEMPLATE_DIRECTORY raise FileNotFoundError(f"No {TEMPLATE_DIRECTORY} directory found above {release_dir}.")
[docs] def load_context(context_file): """Load context paths, resolving the shared storage root environment variable. Parameters ---------- context_file : str or pathlib.Path Context YAML file. Returns ------- dict Context values with ``${SIMTOOLS_SCIENCE_TESTS_ROOT}`` resolved. """ context = load_yaml(Path(context_file)) validate_config(context, "context.schema.yml") placeholder = f"${{{SCIENCE_TESTS_ROOT_ENV}}}" if placeholder not in json.dumps(context): return context root = os.environ.get(SCIENCE_TESTS_ROOT_ENV) if not root or not Path(root).is_absolute(): raise ValueError( f"Set {SCIENCE_TESTS_ROOT_ENV} to an absolute path before loading {context_file}." ) return replace_placeholders_recursively(context, {placeholder: str(Path(root))})
[docs] def select(requested, available, kind): """Return the requested names (all available ones by default), matching case-insensitively. Parameters ---------- requested : iterable of str or None Requested names. available : iterable of str Valid names. kind : str Name kind used in error messages. Returns ------- list of str Unique selected names. """ by_name = {name.casefold(): name for name in available} if requested is None: return list(by_name.values()) selected = list(dict.fromkeys(by_name.get(name.casefold(), name) for name in requested)) unknown = [name for name in selected if name not in by_name.values()] if not selected or unknown: raise ValueError(f"Invalid {kind} selection {unknown}; available: {sorted(available)}") return selected
[docs] def dependency_order(test_ids, catalogue): """Return the tests and all their dependencies once each, dependencies first. Parameters ---------- test_ids : iterable of str Test names. catalogue : dict Test definitions by name. Returns ------- list of str Ordered test names. """ ordered = [] visiting = set() def visit(test_id): if test_id in ordered: return if test_id in visiting: raise ValueError(f"Circular science-test dependency involving {test_id!r}.") if test_id not in catalogue: raise ValueError(f"Unknown science test: {test_id!r}.") visiting.add(test_id) for dependency in catalogue[test_id].get("depends_on", []): visit(dependency) visiting.remove(test_id) ordered.append(test_id) for test_id in test_ids: visit(test_id) return ordered
[docs] def test_definition(bundle, test_id): """Return one catalogue entry with its acceptance contracts and resolved workflow path. Parameters ---------- bundle : ReleaseDefinition Release definitions. test_id : str Test name. Returns ------- dict Catalogue entry extended by ``workflow_path``, ``products``, ``rule``, ``uses_baseline``, and release ``configuration_overrides``. """ definition = dict(bundle.catalogue[test_id]) if test_id not in bundle.products: raise ValueError(f"Missing expected-product contract for {test_id}.") rule_id = definition.get("acceptance_rule") if rule_id and rule_id not in bundle.rules: raise ValueError(f"Missing acceptance rule {rule_id} for {test_id}.") workflow = resolve_workflow(bundle, definition[_WORKFLOW_KEY]) definition.update( workflow_path=workflow, products=bundle.products[test_id], rule=bundle.rules.get(rule_id, {}), uses_baseline=_BASELINE_PLACEHOLDER in workflow.read_text(encoding=_ENCODING) + json.dumps(definition), configuration_overrides=bundle.release.get("configuration_overrides", {}).get(test_id, {}), ) return definition
[docs] def validate_context(bundle, uses_baseline): """Require absolute context paths and separate candidate and baseline directories. Parameters ---------- bundle : ReleaseDefinition Release definitions. uses_baseline : bool Whether any selected test reads baseline products. """ context = bundle.context required = (CANDIDATE_PATH, PRODUCTION_CONFIGURATION_PATH) for key in (*required, BASELINE_PATH) if uses_baseline else required: value = context.get(key) if ( not isinstance(value, str) or not Path(value).is_absolute() or _UNRESOLVED_PLACEHOLDER in value ): raise ValueError(f"Context requires an absolute path for {key}.") if not uses_baseline: return candidate = Path(context[CANDIDATE_PATH]).resolve() baseline = Path(context[BASELINE_PATH]).resolve() if candidate == baseline or candidate in baseline.parents or baseline in candidate.parents: raise ValueError( "Candidate and baseline paths must be separate, non-overlapping directories: " f"{candidate}; {baseline}." )
[docs] def site_replacements(bundle, site, report_path): """Return placeholder values for one site. Parameters ---------- bundle : ReleaseDefinition Release definitions. site : str Site key. report_path : pathlib.Path Work directory of the test. Returns ------- dict Placeholder strings mapped to their values. """ context, release, suite = bundle.context, bundle.release, bundle.suites[site] replacements = {key: str(value) for key, value in context.items() if value is not None} model_source = context.get(MODELS_PATH) or context.get(MODELS_GIT_PATH) if model_source: replacements["__SCIENCE_MODELS_SOURCE_PATH__"] = str(model_source) for key, placeholder in ( ("model_version", "__SCIENCE_MODEL_VERSION__"), ("baseline", BASELINE_LABEL), ): if release.get(key): replacements[placeholder] = str(release[key]) replacements.update( { RELEASE_LABEL: str(release[_RELEASE_LABEL_KEY]), "__SCIENCE_SITE_KEY__": site, "__SCIENCE_SITE__": suite.get("site_label", site.title()), "__SCIENCE_ARRAY_LAYOUT__": suite["array_layout_name"], CANDIDATE_SITE_PATH: str(Path(context.get(CANDIDATE_PATH, "")) / site), BASELINE_SITE_PATH: str(Path(context.get(BASELINE_PATH, "")) / site), REPORT_PATH: str(report_path), } ) site_values = {key: str(value) for key, value in suite.get(_REPLACEMENTS_KEY, {}).items()} reserved = set(replacements) & set(site_values) if reserved: raise ValueError(f"Site replacements cannot override reserved keys: {sorted(reserved)}") return replacements | site_values
[docs] def model_source_arguments(application_args, bundle): """Apply context and release model-source settings over runner arguments. The first of release, context, and runner arguments defining a model path selects the source type; settings of the other source type are cleared. Parameters ---------- application_args : dict or None Runner arguments. bundle : ReleaseDefinition Release definitions. Returns ------- dict Updated copy of the runner arguments. """ release, context = bundle.release, bundle.context args = dict(application_args or {}) for configuration in (context, release): args.update({key: configuration[key] for key in MODEL_SOURCE_KEYS if key in configuration}) source = next( ( configuration for configuration in (release, context, args) if configuration.get(MODELS_GIT_PATH) or configuration.get(MODELS_PATH) ), {}, ) if source.get(MODELS_GIT_PATH): args[MODELS_PATH] = None elif source.get(MODELS_PATH): args[MODELS_GIT_PATH] = None args[MODELS_GIT_REVISION] = None return args
[docs] def shared_runtime_environment(template_dir, replacements, runtime_role=None): """Return the shared runtime environment, or the one selected by ``runtime_role``. Parameters ---------- template_dir : pathlib.Path Template directory containing the optional ``run_time.yml``. replacements : dict Placeholder values. runtime_role : str or None Key in ``runtime_environments``; ``runtime_environment`` is used when omitted. Returns ------- dict or None Runtime environment; None if no runtime file exists and no role is requested. """ runtime_file = template_dir / RUNTIME_FILE if not runtime_file.is_file() and not runtime_role: return None configuration = replace_placeholders_recursively( load_yaml(runtime_file), replacements | {CONFIG_DIRECTORY: str(template_dir)} ) schema.validate_dict_using_schema( configuration, schema_file=RUN_TIME_ENVIRONMENT_SCHEMA, offline=True ) if runtime_role: runtime = configuration.get("runtime_environments", {}).get(runtime_role) if runtime is None: raise ValueError(f"No runtime environment configured for role {runtime_role!r}.") else: runtime = configuration.get("runtime_environment") if _UNRESOLVED_PLACEHOLDER in json.dumps(runtime): raise ValueError(f"Unresolved placeholder in runtime environment: {runtime}") return runtime
[docs] def resolve_workflow(bundle, workflow): """Return a workflow file, preferring a release override over the template. Parameters ---------- bundle : ReleaseDefinition Release definitions. workflow : str Workflow path relative to the template directory. Returns ------- pathlib.Path Workflow file. """ path = Path(workflow) if path.is_absolute() or ".." in path.parts: raise ValueError(f"Workflow must be a relative template path: {workflow}") override = bundle.release_dir / path return override if override.is_file() else bundle.template_dir / path
[docs] def campaign_signature(bundle, model_source): """Return a SHA-256 digest of all definitions and the external context of a campaign. Parameters ---------- bundle : ReleaseDefinition Release definitions. model_source : dict Effective model-source settings. Returns ------- str Hex digest. """ definitions = asdict(bundle) payload = [value for key, value in definitions.items() if not key.endswith("_dir")] payload.append(model_source) for definition in bundle.catalogue.values(): payload.append(resolve_workflow(bundle, definition[_WORKFLOW_KEY]).read_text(_ENCODING)) template_dir = bundle.template_dir for path in [template_dir / RUNTIME_FILE, *sorted((template_dir / "profiles").glob("*"))]: if path.is_file(): payload.append(path.read_text(encoding=_ENCODING)) return hashlib.sha256(json.dumps(payload, sort_keys=True).encode(_ENCODING)).hexdigest()
[docs] def validate_config(configuration, schema_file): """Validate one science-test configuration against its packaged schema. Parameters ---------- configuration : dict Configuration to validate. schema_file : str Schema file name in the science-test schema directory. """ schema.validate_dict_using_schema( configuration, schema_file=SCIENCE_TEST_SCHEMA_PATH / schema_file, offline=True )
[docs] def load_yaml(path): """Load a YAML mapping. Parameters ---------- path : pathlib.Path YAML file. Returns ------- dict File contents. """ if not path.is_file(): raise FileNotFoundError(f"Science-test configuration not found: {path}") data = ascii_handler.collect_data_from_file(path) if not isinstance(data, dict): raise ValueError(f"Expected a mapping in {path}") return data
def _load_suite(bundle, site): """Load site defaults from the template and apply the release override.""" suite = {} for directory in (bundle.template_dir, bundle.release_dir): path = directory / _SITES_KEY / f"{site}.yml" if path.is_file(): suite.update(load_yaml(path)) if not suite: raise FileNotFoundError(f"No site definition found for {site}.") validate_config(suite, "site.schema.yml") if suite["site"] != site: raise ValueError(f"Site definition {site} declares site {suite['site']!r}.") for test_id in dependency_order(suite[_REQUIRED_TESTS_KEY], bundle.catalogue): if site not in bundle.catalogue[test_id][_SITES_KEY]: raise ValueError(f"Test {test_id!r} does not support site {site!r}.") return suite