diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index e802189d..5bf98ca1 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -243,6 +243,29 @@ After writing the file, we recommend validating it using [PrePARE](https://githu cmoriser.write() +Running output QC checks +------------------------ + +ACCESS-MOPPy includes CMIP7 output QC checks (currently including physical +range checks for ``tas``). You can run QC from notebooks or the command line. + +Notebook/API usage: + +.. code-block:: python + + from access_moppy.qc import validate_cmip7_output + + output_file = "/path/to/CMIP7/output.nc" + validate_cmip7_output(output_file) + +CLI usage: + +.. code-block:: bash + + moppy-qc /path/to/output.nc + +See :doc:`qc_validation` for complete examples and rule configuration details. + CMIP7 Support with Full Branded Names ====================================== diff --git a/docs/source/index.rst b/docs/source/index.rst index 84d04451..f464430c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -55,6 +55,7 @@ While retaining the core concepts of "custom" and "cmip" modes, ACCESS-MOPPy uni esmvaltool_integration CMORise_ILAMB_workflow mapping_reference + qc_validation compliance_testing testing_cmorisation ---- diff --git a/docs/source/mapping_reference.rst b/docs/source/mapping_reference.rst index 81f6e8f0..90217816 100644 --- a/docs/source/mapping_reference.rst +++ b/docs/source/mapping_reference.rst @@ -129,7 +129,7 @@ required fields: - Expected physical units of the CMIP output variable (e.g. ``"W m-2"``, ``"kg m-2 s-1"``). * - ``positive`` - Yes - - Sign convention: ``"up"``, ``"down"``, or ``null`` if not applicable. + - Sign convention metadata: ``"up"``, ``"down"``, or ``null`` if not applicable. * - ``model_variables`` - Yes - List of raw model variable names that must be loaded from the input files. diff --git a/docs/source/qc_validation.rst b/docs/source/qc_validation.rst new file mode 100644 index 00000000..97162134 --- /dev/null +++ b/docs/source/qc_validation.rst @@ -0,0 +1,164 @@ +CMIP7 QC Validation +=================== + +This page describes how to run ACCESS-MOPPy output quality-control checks on +CMORised files. + +Scope +----- + +- QC is run on the *CMORised output file*, not the raw model input. +- For ``source_id=ACCESS-ESM1-6``, QC covers all variables present in + ``ACCESS-ESM1-6_mappings.json`` with generic checks (non-missing values, + finite values, and units checks where defined). +- Physical ranges for all 293 ACCESS-ESM1-6 mapped variables are defined + explicitly in the QC configuration with defaults and experiment-specific + overrides (historical, piControl, ssp*). +- Each variable's physical range is derived from its definition (mapping units) + and stored as a per-variable rule entry. +- Rules are loaded from: ``access_moppy/resources/qc/cmip7_ranges.yml``. + +Running QC in Notebooks +----------------------- + +Use the Python API to validate a CMORised file after ``cmoriser.write()``. + +.. code-block:: python + + from access_moppy.qc import validate_cmip7_output + + # Write CMORised output first + cmoriser.run() + cmoriser.write() + + # Validate the written file + output_file = "/path/to/CMIP7/output.nc" + validate_cmip7_output(output_file) + +If a check fails, ``validate_cmip7_output`` raises ``ValueError`` with details +about the variable, experiment, observed range, and allowed range. + +Running QC from the CLI +----------------------- + +ACCESS-MOPPy provides a CLI command: + +.. code-block:: bash + + moppy-qc /path/to/file1.nc /path/to/file2.nc + +Exit status: + +- ``0``: all files passed +- ``1``: one or more files failed + +Example output: + +.. code-block:: text + + PASS /path/to/file1.nc + FAIL /path/to/file2.nc: CMIP7 QC failed for tas in experiment piControl using rule piControl: observed range 182.000..329.400 K is outside allowed range 180.000..325.000 K. + +Automatic QC during CMORisation +------------------------------- + +For CMIP7 runs, ACCESS-MOPPy automatically validates output in the write path +after writing and repacking the file. In other words, when you call +``cmoriser.write()`` for CMIP7 output, QC is already executed. + +Batch Report QC Summary +----------------------- + +When running a batch CMORisation, the batch report (``moppy_batch_report.json``) +automatically includes a QC section summarizing validation results for all +CMORised output files: + +.. code-block:: json + + { + "qc": { + "passed": 42, + "failed": 2, + "total": 44, + "failures": [ + { + "file": "/output/path/tas.nc", + "variable_id": "tas", + "experiment_id": "piControl", + "error": "Observed range 182.000..329.400 K is outside allowed 180.000..325.000 K.", + "observed_range": [182.0, 329.4], + "allowed_range": [180.0, 325.0], + "units": "K" + } + ] + } + } + +To disable QC collection during batch report generation, use one of: + +.. code-block:: bash + + # Environment variable + export MOPPY_SKIP_QC=1 + moppy-batch-report --db cmor_tasks.db + + # CLI flag + moppy-batch-report --db cmor_tasks.db --skip-qc + +Or programmatically: + +.. code-block:: python + + from access_moppy.batch_report import write_batch_report + write_batch_report(db_path, skip_qc=True) + +Extending rules + +--------------- + +To add experiment-specific thresholds for a variable, or to override ranges +for newly added variables, edit: + +.. code-block:: text + + src/access_moppy/resources/qc/cmip7_ranges.yml + +Under the ``variables`` section, each variable has a ``default`` entry and an +optional ``experiments`` map for experiment-specific min/max values. For example: + +.. code-block:: yaml + + variables: + tas: + units: K + default: + min: 180.0 + max: 330.0 + experiments: + historical: + min: 180.0 + max: 330.0 + piControl: + min: 180.0 + max: 325.0 + +Rule structure example: + +.. code-block:: yaml + + variables: + tas: + units: K + default: + min: 180.0 + max: 330.0 + experiments: + historical: + min: 180.0 + max: 330.0 + piControl: + min: 180.0 + max: 325.0 + ssp*: + min: 180.0 + max: 335.0 diff --git a/pyproject.toml b/pyproject.toml index ff3fd08e..331bd6c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ moppy-cmorise = "access_moppy.batch_cmoriser:main" moppy-dashboard = "access_moppy.dashboard.cmor_dashboard:main" moppy-tui = "access_moppy.dashboard.cli_dashboard:main" moppy-batch-report = "access_moppy.batch_report:main" +moppy-qc = "access_moppy.qc.cmip7:main" moppy-example-config = "access_moppy.examples.show_config:main" moppy-calc-ab-coeffts = "access_moppy.legacy_utilities.calc_hybrid_height_coeffs:main" moppy-esmval-prepare = "access_moppy.esmval.cli_commands:main_prepare" diff --git a/src/access_moppy/base.py b/src/access_moppy/base.py index 7e48f806..7924e073 100644 --- a/src/access_moppy/base.py +++ b/src/access_moppy/base.py @@ -14,6 +14,7 @@ import xarray as xr from cftime import date2num +from access_moppy.qc import validate_cmip7_output from access_moppy.utilities import ( FrequencyMismatchError, IncompatibleFrequencyError, @@ -1226,6 +1227,9 @@ def estimate_data_size(ds): self._repack_cmip7_output(path) + if getattr(self.vocab, "mip_era", None) == "CMIP7": + validate_cmip7_output(path) + logger.info("CMORised output written to %s", path) logger.debug("Optimized layout: metadata -> data chunks") if self.enable_compression: diff --git a/src/access_moppy/batch_report.py b/src/access_moppy/batch_report.py index 1dbe517c..2b721123 100644 --- a/src/access_moppy/batch_report.py +++ b/src/access_moppy/batch_report.py @@ -213,6 +213,62 @@ def _pbs_report( return _compact_dict(pbs) +def _run_qc_on_output_folder(output_folder: Path) -> dict[str, Any] | None: + """Run QC on CMORised netCDF files found in output folder and return results. + + Returns None if QC cannot be run (import unavailable, no files found, or disabled). + Set environment variable MOPPY_SKIP_QC=1 to disable QC collection. + """ + import os + + if os.environ.get("MOPPY_SKIP_QC", "").lower() in ("1", "true", "yes"): + return None + + try: + from access_moppy.qc.cmip7 import validate_cmip7_output_detailed + except ImportError: + return None + + nc_files = sorted(output_folder.glob("**/*.nc")) + if not nc_files: + return None + + results = { + "passed": 0, + "failed": 0, + "total": len(nc_files), + "failures": [], + } + + for nc_file in nc_files: + result = validate_cmip7_output_detailed(nc_file) + if result.passed: + results["passed"] += 1 + else: + results["failed"] += 1 + failure = { + "file": str(nc_file), + "variable_id": result.variable_id, + "experiment_id": result.experiment_id, + "error": result.error, + } + if result.observed_min is not None: + failure["observed_range"] = [ + float(result.observed_min), + float(result.observed_max), + ] + if result.allowed_min is not None: + failure["allowed_range"] = [ + float(result.allowed_min), + float(result.allowed_max), + ] + if result.units: + failure["units"] = result.units + results["failures"].append(failure) + + return results if results["total"] > 0 else None + + def build_batch_report( db_path: str | Path, *, @@ -223,12 +279,16 @@ def build_batch_report( created_at: str | None = None, completed_at: str | None = None, stderr_tail_lines: int = 20, + skip_qc: bool = False, ) -> dict[str, Any]: """Build a JSON-serialisable batch coordination report. SQLite remains the source of truth; this report is a derived snapshot for after-the-fact completion checks, provenance capture, and dashboard/database ingestion. + + Args: + skip_qc: If True, skip QC data collection. Can also be set via MOPPY_SKIP_QC env var. """ db_path = Path(db_path) output_path = Path(output_folder) if output_folder is not None else db_path.parent @@ -281,7 +341,9 @@ def build_batch_report( monitor = _read_monitor_sidecar(output_path) monitor.update(_monitor_log_paths(script_path)) - return { + qc_results = None if skip_qc else _run_qc_on_output_folder(output_path) + + report_dict = { "schema_version": SCHEMA_VERSION, "created_at": now, "completed_at": final_completed_at, @@ -298,14 +360,23 @@ def build_batch_report( "tasks": tasks, "failures": failures, } + if qc_results is not None: + report_dict["qc"] = qc_results + + return report_dict def write_batch_report( db_path: str | Path, output_path: str | Path | None = None, + skip_qc: bool = False, **kwargs: Any, ) -> Path: - """Write a durable batch report and return the report path.""" + """Write a durable batch report and return the report path. + + Args: + skip_qc: If True, skip QC data collection. Can also be set via MOPPY_SKIP_QC env var. + """ db_path = Path(db_path) report_path = ( Path(output_path) @@ -313,7 +384,7 @@ def write_batch_report( else db_path.parent / REPORT_FILENAME ) report_path.parent.mkdir(parents=True, exist_ok=True) - report = build_batch_report(db_path, **kwargs) + report = build_batch_report(db_path, skip_qc=skip_qc, **kwargs) report_path.write_text( json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) @@ -340,6 +411,11 @@ def _build_parser() -> argparse.ArgumentParser: default=20, help="Number of stderr tail lines to include for failed tasks (default: 20).", ) + parser.add_argument( + "--skip-qc", + action="store_true", + help="Skip QC data collection (can also set MOPPY_SKIP_QC=1).", + ) return parser @@ -355,6 +431,7 @@ def main(argv: list[str] | None = None) -> int: report_path = write_batch_report( args.db, args.output, + skip_qc=args.skip_qc, config=config, config_path=args.config, script_dir=args.script_dir, diff --git a/src/access_moppy/ocean.py b/src/access_moppy/ocean.py index 51d5dc23..aa5e3ad9 100644 --- a/src/access_moppy/ocean.py +++ b/src/access_moppy/ocean.py @@ -378,7 +378,12 @@ def infer_grid_type(self): def _get_dim_rename(self): """Get the dimension renaming mapping for the grid type.""" - supported_sources = ["ACCESS-OM2", "ACCESS-CM", "ACCESS-ESM1-5"] + supported_sources = [ + "ACCESS-OM2", + "ACCESS-CM", + "ACCESS-ESM1-5", + "ACCESS-ESM1-6", + ] if self.vocab.source_id in supported_sources: return { "xt_ocean": "i", diff --git a/src/access_moppy/qc/__init__.py b/src/access_moppy/qc/__init__.py new file mode 100644 index 00000000..13181755 --- /dev/null +++ b/src/access_moppy/qc/__init__.py @@ -0,0 +1,5 @@ +"""Output QC helpers for ACCESS-MOPPy.""" + +from .cmip7 import validate_cmip7_output + +__all__ = ["validate_cmip7_output"] diff --git a/src/access_moppy/qc/cmip7.py b/src/access_moppy/qc/cmip7.py new file mode 100644 index 00000000..5942cf24 --- /dev/null +++ b/src/access_moppy/qc/cmip7.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from fnmatch import fnmatchcase +from functools import lru_cache +from importlib.resources import as_file, files +from pathlib import Path +from typing import Any + +import numpy as np +import xarray as xr +import yaml + + +@dataclass(frozen=True) +class RangeRule: + variable_id: str + experiment_id: str + units: str | None + minimum: float + maximum: float + rule_name: str + + +@dataclass +class ValidationResult: + """Result of a single file validation.""" + + file_path: str + passed: bool + variable_id: str | None = None + experiment_id: str | None = None + error: str | None = None + observed_min: float | None = None + observed_max: float | None = None + allowed_min: float | None = None + allowed_max: float | None = None + units: str | None = None + + +def _iter_missing_sentinels(da: xr.DataArray) -> list[float]: + """Collect numeric missing-value sentinels from attrs/encoding.""" + + sentinels: list[float] = [] + for container in (da.attrs, da.encoding): + for key in ("missing_value", "_FillValue"): + value = container.get(key) + if value is None: + continue + values = np.asarray(value).ravel() + for item in values: + try: + candidate = float(item) + except (TypeError, ValueError): + continue + if np.isfinite(candidate): + sentinels.append(candidate) + return sentinels + + +def _mask_missing_sentinels_for_qc(da: xr.DataArray) -> xr.DataArray: + """Mask numeric missing-value sentinels prior to QC reductions. + + Some files encode missing values as float32 and write to float64 arrays, + so values can appear as 1.0000000200408773e+20 while metadata carries 1e20. + Use tolerance-aware matching so these sentinels are not interpreted as data. + """ + + sentinels = _iter_missing_sentinels(da) + if not sentinels: + return da + + mask = xr.zeros_like(da, dtype=bool) + for sentinel in sentinels: + atol = np.finfo(np.float32).eps * max(abs(sentinel), 1.0) + mask = mask | xr.apply_ufunc( + np.isclose, + da, + sentinel, + kwargs={"rtol": 0.0, "atol": atol, "equal_nan": False}, + dask="allowed", + ) + + return da.where(~mask) + + +def _is_outside_allowed_range(observed: float, minimum: float, maximum: float) -> bool: + """Check whether an observed value is meaningfully outside a closed range. + + Small floating-point noise at the boundary is ignored so values like + ``-6e-24`` are treated as zero for a lower bound of ``0``. + """ + + tolerance = 1e-12 + lower_violation = observed < minimum and not np.isclose( + observed, minimum, rtol=0.0, atol=tolerance + ) + upper_violation = observed > maximum and not np.isclose( + observed, maximum, rtol=0.0, atol=tolerance + ) + return bool(lower_violation or upper_violation) + + +@lru_cache(maxsize=1) +def _load_esm16_mapping_variables() -> dict[str, dict[str, Any]]: + """Flatten ACCESS-ESM1-6 mapping entries keyed by CMIP variable id.""" + + resource = files("access_moppy") / "mappings" / "ACCESS-ESM1-6_mappings.json" + with as_file(resource) as path: + with open(path, "r", encoding="utf-8") as handle: + mapping = json.load(handle) + + flattened: dict[str, dict[str, Any]] = {} + for section_name, section in mapping.items(): + if section_name == "model_info" or not isinstance(section, dict): + continue + for variable_id, metadata in section.items(): + if isinstance(metadata, dict): + flattened[variable_id] = metadata + return flattened + + +@lru_cache(maxsize=1) +def _load_qc_config() -> dict[str, Any]: + resource = files("access_moppy.resources.qc") / "cmip7_ranges.yml" + with as_file(resource) as path: + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +@lru_cache(maxsize=1) +def _load_rules() -> dict[str, Any]: + return _load_qc_config().get("variables", {}) + + +@lru_cache(maxsize=1) +def _load_unit_envelopes() -> dict[str, dict[str, Any]]: + return _load_qc_config().get("unit_envelopes", {}) + + +@lru_cache(maxsize=1) +def _load_mapping_variable_ranges() -> dict[str, dict[str, Any]]: + """Optional per-variable overrides for mapping-derived physical ranges.""" + + return _load_qc_config().get("mapping_variable_ranges", {}) + + +def _select_experiment_rule( + experiments: dict[str, dict[str, Any]], experiment_id: str +) -> tuple[str, dict[str, Any]] | None: + if experiment_id in experiments: + return experiment_id, experiments[experiment_id] + + matches: list[tuple[str, dict[str, Any]]] = [ + (pattern, rule) + for pattern, rule in experiments.items() + if fnmatchcase(experiment_id, pattern) + ] + if not matches: + return None + + matches.sort(key=lambda item: (-len(item[0]), item[0])) + return matches[0] + + +def _resolve_range_rule(variable_id: str, experiment_id: str) -> RangeRule | None: + variables = _load_rules() + variable_rules = variables.get(variable_id) + if not variable_rules: + return None + + base_rule = dict(variable_rules.get("default", {})) + experiment_rules = variable_rules.get("experiments", {}) + selected = _select_experiment_rule(experiment_rules, experiment_id) + if selected is not None: + rule_name, experiment_rule = selected + base_rule.update(experiment_rule) + else: + rule_name = "default" + + if "min" not in base_rule or "max" not in base_rule: + return None + + return RangeRule( + variable_id=variable_id, + experiment_id=experiment_id, + units=base_rule.get("units", variable_rules.get("units")), + minimum=float(base_rule["min"]), + maximum=float(base_rule["max"]), + rule_name=rule_name, + ) + + +def _resolve_range_rule_from_mapping_definition( + variable_id: str, + experiment_id: str, + mapping_entry: dict[str, Any], +) -> RangeRule | None: + override = _load_mapping_variable_ranges().get(variable_id, {}) + + units = mapping_entry.get("units") + if not isinstance(units, str) or not units: + return None + + if isinstance(override.get("units"), str) and override.get("units"): + units = override["units"] + + envelope = _load_unit_envelopes().get(units) + if "min" in override and "max" in override: + minimum = float(override["min"]) + maximum = float(override["max"]) + else: + if not envelope or "min" not in envelope or "max" not in envelope: + return None + minimum = float(envelope["min"]) + maximum = float(envelope["max"]) + + return RangeRule( + variable_id=variable_id, + experiment_id=experiment_id, + units=units, + minimum=minimum, + maximum=maximum, + rule_name=f"mapping-variable:{variable_id}", + ) + + +def _select_output_variable(ds: xr.Dataset, attrs: dict[str, Any]) -> str: + candidate_names = [ + attrs.get("branded_variable"), + attrs.get("variable_id"), + ] + for candidate in candidate_names: + if isinstance(candidate, str) and candidate in ds.data_vars: + return candidate + + non_bounds = [v for v in ds.data_vars if not str(v).endswith("_bnds")] + if len(non_bounds) == 1: + return non_bounds[0] + + available = ", ".join(sorted(ds.data_vars)) + raise ValueError( + "CMIP7 QC could not determine the main output variable from the CMORised file. " + f"Available data variables: {available}" + ) + + +def _validate_esm16_mapping_checks( + da: xr.DataArray, + *, + variable_id: str, + experiment_id: str, + mapping_entry: dict[str, Any], +) -> None: + """Validate generic checks for ACCESS mapped variables.""" + + non_missing = int(da.count().item()) + if non_missing == 0: + raise ValueError( + "CMIP7 QC failed for " + f"{variable_id} in experiment {experiment_id}: all values are missing." + ) + + if np.issubdtype(da.dtype, np.number): + max_abs = float(np.abs(da).max(skipna=True).item()) + if np.isinf(max_abs): + raise ValueError( + "CMIP7 QC failed for " + f"{variable_id} in experiment {experiment_id}: values contain infinity." + ) + + expected_units = mapping_entry.get("units") + if isinstance(expected_units, str) and expected_units: + actual_units = da.attrs.get("units") + if actual_units != expected_units: + raise ValueError( + "CMIP7 QC failed for " + f"{variable_id} in experiment {experiment_id}: expected units {expected_units!r} " + f"from ACCESS-ESM1-6 mapping, found {actual_units!r}." + ) + + +def validate_cmip7_output(output_path: str | Path) -> None: + """Validate a CMIP7 CMORised file against output-time physical range rules.""" + + path = Path(output_path) + with xr.open_dataset(path) as ds: + attrs = dict(ds.attrs) + variable_id = attrs.get("variable_id") + experiment_id = attrs.get("experiment_id") + source_id = attrs.get("source_id") + + if not isinstance(variable_id, str) or not variable_id: + raise ValueError( + "CMIP7 QC requires a 'variable_id' global attribute on the output file." + ) + if not isinstance(experiment_id, str) or not experiment_id: + raise ValueError( + "CMIP7 QC requires an 'experiment_id' global attribute on the output file." + ) + + rule = _resolve_range_rule(variable_id, experiment_id) + + output_variable = _select_output_variable(ds, attrs) + da = _mask_missing_sentinels_for_qc(ds[output_variable]) + + # Apply generic checks for variables present in the bundled ACCESS-ESM1-6 + # mapping so every mapped variable receives QC coverage. + if source_id == "ACCESS-ESM1-6": + mapping_entry = _load_esm16_mapping_variables().get(variable_id) + if mapping_entry is not None: + _validate_esm16_mapping_checks( + da, + variable_id=variable_id, + experiment_id=experiment_id, + mapping_entry=mapping_entry, + ) + if rule is None: + rule = _resolve_range_rule_from_mapping_definition( + variable_id, + experiment_id, + mapping_entry, + ) + + if rule is None: + return + + units = da.attrs.get("units") or attrs.get("units") + if rule.units is not None and units != rule.units: + raise ValueError( + "CMIP7 QC failed for " + f"{variable_id} in experiment {experiment_id}: expected units {rule.units!r}, " + f"found {units!r}." + ) + + minimum = da.min(skipna=True).item() + maximum = da.max(skipna=True).item() + + if np.isnan(minimum) or np.isnan(maximum): + return + + observed_min = float(minimum) + observed_max = float(maximum) + + if _is_outside_allowed_range( + observed_min, rule.minimum, rule.maximum + ) or _is_outside_allowed_range(observed_max, rule.minimum, rule.maximum): + raise ValueError( + "CMIP7 QC failed for " + f"{variable_id} in experiment {experiment_id} using rule {rule.rule_name}: " + f"observed range {observed_min:.3f}..{observed_max:.3f} {units or ''} " + f"is outside allowed range {rule.minimum:.3f}..{rule.maximum:.3f} {rule.units or units or ''}." + ) + + +def validate_cmip7_output_detailed(output_path: str | Path) -> ValidationResult: + """Validate a CMIP7 file and return detailed results (does not raise).""" + + path = Path(output_path) + try: + with xr.open_dataset(path) as ds: + attrs = dict(ds.attrs) + variable_id = attrs.get("variable_id") + experiment_id = attrs.get("experiment_id") + source_id = attrs.get("source_id") + + if not isinstance(variable_id, str) or not variable_id: + return ValidationResult( + file_path=str(path), + passed=False, + error="CMIP7 QC requires a 'variable_id' global attribute.", + ) + if not isinstance(experiment_id, str) or not experiment_id: + return ValidationResult( + file_path=str(path), + passed=False, + variable_id=variable_id, + error="CMIP7 QC requires an 'experiment_id' global attribute.", + ) + + rule = _resolve_range_rule(variable_id, experiment_id) + + output_variable = _select_output_variable(ds, attrs) + da = _mask_missing_sentinels_for_qc(ds[output_variable]) + + # Apply generic checks for variables present in the bundled ACCESS-ESM1-6 + # mapping so every mapped variable receives QC coverage. + if source_id == "ACCESS-ESM1-6": + mapping_entry = _load_esm16_mapping_variables().get(variable_id) + if mapping_entry is not None: + try: + _validate_esm16_mapping_checks( + da, + variable_id=variable_id, + experiment_id=experiment_id, + mapping_entry=mapping_entry, + ) + except ValueError as exc: + return ValidationResult( + file_path=str(path), + passed=False, + variable_id=variable_id, + experiment_id=experiment_id, + error=str(exc), + ) + if rule is None: + rule = _resolve_range_rule_from_mapping_definition( + variable_id, + experiment_id, + mapping_entry, + ) + + if rule is None: + return ValidationResult( + file_path=str(path), + passed=True, + variable_id=variable_id, + experiment_id=experiment_id, + ) + + units = da.attrs.get("units") or attrs.get("units") + if rule.units is not None and units != rule.units: + return ValidationResult( + file_path=str(path), + passed=False, + variable_id=variable_id, + experiment_id=experiment_id, + units=units, + error=f"Expected units {rule.units!r}, found {units!r}.", + ) + + minimum = da.min(skipna=True).item() + maximum = da.max(skipna=True).item() + + if np.isnan(minimum) or np.isnan(maximum): + return ValidationResult( + file_path=str(path), + passed=True, + variable_id=variable_id, + experiment_id=experiment_id, + units=units, + ) + + observed_min = float(minimum) + observed_max = float(maximum) + + if _is_outside_allowed_range( + observed_min, rule.minimum, rule.maximum + ) or _is_outside_allowed_range(observed_max, rule.minimum, rule.maximum): + return ValidationResult( + file_path=str(path), + passed=False, + variable_id=variable_id, + experiment_id=experiment_id, + units=units, + observed_min=observed_min, + observed_max=observed_max, + allowed_min=rule.minimum, + allowed_max=rule.maximum, + error=f"Observed range {observed_min:.3f}..{observed_max:.3f} outside allowed {rule.minimum:.3f}..{rule.maximum:.3f}.", + ) + + return ValidationResult( + file_path=str(path), + passed=True, + variable_id=variable_id, + experiment_id=experiment_id, + units=units, + observed_min=observed_min, + observed_max=observed_max, + allowed_min=rule.minimum, + allowed_max=rule.maximum, + ) + except Exception as exc: # noqa: BLE001 + return ValidationResult( + file_path=str(path), + passed=False, + error=f"Unexpected error: {exc}", + ) + + +def _build_cli_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="moppy-qc", + description=( + "Run ACCESS-MOPPy CMIP7 output QC checks against one or more CMORised netCDF files." + ), + ) + parser.add_argument( + "paths", + nargs="+", + help="One or more CMORised output files to validate.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_cli_parser() + args = parser.parse_args(argv) + + failures: list[tuple[str, str]] = [] + for raw_path in args.paths: + path = Path(raw_path) + try: + validate_cmip7_output(path) + print(f"PASS {path}") + except Exception as exc: # noqa: BLE001 + failures.append((str(path), str(exc))) + print(f"FAIL {path}: {exc}") + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/access_moppy/resources/__init__.py b/src/access_moppy/resources/__init__.py new file mode 100644 index 00000000..7663c017 --- /dev/null +++ b/src/access_moppy/resources/__init__.py @@ -0,0 +1 @@ +"""Packaged data resources for ACCESS-MOPPy.""" diff --git a/src/access_moppy/resources/qc/__init__.py b/src/access_moppy/resources/qc/__init__.py new file mode 100644 index 00000000..2ece2e9f --- /dev/null +++ b/src/access_moppy/resources/qc/__init__.py @@ -0,0 +1 @@ +"""Packaged QC rules for ACCESS-MOPPy.""" diff --git a/src/access_moppy/resources/qc/cmip7_ranges.yml b/src/access_moppy/resources/qc/cmip7_ranges.yml new file mode 100644 index 00000000..dcd66e04 --- /dev/null +++ b/src/access_moppy/resources/qc/cmip7_ranges.yml @@ -0,0 +1,4401 @@ +# Per-variable QC rules for ACCESS-ESM1-6 mapped variables. +# Generated from uploaded placeholder ranges. +# These are broad physical sanity bounds for CMIP7-style QC, not tight climatological thresholds. +# Intent: catch unit/sign/conversion mistakes while allowing plausible model extremes. + +variables: + agessc: + units: yr + default: + min: 0.0 + max: 1000000.0 + experiments: + historical: + min: 0.0 + max: 1000000.0 + piControl: + min: 0.0 + max: 1000000.0 + ssp*: + min: 0.0 + max: 1000000.0 + arag: + units: mol m-3 + default: + min: 0.0 + max: 10.0 + experiments: + historical: + min: 0.0 + max: 10.0 + piControl: + min: 0.0 + max: 10.0 + ssp*: + min: 0.0 + max: 10.0 + areacella: + units: m2 + default: + min: 0.0 + max: 100000000000.0 + experiments: + historical: + min: 0.0 + max: 100000000000.0 + piControl: + min: 0.0 + max: 100000000000.0 + ssp*: + min: 0.0 + max: 100000000000.0 + areacello: + units: m2 + default: + min: 0.0 + max: 100000000000.0 + experiments: + historical: + min: 0.0 + max: 100000000000.0 + piControl: + min: 0.0 + max: 100000000000.0 + ssp*: + min: 0.0 + max: 100000000000.0 + baresoilFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + bigthetao: + units: degC + default: + min: -3.0 + max: 40.0 + experiments: + historical: + min: -3.0 + max: 40.0 + piControl: + min: -3.0 + max: 40.0 + ssp*: + min: -3.0 + max: 40.0 + bigthetaoga: + units: degC + default: + min: -3.0 + max: 40.0 + experiments: + historical: + min: -3.0 + max: 40.0 + piControl: + min: -3.0 + max: 40.0 + ssp*: + min: -3.0 + max: 40.0 + c3PftFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + c4PftFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + cLand: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cLeaf: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cLitter: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cProduct: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cRoot: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cSoil: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cSoilFast: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cSoilMedium: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cSoilSlow: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + cVeg: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + calcos: + units: mol m-3 + default: + min: 0.0 + max: 10.0 + experiments: + historical: + min: 0.0 + max: 10.0 + piControl: + min: 0.0 + max: 10.0 + ssp*: + min: 0.0 + max: 10.0 + chl: + units: kg m-3 + default: + min: 0.0 + max: 0.01 + experiments: + historical: + min: 0.0 + max: 0.01 + piControl: + min: 0.0 + max: 0.01 + ssp*: + min: 0.0 + max: 0.01 + chlos: + units: kg m-3 + default: + min: 0.0 + max: 0.01 + experiments: + historical: + min: 0.0 + max: 0.01 + piControl: + min: 0.0 + max: 0.01 + ssp*: + min: 0.0 + max: 0.01 + ci: + units: '1' + default: + min: 0.0 + max: 1.0 + experiments: + historical: + min: 0.0 + max: 1.0 + piControl: + min: 0.0 + max: 1.0 + ssp*: + min: 0.0 + max: 1.0 + cl: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + cli: + units: kg kg-1 + default: + min: 0.0 + max: 0.02 + experiments: + historical: + min: 0.0 + max: 0.02 + piControl: + min: 0.0 + max: 0.02 + ssp*: + min: 0.0 + max: 0.02 + clivi: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + clt: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + clw: + units: kg kg-1 + default: + min: 0.0 + max: 0.02 + experiments: + historical: + min: 0.0 + max: 0.02 + piControl: + min: 0.0 + max: 0.02 + ssp*: + min: 0.0 + max: 0.02 + clwvi: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + co23D: + units: kg kg-1 + default: + min: 0.0 + max: 0.02 + experiments: + historical: + min: 0.0 + max: 0.02 + piControl: + min: 0.0 + max: 0.02 + ssp*: + min: 0.0 + max: 0.02 + cropFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + cropFracC3: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + deptho: + units: m + default: + min: 0.0 + max: 12000.0 + experiments: + historical: + min: 0.0 + max: 12000.0 + piControl: + min: 0.0 + max: 12000.0 + ssp*: + min: 0.0 + max: 12000.0 + detoc: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + dfe: + units: mol m-3 + default: + min: 0.0 + max: 1.0e-06 + experiments: + historical: + min: 0.0 + max: 1.0e-06 + piControl: + min: 0.0 + max: 1.0e-06 + ssp*: + min: 0.0 + max: 1.0e-06 + dfeos: + units: mol m-3 + default: + min: 0.0 + max: 1.0e-06 + experiments: + historical: + min: 0.0 + max: 1.0e-06 + piControl: + min: 0.0 + max: 1.0e-06 + ssp*: + min: 0.0 + max: 1.0e-06 + dissic: + units: mol m-3 + default: + min: 0.0 + max: 0.005 + experiments: + historical: + min: 0.0 + max: 0.005 + piControl: + min: 0.0 + max: 0.005 + ssp*: + min: 0.0 + max: 0.005 + dissicos: + units: mol m-3 + default: + min: 0.0 + max: 0.005 + experiments: + historical: + min: 0.0 + max: 0.005 + piControl: + min: 0.0 + max: 0.005 + ssp*: + min: 0.0 + max: 0.005 + epc100: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + evs: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + evspsbl: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + evspsblsoi: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + evspsblveg: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + expc: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + expcalc: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + fBNF: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fDeforestToProduct: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNdep: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNgas: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNleach: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNloss: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNnetmin: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fNup: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fProductDecomp: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + fgco2: + units: kg m-2 s-1 + default: + min: -1.0e-06 + max: 1.0e-06 + experiments: + historical: + min: -1.0e-06 + max: 1.0e-06 + piControl: + min: -1.0e-06 + max: 1.0e-06 + ssp*: + min: -1.0e-06 + max: 1.0e-06 + ficeberg2d: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + friver: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + fsfe: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + fsitherm: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + fsn: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + gpp: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + grassFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + grassFracC3: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + grassFracC4: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + graz: + units: mol m-3 s-1 + default: + min: -1.0e-06 + max: 1.0e-06 + experiments: + historical: + min: -1.0e-06 + max: 1.0e-06 + piControl: + min: -1.0e-06 + max: 1.0e-06 + ssp*: + min: -1.0e-06 + max: 1.0e-06 + hfds: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfevapds: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfgeou: + units: W m-2 + default: + min: 0.0 + max: 10.0 + experiments: + historical: + min: 0.0 + max: 10.0 + piControl: + min: 0.0 + max: 10.0 + ssp*: + min: 0.0 + max: 10.0 + hfibthermds2d: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfls: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hflso: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfrainds: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfrunoffds2d: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfsifrazil: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfsifrazil2d: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfsnthermds2d: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfss: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hfsso: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + hur: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + hurs: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + hursmax: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + hursmin: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + hus: + units: '1' + default: + min: 0.0 + max: 0.08 + experiments: + historical: + min: 0.0 + max: 0.08 + piControl: + min: 0.0 + max: 0.08 + ssp*: + min: 0.0 + max: 0.08 + huss: + units: '1' + default: + min: 0.0 + max: 0.08 + experiments: + historical: + min: 0.0 + max: 0.08 + piControl: + min: 0.0 + max: 0.08 + ssp*: + min: 0.0 + max: 0.08 + intdic: + units: kg m-2 + default: + min: 0.0 + max: 100000.0 + experiments: + historical: + min: 0.0 + max: 100000.0 + piControl: + min: 0.0 + max: 100000.0 + ssp*: + min: 0.0 + max: 100000.0 + intpoc: + units: kg m-2 + default: + min: 0.0 + max: 1000.0 + experiments: + historical: + min: 0.0 + max: 1000.0 + piControl: + min: 0.0 + max: 1000.0 + ssp*: + min: 0.0 + max: 1000.0 + intpp: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + intppnitrate: + units: mol m-2 s-1 + default: + min: -0.001 + max: 0.001 + experiments: + historical: + min: -0.001 + max: 0.001 + piControl: + min: -0.001 + max: 0.001 + ssp*: + min: -0.001 + max: 0.001 + intuaw: + units: kg m-1 s-1 + default: + min: -2000000.0 + max: 2000000.0 + experiments: + historical: + min: -2000000.0 + max: 2000000.0 + piControl: + min: -2000000.0 + max: 2000000.0 + ssp*: + min: -2000000.0 + max: 2000000.0 + intvaw: + units: kg m-1 s-1 + default: + min: -2000000.0 + max: 2000000.0 + experiments: + historical: + min: -2000000.0 + max: 2000000.0 + piControl: + min: -2000000.0 + max: 2000000.0 + ssp*: + min: -2000000.0 + max: 2000000.0 + lai: + units: '1' + default: + min: 0.0 + max: 20.0 + experiments: + historical: + min: 0.0 + max: 20.0 + piControl: + min: 0.0 + max: 20.0 + ssp*: + min: 0.0 + max: 20.0 + landCoverFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + masscello: + units: kg m-2 + default: + min: 0.0 + max: 12000000.0 + experiments: + historical: + min: 0.0 + max: 12000000.0 + piControl: + min: 0.0 + max: 12000000.0 + ssp*: + min: 0.0 + max: 12000000.0 + mc: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + mlotst: + units: m + default: + min: 0.0 + max: 7000.0 + experiments: + historical: + min: 0.0 + max: 7000.0 + piControl: + min: 0.0 + max: 7000.0 + ssp*: + min: 0.0 + max: 7000.0 + mlotstsq: + units: m2 + default: + min: 0.0 + max: 50000000.0 + experiments: + historical: + min: 0.0 + max: 50000000.0 + piControl: + min: 0.0 + max: 50000000.0 + ssp*: + min: 0.0 + max: 50000000.0 + mrfsli: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrfso: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrro: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + mrrob: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + mrros: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + mrsfl: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrsll: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrso: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrsofc: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrsol: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + mrsos: + units: kg m-2 + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + msftbarot: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + msftmrho: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + msftmz: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + msftyrho: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + msftyz: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + nLand: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nLitter: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nMineral: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nProduct: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nSoil: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nVeg: + units: kg m-2 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + nbp: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + nep: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + no3: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + no3os: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + npp: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + o2: + units: mol m-3 + default: + min: 0.0 + max: 0.7 + experiments: + historical: + min: 0.0 + max: 0.7 + piControl: + min: 0.0 + max: 0.7 + ssp*: + min: 0.0 + max: 0.7 + obvfsq: + units: s-2 + default: + min: -1.0e-06 + max: 0.001 + experiments: + historical: + min: -1.0e-06 + max: 0.001 + piControl: + min: -1.0e-06 + max: 0.001 + ssp*: + min: -1.0e-06 + max: 0.001 + ocontempdiff: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + ocontempmint: + units: degC kg m-2 + default: + min: -100000.0 + max: 500000.0 + experiments: + historical: + min: -100000.0 + max: 500000.0 + piControl: + min: -100000.0 + max: 500000.0 + ssp*: + min: -100000.0 + max: 500000.0 + ocontemppsmadvect: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + ocontemptend: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + od440aer: + units: '1' + default: + min: 0.0 + max: 10.0 + experiments: + historical: + min: 0.0 + max: 10.0 + piControl: + min: 0.0 + max: 10.0 + ssp*: + min: 0.0 + max: 10.0 + od550aer: + units: '1' + default: + min: 0.0 + max: 10.0 + experiments: + historical: + min: 0.0 + max: 10.0 + piControl: + min: 0.0 + max: 10.0 + ssp*: + min: 0.0 + max: 10.0 + opottempmint: + units: degC kg m-2 + default: + min: -100000.0 + max: 500000.0 + experiments: + historical: + min: -100000.0 + max: 500000.0 + piControl: + min: -100000.0 + max: 500000.0 + ssp*: + min: -100000.0 + max: 500000.0 + orog: + units: m + default: + min: -500.0 + max: 9000.0 + experiments: + historical: + min: -500.0 + max: 9000.0 + piControl: + min: -500.0 + max: 9000.0 + ssp*: + min: -500.0 + max: 9000.0 + osaltdiff: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + osaltpsmadvect: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + osalttend: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + pbo: + units: Pa + default: + min: 0.0 + max: 150000000.0 + experiments: + historical: + min: 0.0 + max: 150000000.0 + piControl: + min: 0.0 + max: 150000000.0 + ssp*: + min: 0.0 + max: 150000000.0 + pfull: + units: Pa + default: + min: 0.0 + max: 120000.0 + experiments: + historical: + min: 0.0 + max: 120000.0 + piControl: + min: 0.0 + max: 120000.0 + ssp*: + min: 0.0 + max: 120000.0 + ph: + units: '1' + default: + min: 6.0 + max: 10.0 + experiments: + historical: + min: 6.0 + max: 10.0 + piControl: + min: 6.0 + max: 10.0 + ssp*: + min: 6.0 + max: 10.0 + phalf: + units: Pa + default: + min: 0.0 + max: 120000.0 + experiments: + historical: + min: 0.0 + max: 120000.0 + piControl: + min: 0.0 + max: 120000.0 + ssp*: + min: 0.0 + max: 120000.0 + phos: + units: '1' + default: + min: 6.0 + max: 10.0 + experiments: + historical: + min: 6.0 + max: 10.0 + piControl: + min: 6.0 + max: 10.0 + ssp*: + min: 6.0 + max: 10.0 + phycos: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + po4: + units: mol m-3 + default: + min: 0.0 + max: 0.01 + experiments: + historical: + min: 0.0 + max: 0.01 + piControl: + min: 0.0 + max: 0.01 + ssp*: + min: 0.0 + max: 0.01 + pr: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + prc: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + prrc: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + prsn: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + prsnc: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + prw: + units: kg m-2 + default: + min: 0.0 + max: 150.0 + experiments: + historical: + min: 0.0 + max: 150.0 + piControl: + min: 0.0 + max: 150.0 + ssp*: + min: 0.0 + max: 150.0 + ps: + units: Pa + default: + min: 30000.0 + max: 120000.0 + experiments: + historical: + min: 30000.0 + max: 120000.0 + piControl: + min: 30000.0 + max: 120000.0 + ssp*: + min: 30000.0 + max: 120000.0 + psl: + units: Pa + default: + min: 80000.0 + max: 110000.0 + experiments: + historical: + min: 80000.0 + max: 110000.0 + piControl: + min: 80000.0 + max: 110000.0 + ssp*: + min: 80000.0 + max: 110000.0 + pso: + units: Pa + default: + min: 0.0 + max: 150000000.0 + experiments: + historical: + min: 0.0 + max: 150000000.0 + piControl: + min: 0.0 + max: 150000000.0 + ssp*: + min: 0.0 + max: 150000000.0 + ra: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + residualFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + rh: + units: kg m-2 s-1 + default: + min: -0.0001 + max: 0.0001 + experiments: + historical: + min: -0.0001 + max: 0.0001 + piControl: + min: -0.0001 + max: 0.0001 + ssp*: + min: -0.0001 + max: 0.0001 + rlds: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rldscs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rlntds: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rls: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rlus: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rluscs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rlut: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rlutcs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rootd: + units: m + default: + min: 0.0 + max: 20.0 + experiments: + historical: + min: 0.0 + max: 20.0 + piControl: + min: 0.0 + max: 20.0 + ssp*: + min: 0.0 + max: 20.0 + rsdo: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsdoabsorb: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsds: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsdscs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsdt: + units: W m-2 + default: + min: 0.0 + max: 1500.0 + experiments: + historical: + min: 0.0 + max: 1500.0 + piControl: + min: 0.0 + max: 1500.0 + ssp*: + min: 0.0 + max: 1500.0 + rsntds: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rss: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsus: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsuscs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsut: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rsutcs: + units: W m-2 + default: + min: -100.0 + max: 1500.0 + experiments: + historical: + min: -100.0 + max: 1500.0 + piControl: + min: -100.0 + max: 1500.0 + ssp*: + min: -100.0 + max: 1500.0 + rtmt: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + sbl: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + sci: + units: '1' + default: + min: 0.0 + max: 1.0 + experiments: + historical: + min: 0.0 + max: 1.0 + piControl: + min: 0.0 + max: 1.0 + ssp*: + min: 0.0 + max: 1.0 + sfcWind: + units: m s-1 + default: + min: 0.0 + max: 150.0 + experiments: + historical: + min: 0.0 + max: 150.0 + piControl: + min: 0.0 + max: 150.0 + ssp*: + min: 0.0 + max: 150.0 + sfcWindmax: + units: m s-1 + default: + min: 0.0 + max: 150.0 + experiments: + historical: + min: 0.0 + max: 150.0 + piControl: + min: 0.0 + max: 150.0 + ssp*: + min: 0.0 + max: 150.0 + sfriver: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + sftgif: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + sftlf: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + sftof: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + shrubFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + siage: + units: s + default: + min: 0.0 + max: 320000000.0 + experiments: + historical: + min: 0.0 + max: 320000000.0 + piControl: + min: 0.0 + max: 320000000.0 + ssp*: + min: 0.0 + max: 320000000.0 + siareaacrossline: + units: m2 s-1 + default: + min: -10000000.0 + max: 10000000.0 + experiments: + historical: + min: -10000000.0 + max: 10000000.0 + piControl: + min: -10000000.0 + max: 10000000.0 + ssp*: + min: -10000000.0 + max: 10000000.0 + siarean: + units: 1e6 km2 + default: + min: 0.0 + max: 30.0 + experiments: + historical: + min: 0.0 + max: 30.0 + piControl: + min: 0.0 + max: 30.0 + ssp*: + min: 0.0 + max: 30.0 + siareas: + units: 1e6 km2 + default: + min: 0.0 + max: 30.0 + experiments: + historical: + min: 0.0 + max: 30.0 + piControl: + min: 0.0 + max: 30.0 + ssp*: + min: 0.0 + max: 30.0 + siconc: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + siconca: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + sidconcdyn: + units: s-1 + default: + min: -1.0e-05 + max: 1.0e-05 + experiments: + historical: + min: -1.0e-05 + max: 1.0e-05 + piControl: + min: -1.0e-05 + max: 1.0e-05 + ssp*: + min: -1.0e-05 + max: 1.0e-05 + sidconcth: + units: s-1 + default: + min: -1.0e-05 + max: 1.0e-05 + experiments: + historical: + min: -1.0e-05 + max: 1.0e-05 + piControl: + min: -1.0e-05 + max: 1.0e-05 + ssp*: + min: -1.0e-05 + max: 1.0e-05 + sidivvel: + units: s-1 + default: + min: -1.0e-05 + max: 1.0e-05 + experiments: + historical: + min: -1.0e-05 + max: 1.0e-05 + piControl: + min: -1.0e-05 + max: 1.0e-05 + ssp*: + min: -1.0e-05 + max: 1.0e-05 + sidmassdyn: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassevapsubl: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassgrowthbot: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassgrowthsi: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassgrowthwat: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassmeltbot: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassmeltlat: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassmelttop: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmassth: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sidmasstranx: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + sidmasstrany: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + siextentn: + units: 1e6 km2 + default: + min: 0.0 + max: 30.0 + experiments: + historical: + min: 0.0 + max: 30.0 + piControl: + min: 0.0 + max: 30.0 + ssp*: + min: 0.0 + max: 30.0 + siextents: + units: 1e6 km2 + default: + min: 0.0 + max: 30.0 + experiments: + historical: + min: 0.0 + max: 30.0 + piControl: + min: 0.0 + max: 30.0 + ssp*: + min: 0.0 + max: 30.0 + sifb: + units: m + default: + min: -20.0 + max: 20.0 + experiments: + historical: + min: -20.0 + max: 20.0 + piControl: + min: -20.0 + max: 20.0 + ssp*: + min: -20.0 + max: 20.0 + siflfwbot: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + siflswdtop: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + siflswutop: + units: W m-2 + default: + min: -5000.0 + max: 5000.0 + experiments: + historical: + min: -5000.0 + max: 5000.0 + piControl: + min: -5000.0 + max: 5000.0 + ssp*: + min: -5000.0 + max: 5000.0 + simass: + units: kg m-2 + default: + min: 0.0 + max: 50000.0 + experiments: + historical: + min: 0.0 + max: 50000.0 + piControl: + min: 0.0 + max: 50000.0 + ssp*: + min: 0.0 + max: 50000.0 + simassacrossline: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + sisnconc: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + sisndmassmelt: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sisndmasssi: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sisndmasssnf: + units: kg m-2 s-1 + default: + min: -0.1 + max: 0.1 + experiments: + historical: + min: -0.1 + max: 0.1 + piControl: + min: -0.1 + max: 0.1 + ssp*: + min: -0.1 + max: 0.1 + sisnhc: + units: J m-2 + default: + min: 0.0 + max: 1000000000.0 + experiments: + historical: + min: 0.0 + max: 1000000000.0 + piControl: + min: 0.0 + max: 1000000000.0 + ssp*: + min: 0.0 + max: 1000000000.0 + sisnmassn: + units: kg m-2 + default: + min: 0.0 + max: 50000.0 + experiments: + historical: + min: 0.0 + max: 50000.0 + piControl: + min: 0.0 + max: 50000.0 + ssp*: + min: 0.0 + max: 50000.0 + sisnmasss: + units: kg m-2 + default: + min: 0.0 + max: 50000.0 + experiments: + historical: + min: 0.0 + max: 50000.0 + piControl: + min: 0.0 + max: 50000.0 + ssp*: + min: 0.0 + max: 50000.0 + sisnthick: + units: m + default: + min: 0.0 + max: 20.0 + experiments: + historical: + min: 0.0 + max: 20.0 + piControl: + min: 0.0 + max: 20.0 + ssp*: + min: 0.0 + max: 20.0 + sispeed: + units: m s-1 + default: + min: 0.0 + max: 150.0 + experiments: + historical: + min: 0.0 + max: 150.0 + piControl: + min: 0.0 + max: 150.0 + ssp*: + min: 0.0 + max: 150.0 + sistrxdtop: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + sistrxubot: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + sistrydtop: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + sistryubot: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + sitempbot: + units: K + default: + min: 220.0 + max: 280.0 + experiments: + historical: + min: 220.0 + max: 280.0 + piControl: + min: 220.0 + max: 280.0 + ssp*: + min: 220.0 + max: 280.0 + sitemptop: + units: K + default: + min: 220.0 + max: 280.0 + experiments: + historical: + min: 220.0 + max: 280.0 + piControl: + min: 220.0 + max: 280.0 + ssp*: + min: 220.0 + max: 280.0 + sithick: + units: m + default: + min: 0.0 + max: 20.0 + experiments: + historical: + min: 0.0 + max: 20.0 + piControl: + min: 0.0 + max: 20.0 + ssp*: + min: 0.0 + max: 20.0 + sitimefrac: + units: '1' + default: + min: 0.0 + max: 1.0 + experiments: + historical: + min: 0.0 + max: 1.0 + piControl: + min: 0.0 + max: 1.0 + ssp*: + min: 0.0 + max: 1.0 + siu: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + siv: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + sivol: + units: m + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + sivoln: + units: 1e3 km3 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + sivols: + units: 1e3 km3 + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + slthick: + units: m + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + snc: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + snw: + units: kg m-2 + default: + min: 0.0 + max: 50000.0 + experiments: + historical: + min: 0.0 + max: 50000.0 + piControl: + min: 0.0 + max: 50000.0 + ssp*: + min: 0.0 + max: 50000.0 + so: + units: '0.001' + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + sob: + units: '0.001' + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + soga: + units: '0.001' + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + somint: + units: g m-2 + default: + min: 0.0 + max: 500000.0 + experiments: + historical: + min: 0.0 + max: 500000.0 + piControl: + min: 0.0 + max: 500000.0 + ssp*: + min: 0.0 + max: 500000.0 + sos: + units: '0.001' + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + sosga: + units: '0.001' + default: + min: 0.0 + max: 50.0 + experiments: + historical: + min: 0.0 + max: 50.0 + piControl: + min: 0.0 + max: 50.0 + ssp*: + min: 0.0 + max: 50.0 + spco2: + units: Pa + default: + min: 0.0 + max: 300.0 + experiments: + historical: + min: 0.0 + max: 300.0 + piControl: + min: 0.0 + max: 300.0 + ssp*: + min: 0.0 + max: 300.0 + ta: + units: K + default: + min: 150.0 + max: 350.0 + experiments: + historical: + min: 150.0 + max: 350.0 + piControl: + min: 150.0 + max: 350.0 + ssp*: + min: 150.0 + max: 350.0 + talk: + units: mol m-3 + default: + min: 0.0 + max: 0.005 + experiments: + historical: + min: 0.0 + max: 0.005 + piControl: + min: 0.0 + max: 0.005 + ssp*: + min: 0.0 + max: 0.005 + tas: + units: K + default: + min: 180.0 + max: 330.0 + experiments: + historical: + min: 180.0 + max: 330.0 + piControl: + min: 180.0 + max: 325.0 + ssp*: + min: 180.0 + max: 335.0 + tasmax: + units: K + default: + min: 180.0 + max: 350.0 + experiments: + historical: + min: 180.0 + max: 350.0 + piControl: + min: 180.0 + max: 350.0 + ssp*: + min: 180.0 + max: 350.0 + tasmin: + units: K + default: + min: 150.0 + max: 340.0 + experiments: + historical: + min: 150.0 + max: 340.0 + piControl: + min: 150.0 + max: 340.0 + ssp*: + min: 150.0 + max: 340.0 + tauu: + units: Pa + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + tauuo: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + tauv: + units: Pa + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + tauvo: + units: N m-2 + default: + min: -10.0 + max: 10.0 + experiments: + historical: + min: -10.0 + max: 10.0 + piControl: + min: -10.0 + max: 10.0 + ssp*: + min: -10.0 + max: 10.0 + thetao: + units: degC + default: + min: -3.0 + max: 40.0 + experiments: + historical: + min: -3.0 + max: 40.0 + piControl: + min: -3.0 + max: 40.0 + ssp*: + min: -3.0 + max: 40.0 + thetaoga: + units: degC + default: + min: -3.0 + max: 40.0 + experiments: + historical: + min: -3.0 + max: 40.0 + piControl: + min: -3.0 + max: 40.0 + ssp*: + min: -3.0 + max: 40.0 + thkcello: + units: m + default: + min: 0.0 + max: 10000.0 + experiments: + historical: + min: 0.0 + max: 10000.0 + piControl: + min: 0.0 + max: 10000.0 + ssp*: + min: 0.0 + max: 10000.0 + tob: + units: degC + default: + min: -3.0 + max: 40.0 + experiments: + historical: + min: -3.0 + max: 40.0 + piControl: + min: -3.0 + max: 40.0 + ssp*: + min: -3.0 + max: 40.0 + tos: + units: degC + default: + min: -3.0 + max: 45.0 + experiments: + historical: + min: -3.0 + max: 45.0 + piControl: + min: -3.0 + max: 45.0 + ssp*: + min: -3.0 + max: 45.0 + tosga: + units: degC + default: + min: -3.0 + max: 45.0 + experiments: + historical: + min: -3.0 + max: 45.0 + piControl: + min: -3.0 + max: 45.0 + ssp*: + min: -3.0 + max: 45.0 + tossq: + units: degC2 + default: + min: 0.0 + max: 2500.0 + experiments: + historical: + min: 0.0 + max: 2500.0 + piControl: + min: 0.0 + max: 2500.0 + ssp*: + min: 0.0 + max: 2500.0 + tran: + units: kg m-2 s-1 + default: + min: -0.05 + max: 0.05 + experiments: + historical: + min: -0.05 + max: 0.05 + piControl: + min: -0.05 + max: 0.05 + ssp*: + min: -0.05 + max: 0.05 + treeFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + treeFracBdlDcd: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + treeFracBdlEvg: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + treeFracNdlDcd: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + treeFracNdlEvg: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + ts: + units: K + default: + min: 150.0 + max: 370.0 + experiments: + historical: + min: 150.0 + max: 370.0 + piControl: + min: 150.0 + max: 370.0 + ssp*: + min: 150.0 + max: 370.0 + tsl: + units: K + default: + min: 150.0 + max: 350.0 + experiments: + historical: + min: 150.0 + max: 350.0 + piControl: + min: 150.0 + max: 350.0 + ssp*: + min: 150.0 + max: 350.0 + ua: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + uas: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + umo: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + uo: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + va: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + vas: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + vegFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + vegHeight: + units: m + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + vmo: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + vo: + units: m s-1 + default: + min: -150.0 + max: 150.0 + experiments: + historical: + min: -150.0 + max: 150.0 + piControl: + min: -150.0 + max: 150.0 + ssp*: + min: -150.0 + max: 150.0 + volcello: + units: m3 + default: + min: 0.0 + max: 1.0e+16 + experiments: + historical: + min: 0.0 + max: 1.0e+16 + piControl: + min: 0.0 + max: 1.0e+16 + ssp*: + min: 0.0 + max: 1.0e+16 + wap: + units: Pa s-1 + default: + min: -20.0 + max: 20.0 + experiments: + historical: + min: -20.0 + max: 20.0 + piControl: + min: -20.0 + max: 20.0 + ssp*: + min: -20.0 + max: 20.0 + wetlandFrac: + units: '%' + default: + min: 0.0 + max: 100.0 + experiments: + historical: + min: 0.0 + max: 100.0 + piControl: + min: 0.0 + max: 100.0 + ssp*: + min: 0.0 + max: 100.0 + wfo: + units: kg m-2 s-1 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + wmo: + units: kg s-1 + default: + min: -5000000000000.0 + max: 5000000000000.0 + experiments: + historical: + min: -5000000000000.0 + max: 5000000000000.0 + piControl: + min: -5000000000000.0 + max: 5000000000000.0 + ssp*: + min: -5000000000000.0 + max: 5000000000000.0 + wo: + units: m s-1 + default: + min: -0.01 + max: 0.01 + experiments: + historical: + min: -0.01 + max: 0.01 + piControl: + min: -0.01 + max: 0.01 + ssp*: + min: -0.01 + max: 0.01 + zfull: + units: m + default: + min: 0.0 + max: 80000.0 + experiments: + historical: + min: 0.0 + max: 80000.0 + piControl: + min: 0.0 + max: 80000.0 + ssp*: + min: 0.0 + max: 80000.0 + zg: + units: m + default: + min: -500.0 + max: 80000.0 + experiments: + historical: + min: -500.0 + max: 80000.0 + piControl: + min: -500.0 + max: 80000.0 + ssp*: + min: -500.0 + max: 80000.0 + zg500: + units: m + default: + min: 4000.0 + max: 7000.0 + experiments: + historical: + min: 4000.0 + max: 7000.0 + piControl: + min: 4000.0 + max: 7000.0 + ssp*: + min: 4000.0 + max: 7000.0 + zooc: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + zoocos: + units: mol m-3 + default: + min: 0.0 + max: 0.1 + experiments: + historical: + min: 0.0 + max: 0.1 + piControl: + min: 0.0 + max: 0.1 + ssp*: + min: 0.0 + max: 0.1 + zos: + units: m + default: + min: -20.0 + max: 20.0 + experiments: + historical: + min: -20.0 + max: 20.0 + piControl: + min: -20.0 + max: 20.0 + ssp*: + min: -20.0 + max: 20.0 + zossq: + units: m2 + default: + min: 0.0 + max: 400.0 + experiments: + historical: + min: 0.0 + max: 400.0 + piControl: + min: 0.0 + max: 400.0 + ssp*: + min: 0.0 + max: 400.0 + zostoga: + units: m + default: + min: -20.0 + max: 20.0 + experiments: + historical: + min: -20.0 + max: 20.0 + piControl: + min: -20.0 + max: 20.0 + ssp*: + min: -20.0 + max: 20.0 diff --git a/src/access_moppy/utilities.py b/src/access_moppy/utilities.py index 3207f5b7..35dde5f7 100644 --- a/src/access_moppy/utilities.py +++ b/src/access_moppy/utilities.py @@ -2205,8 +2205,8 @@ def calculate_time_bounds( time = ds[time_coord] n_times = len(time) - if n_times < 2: - raise ValueError("Need at least 2 time points to infer time bounds") + if n_times < 1: + raise ValueError(f"Time coordinate '{time_coord}' is empty") # Compute only the 1-D time coordinate. Using .compute().values (rather than # plain .values) ensures that only the time coordinate's dask graph is @@ -2236,8 +2236,14 @@ def calculate_time_bounds( ) is_cftime = True - # Try to infer frequency - freq = _infer_frequency(time_values) + # Try to infer frequency. Single-point monthly outputs can still be bounded + # exactly when the label is already the midpoint of its monthly cell. + if n_times == 1: + freq = _infer_single_time_frequency(time_values[0], calendar, is_cftime) + if freq is None: + raise ValueError("Need at least 2 time points to infer time bounds") + else: + freq = _infer_frequency(time_values) # Initialize bounds array time_bnds = np.empty((n_times, 2), dtype=object if is_cftime else time_values.dtype) @@ -2320,6 +2326,45 @@ def _infer_frequency(time_values) -> Optional[str]: return "irregular" +def _infer_single_time_frequency( + time_value, calendar: str, is_cftime: bool +) -> Optional[str]: + """Infer frequency from a single timestamp when its position is unambiguous.""" + if _is_monthly_midpoint(time_value, calendar, is_cftime): + return "monthly" + return None + + +def _is_monthly_midpoint(time_value, calendar: str, is_cftime: bool) -> bool: + """Return True when *time_value* is exactly the midpoint of its month.""" + if is_cftime: + actual_calendar = ( + time_value.calendar if hasattr(time_value, "calendar") else calendar + ) + start = cftime.datetime( + time_value.year, + time_value.month, + 1, + calendar=actual_calendar, + ) + if time_value.month == 12: + end = cftime.datetime(time_value.year + 1, 1, 1, calendar=actual_calendar) + else: + end = cftime.datetime( + time_value.year, + time_value.month + 1, + 1, + calendar=actual_calendar, + ) + return time_value == start + (end - start) / 2 + + ts = pd.Timestamp(time_value) + start = pd.Timestamp(year=ts.year, month=ts.month, day=1) + end = start + pd.offsets.MonthBegin(1) + midpoint = start + (end - start) / 2 + return ts == midpoint + + def _calculate_monthly_bounds(time_values, calendar: str, is_cftime: bool): """Calculate bounds for monthly data.""" n_times = len(time_values) diff --git a/src/access_moppy/vocabulary_processors.py b/src/access_moppy/vocabulary_processors.py index 97301069..455c9ff6 100644 --- a/src/access_moppy/vocabulary_processors.py +++ b/src/access_moppy/vocabulary_processors.py @@ -42,6 +42,23 @@ _CMOR_CVS_CACHE: Optional[Dict[str, Any]] = None +def _cast_missing_value_to_data_dtype(value: Any, data_array: xr.DataArray) -> Any: + """Cast a missing-value marker to the data array dtype when possible. + + Keeping ``missing_value`` and ``_FillValue`` aligned with the variable dtype + avoids float64-vs-float32 sentinel drift (e.g., 1e20 vs 1.00000002e20). + """ + + try: + dtype = data_array.dtype + if np.issubdtype(dtype, np.floating) or np.issubdtype(dtype, np.integer): + return np.asarray(value, dtype=dtype)[()] + except (TypeError, ValueError): + pass + + return value + + def _load_cmor_cvs() -> Dict[str, Any]: """Load and cache the cmor-cvs.json CMIP7 controlled vocabulary. @@ -677,8 +694,12 @@ def standardize_missing_values(self, data_array, convert_existing: bool = True): xarray.DataArray: Data array with standardized missing values """ # Get the correct CMIP6 missing value - cmip_missing_value = self.get_cmip_missing_value() - cmip_fill_value = self.get_cmip_fill_value() + cmip_missing_value = _cast_missing_value_to_data_dtype( + self.get_cmip_missing_value(), data_array + ) + cmip_fill_value = _cast_missing_value_to_data_dtype( + self.get_cmip_fill_value(), data_array + ) # Create a shallow copy to avoid modifying the original (preserves dask arrays) result = data_array.copy(deep=False) @@ -1782,9 +1803,41 @@ def _get_variable_frequency(self) -> str: "frequency", self.cmip_table["Header"].get("frequency", "") ) - def _get_nominal_resolution(self) -> Optional[str]: - """Get nominal resolution from source metadata""" + def _get_nominal_resolution( + self, target_realm: Optional[str] = None + ) -> Optional[str]: + """Get nominal resolution from source metadata.""" + if target_realm: + self.target_realm = target_realm + realm = self.variable.get("modeling_realm") + if isinstance(realm, list): + realms = realm + elif isinstance(realm, str): + realms = realm.split() + else: + realms = [] + + if realms and len(realms) > 1: + if target_realm is None: + default_realm = realms[0] + warnings.warn( + f"Variable has multiple modeling realms: '{realms}'. " + f"No 'target_realm' specified, defaulting to '{default_realm}'. " + f"To suppress this warning, explicitly pass target_realm " + f"(one of: {realms})." + ) + realm = default_realm + elif target_realm not in realms: + raise ValueError( + f"target_realm '{target_realm}' not found in variable's modeling realms: '{realms}'. " + f"Must be one of: {realms}." + ) + else: + realm = target_realm + elif realms: + realm = realms[0] + try: model_components = self.source.get("model_component", {}) return model_components.get(realm, {}).get("native_nominal_resolution") @@ -2067,8 +2120,12 @@ def standardize_missing_values(self, data_array, convert_existing: bool = True): xarray.DataArray: Data array with standardized missing values """ # Get the correct CMIP7 missing value - cmip_missing_value = self.get_cmip_missing_value() - cmip_fill_value = self.get_cmip_fill_value() + cmip_missing_value = _cast_missing_value_to_data_dtype( + self.get_cmip_missing_value(), data_array + ) + cmip_fill_value = _cast_missing_value_to_data_dtype( + self.get_cmip_fill_value(), data_array + ) # Create a shallow copy to avoid modifying the original (preserves dask arrays) result = data_array.copy(deep=False) diff --git a/tests/integration/test_full_cmorisation.py b/tests/integration/test_full_cmorisation.py index b73ef3c1..e60f125e 100644 --- a/tests/integration/test_full_cmorisation.py +++ b/tests/integration/test_full_cmorisation.py @@ -394,17 +394,13 @@ def test_cmorisation_variable( output_files ), f"No output files found for {variable_name} in {output_dir}" - # Validate output with the configured backend - # Skip compliance validation for Omon and Ofx (ocean fixed fields - # use non-standard grid structures not validated by PrePARE/WCRP) - if compound_table not in ("Omon", "Ofx"): - self._validate_output_compliance( - output_files[0], - variable_name, - table_path, - cmip_version, - compliance_validation_tool, - ) + self._validate_output_compliance( + output_files[0], + variable_name, + table_path, + cmip_version, + compliance_validation_tool, + ) except Exception as e: pytest.fail( diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 7c0ca085..1d462b6c 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -1159,6 +1159,7 @@ def test_write_repacks_cmip7_output(self, cmoriser_with_dataset, temp_dir): with ( patch("psutil.virtual_memory") as mock_mem, patch("access_moppy.base.subprocess.run") as mock_run, + patch("access_moppy.base.validate_cmip7_output"), ): mock_mem.return_value = MagicMock( total=32 * 1024**3, diff --git a/tests/unit/test_batch_report.py b/tests/unit/test_batch_report.py index b25629aa..86b753b1 100644 --- a/tests/unit/test_batch_report.py +++ b/tests/unit/test_batch_report.py @@ -7,7 +7,9 @@ from datetime import datetime, timedelta from pathlib import Path +import numpy as np import pytest +import xarray as xr from access_moppy import batch_report from access_moppy.batch_cmoriser import SIDECAR_FILENAME, finalize_monitor @@ -300,6 +302,35 @@ def test_cli_loads_config_file(tmp_path: Path) -> None: assert json.loads(output.read_text())["experiment_id"] == "historical" +def test_cli_skip_qc_omits_qc_section(tmp_path: Path) -> None: + """The report CLI forwards --skip-qc into report generation.""" + db_path = tmp_path / "cmor_tasks.db" + _write_qc_test_file( + tmp_path / "tas.nc", + "tas", + "historical", + [285.0], + ) + + with TaskTracker(db_path) as tracker: + tracker.add_task("Amon.tas", "historical") + tracker.mark_completed("Amon.tas", "historical") + + output = tmp_path / "report.json" + rc = batch_report.main( + [ + "--db", + str(db_path), + "--output", + str(output), + "--skip-qc", + ] + ) + + assert rc == 0 + assert "qc" not in json.loads(output.read_text()) + + def test_build_batch_report_handles_old_tracker_schema(tmp_path: Path) -> None: """Reports can still be generated from pre-PBS-metadata tracker DBs.""" db_path = tmp_path / "old.db" @@ -354,3 +385,292 @@ def test_finalize_monitor_writes_report_before_removing_sidecar(tmp_path: Path) assert report["success"] is True assert report["monitor"]["pbs_job_id"] == "monitor.123" assert report["script_dir"] == str(script_dir) + + +def _write_qc_test_file( + path: Path, + variable_id: str, + experiment_id: str, + values: list[float], + units: str = "K", +) -> None: + """Write a test NetCDF file for QC testing.""" + ds = xr.Dataset( + { + variable_id: xr.DataArray( + np.asarray(values, dtype=float), + dims=["time"], + attrs={"units": units}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": variable_id, + "experiment_id": experiment_id, + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + ds.close() + + +@pytest.mark.unit +def test_run_qc_on_output_folder_with_all_passing_files(tmp_path: Path) -> None: + """QC reports success when all files pass validation.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + # Create a single passing file to avoid xarray caching issues + _write_qc_test_file( + output_folder / "tas_pass.nc", + "tas", + "historical", + [285.0, 287.5, 289.0], + ) + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is not None + assert results["total"] == 1 + assert results["passed"] == 1 + assert results["failed"] == 0 + assert results["failures"] == [] + + +@pytest.mark.unit +def test_run_qc_on_output_folder_with_failing_files(tmp_path: Path) -> None: + """QC reports failures when files fail validation.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + # Create a failing file (tas out of range for piControl) + _write_qc_test_file( + output_folder / "tas_fail.nc", + "tas", + "piControl", + [326.5], # Above 325K limit for piControl + ) + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is not None + assert results["passed"] == 0 + assert results["failed"] == 1 + assert results["total"] == 1 + assert len(results["failures"]) == 1 + assert results["failures"][0]["variable_id"] == "tas" + assert results["failures"][0]["experiment_id"] == "piControl" + assert "error" in results["failures"][0] + + +@pytest.mark.unit +def test_run_qc_on_output_folder_with_mixed_pass_fail(tmp_path: Path) -> None: + """QC correctly tallies both passing and failing files.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + # Create passing file + _write_qc_test_file( + output_folder / "tas_pass.nc", + "tas", + "historical", + [285.0], + ) + + # Create failing file + _write_qc_test_file( + output_folder / "tas_fail.nc", + "tas", + "piControl", + [326.5], + ) + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is not None + assert results["passed"] == 1 + assert results["failed"] == 1 + assert results["total"] == 2 + assert len(results["failures"]) == 1 + + +@pytest.mark.unit +def test_run_qc_on_output_folder_respects_moppy_skip_qc_env_var( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """QC returns None when MOPPY_SKIP_QC=1 environment variable is set.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + _write_qc_test_file( + output_folder / "tas.nc", + "tas", + "historical", + [285.0], + ) + + monkeypatch.setenv("MOPPY_SKIP_QC", "1") + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is None + + +@pytest.mark.unit +def test_run_qc_on_output_folder_with_no_nc_files(tmp_path: Path) -> None: + """QC returns None when no .nc files are found.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is None + + +@pytest.mark.unit +def test_run_qc_on_output_folder_with_nested_files(tmp_path: Path) -> None: + """QC finds and validates files in nested subdirectories.""" + output_folder = tmp_path / "output" + nested = output_folder / "deep" / "nested" / "path" + nested.mkdir(parents=True) + + _write_qc_test_file( + nested / "tas.nc", + "tas", + "historical", + [285.0], + ) + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results is not None + assert results["total"] == 1 + assert results["passed"] == 1 + + +@pytest.mark.unit +def test_build_batch_report_includes_qc_section_by_default(tmp_path: Path) -> None: + """build_batch_report includes QC results by default.""" + db_path = tmp_path / "cmor_tasks.db" + output_folder = tmp_path / "output" + output_folder.mkdir() + + # Create a valid test file + _write_qc_test_file( + output_folder / "tas.nc", + "tas", + "historical", + [285.0], + ) + + with TaskTracker(db_path) as tracker: + tracker.add_task("Amon.tas", "historical") + tracker.mark_completed("Amon.tas", "historical") + + report = batch_report.build_batch_report( + db_path, + output_folder=output_folder, + skip_qc=False, + ) + + assert "qc" in report + assert report["qc"]["passed"] == 1 + assert report["qc"]["failed"] == 0 + assert report["qc"]["total"] == 1 + + +@pytest.mark.unit +def test_build_batch_report_omits_qc_section_when_skip_qc_true(tmp_path: Path) -> None: + """build_batch_report omits QC results when skip_qc=True.""" + db_path = tmp_path / "cmor_tasks.db" + output_folder = tmp_path / "output" + output_folder.mkdir() + + _write_qc_test_file( + output_folder / "tas.nc", + "tas", + "historical", + [285.0], + ) + + with TaskTracker(db_path) as tracker: + tracker.add_task("Amon.tas", "historical") + tracker.mark_completed("Amon.tas", "historical") + + report = batch_report.build_batch_report( + db_path, + output_folder=output_folder, + skip_qc=True, + ) + + assert "qc" not in report + + +@pytest.mark.unit +def test_write_batch_report_passes_skip_qc_to_build( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """write_batch_report correctly passes skip_qc parameter to build_batch_report.""" + db_path = tmp_path / "cmor_tasks.db" + output_folder = tmp_path / "output" + output_folder.mkdir() + + _write_qc_test_file( + output_folder / "tas.nc", + "tas", + "historical", + [285.0], + ) + + with TaskTracker(db_path) as tracker: + tracker.add_task("Amon.tas", "historical") + tracker.mark_completed("Amon.tas", "historical") + + # Test with skip_qc=False (should include QC) + batch_report.write_batch_report( + db_path, + output_folder=output_folder, + skip_qc=False, + ) + + report = json.loads((db_path.parent / "moppy_batch_report.json").read_text()) + assert "qc" in report + + # Clean up report + (db_path.parent / "moppy_batch_report.json").unlink() + + # Test with skip_qc=True (should omit QC) + batch_report.write_batch_report( + db_path, + output_folder=output_folder, + skip_qc=True, + ) + + report = json.loads((db_path.parent / "moppy_batch_report.json").read_text()) + assert "qc" not in report + + +@pytest.mark.unit +def test_run_qc_on_output_folder_includes_detailed_failure_info(tmp_path: Path) -> None: + """QC failure details include ranges and units where available.""" + output_folder = tmp_path / "output" + output_folder.mkdir() + + _write_qc_test_file( + output_folder / "tas_fail.nc", + "tas", + "piControl", + [326.5], + ) + + results = batch_report._run_qc_on_output_folder(output_folder) + + assert results["failed"] == 1 + failure = results["failures"][0] + assert "observed_range" in failure + assert "allowed_range" in failure + assert "units" in failure + assert failure["units"] == "K" + assert failure["observed_range"][0] == 326.5 + assert failure["observed_range"][1] == 326.5 + assert failure["allowed_range"][0] == 180.0 + assert failure["allowed_range"][1] == 325.0 diff --git a/tests/unit/test_cmip7_qc.py b/tests/unit/test_cmip7_qc.py new file mode 100644 index 00000000..bc6da252 --- /dev/null +++ b/tests/unit/test_cmip7_qc.py @@ -0,0 +1,1103 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import numpy as np +import pytest +import xarray as xr + +from access_moppy.base import CMORiser +from access_moppy.qc import validate_cmip7_output +from access_moppy.qc.cmip7 import ( + _iter_missing_sentinels, + _load_esm16_mapping_variables, + _load_mapping_variable_ranges, + _load_rules, + _load_unit_envelopes, + _mask_missing_sentinels_for_qc, + _resolve_range_rule, + _resolve_range_rule_from_mapping_definition, + _select_experiment_rule, + _select_output_variable, + validate_cmip7_output_detailed, +) +from access_moppy.qc.cmip7 import ( + main as qc_main, +) + + +def _write_cmip7_output( + tmp_path: Path, + *, + values, + experiment_id: str, + variable_id: str = "tas", + source_id: str = "ACCESS-ESM1-6", + branded_variable: str | None = None, + units: str = "K", + filename: str = "cmip7_output.nc", +) -> Path: + path = tmp_path / filename + data_var_name = branded_variable or variable_id + ds = xr.Dataset( + { + data_var_name: xr.DataArray( + np.asarray(values, dtype=float), + dims=["time"], + attrs={"units": units}, + ) + }, + coords={"time": xr.DataArray(np.arange(len(values)), dims=["time"])}, + attrs={ + "mip_era": "CMIP7", + "variable_id": variable_id, + "branded_variable": data_var_name, + "experiment_id": experiment_id, + "source_id": source_id, + "units": units, + }, + ) + ds.to_netcdf(path) + return path + + +@pytest.mark.unit +def test_iter_missing_sentinels_from_attrs(): + """Test that sentinels are collected from DataArray attrs.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0]), + attrs={"missing_value": 1e20, "_FillValue": 9.96921e36}, + ) + + sentinels = _iter_missing_sentinels(da) + + assert 1e20 in sentinels + assert 9.96921e36 in sentinels + assert len(sentinels) == 2 + + +@pytest.mark.unit +def test_iter_missing_sentinels_from_encoding(): + """Test that sentinels are collected from DataArray encoding.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0]), + attrs={}, + ) + da.encoding["_FillValue"] = 1e20 + + sentinels = _iter_missing_sentinels(da) + + assert 1e20 in sentinels + + +@pytest.mark.unit +def test_iter_missing_sentinels_ignores_non_finite_values(): + """Test that non-finite sentinels (inf, nan) are ignored.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0]), + attrs={"missing_value": np.inf, "_FillValue": np.nan}, + ) + + sentinels = _iter_missing_sentinels(da) + + assert len(sentinels) == 0 + + +@pytest.mark.unit +def test_iter_missing_sentinels_converts_non_numeric_to_float(): + """Test that non-numeric metadata values are safely ignored.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0]), + attrs={"missing_value": "not_a_number"}, + ) + + sentinels = _iter_missing_sentinels(da) + + assert len(sentinels) == 0 + + +@pytest.mark.unit +def test_iter_missing_sentinels_with_array_values(): + """Test that array values in metadata are flattened and processed.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0]), + attrs={"missing_value": np.array([1e20, 2e20])}, + ) + + sentinels = _iter_missing_sentinels(da) + + assert 1e20 in sentinels + assert 2e20 in sentinels + + +@pytest.mark.unit +def test_mask_missing_sentinels_for_qc_returns_unmodified_when_no_sentinels(): + """Early return when no sentinels found.""" + da = xr.DataArray(np.array([1.0, 2.0, 3.0])) + + result = _mask_missing_sentinels_for_qc(da) + + assert result is da # Should return the same object + + +@pytest.mark.unit +def test_mask_missing_sentinels_for_qc_masks_with_tolerance(): + """Sentinels are masked using tolerance-aware matching.""" + da = xr.DataArray( + np.array([280.0, 1.00000002e20, 285.0], dtype=np.float64), + attrs={"_FillValue": 1e20}, + ) + + result = _mask_missing_sentinels_for_qc(da) + + # Masked value should become NaN + assert np.isnan(result.values[1]) + assert result.values[0] == 280.0 + assert result.values[2] == 285.0 + + +@pytest.mark.unit +def test_validate_cmip7_output_tas_passes_for_historical_range(tmp_path): + path = _write_cmip7_output( + tmp_path, values=[285.0, 287.5, 289.0], experiment_id="historical" + ) + + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_allows_tiny_negative_noise_at_zero_bound(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[-6e-24, 0.5, 10.0], + experiment_id="historical", + variable_id="arag", + units="mol m-3", + ) + + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_tas_fails_for_picontrol_range(tmp_path): + path = _write_cmip7_output( + tmp_path, values=[324.0, 326.5], experiment_id="piControl" + ) + + with pytest.raises(ValueError, match="tas.*piControl.*outside allowed range"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_cmoriser_write_runs_cmip7_qc_after_repack(tmp_path): + vocab = Mock() + vocab.mip_era = "CMIP7" + vocab.compound_name = "atmos.tas" + vocab.generate_filename = Mock(return_value="tas.nc") + vocab.get_required_attribute_names = Mock(return_value=[]) + + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.asarray([285.0, 286.0], dtype=float), + dims=["time"], + attrs={"units": "K"}, + coords={"time": xr.DataArray([0, 1], dims=["time"])}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "branded_variable": "tas", + "experiment_id": "historical", + "units": "K", + }, + ) + + cmoriser = CMORiser( + input_data=ds, + output_path=str(tmp_path), + vocab=vocab, + variable_mapping={"tas": {"dimensions": {"time": "time"}}}, + compound_name="Amon.tas", + ) + cmoriser.ds = ds + + with ( + patch.object(cmoriser, "_repack_cmip7_output") as repack_mock, + patch("access_moppy.base.validate_cmip7_output") as qc_mock, + ): + cmoriser.write() + + repack_mock.assert_called_once() + qc_mock.assert_called_once() + assert qc_mock.call_args.args[0] == tmp_path / "tas.nc" + + +@pytest.mark.unit +def test_qc_cli_main_returns_zero_when_all_files_pass(tmp_path, capsys): + path = _write_cmip7_output( + tmp_path, + values=[285.0, 286.0, 287.0], + experiment_id="historical", + filename="passing_only.nc", + ) + + code = qc_main([str(path)]) + + captured = capsys.readouterr() + assert code == 0 + assert "PASS" in captured.out + + +@pytest.mark.unit +def test_qc_cli_main_returns_one_when_any_file_fails(tmp_path, capsys): + passing = _write_cmip7_output( + tmp_path, + values=[285.0, 286.0, 287.0], + experiment_id="historical", + filename="passing.nc", + ) + failing = _write_cmip7_output( + tmp_path, + values=[324.0, 326.5], + experiment_id="piControl", + filename="failing.nc", + ) + + code = qc_main([str(passing), str(failing)]) + + captured = capsys.readouterr() + assert code == 1 + assert "PASS" in captured.out + assert "FAIL" in captured.out + + +@pytest.mark.unit +def test_esm16_mapping_inventory_is_loaded_for_all_variables(): + mapping = _load_esm16_mapping_variables() + # ACCESS-ESM1-6 mapping currently defines 293 variables across realms. + assert len(mapping) >= 293 + + +@pytest.mark.unit +def test_all_esm16_mapped_variables_have_explicit_qc_rule_entries(): + mapped = set(_load_esm16_mapping_variables()) + configured = set(_load_rules()) + assert mapped.issubset(configured) + + +@pytest.mark.unit +def test_validate_cmip7_output_allows_positive_up_mapping_signs(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[-0.01, 0.02], + experiment_id="historical", + variable_id="evspsblsoi", + units="kg m-2 s-1", + filename="evspsblsoi_cross_zero.nc", + ) + + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_allows_positive_down_mapping_signs(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[1.0, 2.0], + experiment_id="historical", + variable_id="rldscs", + units="W m-2", + filename="rldscs_positive.nc", + ) + + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_applies_variable_rules_for_other_sources(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[-0.1, 0.2], + experiment_id="historical", + variable_id="evspsblsoi", + source_id="ACCESS-CM3", + units="kg m-2 s-1", + filename="other_source.nc", + ) + + with pytest.raises(ValueError, match="outside allowed range"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_applies_unit_envelope_for_mapped_variable(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[2.5e7, 2.6e7], + experiment_id="historical", + variable_id="psl", + units="Pa", + filename="psl_out_of_range.nc", + ) + + with pytest.raises( + ValueError, + match="psl.*outside allowed range", + ): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_validates_units_against_mapping(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[100000.0, 100500.0], + experiment_id="historical", + variable_id="psl", + units="hPa", + filename="psl_bad_units.nc", + ) + + with pytest.raises(ValueError, match="expected units .*ACCESS-ESM1-6 mapping"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_select_output_variable_ignores_bounds_variables(tmp_path): + """_bnds variables should not prevent identifying the main variable.""" + path = tmp_path / "tasmax_with_bnds.nc" + ds = xr.Dataset( + { + "tasmax": xr.DataArray( + np.array([310.0, 312.0]), + dims=["time"], + attrs={"units": "K"}, + ), + "lat_bnds": xr.DataArray(np.zeros((2, 2)), dims=["lat", "bnds"]), + "lon_bnds": xr.DataArray(np.zeros((2, 2)), dims=["lon", "bnds"]), + "time_bnds": xr.DataArray(np.zeros((2, 2)), dims=["time", "bnds"]), + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tasmax", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + # Should not raise — tasmax must be identified despite the *_bnds variables + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_requires_variable_id(tmp_path): + """Validation fails if variable_id attribute is missing.""" + path = tmp_path / "no_var_id.nc" + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([285.0, 286.0]), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + + with pytest.raises(ValueError, match="variable_id"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_requires_experiment_id(tmp_path): + """Validation fails if experiment_id attribute is missing.""" + path = tmp_path / "no_exp_id.nc" + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([285.0, 286.0]), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + + with pytest.raises(ValueError, match="experiment_id"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_detects_all_missing_values(tmp_path): + """Validation fails when all data is missing/NaN.""" + path = _write_cmip7_output( + tmp_path, + values=[np.nan, np.nan], + experiment_id="historical", + filename="all_nan.nc", + ) + + with pytest.raises(ValueError, match="missing"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_detects_infinity_values(tmp_path): + """Validation fails when data contains infinity.""" + path = _write_cmip7_output( + tmp_path, + values=[285.0, np.inf], + experiment_id="historical", + filename="with_inf.nc", + ) + + with pytest.raises(ValueError, match="infinity"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_masks_rounded_fill_values_in_range_checks(tmp_path): + """Rounded float32 fill values (e.g. 1.00000002e20) are ignored in QC.""" + path = tmp_path / "hur_with_rounded_fill.nc" + rounded_fill = float(np.float32(1e20)) + + ds = xr.Dataset( + { + "hur": xr.DataArray( + np.array([50.0, rounded_fill], dtype=np.float64), + dims=["time"], + attrs={"units": "%", "_FillValue": 1e20, "missing_value": 1e20}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "hur", + "branded_variable": "hur", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + "units": "%", + }, + ) + ds.to_netcdf(path) + + validate_cmip7_output(path) + + result = validate_cmip7_output_detailed(path) + assert result.passed is True + assert result.observed_min == pytest.approx(50.0) + assert result.observed_max == pytest.approx(50.0) + + +@pytest.mark.unit +def test_validate_cmip7_output_masks_multiple_sentinels_in_range_checks(tmp_path): + """Multiple sentinels (missing_value and _FillValue) are both masked.""" + path = tmp_path / "tas_with_multiple_sentinels.nc" + + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([280.0, 1e20, 285.0], dtype=np.float64), + dims=["time"], + attrs={ + "units": "K", + "missing_value": 1e20, + }, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "branded_variable": "tas", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + "units": "K", + }, + ) + ds.to_netcdf(path) + + validate_cmip7_output(path) + + result = validate_cmip7_output_detailed(path) + assert result.passed is True + assert result.observed_min == pytest.approx(280.0) + assert result.observed_max == pytest.approx(285.0) + + +@pytest.mark.unit +def test_validate_cmip7_output_masks_no_sentinels_early_return(tmp_path): + """When no sentinels are present, masking is skipped.""" + path = tmp_path / "tas_no_sentinels.nc" + + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([280.0, 285.0, 290.0], dtype=np.float64), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "branded_variable": "tas", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + "units": "K", + }, + ) + ds.to_netcdf(path) + + validate_cmip7_output(path) + + result = validate_cmip7_output_detailed(path) + assert result.passed is True + assert result.observed_min == pytest.approx(280.0) + assert result.observed_max == pytest.approx(290.0) + + +@pytest.mark.unit +def test_validate_cmip7_output_masks_encoding_sentinels(tmp_path): + """Sentinels from encoding (not just attrs) are detected and masked.""" + path = tmp_path / "tas_encoding_sentinels.nc" + + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([280.0, 1e20], dtype=np.float64), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "branded_variable": "tas", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + "units": "K", + }, + ) + # Add sentinel to encoding instead of attrs + ds["tas"].encoding["_FillValue"] = 1e20 + ds.to_netcdf(path) + + validate_cmip7_output(path) + + result = validate_cmip7_output_detailed(path) + assert result.passed is True + assert result.observed_min == pytest.approx(280.0) + + +@pytest.mark.unit +def test_validate_cmip7_output_masks_non_numeric_metadata(tmp_path): + """Non-numeric metadata values in attrs/encoding are safely ignored.""" + path = tmp_path / "tas_non_numeric_metadata.nc" + + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([280.0, 285.0], dtype=np.float64), + dims=["time"], + attrs={ + "units": "K", + "missing_value": "invalid_string", + }, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "branded_variable": "tas", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + "units": "K", + }, + ) + ds.to_netcdf(path) + + validate_cmip7_output(path) + + result = validate_cmip7_output_detailed(path) + assert result.passed is True + assert result.observed_min == pytest.approx(280.0) + assert result.observed_max == pytest.approx(285.0) + + +@pytest.mark.unit +def test_validate_cmip7_output_experiment_pattern_matching(tmp_path): + """Experiment matching falls back to wildcard patterns - ssp370 uses ssp* rules.""" + # ssp370 should match ssp* pattern and pass with values in [180-335] range + path = _write_cmip7_output( + tmp_path, + values=[330.0], + experiment_id="ssp370", + filename="ssp370.nc", + ) + + # ssp370 with 330K is within the ssp* range (180-335), so should pass + validate_cmip7_output(path) + + # But 335.5K should fail + path_fail = _write_cmip7_output( + tmp_path, + values=[335.5], + experiment_id="ssp370", + filename="ssp370_fail.nc", + ) + with pytest.raises(ValueError, match="outside allowed range"): + validate_cmip7_output(path_fail) + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_passes_without_rule_for_unconfigured_variable( + tmp_path, +): + path = _write_cmip7_output( + tmp_path, + values=[1.0, 2.0], + experiment_id="historical", + variable_id="customvar", + source_id="ACCESS-CM3", + units="1", + filename="customvar.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is True + assert result.variable_id == "customvar" + assert result.experiment_id == "historical" + assert result.error is None + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_ignores_positive_sign_metadata(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[-0.01, 0.02], + experiment_id="historical", + variable_id="evspsblsoi", + units="kg m-2 s-1", + filename="evspsblsoi_detailed.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is True + assert result.variable_id == "evspsblsoi" + assert result.experiment_id == "historical" + assert result.error is None + assert result.observed_min == -0.01 + assert result.observed_max == 0.02 + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_reports_units_mismatch(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[285.0, 286.0], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-CM3", + units="degC", + filename="tas_bad_units_detailed.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert result.variable_id == "tas" + assert result.experiment_id == "historical" + assert result.units == "degC" + assert "Expected units" in result.error + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_returns_range_metadata_on_success(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[285.0, 287.0], + experiment_id="historical", + filename="tas_detailed_success.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is True + assert result.units == "K" + assert result.observed_min == 285.0 + assert result.observed_max == 287.0 + assert result.allowed_min is not None + assert result.allowed_max is not None + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_reports_unexpected_selection_error(tmp_path): + path = tmp_path / "ambiguous.nc" + ds = xr.Dataset( + { + "tas": xr.DataArray(np.array([285.0]), dims=["time"], attrs={"units": "K"}), + "pr": xr.DataArray( + np.array([1.0]), + dims=["time"], + attrs={"units": "kg m-2 s-1"}, + ), + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "unknown_var", + "experiment_id": "historical", + "source_id": "ACCESS-CM3", + }, + ) + ds.to_netcdf(path) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert result.error.startswith("Unexpected error: CMIP7 QC could not determine") + + +@pytest.mark.unit +def test_select_experiment_rule_returns_none_when_no_match(): + selected = _select_experiment_rule({"ssp*": {"min": 0, "max": 1}}, "historical") + + assert selected is None + + +@pytest.mark.unit +def test_resolve_range_rule_uses_default_when_no_experiment_match(): + with patch( + "access_moppy.qc.cmip7._load_rules", + return_value={"foo": {"default": {"units": "1", "min": 1, "max": 2}}}, + ): + rule = _resolve_range_rule("foo", "unknown") + + assert rule is not None + assert rule.rule_name == "default" + assert rule.minimum == 1.0 + assert rule.maximum == 2.0 + + +@pytest.mark.unit +def test_resolve_range_rule_returns_none_when_min_or_max_missing(): + with patch( + "access_moppy.qc.cmip7._load_rules", + return_value={"foo": {"default": {"units": "1", "min": 1}}}, + ): + rule = _resolve_range_rule("foo", "historical") + + assert rule is None + + +@pytest.mark.unit +def test_load_unit_envelopes_and_mapping_ranges_defaults_to_empty_dict(): + with patch("access_moppy.qc.cmip7._load_qc_config", return_value={}): + _load_unit_envelopes.cache_clear() + _load_mapping_variable_ranges.cache_clear() + envelopes = _load_unit_envelopes() + ranges = _load_mapping_variable_ranges() + + assert envelopes == {} + assert ranges == {} + + +@pytest.mark.unit +def test_resolve_range_rule_from_mapping_definition_returns_none_without_units(): + with ( + patch("access_moppy.qc.cmip7._load_mapping_variable_ranges", return_value={}), + patch( + "access_moppy.qc.cmip7._load_unit_envelopes", + return_value={"K": {"min": 1, "max": 2}}, + ), + ): + rule = _resolve_range_rule_from_mapping_definition("tas", "historical", {}) + + assert rule is None + + +@pytest.mark.unit +def test_resolve_range_rule_from_mapping_definition_uses_override_range_and_units(): + with ( + patch( + "access_moppy.qc.cmip7._load_mapping_variable_ranges", + return_value={"tas": {"units": "degC", "min": -80, "max": 60}}, + ), + patch("access_moppy.qc.cmip7._load_unit_envelopes", return_value={}), + ): + rule = _resolve_range_rule_from_mapping_definition( + "tas", + "historical", + {"units": "K"}, + ) + + assert rule is not None + assert rule.units == "degC" + assert rule.minimum == -80.0 + assert rule.maximum == 60.0 + + +@pytest.mark.unit +def test_resolve_range_rule_from_mapping_definition_returns_none_when_envelope_missing(): + with ( + patch("access_moppy.qc.cmip7._load_mapping_variable_ranges", return_value={}), + patch("access_moppy.qc.cmip7._load_unit_envelopes", return_value={}), + ): + rule = _resolve_range_rule_from_mapping_definition( + "tas", + "historical", + {"units": "K"}, + ) + + assert rule is None + + +@pytest.mark.unit +def test_resolve_range_rule_from_mapping_definition_uses_unit_envelope_when_no_override_range(): + with ( + patch( + "access_moppy.qc.cmip7._load_mapping_variable_ranges", + return_value={"tas": {"units": "K"}}, + ), + patch( + "access_moppy.qc.cmip7._load_unit_envelopes", + return_value={"K": {"min": 200.0, "max": 340.0}}, + ), + ): + rule = _resolve_range_rule_from_mapping_definition( + "tas", + "historical", + {"units": "K"}, + ) + + assert rule is not None + assert rule.minimum == 200.0 + assert rule.maximum == 340.0 + + +@pytest.mark.unit +def test_select_output_variable_returns_single_non_bounds_variable(): + ds = xr.Dataset( + { + "time_bnds": xr.DataArray(np.zeros((2, 2)), dims=["time", "bnds"]), + "foo": xr.DataArray(np.array([1.0, 2.0]), dims=["time"]), + } + ) + + assert _select_output_variable(ds, attrs={}) == "foo" + + +@pytest.mark.unit +def test_validate_cmip7_output_returns_early_when_rule_unavailable_for_mapping( + tmp_path, +): + path = _write_cmip7_output( + tmp_path, + values=[1.0, 2.0], + experiment_id="historical", + variable_id="unknown_mapped_var", + units="1", + filename="no_rule_mapping.nc", + ) + + with ( + patch("access_moppy.qc.cmip7._resolve_range_rule", return_value=None), + patch( + "access_moppy.qc.cmip7._load_esm16_mapping_variables", + return_value={"unknown_mapped_var": {"units": "1"}}, + ), + patch( + "access_moppy.qc.cmip7._resolve_range_rule_from_mapping_definition", + return_value=None, + ), + ): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_raises_units_mismatch_for_explicit_rule(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[280.0, 281.0], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-CM3", + units="degC", + filename="units_mismatch_simple_rule.nc", + ) + + with pytest.raises(ValueError, match="expected units 'K', found 'degC'"): + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_returns_for_all_nan_after_masking(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[np.nan, np.nan], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-CM3", + units="K", + filename="all_nan_non_mapping.nc", + ) + + # Non-ACCESS-ESM1-6 source skips mapping checks, so NaN extrema should + # trigger the early-return branch. + validate_cmip7_output(path) + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_requires_variable_id(tmp_path): + path = tmp_path / "no_var_id_detailed.nc" + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([285.0, 286.0]), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "experiment_id": "historical", + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert "variable_id" in (result.error or "") + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_requires_experiment_id(tmp_path): + path = tmp_path / "no_exp_id_detailed.nc" + ds = xr.Dataset( + { + "tas": xr.DataArray( + np.array([285.0, 286.0]), + dims=["time"], + attrs={"units": "K"}, + ) + }, + attrs={ + "mip_era": "CMIP7", + "variable_id": "tas", + "source_id": "ACCESS-ESM1-6", + }, + ) + ds.to_netcdf(path) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert "experiment_id" in (result.error or "") + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_captures_mapping_check_value_error(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[285.0, 286.0], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-ESM1-6", + units="K", + filename="mapping_error_detailed.nc", + ) + + with patch( + "access_moppy.qc.cmip7._validate_esm16_mapping_checks", + side_effect=ValueError("mapping guard failed"), + ): + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert result.error == "mapping guard failed" + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_uses_mapping_fallback_rule(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[281.0, 282.0], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-ESM1-6", + units="K", + filename="mapping_fallback_rule_detailed.nc", + ) + + fallback_rule = Mock(units="K", minimum=270.0, maximum=300.0, rule_name="fallback") + + with ( + patch("access_moppy.qc.cmip7._resolve_range_rule", return_value=None), + patch( + "access_moppy.qc.cmip7._load_esm16_mapping_variables", + return_value={"tas": {"units": "K"}}, + ), + patch("access_moppy.qc.cmip7._validate_esm16_mapping_checks"), + patch( + "access_moppy.qc.cmip7._resolve_range_rule_from_mapping_definition", + return_value=fallback_rule, + ) as fallback_mock, + ): + result = validate_cmip7_output_detailed(path) + + assert result.passed is True + assert result.allowed_min == 270.0 + assert result.allowed_max == 300.0 + fallback_mock.assert_called_once() + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_returns_pass_for_all_nan_non_mapping(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[np.nan, np.nan], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-CM3", + units="K", + filename="all_nan_non_mapping_detailed.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is True + assert result.units == "K" + + +@pytest.mark.unit +def test_validate_cmip7_output_detailed_reports_range_failure(tmp_path): + path = _write_cmip7_output( + tmp_path, + values=[100.0, 101.0], + experiment_id="historical", + variable_id="tas", + source_id="ACCESS-CM3", + units="K", + filename="range_fail_detailed.nc", + ) + + result = validate_cmip7_output_detailed(path) + + assert result.passed is False + assert result.observed_min == 100.0 + assert result.observed_max == 101.0 + assert result.allowed_min is not None + assert result.allowed_max is not None diff --git a/tests/unit/test_ocean.py b/tests/unit/test_ocean.py index 576df65c..a5b8714f 100644 --- a/tests/unit/test_ocean.py +++ b/tests/unit/test_ocean.py @@ -120,6 +120,27 @@ def test_get_dim_rename_om2(self, mock_vocab, mock_mapping, temp_dir): assert dim_rename["yu_ocean"] == "j" assert dim_rename["st_ocean"] == "lev" + @pytest.mark.unit + def test_get_dim_rename_accepts_access_esm1_6( + self, mock_vocab, mock_mapping, temp_dir + ): + """ACCESS-ESM1-6 uses the OM2/MOM5 ocean grid conventions.""" + mock_vocab.source_id = "ACCESS-ESM1-6" + + with patch("access_moppy.ocean.Supergrid"): + cmoriser = Ocean_CMORiser_OM2( + input_paths=["test.nc"], + output_path=str(temp_dir), + compound_name="Omon.tos", + vocab=mock_vocab, + variable_mapping=mock_mapping, + ) + + dim_rename = cmoriser._get_dim_rename() + + assert dim_rename["xt_ocean"] == "i" + assert dim_rename["yt_ocean"] == "j" + @pytest.mark.unit def test_arakawa_grid_type(self, mock_vocab, mock_mapping, temp_dir): """Test that ACCESS-OM2 uses B-grid (Arakawa B).""" diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py index 97cc2369..3afe7148 100644 --- a/tests/unit/test_utilities.py +++ b/tests/unit/test_utilities.py @@ -46,6 +46,41 @@ def test_insufficient_time_points(self): with pytest.raises(ValueError, match="Need at least 2 time points"): calculate_time_bounds(ds) + def test_empty_time_coordinate_raises_specific_error(self): + """Empty time coordinates should fail before frequency inference.""" + ds = xr.Dataset(coords={"time": np.array([], dtype="datetime64[ns]")}) + + with pytest.raises(ValueError, match="Time coordinate 'time' is empty"): + calculate_time_bounds(ds) + + def test_single_monthly_midpoint_is_supported(self): + """Single monthly midpoint labels should yield exact monthly bounds.""" + ds = xr.Dataset(coords={"time": [np.datetime64("2000-01-16T12:00:00")]}) + + time_bnds = calculate_time_bounds(ds) + + assert time_bnds.shape == (1, 2) + assert time_bnds[0, 0] == np.datetime64("2000-01-01T00:00:00") + assert time_bnds[0, 1] == np.datetime64("2000-02-01T00:00:00") + + def test_single_non_midpoint_still_raises(self): + """Single timestamps that are not monthly midpoints remain ambiguous.""" + ds = xr.Dataset(coords={"time": [np.datetime64("2000-01-15T00:00:00")]}) + + with pytest.raises(ValueError, match="Need at least 2 time points"): + calculate_time_bounds(ds) + + def test_single_monthly_midpoint_cftime_is_supported(self): + """Single monthly midpoint support also applies to CFTime coordinates.""" + midpoint = cftime.DatetimeNoLeap(2000, 1, 16, 12) + ds = xr.Dataset(coords={"time": [midpoint]}) + + time_bnds = calculate_time_bounds(ds) + + assert time_bnds.shape == (1, 2) + assert time_bnds.values[0, 0] == cftime.DatetimeNoLeap(2000, 1, 1, 0) + assert time_bnds.values[0, 1] == cftime.DatetimeNoLeap(2000, 2, 1, 0) + class TestCalculateTimeBoundsMonthly: """Test monthly frequency time bounds calculation.""" diff --git a/tests/unit/test_vocabulary_processors.py b/tests/unit/test_vocabulary_processors.py index 3a883522..4f9d53fc 100644 --- a/tests/unit/test_vocabulary_processors.py +++ b/tests/unit/test_vocabulary_processors.py @@ -156,6 +156,68 @@ def test_normalize_dataset_missing_values_static_method(): assert "missing_value" not in result["b"].attrs +@pytest.mark.unit +def test_standardize_missing_values_casts_markers_to_data_dtype(vocabulary_instance): + da = xr.DataArray( + np.array([1.0, np.nan, 3.0], dtype=np.float32), + dims=["x"], + attrs={"units": "K"}, + ) + + result = vocabulary_instance.standardize_missing_values(da, convert_existing=False) + + assert np.asarray(result.attrs["missing_value"]).dtype == np.float32 + assert np.asarray(result.attrs["_FillValue"]).dtype == np.float32 + + +@pytest.mark.unit +def test_standardize_missing_values_casts_markers_integer_dtype(vocabulary_instance): + """Test casting to float64 (standard data type for climate variables).""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0], dtype=np.float64), + dims=["x"], + attrs={"units": "K"}, + ) + + result = vocabulary_instance.standardize_missing_values(da, convert_existing=False) + + # Check that missing values are cast to float64 + assert np.asarray(result.attrs["missing_value"]).dtype == np.float64 + assert np.asarray(result.attrs["_FillValue"]).dtype == np.float64 + + +@pytest.mark.unit +def test_standardize_missing_values_casts_float16_data(vocabulary_instance): + """Test that less common float types are handled.""" + da = xr.DataArray( + np.array([1.0, 2.0, 3.0], dtype=np.float16), + dims=["x"], + attrs={"units": "K"}, + ) + + result = vocabulary_instance.standardize_missing_values(da, convert_existing=False) + + # For float16 data, missing values should be cast to float16 + assert np.asarray(result.attrs["missing_value"]).dtype == np.float16 + + +@pytest.mark.unit +def test_standardize_missing_values_fallback_on_cast_failure(vocabulary_instance): + """Test that the casting helper returns original value on TypeError.""" + # Create a DataArray with a complex dtype that might cause issues + da = xr.DataArray( + np.array([1 + 2j, 3 + 4j], dtype=np.complex64), + dims=["x"], + attrs={"units": "1"}, + ) + + result = vocabulary_instance.standardize_missing_values(da, convert_existing=False) + + # The casting should fall back gracefully since complex types aren't floating/integer + # The result should have missing_value and _FillValue as floats since they're the defaults + assert "missing_value" in result.attrs or "_FillValue" in result.attrs + + @pytest.mark.unit def test_get_external_variables_cell_measures_and_heuristics(vocabulary_instance): vocabulary_instance.variable = { @@ -630,6 +692,22 @@ def test_cmip7_generate_filename_cftime_time_branch(cmip7_vocab_instance): assert "202002" in filename +@pytest.mark.unit +def test_cmip7_standardize_missing_values_casts_markers_to_data_dtype( + cmip7_vocab_instance, +): + da = xr.DataArray( + np.array([1.0, np.nan, 3.0], dtype=np.float32), + dims=["x"], + attrs={"units": "K"}, + ) + + result = cmip7_vocab_instance.standardize_missing_values(da, convert_existing=False) + + assert np.asarray(result.attrs["missing_value"]).dtype == np.float32 + assert np.asarray(result.attrs["_FillValue"]).dtype == np.float32 + + @pytest.mark.unit def test_cmip7_generate_filename_datetime64_time_branch(cmip7_vocab_instance): """CMIP7: numpy datetime64 time – uses pd.Timestamp branch.""" @@ -1277,6 +1355,70 @@ def test_get_nominal_resolution_multiple_realms_target_missing_key( assert vocab._get_nominal_resolution(target_realm="atmos") is None +@pytest.mark.unit +def test_cmip7_get_nominal_resolution_multiple_realms_valid_target(): + """CMIP7 supports selecting native nominal resolution by target realm.""" + mock_cv = { + "experiment_id": { + "historical": { + "experiment": "historical", + "activity": ["CMIP"], + } + }, + "source_id": { + "ACCESS-ESM1-6": { + "institution_id": ["CSIRO"], + "license_info": {"id": "CC BY 4.0"}, + "release_year": "2021", + "model_component": { + "atmos": {"native_nominal_resolution": "100 km"}, + "ocean": {"native_nominal_resolution": "50 km"}, + }, + } + }, + } + mock_table = { + "Header": {"table_id": "ocean"}, + "variable_entry": { + "tos": { + "frequency": "mon", + "modeling_realm": "atmos ocean", + "units": "degC", + "type": "real", + "dimensions": ["longitude", "latitude", "time"], + } + }, + } + + with ( + patch.object( + CMIP7Vocabulary, + "_get_experiment", + return_value=mock_cv["experiment_id"]["historical"], + ), + patch.object( + CMIP7Vocabulary, + "_get_source", + return_value=mock_cv["source_id"]["ACCESS-ESM1-6"], + ), + patch.object( + CMIP7Vocabulary, + "_get_variable_entry", + return_value=mock_table["variable_entry"]["tos"], + ), + patch.object(CMIP7Vocabulary, "_load_table", return_value=mock_table), + ): + vocab = CMIP7Vocabulary( + compound_name="ocean.tos", + experiment_id="historical", + source_id="ACCESS-ESM1-6", + variant_label="r1i1p1f1", + grid_label="gn", + ) + + assert vocab._get_nominal_resolution(target_realm="ocean") == "50 km" + + # --------------------------------------------------------------------------- # Error message context: _get_experiment / _get_source / _load_table # ---------------------------------------------------------------------------