diff --git a/CHANGELOG.md b/CHANGELOG.md index 202aa8d..c0f88de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ uses Semantic Versioning for public releases. ## Unreleased ### Fixed +- `temporal_mean` and `anomaly` now say how much time they averaged over. + Measured on a 6-element, 12-step file: a variable missing at every step + returned `outcome: complete`, `status: complete`, no warning codes and a + full-length field of NaN; a one-step file returned a "temporal mean" that was + the value and a "temporal anomaly" of exactly `0.0` at every element, which + is what that operation returns for any data whatsoever; and a file holding 1, + 2 and 12 usable steps at different elements returned a single finite + min/max/mean mixing all three. Results now carry a `temporal_coverage` block + with `n_time`, `samples_min`/`samples_max`, `n_elements_with_value`, + `n_series_with_data` and, under `groupby`, `n_bins` with + `bin_occupancy_min`/`bin_occupancy_max`. +- An empty mean fails `temporal_coverage_nonzero` and returns the refusal + payload with no number; a single-step baseline fails + `anomaly_baseline_multisample` on `anomaly` alone. A single-step + `temporal_mean` only warns, because the value it returns was measured and + only the word "mean" is wrong. `TEMPORAL_SAMPLES_RAGGED` fires on elements + averaged over different numbers of steps, excluding elements that never held + data so a land-masked field stays quiet, and `TEMPORAL_BINS_SINGLE_SAMPLE` + fires when a `groupby` bin holds one step — a monthly climatology built from + three months is three single observations. - `subset_bbox`, `subset_polygon` and `cross_section` now refuse a selection that kept no faces. A bounding box at 160-170W / 70-80S applied to a mesh covering 0-40E / 0-40N returned `outcome: complete`, `status: complete`, no diff --git a/docs/tools.md b/docs/tools.md index fad5b71..951de89 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -238,6 +238,29 @@ way to arrive here. `cross_section` is the one case UXarray already caught: it raises rather than returning empty, and that error is turned into the same refusal so the extent reaches the caller. Any other error still propagates. +`temporal_mean` and `anomaly` both reduce along `time`, and both return a +full-length array whatever went in. Both report a **`temporal_coverage`** block +giving `n_time`, the `samples_min`/`samples_max` range of usable steps per +element, `n_elements_with_value`, `n_series_with_data`, and — when `groupby` is +set — `n_bins` with `bin_occupancy_min`/`bin_occupancy_max`. + +A mean over a variable that is missing at every step **refuses**: it returns a +field of NaN shaped exactly like a climatology. A mean over a single step +**warns** with `TEMPORAL_SINGLE_SAMPLE` rather than refusing, because the value +it returns is real — only the name "mean" is wrong. `TEMPORAL_SAMPLES_RAGGED` +says elements averaged different numbers of steps, since `mean(dim="time")` +skips missing values and a face with one usable step comes back looking like a +face with twelve. Elements that never held data are excluded from that range, +so an ordinary land-masked field does not warn. `TEMPORAL_BINS_SINGLE_SAMPLE` +says at least one group holds one time step: `groupby="month"` over three +months is a monthly climatology in name and three single observations in fact. + +`anomaly` additionally **refuses** when the baseline covers one time step. Its +baseline is the mean over time of the same variable, so with one step the +baseline equals the value and every anomaly is exactly zero — the same array +for any data whatsoever, which is the condition this server refuses over rather +than returning the number. + `ensemble_mean` and `ensemble_spread` combine several files cell-by-cell, and nothing in the shapes says the files measure the same thing on the same mesh. Both report a **`member_evidence`** block naming the per-member `units`, the diff --git a/src/uxarray_mcp/domain/temporal_coverage.py b/src/uxarray_mcp/domain/temporal_coverage.py new file mode 100644 index 0000000..e9b4935 --- /dev/null +++ b/src/uxarray_mcp/domain/temporal_coverage.py @@ -0,0 +1,156 @@ +"""How much time a temporal mean or a temporal anomaly actually averaged over. + +``calculate_temporal_mean`` and ``calculate_anomaly`` both reduce along +``time`` and both return an array shaped like an answer no matter what went +in. Measured on a 6-face, 12-step file: a variable that is NaN at every step +returns ``outcome: complete``, ``status: complete``, no warning codes, and a +full-length field of NaN. A one-step file returns a "temporal mean" that is +the single value, and a "temporal anomaly" that is exactly zero at every +face -- zero by construction, not zero because the field sat on its baseline, +and there is nothing in the result that distinguishes the two. + +Between those extremes the sample count itself is a claim. ``mean(dim="time")`` +skips missing values, so a face with one usable step and a face with all twelve +both come back as a plain number, and the summary that follows mixes them. On +the probe file with one face holding a single step and another holding two, the +result reported a finite min/max/mean over faces averaged across 1, 2 and 12 +samples. + +So this module reports three separate things: how many steps the source had, +how many of them each element could actually use, and -- when the caller +grouped -- how many steps landed in each bin. A ``groupby="month"`` over three +months is a twelve-bin climatology in name and three single-sample bins in +fact. + +Elements that hold no data at all are counted but kept out of the sample-count +range. A land-masked field has faces that never carried a value, and folding +their zero into the minimum would make every masked field look ragged. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def compute_temporal_coverage( + source: Any, + result: Any, + *, + groupby: str | None = None, +) -> dict[str, Any]: + """Measure what the reduction along ``time`` had to work with. + + ``source`` is the variable as it was read, still carrying its time + dimension; ``result`` is what the reduction returned. Anything that cannot + be read is reported as ``None`` rather than guessed, since an unknown + sample count must not be presented as a full one. + """ + coverage: dict[str, Any] = { + "n_time": None, + "samples_min": None, + "samples_max": None, + "n_elements": 0, + "n_elements_with_value": 0, + "n_series": None, + "n_series_with_data": None, + "groupby": groupby, + } + + result_values = _as_float(result) + if result_values is not None: + coverage["n_elements"] = int(result_values.size) + coverage["n_elements_with_value"] = int(np.isfinite(result_values).sum()) + + source_values = _as_float(source) + axis = _time_axis(source) + if source_values is not None and axis is not None: + coverage["n_time"] = int(source_values.shape[axis]) + # One series per element of the non-time dimensions. Deliberately not + # counted against ``n_elements``: a grouped result has one entry per + # bin per element, so the two populations are different sizes and + # comparing them would read as a loss that did not happen. + per_series = np.isfinite(source_values).sum(axis=axis) + with_data = per_series > 0 + coverage["n_series"] = int(per_series.size) + coverage["n_series_with_data"] = int(with_data.sum()) + coverage["samples_max"] = int(per_series.max()) if per_series.size else None + # Elements that never carried data are deliberately excluded: their + # zero is ordinary masking, not an uneven average. + coverage["samples_min"] = ( + int(per_series[with_data].min()) if bool(with_data.any()) else None + ) + + if groupby is not None: + occupancy = _bin_occupancy(source, groupby) + if occupancy is not None: + coverage["n_bins"] = len(occupancy) + coverage["bin_occupancy_min"] = min(occupancy) if occupancy else None + coverage["bin_occupancy_max"] = max(occupancy) if occupancy else None + + return coverage + + +def temporal_coverage_warning_codes(coverage: dict[str, Any]) -> list[str]: + """Codes for a temporal reduction, worst first.""" + if not coverage.get("n_elements", 0): + return [] + if not coverage.get("n_elements_with_value", 0): + return ["TEMPORAL_COVERAGE_ZERO"] + + codes: list[str] = [] + n_time = coverage.get("n_time") + if n_time == 1: + codes.append("TEMPORAL_SINGLE_SAMPLE") + + samples_min = coverage.get("samples_min") + samples_max = coverage.get("samples_max") + if ( + samples_min is not None + and samples_max is not None + and samples_min != samples_max + ): + codes.append("TEMPORAL_SAMPLES_RAGGED") + + # A single-step file already reported TEMPORAL_SINGLE_SAMPLE, and every bin + # it produced holds that one step; saying so twice would add nothing. + if coverage.get("bin_occupancy_min") == 1 and n_time != 1: + codes.append("TEMPORAL_BINS_SINGLE_SAMPLE") + return codes + + +def _as_float(data: Any) -> np.ndarray | None: + """The values as float, or None when they cannot be read as numbers.""" + if data is None: + return None + try: + return np.asarray(getattr(data, "values", data), dtype=float) + except (AttributeError, TypeError, ValueError): + return None + + +def _time_axis(data: Any) -> int | None: + """Position of the time dimension, or None when there is not one.""" + dims = getattr(data, "dims", None) + if dims is None: + return None + try: + return list(dims).index("time") + except ValueError: + return None + + +def _bin_occupancy(source: Any, groupby: str) -> list[int] | None: + """How many time steps landed in each group, or None if ungroupable. + + Counted from the time coordinate rather than from the grouped result, + which is per-element and would report the usable-sample count instead of + the bin size. The two differ exactly where data is missing, and both are + worth having separately. + """ + try: + counts = source["time"].groupby(f"time.{groupby}").count() + return [int(value) for value in np.asarray(counts.values).ravel()] + except (AttributeError, KeyError, TypeError, ValueError): + return None diff --git a/src/uxarray_mcp/preconditions.py b/src/uxarray_mcp/preconditions.py index cfd397d..8b904b4 100644 --- a/src/uxarray_mcp/preconditions.py +++ b/src/uxarray_mcp/preconditions.py @@ -665,3 +665,62 @@ def enforce( "failed_checks": [c["id"] for c in failed], "override_used": overridden, } + + +def evaluate_temporal_preconditions( + operation: str, + coverage: dict[str, Any], +) -> list[dict[str, Any]]: + """Declare that a temporal mean must average over something. + + A variable that is missing at every step still reduces to a full-length + field, so an entirely NaN mean is shaped exactly like a climatology. That + is the only refusable state here: averaging over a single step returns the + value that was there, which is a real measurement wearing the wrong name, + and it stays a warning. + """ + with_value = coverage.get("n_elements_with_value", 0) + n_elements = coverage.get("n_elements", 0) + n_time = coverage.get("n_time") + detail = f"{operation}: {with_value} of {n_elements} values are finite." + if n_time is not None: + detail += f" The variable has {n_time} time steps." + return [ + _check( + "temporal_coverage_nonzero", + with_value > 0, + detail, + "The variable is missing at every time step, so the average has " + "nothing to average. Check the variable name and the region or " + "level being read, or pick a variable that is not entirely masked.", + ) + ] + + +def evaluate_temporal_anomaly_preconditions( + operation: str, + coverage: dict[str, Any], +) -> list[dict[str, Any]]: + """Declare what a temporal anomaly needs beyond a non-empty field. + + The baseline is the mean over time of the same variable, so with one time + step the baseline *is* the value and every anomaly is exactly zero. + Measured on a one-step file: ``min``, ``max`` and ``mean`` all came back + ``0.0`` with ``outcome: complete``. Nothing in that array was measured -- + it would read the same for any data whatsoever -- which is the condition + this server refuses over rather than returning the number. + """ + checks = evaluate_temporal_preconditions(operation, coverage) + n_time = coverage.get("n_time") + checks.append( + _check( + "anomaly_baseline_multisample", + n_time is None or n_time > 1, + f"{operation}: the baseline is a mean over {n_time} time steps.", + "A single time step makes the baseline equal to the value, so " + "every anomaly is zero by construction rather than by measurement. " + "Read a file covering more than one time step, or take the " + "difference against a separate baseline file with compare_fields.", + ) + ) + return checks diff --git a/src/uxarray_mcp/tools/advanced.py b/src/uxarray_mcp/tools/advanced.py index 6fbe029..7b88ea5 100644 --- a/src/uxarray_mcp/tools/advanced.py +++ b/src/uxarray_mcp/tools/advanced.py @@ -20,6 +20,7 @@ compute_target_coverage, ) from uxarray_mcp.domain.subset_coverage import compute_subset_coverage, mesh_extent +from uxarray_mcp.domain.temporal_coverage import compute_temporal_coverage from uxarray_mcp.next_steps import call, needed from uxarray_mcp.preconditions import normalize_units from uxarray_mcp.provenance import attach_provenance @@ -1398,6 +1399,9 @@ def calculate_temporal_mean( "groupby": groupby, "summary": summarize_array(result_data), "result_handle": result_handle, + "temporal_coverage": compute_temporal_coverage( + data, result_data, groupby=groupby + ), } result = attach_provenance( result, @@ -1446,6 +1450,10 @@ def calculate_anomaly( "baseline": baseline, "summary": summarize_array(anomaly), "result_handle": result_handle, + # The anomaly keeps its time dimension, so coverage is measured + # against the source it was differenced from rather than against its + # own shape: the loss is in the baseline, one reduction down. + "temporal_coverage": compute_temporal_coverage(data, anomaly), } result = attach_provenance( result, diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index c10f055..c2c27f0 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -13,6 +13,7 @@ from uxarray_mcp.domain.anomaly_coverage import anomaly_coverage_warning_codes from uxarray_mcp.domain.profile_coverage import profile_coverage_warning_codes from uxarray_mcp.domain.subset_coverage import subset_coverage_warning_codes +from uxarray_mcp.domain.temporal_coverage import temporal_coverage_warning_codes from uxarray_mcp.postconditions import ( evaluate_area_postconditions, postcondition_block, @@ -28,6 +29,8 @@ evaluate_profile_preconditions, evaluate_remap_preconditions, evaluate_subset_preconditions, + evaluate_temporal_anomaly_preconditions, + evaluate_temporal_preconditions, evaluate_validation_preconditions, evaluate_vector_preconditions, ) @@ -92,6 +95,11 @@ {"subset_bbox", "subset_polygon", "cross_section"} ) +#: Operations that reduce along ``time``. Both return a full-length array +#: whatever went in, so how many steps each value averaged is invisible in +#: the result and is measured separately. +TEMPORAL_OPERATIONS: frozenset[str] = frozenset({"temporal_mean", "anomaly"}) + #: Vocabulary an agent is likely to reach for, mapped to the operation that #: actually serves that intent. These are not aliases -- the call still fails -- #: but naming the right operation turns a dead end into a one-step repair. @@ -326,6 +334,22 @@ def _finalize_analysis_result( warning_codes.extend(codes) preconditions = evaluate_subset_preconditions(operation, coverage) + elif operation in TEMPORAL_OPERATIONS and "temporal_coverage" in result: + # Keyed off the block being present for the same reason as the other + # coverage gates: a remote worker on an older build sends none, and + # absent measurement stays unknown rather than becoming a claim. + coverage = result["temporal_coverage"] + codes = temporal_coverage_warning_codes(coverage) + physically_interpretable = not codes + if codes: + status = "warning" + warning_codes.extend(codes) + preconditions = ( + evaluate_temporal_anomaly_preconditions(operation, coverage) + if operation == "anomaly" + else evaluate_temporal_preconditions(operation, coverage) + ) + # Refuses by default when a declared precondition fails: raises # PreconditionRefusal unless the caller passed the override token. # `validate_dataset` is exempt from refusal -- reporting that a dataset diff --git a/tests/test_temporal_coverage.py b/tests/test_temporal_coverage.py new file mode 100644 index 0000000..903ab1b --- /dev/null +++ b/tests/test_temporal_coverage.py @@ -0,0 +1,411 @@ +"""A temporal mean and a temporal anomaly say how much time they averaged. + +Both operations reduce along ``time`` and both return a full-length array +whatever went in, so the degenerate cases are shaped exactly like answers. +Measured before this gate existed, on a 6-element, 12-step file: an all-NaN +variable returned ``outcome: complete`` with a field of NaN, a one-step file +returned an "anomaly" of exactly 0.0 everywhere, and a file with 1, 2 and 12 +usable steps at different elements returned one finite mean over all of them. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from uxarray_mcp.domain.temporal_coverage import ( + compute_temporal_coverage, + temporal_coverage_warning_codes, +) +from uxarray_mcp.preconditions import ( + OVERRIDE_TOKEN, + PreconditionRefusal, + evaluate_temporal_anomaly_preconditions, +) +from uxarray_mcp.tools.frontdoor import run_analysis + +N_ELEMENT = 6 + + +def _write( + tmp_path, + name: str, + n_time: int, + *, + mode: str = "full", + freq: str = "MS", +) -> str: + """A face-dimensioned variable over ``n_time`` monthly steps.""" + times = pd.date_range("2000-01-01", periods=n_time, freq=freq) + values = np.arange(n_time * N_ELEMENT, dtype=float).reshape(n_time, N_ELEMENT) + if mode == "all_nan": + values[:] = np.nan + elif mode == "half_masked": + values[:, N_ELEMENT // 2 :] = np.nan + elif mode == "ragged": + # Element 0 keeps one step and element 1 keeps two; the rest keep all + # of them, so a single mean mixes 1-, 2- and n_time-sample estimates. + values[1:, 0] = np.nan + values[2:, 1] = np.nan + path = tmp_path / name + xr.Dataset({"t2m": (("time", "n_face"), values)}, coords={"time": times}).to_netcdf( + path + ) + return str(path) + + +def _series(values, times=None) -> xr.DataArray: + array = np.asarray(values, dtype=float) + if times is None: + times = pd.date_range("2000-01-01", periods=array.shape[0], freq="MS") + return xr.DataArray(array, dims=("time", "n_face"), coords={"time": times}) + + +# --- the measurement ------------------------------------------------------- + + +def test_a_complete_series_reports_every_step_at_every_element(): + source = _series([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + coverage = compute_temporal_coverage(source, source.mean(dim="time")) + assert coverage["n_time"] == 3 + assert coverage["samples_min"] == 3 + assert coverage["samples_max"] == 3 + assert coverage["n_elements"] == 2 + assert coverage["n_elements_with_value"] == 2 + assert coverage["n_series_with_data"] == 2 + + +def test_an_element_that_never_held_data_is_kept_out_of_the_sample_range(): + # Left column full, right column entirely missing. The minimum must not + # collapse to zero, or every land-masked field would look ragged. + source = _series([[1.0, np.nan], [2.0, np.nan], [3.0, np.nan]]) + coverage = compute_temporal_coverage(source, source.mean(dim="time")) + assert coverage["samples_min"] == 3 + assert coverage["samples_max"] == 3 + assert coverage["n_series"] == 2 + assert coverage["n_series_with_data"] == 1 + assert coverage["n_elements_with_value"] == 1 + + +def test_uneven_sample_counts_are_reported_as_a_range(): + source = _series([[1.0, 1.0], [np.nan, 2.0], [np.nan, 3.0]]) + coverage = compute_temporal_coverage(source, source.mean(dim="time")) + assert coverage["samples_min"] == 1 + assert coverage["samples_max"] == 3 + + +def test_a_source_with_no_time_dimension_leaves_the_counts_unknown(): + source = xr.DataArray(np.array([1.0, 2.0]), dims=("n_face",)) + coverage = compute_temporal_coverage(source, source) + assert coverage["n_time"] is None + assert coverage["samples_min"] is None + assert coverage["n_series_with_data"] is None + # The result was still readable, so its own size is known. + assert coverage["n_elements"] == 2 + + +def test_bin_occupancy_counts_time_steps_not_usable_values(): + # Three monthly steps, one entirely missing. The bin still holds a step. + source = _series([[1.0, 1.0], [np.nan, np.nan], [3.0, 3.0]]) + coverage = compute_temporal_coverage( + source, source.groupby("time.month").mean(), groupby="month" + ) + assert coverage["n_bins"] == 3 + assert coverage["bin_occupancy_min"] == 1 + assert coverage["bin_occupancy_max"] == 1 + + +def test_a_season_grouping_over_a_year_fills_each_bin_three_times(): + times = pd.date_range("2000-01-01", periods=12, freq="MS") + source = _series(np.ones((12, 2)), times=times) + coverage = compute_temporal_coverage( + source, source.groupby("time.season").mean(), groupby="season" + ) + assert coverage["n_bins"] == 4 + assert coverage["bin_occupancy_min"] == 3 + assert coverage["bin_occupancy_max"] == 3 + + +def test_an_ungroupable_source_omits_the_bin_block_rather_than_guessing(): + source = xr.DataArray(np.ones((2, 2)), dims=("time", "n_face")) + coverage = compute_temporal_coverage(source, source, groupby="month") + assert "n_bins" not in coverage + assert coverage["groupby"] == "month" + + +# --- the codes ------------------------------------------------------------- + + +def test_a_full_series_earns_no_code(): + coverage = { + "n_elements": 6, + "n_elements_with_value": 6, + "n_time": 12, + "samples_min": 12, + "samples_max": 12, + } + assert temporal_coverage_warning_codes(coverage) == [] + + +def test_an_empty_result_reports_zero_coverage_alone(): + coverage = { + "n_elements": 6, + "n_elements_with_value": 0, + "n_time": 1, + "samples_min": None, + "samples_max": None, + } + assert temporal_coverage_warning_codes(coverage) == ["TEMPORAL_COVERAGE_ZERO"] + + +def test_a_single_step_is_flagged(): + coverage = { + "n_elements": 6, + "n_elements_with_value": 6, + "n_time": 1, + "samples_min": 1, + "samples_max": 1, + } + assert temporal_coverage_warning_codes(coverage) == ["TEMPORAL_SINGLE_SAMPLE"] + + +def test_a_single_step_does_not_also_report_its_bins(): + # Every bin of a one-step file holds that one step; the single-sample code + # already said so and repeating it per bin adds nothing. + coverage = { + "n_elements": 6, + "n_elements_with_value": 6, + "n_time": 1, + "samples_min": 1, + "samples_max": 1, + "bin_occupancy_min": 1, + } + assert temporal_coverage_warning_codes(coverage) == ["TEMPORAL_SINGLE_SAMPLE"] + + +def test_uneven_samples_are_flagged(): + coverage = { + "n_elements": 6, + "n_elements_with_value": 6, + "n_time": 12, + "samples_min": 1, + "samples_max": 12, + } + assert temporal_coverage_warning_codes(coverage) == ["TEMPORAL_SAMPLES_RAGGED"] + + +def test_a_bin_holding_one_step_is_flagged(): + coverage = { + "n_elements": 18, + "n_elements_with_value": 18, + "n_time": 3, + "samples_min": 3, + "samples_max": 3, + "bin_occupancy_min": 1, + } + assert temporal_coverage_warning_codes(coverage) == ["TEMPORAL_BINS_SINGLE_SAMPLE"] + + +def test_an_empty_array_earns_no_code(): + assert temporal_coverage_warning_codes({"n_elements": 0}) == [] + + +# --- temporal_mean end to end --------------------------------------------- + + +def test_a_full_series_is_interpretable(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "full.nc", 12), + variable_name="t2m", + ) + assert result["outcome"] == "complete" + status = result["scientific_status"] + assert status["status"] == "complete" + assert status["physically_interpretable"] is True + assert status["warning_codes"] == [] + assert result["temporal_coverage"]["n_time"] == 12 + + +def test_a_mean_over_nothing_refuses(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "nan.nc", 12, mode="all_nan"), + variable_name="t2m", + ) + assert result["outcome"] == "input_required" + failed = result["refusal"]["failed_checks"] + assert [check["id"] for check in failed] == ["temporal_coverage_nonzero"] + assert "12 time steps" in failed[0]["detail"] + assert "summary" not in result + + +def test_a_mean_over_one_step_warns_and_still_answers(tmp_path): + # The value is real; calling it a climatology is what is wrong, so this + # returns the number with a code rather than refusing. + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "one.nc", 1), + variable_name="t2m", + ) + assert result["outcome"] == "complete" + status = result["scientific_status"] + assert status["status"] == "warning" + assert status["physically_interpretable"] is False + assert "TEMPORAL_SINGLE_SAMPLE" in status["warning_codes"] + assert result["summary"]["mean"] == pytest.approx(2.5) + + +def test_uneven_samples_warn(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "ragged.nc", 12, mode="ragged"), + variable_name="t2m", + ) + assert result["scientific_status"]["warning_codes"] == ["TEMPORAL_SAMPLES_RAGGED"] + coverage = result["temporal_coverage"] + assert coverage["samples_min"] == 1 + assert coverage["samples_max"] == 12 + + +def test_an_ordinary_masked_field_neither_refuses_nor_warns(tmp_path): + # Half the elements never carried data. That is a land mask, not a defect, + # and a code that fires here would be ignored everywhere else. + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "half.nc", 12, mode="half_masked"), + variable_name="t2m", + ) + assert result["outcome"] == "complete" + assert result["scientific_status"]["warning_codes"] == [] + coverage = result["temporal_coverage"] + assert coverage["n_series_with_data"] == N_ELEMENT // 2 + assert coverage["samples_min"] == 12 + + +def test_a_monthly_climatology_from_one_year_says_each_month_is_one_sample(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "three.nc", 3), + variable_name="t2m", + groupby="month", + ) + assert result["scientific_status"]["warning_codes"] == [ + "TEMPORAL_BINS_SINGLE_SAMPLE" + ] + assert result["temporal_coverage"]["bin_occupancy_min"] == 1 + + +def test_a_seasonal_mean_over_a_full_year_is_quiet(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "year.nc", 12), + variable_name="t2m", + groupby="season", + ) + assert result["scientific_status"]["warning_codes"] == [] + assert result["temporal_coverage"]["n_bins"] == 4 + + +def test_the_override_returns_the_empty_mean_marked_uninterpretable(tmp_path): + result = run_analysis( + operation="temporal_mean", + data_path=_write(tmp_path, "nan2.nc", 12, mode="all_nan"), + variable_name="t2m", + acknowledge=OVERRIDE_TOKEN, + ) + assert result["outcome"] == "complete" + assert result["preconditions"]["status"] == "overridden" + assert result["scientific_status"]["physically_interpretable"] is False + assert ( + "PRECONDITION_FAILED_TEMPORAL_COVERAGE_NONZERO" + in (result["scientific_status"]["warning_codes"]) + ) + + +# --- anomaly end to end ---------------------------------------------------- + + +def test_an_anomaly_over_a_full_series_is_interpretable(tmp_path): + result = run_analysis( + operation="anomaly", + data_path=_write(tmp_path, "afull.nc", 12), + variable_name="t2m", + ) + assert result["outcome"] == "complete" + assert result["scientific_status"]["physically_interpretable"] is True + assert result["temporal_coverage"]["n_time"] == 12 + + +def test_an_anomaly_against_a_one_step_baseline_refuses(tmp_path): + # Every value would be exactly zero, whatever the data said. + result = run_analysis( + operation="anomaly", + data_path=_write(tmp_path, "aone.nc", 1), + variable_name="t2m", + ) + assert result["outcome"] == "input_required" + failed = result["refusal"]["failed_checks"] + assert [check["id"] for check in failed] == ["anomaly_baseline_multisample"] + assert "zero by construction" in failed[0]["repair"] + + +def test_an_anomaly_with_no_data_refuses_on_coverage(tmp_path): + result = run_analysis( + operation="anomaly", + data_path=_write(tmp_path, "anan.nc", 12, mode="all_nan"), + variable_name="t2m", + ) + assert [check["id"] for check in result["refusal"]["failed_checks"]] == [ + "temporal_coverage_nonzero" + ] + + +def test_the_anomaly_gate_keeps_both_checks(): + checks = evaluate_temporal_anomaly_preconditions( + "anomaly", + {"n_elements": 6, "n_elements_with_value": 6, "n_time": 12}, + ) + assert [check["id"] for check in checks] == [ + "temporal_coverage_nonzero", + "anomaly_baseline_multisample", + ] + assert all(check["passed"] for check in checks) + + +def test_an_unknown_step_count_does_not_refuse_the_baseline_check(): + # A worker that could not report `n_time` leaves the count unknown, and + # unknown must not be treated as one. + checks = evaluate_temporal_anomaly_preconditions( + "anomaly", + {"n_elements": 6, "n_elements_with_value": 6, "n_time": None}, + ) + assert all(check["passed"] for check in checks) + + +def test_the_override_returns_the_zero_anomaly_marked_uninterpretable(tmp_path): + result = run_analysis( + operation="anomaly", + data_path=_write(tmp_path, "aone2.nc", 1), + variable_name="t2m", + acknowledge=OVERRIDE_TOKEN, + ) + assert result["outcome"] == "complete" + assert result["preconditions"]["status"] == "overridden" + assert result["summary"]["max"] == pytest.approx(0.0) + assert result["scientific_status"]["physically_interpretable"] is False + + +def test_a_variable_without_a_time_dimension_still_fails_on_its_own_terms(tmp_path): + # The gate must not swallow the pre-existing error for a variable that has + # no time axis at all; that is a different problem with a different fix. + path = tmp_path / "notime.nc" + xr.Dataset({"t2m": (("n_face",), np.arange(N_ELEMENT, dtype=float))}).to_netcdf( + path + ) + with pytest.raises((ValueError, PreconditionRefusal), match="time"): + run_analysis( + operation="temporal_mean", data_path=str(path), variable_name="t2m" + )