Source code for science_tests.execution

"""Prepare and execute science-test workflows for one site.

A workflow is resolved from its template: applications referencing the telescope
placeholder are repeated for each telescope of the site, placeholders are replaced, release
configuration overrides are applied, and the shared runtime environment is added. The
effective workflow is written to the test's work directory and executed from there.
"""

import copy
import json
import logging
import os
import shutil
import subprocess
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path

import yaml

from simtools.constants import SCHEMA_PATH
from simtools.data_model import schema
from simtools.io.ascii_handler import to_builtin
from simtools.production_configuration.comparison import validate_comparison_inputs
from simtools.production_configuration.compatibility import preview
from simtools.production_configuration.production_file_selection import normalize_for_comparison
from simtools.production_configuration.trigger_histograms import validate_derived_workflow
from simtools.runners.simtools_runner import (
    apply_execution_overrides,
    get_explicit_env_file,
    model_source_options,
    resolve_nested_configuration_files,
    run_applications,
)
from simtools.science_tests import definitions as defs
from simtools.science_tests.acceptance import AcceptanceError, evaluate_products
from simtools.science_tests.production import (
    archive_failed_production,
    check_inputs,
    collect_production,
)
from simtools.science_tests.reports import write_test_report
from simtools.utils import names
from simtools.utils.value_conversion import replace_placeholders_recursively
from simtools.version import __version__

EXECUTION_LOG = "execution.log"
WORKFLOW_RECORD = "workflow.yml"
STATUS = "status"
PASSED = {"pass", "warn"}
_APPLICATIONS = "applications"
_CONFIGURATION = "configuration"
_RUNTIME_ENVIRONMENT = "runtime_environment"
_IGNORE_RUNTIME = "ignore_runtime_environment"
_DERIVED_OUTPUT = "derived_output"
_PRODUCES_PRODUCTION = "produces_production"
_EXECUTION = "execution"
_REASON = "reason"
_TELESCOPES_KEY = "camera_telescopes"
_WORKFLOW_PATH = "workflow_path"
_LOG_FILE = "log_file"
_PROVENANCE_PATH = "provenance_path"
_WORKFLOW = "workflow"
_BASELINE = "baseline"
_RUNTIME = "runtime"
_OUTPUT_PATHS = "output_paths"
_PLANNED = "planned"
_COLLECT_PRODUCTION = "collect_production"
_PENDING = "pending"
_SUBMITTED = "submitted"
_IMAGE = "image"
_CONTAINER_IMAGE = "container_image"
# Settings that change on every execution and do not invalidate derived products.
_PER_EXECUTION_SETTINGS = {"activity_id", "disable_log_file", "label"}

logger = logging.getLogger(__name__)


[docs] @dataclass class PreparedTest: """Resolved workflow and planned result of one site/test pair.""" site: str test_id: str definition: dict replacements: dict args: dict workflow: dict work: Path report_root: Path result: dict
[docs] def prepare_test(bundle, site, test_id, application_args, run_id): """Resolve one site/test workflow without executing it. Parameters ---------- bundle : ReleaseDefinition Release definitions. site, test_id : str Site and test names. application_args : dict Runner arguments including the model source. run_id : str Identifier shared by all tests of one runner call. Returns ------- PreparedTest Prepared test with its planned result. """ definition = defs.test_definition(bundle, test_id) if site not in definition["sites"]: raise ValueError(f"Test {test_id!r} does not support site {site!r}.") work = Path(bundle.context[defs.CANDIDATE_PATH]) / "work" / "science-tests" / site / test_id workflow_path = definition[_WORKFLOW_PATH] replacements = defs.site_replacements(bundle, site, work) | { defs.CONFIG_DIRECTORY: str(workflow_path.parent.resolve()) } args = { _IGNORE_RUNTIME: False, "ignore_existing_parameter_version": False, **application_args, "steps": None, _LOG_FILE: str(work / EXECUTION_LOG), _PROVENANCE_PATH: str(work / WORKFLOW_RECORD), } workflow = _resolve_workflow(bundle, site, definition, replacements, args) runtime = None if args[_IGNORE_RUNTIME] else workflow.get(_RUNTIME_ENVIRONMENT) output_paths = [app[_CONFIGURATION].get("output_path") for app in workflow[_APPLICATIONS]] result = { "id": f"{test_id}.{site}", "test": test_id, "site": site, _WORKFLOW: str(definition[_WORKFLOW]), _BASELINE: bundle.release.get(_BASELINE), "candidate": bundle.release["release_label"], "execution_provenance": str(work), "report": None, "run_id": run_id, _RUNTIME: _runtime_description(runtime), "software": { "simtools": __version__, "simulation_models": args.get(defs.MODELS_GIT_REVISION), }, _OUTPUT_PATHS: list(dict.fromkeys(filter(None, output_paths))) or [replacements[defs.CANDIDATE_SITE_PATH]], _EXECUTION: _PLANNED, STATUS: _PLANNED, } if definition.get(_PRODUCES_PRODUCTION): result["dependency_compatibility"] = _preview_production( workflow, args, replacements, runtime ) return PreparedTest( site=site, test_id=test_id, definition=definition, replacements=replacements, args=args, workflow=workflow, work=work, report_root=bundle.release_dir / "reports" / site / test_id, result=result, )
[docs] def execute_tests(tests, catalogue, release_dir, run_id, overwrite=False): """Execute prepared tests in order, blocking tests whose selected dependencies failed. Parameters ---------- tests : list of PreparedTest Prepared tests in dependency order. catalogue : dict Test definitions by name. release_dir : pathlib.Path Release directory for published reports. run_id : str Identifier used for archives. overwrite : bool Rebuild derived products and allow retrying failed productions. Returns ------- list of dict Test results. """ results = [] for test in tests: result = test.result upstream = { f"{name}.{test.site}" for name in defs.dependency_order([test.test_id], catalogue)[:-1] } upstream_status = {r[STATUS] for r in results if r["id"] in upstream} if upstream_status - PASSED: status = _PENDING if upstream_status <= PASSED | {_SUBMITTED, _PENDING} else "blocked" results.append( result | { STATUS: status, _EXECUTION: status, _REASON: "Dependency failed or is not complete.", } ) continue _log_test(result, "Running") run_test(test, release_dir, run_id, overwrite) results.append(result) return results
[docs] def run_test(test, release_dir, run_id, overwrite=False): """Execute one prepared test, evaluate its products, and publish its report. Parameters ---------- test : PreparedTest Prepared test. release_dir : pathlib.Path Release directory for published reports. run_id : str Identifier used for archives. overwrite : bool Rebuild derived products and allow retrying failed productions. """ definition, replacements, result = test.definition, test.replacements, test.result result.update({_EXECUTION: "completed", STATUS: "pass"}) try: if overwrite and definition.get(_PRODUCES_PRODUCTION): archive_failed_production( definition, replacements, run_id, (test.work, test.report_root) ) check_inputs(definition, replacements) validate_comparison_inputs( test.workflow[_APPLICATIONS], [replacements[defs.CANDIDATE_SITE_PATH], replacements[defs.BASELINE_SITE_PATH]], ) result.update(_execute(test, overwrite)) except AcceptanceError as exc: result.update(status="warn", reason=f"Acceptance warning: {exc}") except ( OSError, ValueError, RuntimeError, subprocess.SubprocessError, ) as exc: result.update(status="incomplete", execution="failed", error=str(exc)) write_test_report( test.work, test.report_root, release_dir, result, definition.get("publish_report", True), ) logger.info("%s: %s %s", result["id"], result[STATUS], result.get(_REASON, ""))
[docs] def log_planned(test): """Log the runtime and output paths of a planned test.""" _log_test(test.result, "Planned")
def _log_test(result, action): logger.info("%s science test: %s", action, result["id"]) logger.info(" Runtime: %s", result[_RUNTIME]) logger.info(" Output: %s", ", ".join(result[_OUTPUT_PATHS])) def _execute(test, overwrite): """Collect, reuse, or execute the workflow and return its evaluated status.""" definition, replacements, args = test.definition, test.replacements, test.args if definition.get(_COLLECT_PRODUCTION): collected = collect_production(definition, replacements) if collected.get(STATUS) == _PENDING: return collected return evaluate_products(definition, replacements) | collected if not overwrite and _reusable_derived_product(test): return evaluate_products(definition, replacements) | { _EXECUTION: "reused", _REASON: "Reused complete derived products; use --overwrite to rebuild them.", } if definition.get(_DERIVED_OUTPUT): _remove(Path(replace_placeholders_recursively(definition[_DERIVED_OUTPUT], replacements))) _remove(test.work) args["config_file"] = str(_write_workflow(test.work, test.workflow)) try: with _selected_model_source_environment(args): run_applications(args, replacements=replacements) finally: log_file = Path(args[_LOG_FILE]) if log_file.is_file(): test.result["execution_log"] = str(log_file) if definition.get(_DERIVED_OUTPUT): validate_derived_workflow(test.workflow[_APPLICATIONS]) evaluated = evaluate_products(definition, replacements) if definition.get(_PRODUCES_PRODUCTION): return { STATUS: _SUBMITTED, _EXECUTION: _SUBMITTED, _REASON: "Jobs submitted; run the collection test after they finish.", } return evaluated def _resolve_workflow(bundle, site, definition, replacements, args): """Expand, resolve, override, and validate one test workflow.""" workflow = defs.load_yaml(definition[_WORKFLOW_PATH]) telescopes = bundle.suites[site].get(_TELESCOPES_KEY, []) workflow[_APPLICATIONS] = [ expanded for application in workflow.get(_APPLICATIONS, []) for expanded in _expand_telescopes(application, telescopes) ] workflow = replace_placeholders_recursively(workflow, replacements) allowed_changes = set(bundle.release.get("expected_changes", [])) for application in workflow[_APPLICATIONS]: configuration = application.setdefault(_CONFIGURATION, {}) configuration.update(definition["configuration_overrides"]) undeclared = set(configuration.get("compare_by", [])) - allowed_changes if undeclared: raise ValueError(f"Comparison by {sorted(undeclared)} is not in expected_changes.") apply_execution_overrides( workflow[_APPLICATIONS], args, model_source_options(args), get_explicit_env_file(args) ) production = definition.get(_PRODUCES_PRODUCTION) or definition.get(_COLLECT_PRODUCTION) if not production and _RUNTIME_ENVIRONMENT not in workflow: runtime = defs.shared_runtime_environment( bundle.template_dir, replacements, definition.get("runtime_role") ) if runtime is not None: workflow[_RUNTIME_ENVIRONMENT] = runtime schema.validate_dict_using_schema( workflow, schema_file=SCHEMA_PATH / "application_workflow.metaschema.yml", offline=True ) return workflow def _expand_telescopes(application, telescopes): """Repeat an application for each telescope if it references the telescope placeholder.""" if defs.TELESCOPE not in json.dumps(application): return [application] if not telescopes: raise ValueError(f"Workflow uses {defs.TELESCOPE}; set {_TELESCOPES_KEY} for the site.") return [ replace_placeholders_recursively( application, {defs.TELESCOPE: names.validate_array_element_name(telescope)} ) for telescope in telescopes ] def _runtime_description(runtime): if runtime is None: return "host (no container)" return f"{runtime.get('container_engine', 'docker')} (image: {runtime.get(_IMAGE)})" def _preview_production(workflow, args, replacements, runtime): """Check dependency compatibility of the production application and its image.""" applications = copy.deepcopy(workflow[_APPLICATIONS][:1]) resolve_nested_configuration_files(applications, replacements) selected = dict(args) image = runtime.get(_IMAGE) if runtime else None for application in applications: selected.update(application.get(_CONFIGURATION, {})) backend = selected.get("backend_config") if isinstance(backend, dict) and backend.get(_CONTAINER_IMAGE): image = backend[_CONTAINER_IMAGE] return preview(selected, image or replacements.get("__SCIENCE_CONTAINER_IMAGE_PATH__")) def _reusable_derived_product(test): """Return whether existing derived products were produced with the current settings.""" definition = test.definition if not definition.get(_DERIVED_OUTPUT): return False output = Path(replace_placeholders_recursively(definition[_DERIVED_OUTPUT], test.replacements)) if not output.is_dir() or not any(output.iterdir()): return False try: previous = defs.load_yaml(Path(test.args[_PROVENANCE_PATH])) if _settings(previous) != _settings(test.workflow): raise ValueError("derivation settings changed") validate_derived_workflow(test.workflow[_APPLICATIONS]) except (OSError, ValueError, KeyError) as exc: raise ValueError( f"Derived products are incomplete or stale ({exc}); rerun with --overwrite." ) from exc return True def _settings(workflow): """Return application configurations without per-execution bookkeeping.""" return normalize_for_comparison( [ { key: value for key, value in app[_CONFIGURATION].items() if key not in _PER_EXECUTION_SETTINGS } for app in workflow[_APPLICATIONS] ] ) def _write_workflow(work, workflow): """Write the effective workflow executed by the runner.""" work.mkdir(parents=True, exist_ok=True) path = work / WORKFLOW_RECORD path.write_text( yaml.safe_dump(to_builtin(workflow), sort_keys=False, explicit_start=True), encoding="utf-8", ) return path def _remove(path): if path.exists(): shutil.rmtree(path) @contextmanager def _selected_model_source_environment(args): """Hide the inactive model source of a parent environment file from child applications.""" inactive = None if args.get(defs.MODELS_GIT_PATH): inactive = "SIMTOOLS_SIMULATION_MODELS_PATH" elif args.get(defs.MODELS_PATH): inactive = "SIMTOOLS_SIMULATION_MODELS_GIT_PATH" previous = os.environ.get(inactive) if inactive else None if inactive: os.environ[inactive] = "" try: yield finally: if inactive and previous is None: os.environ.pop(inactive, None) elif inactive: os.environ[inactive] = previous