diff --git a/CHANGELOG.md b/CHANGELOG.md index ff74d4e..d5bc5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ uses Semantic Versioning for public releases. ## Unreleased ### Fixed +- `zonal_anomaly` now says how much of the anomaly field the band means + actually defined, and refuses when they defined none of it. The anomaly is + a per-face field rather than a binned profile, so the bin coverage added for + `calculate_zonal_mean` did not apply and the loss stayed invisible: a band + mean is undefined as soon as one face in the band is missing, and every face + in that band comes back NaN including faces that carried a value. Measured on + a 90-face regional mesh, one missing value per latitude band emptied all 90 + faces while 85 of them held data, and the result was `outcome: complete`, + `status: complete`, no warning codes, and `stats` of `{min: null, max: null, + mean: null, std: null}`. The partial case was quieter still — 30 faces with + data, 18 anomalies returned, 12 measurable faces dropped, and finite + min/max/mean/std computed from the survivors. +- Results now carry an `anomaly_coverage` block with `n_face`, + `n_face_with_data`, `n_face_with_anomaly`, `n_face_data_lost` and `cause`. + `ANOMALY_COVERAGE_PARTIAL` fires on lost faces rather than on empty ones, so + an ordinary land-masked field does not warn on every call; a face that had + data and no anomaly is a deduction from `value - band_mean`, not a guess. + Zero coverage fails `anomaly_coverage_nonzero` and returns the refusal + payload with no number. The repair names the missing values rather than + `lat_spec` when faces did carry data, because no choice of bands can avoid a + gap that is in every band; when nothing was measurable it names the variable + and the time/level slice instead. - The `file://` links returned for large figures are now fetchable. A figure at or above the inline payload limit is written to the artifact store and handed back as an MCP `resource_link`, which is the right trade — base64 inflates the diff --git a/docs/tools.md b/docs/tools.md index 81d7ab6..dea466a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -195,6 +195,32 @@ missing data, so the source field is checked as well. `cause` is when it carries missing values or was not available to check — in which case the repair says so rather than asserting one explanation. +`zonal_anomaly` subtracts a band mean from every face, so its answer is a +per-face field rather than a profile and there are no bins in it to count. +The loss happens one level down and is invisible in the result's shape: a band +mean is undefined as soon as one face in the band is missing, and every face in +that band then comes back NaN, *including faces that carried a value*. The +result therefore reports an **`anomaly_coverage`** block giving `n_face`, +`n_face_with_data`, `n_face_with_anomaly`, `n_face_data_lost` and `cause`. + +The two kinds of empty face are counted separately on purpose. A face that +never had data has no anomaly for an ordinary reason — a land-masked field +would otherwise warn on every call — so `ANOMALY_COVERAGE_PARTIAL` fires on +`n_face_data_lost`, not on the plain count of NaN. That a face lost its anomaly +is a deduction rather than a guess: the anomaly is `value - band_mean`, so a +finite value with a non-finite anomaly means the band mean was non-finite. +`cause` is `band_mean_undefined` in that case, `missing_input` when every +absent anomaly is explained by absent data, and `ambiguous` when the source +field was unavailable or does not line up with the result. + +An anomaly field with no anomaly in it **refuses**, for the same reason a +wholly empty profile does. The repair depends on which way it emptied: when +nothing was measurable to begin with it names the variable and the time/level +slice, and when faces did carry data it names the missing values rather than +`lat_spec`, because no choice of bands can avoid a gap that is in every band. +Measured on a 90-face regional mesh, one missing value per latitude band was +enough to empty all 90 faces while 85 of them carried data. + `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/anomaly_coverage.py b/src/uxarray_mcp/domain/anomaly_coverage.py new file mode 100644 index 0000000..7aa1ddb --- /dev/null +++ b/src/uxarray_mcp/domain/anomaly_coverage.py @@ -0,0 +1,109 @@ +"""How much of an anomaly field the band means actually defined. + +``zonal_anomaly`` subtracts, from each face, the zonal mean of the latitude +band that face falls in. That makes it a per-face field rather than a binned +profile, so :mod:`~uxarray_mcp.domain.profile_coverage` does not apply: there +are no bins in the answer to count. The loss happens one level down, and it is +invisible in the result's shape. + +A band mean is undefined when the band holds no usable data, and every face in +that band then comes back NaN -- *including faces that carried a perfectly good +value*. Measured on a 90-face regional mesh with 30 faces carrying data, only +18 faces received an anomaly: 12 faces had data and lost it to a band mean that +did not exist. The returned array is still 90 long, ``min``/``max``/``mean``/ +``std`` are still finite, and nothing in the result says two thirds of the field +is missing or that some of the missing part was measurable. + +Two kinds of missing face are therefore counted separately. A face that never +had data has no anomaly for an ordinary reason -- a masked ocean field would +otherwise warn on every call -- and is not worth a warning. A face that had +data and still has no anomaly is a real loss, and it is a *deduction* rather +than a guess: the anomaly is ``value - band_mean``, so a finite value with a +non-finite anomaly means the band mean was non-finite. +""" + +from __future__ import annotations + +from typing import Any, Sequence + +import numpy as np + + +def compute_anomaly_coverage( + values: Sequence[float], + *, + source: Any = None, +) -> dict[str, Any]: + """Report how many faces received an anomaly, and how many lost one. + + Parameters + ---------- + values + The anomaly field as returned by the operation, one entry per face. + source + The field the anomaly was taken from, if available. Used to separate + a face that never had data from a face whose band mean was undefined. + + Returns + ------- + dict + ``n_face``, ``n_face_with_data`` (``None`` when the source was not + supplied), ``n_face_with_anomaly``, ``n_face_data_lost`` and ``cause``. + ``cause`` is ``"band_mean_undefined"`` only when faces that carried + data came back without an anomaly, ``"missing_input"`` when every + absent anomaly is explained by absent data, and ``"ambiguous"`` when + the source was not supplied or does not line up with the result. + """ + anomaly = np.asarray(values, dtype=float) + anomaly_finite = np.isfinite(anomaly) + n_face = int(anomaly.size) + n_with_anomaly = int(anomaly_finite.sum()) + + n_with_data: int | None = None + n_lost: int | None = None + comparable = False + if source is not None: + source_values = np.asarray(getattr(source, "values", source), dtype=float) + if source_values.shape == anomaly.shape: + source_finite = np.isfinite(source_values) + n_with_data = int(source_finite.sum()) + n_lost = int((source_finite & ~anomaly_finite).sum()) + # An anomaly where the source had nothing is not a shape this + # module models -- `value - band_mean` cannot be finite when + # `value` is not -- so rather than reason from it, say so. + comparable = not bool((~source_finite & anomaly_finite).any()) + + if n_with_data is None: + # Nobody supplied the field, so a missing anomaly has two possible + # explanations and this does not pick one. + cause = "none" if n_with_anomaly == n_face else "ambiguous" + elif not comparable: + cause = "ambiguous" + elif n_lost: + cause = "band_mean_undefined" + elif n_with_anomaly == n_face: + cause = "none" + else: + cause = "missing_input" + + return { + "n_face": n_face, + "n_face_with_data": n_with_data, + "n_face_with_anomaly": n_with_anomaly, + "n_face_data_lost": n_lost, + "cause": cause, + } + + +def anomaly_coverage_warning_codes(coverage: dict[str, Any]) -> list[str]: + """Stable codes for an anomaly field that is empty or lost measured data.""" + if not coverage.get("n_face", 0): + return [] + if not coverage.get("n_face_with_anomaly", 0): + return ["ANOMALY_COVERAGE_ZERO"] + # Deliberately not `n_face_with_anomaly < n_face`. Faces that never held + # data have no anomaly for an ordinary reason, and warning about them + # would fire on every masked field and teach callers to ignore the code. + if coverage.get("n_face_data_lost"): + return ["ANOMALY_COVERAGE_PARTIAL"] + return [] diff --git a/src/uxarray_mcp/domain/zonal.py b/src/uxarray_mcp/domain/zonal.py index df7fe54..98925e7 100644 --- a/src/uxarray_mcp/domain/zonal.py +++ b/src/uxarray_mcp/domain/zonal.py @@ -4,6 +4,7 @@ from typing import Any, Optional +from uxarray_mcp.domain.anomaly_coverage import compute_anomaly_coverage from uxarray_mcp.domain.dims import face_slice_selection from uxarray_mcp.domain.profile_coverage import compute_profile_coverage @@ -174,6 +175,7 @@ def compute_zonal_anomaly_stats( "conservative": conservative, "n_face": int(uxds.uxgrid.n_face), "stats": stats, + "anomaly_coverage": compute_anomaly_coverage(vals, source=var), "interpretation": "per-face deviation from the zonal mean of its latitude band", "grid_info": { "n_face": int(uxds.uxgrid.n_face), diff --git a/src/uxarray_mcp/preconditions.py b/src/uxarray_mcp/preconditions.py index 38c99cd..8f1d7e2 100644 --- a/src/uxarray_mcp/preconditions.py +++ b/src/uxarray_mcp/preconditions.py @@ -444,6 +444,60 @@ def evaluate_profile_preconditions( ] +def evaluate_anomaly_preconditions( + operation: str, + coverage: dict[str, Any], +) -> list[dict[str, Any]]: + """Declare that an anomaly field must carry at least one anomaly. + + ``zonal_anomaly`` returns one entry per face whether or not any band mean + was defined, so an entirely NaN field is shaped exactly like an answer -- + the same state ``profile_coverage_nonzero`` refuses over, arriving through + a per-face field instead of through bins. + + The repair depends on which of the two ways it emptied. When no face had + data to begin with, telling the caller to move the bands would send them + after the wrong thing. + + Faces that had data and lost their anomaly stay a warning. A band that + holds a little missing data legitimately loses that band, and refusing + there would make ordinary masked fields unusable. + """ + with_anomaly = coverage.get("n_face_with_anomaly", 0) + n_face = coverage.get("n_face", 0) + with_data = coverage.get("n_face_with_data") + if with_data == 0: + repair = ( + "The variable carries no usable values on this mesh, so there is " + "nothing to take an anomaly of. Check the variable name and the " + "time/level slice, or pick a variable that is not entirely masked." + ) + else: + # Reaching here means faces carried data and still got nothing back, + # so the bands are not the thing to move: a band mean is undefined as + # soon as one face in the band is missing, and on a 90-face mesh a + # single gap per band emptied all 90 faces. Naming lat_spec first + # would send the caller after a change that cannot help. + repair = ( + "Every latitude band contains at least one missing value, and a " + "band mean is undefined if any face in the band is missing, so no " + "face gets an anomaly. Drop or fill the missing faces before " + "taking the anomaly. Bands chosen with lat_spec to avoid them " + "work too, but only if some band ends up entirely free of gaps." + ) + detail = f"{operation}: {with_anomaly} of {n_face} faces received an anomaly." + if with_data: + detail += f" {with_data} faces carried data." + return [ + _check( + "anomaly_coverage_nonzero", + with_anomaly > 0, + detail, + repair, + ) + ] + + def _request_state(operation: str, failed: list[dict[str, Any]]) -> str: """An opaque token identifying exactly this refusal. diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index 9b0a939..c825162 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -10,6 +10,7 @@ from functools import wraps from typing import Any +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.postconditions import ( evaluate_area_postconditions, @@ -20,6 +21,7 @@ OUTCOME_COMPLETE, PreconditionRefusal, enforce, + evaluate_anomaly_preconditions, evaluate_comparison_preconditions, evaluate_ensemble_preconditions, evaluate_profile_preconditions, @@ -293,6 +295,17 @@ def _finalize_analysis_result( status = "warning" warning_codes.extend(codes) preconditions = evaluate_profile_preconditions(operation, coverage) + elif operation == "zonal_anomaly" and "anomaly_coverage" in result: + # Same reason the profile branch keys off the block being present: + # a remote worker on an older build sends no coverage, and absent + # measurement stays unknown rather than becoming a claim. + coverage = result["anomaly_coverage"] + codes = anomaly_coverage_warning_codes(coverage) + physically_interpretable = not codes + if codes: + status = "warning" + warning_codes.extend(codes) + preconditions = evaluate_anomaly_preconditions(operation, coverage) # Refuses by default when a declared precondition fails: raises # PreconditionRefusal unless the caller passed the override token. diff --git a/tests/test_anomaly_coverage.py b/tests/test_anomaly_coverage.py new file mode 100644 index 0000000..54d91b0 --- /dev/null +++ b/tests/test_anomaly_coverage.py @@ -0,0 +1,258 @@ +"""An anomaly field emptied by undefined band means must not look answered. + +Before this gate, a 90-face mesh whose variable held one missing value per +latitude band returned `outcome: complete`, `status: complete`, no warning +codes, and `stats` of `{min: None, max: None, mean: None, std: None}` -- 85 +faces carried data, every band mean was undefined, and nothing said so. The +partial case was quieter still: 30 faces with data, 18 anomalies returned, 12 +measurable faces silently dropped, and finite min/max/mean/std computed from +the survivors. + +Two kinds of empty face are separated here. A face that never had data has no +anomaly for an ordinary reason -- masked ocean fields would otherwise warn on +every call -- and the tests below pin that down so the warning stays worth +reading. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest +import uxarray as ux +import xarray as xr + +from uxarray_mcp.domain.anomaly_coverage import ( + anomaly_coverage_warning_codes, + compute_anomaly_coverage, +) +from uxarray_mcp.preconditions import OVERRIDE_TOKEN +from uxarray_mcp.tools.frontdoor import run_analysis + +NAN = float("nan") + +#: Faces per latitude band on the fixture mesh below: 18 longitudes wide. +FACES_PER_BAND = 18 + + +@pytest.fixture +def regional_grid(tmp_path): + """A mesh spanning 0-40N in five 18-face latitude rows.""" + lon = np.arange(0.0, 360.0, 20.0) + lat = np.arange(0.0, 41.0, 10.0) + grid = ux.Grid.from_structured(lon=lon, lat=lat) + grid_file = tmp_path / "regional.nc" + grid.to_xarray().to_netcdf(grid_file) + return grid_file, grid.n_face + + +def _write(tmp_path, name, values): + path = tmp_path / f"{name}.nc" + xr.Dataset({"t": (["n_face"], values, {"units": "K"})}).to_netcdf(path) + return path + + +def _analyze(grid_file, data_file, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return run_analysis( + operation="zonal_anomaly", + grid_path=str(grid_file), + data_path=str(data_file), + variable_name="t", + **kwargs, + ) + + +def _field(n_face, seed=3): + rng = np.random.default_rng(seed) + return 250.0 + 30.0 * rng.random(n_face) + + +# --------------------------------------------------------------------------- +# The measurement +# --------------------------------------------------------------------------- + + +def test_a_complete_field_reports_full_coverage(): + coverage = compute_anomaly_coverage([1.0, -1.0, 2.0], source=[10.0, 8.0, 11.0]) + assert coverage == { + "n_face": 3, + "n_face_with_data": 3, + "n_face_with_anomaly": 3, + "n_face_data_lost": 0, + "cause": "none", + } + + +def test_a_face_that_never_had_data_is_not_counted_as_lost(): + coverage = compute_anomaly_coverage([1.0, NAN], source=[10.0, NAN]) + assert coverage["n_face_with_data"] == 1 + assert coverage["n_face_data_lost"] == 0 + assert coverage["cause"] == "missing_input" + + +def test_a_face_that_had_data_and_lost_its_anomaly_is_counted(): + coverage = compute_anomaly_coverage([1.0, NAN], source=[10.0, 8.0]) + assert coverage["n_face_data_lost"] == 1 + assert coverage["cause"] == "band_mean_undefined" + + +def test_coverage_without_a_source_does_not_guess(): + coverage = compute_anomaly_coverage([1.0, NAN]) + assert coverage["n_face_with_data"] is None + assert coverage["n_face_data_lost"] is None + assert coverage["cause"] == "ambiguous" + + +def test_a_source_of_a_different_shape_is_not_compared(): + coverage = compute_anomaly_coverage([1.0, NAN], source=[10.0, 8.0, 6.0]) + assert coverage["n_face_with_data"] is None + assert coverage["cause"] == "ambiguous" + + +def test_an_anomaly_where_the_source_had_nothing_is_not_reasoned_from(): + # `value - band_mean` cannot be finite when `value` is not, so this is a + # shape the module does not model and must not explain away. + coverage = compute_anomaly_coverage([1.0, 2.0], source=[10.0, NAN]) + assert coverage["cause"] == "ambiguous" + assert coverage["n_face_with_data"] == 1 + + +# --------------------------------------------------------------------------- +# The codes +# --------------------------------------------------------------------------- + + +def test_full_coverage_warns_about_nothing(): + assert ( + anomaly_coverage_warning_codes(compute_anomaly_coverage([1.0], source=[2.0])) + == [] + ) + + +def test_an_empty_field_is_zero_coverage(): + codes = anomaly_coverage_warning_codes( + compute_anomaly_coverage([NAN, NAN], source=[1.0, 2.0]) + ) + assert codes == ["ANOMALY_COVERAGE_ZERO"] + + +def test_lost_data_is_partial_coverage(): + codes = anomaly_coverage_warning_codes( + compute_anomaly_coverage([1.0, NAN], source=[10.0, 8.0]) + ) + assert codes == ["ANOMALY_COVERAGE_PARTIAL"] + + +def test_a_masked_field_that_lost_nothing_does_not_warn(): + # The whole point of counting lost faces rather than empty ones: an + # ordinary land-masked field must not warn on every call. + codes = anomaly_coverage_warning_codes( + compute_anomaly_coverage([1.0, NAN], source=[10.0, NAN]) + ) + assert codes == [] + + +def test_an_empty_measurement_yields_no_codes(): + assert anomaly_coverage_warning_codes({"n_face": 0}) == [] + + +# --------------------------------------------------------------------------- +# End to end through the front door +# --------------------------------------------------------------------------- + + +def test_a_complete_field_is_interpretable(tmp_path, regional_grid): + grid_file, n_face = regional_grid + data_file = _write(tmp_path, "full", _field(n_face)) + result = _analyze(grid_file, data_file) + + assert result["outcome"] == "complete" + assert result["anomaly_coverage"]["n_face_data_lost"] == 0 + assert result["scientific_status"]["physically_interpretable"] is True + assert result["scientific_status"]["warning_codes"] == [] + + +def test_faces_that_lose_their_anomaly_are_reported(tmp_path, regional_grid): + """The measured case: data present, most of it dropped, stats still finite.""" + grid_file, n_face = regional_grid + values = _field(n_face) + values[:60] = NAN + data_file = _write(tmp_path, "partial", values) + result = _analyze(grid_file, data_file) + + coverage = result["anomaly_coverage"] + assert coverage["n_face_with_data"] == 30 + assert coverage["n_face_with_anomaly"] == 18 + assert coverage["n_face_data_lost"] == 12 + assert coverage["cause"] == "band_mean_undefined" + # The number still comes back -- this is a warning, not a refusal -- but + # it no longer comes back claiming to describe the whole field. + assert result["outcome"] == "complete" + assert result["stats"]["mean"] is not None + assert result["scientific_status"]["physically_interpretable"] is False + assert "ANOMALY_COVERAGE_PARTIAL" in result["scientific_status"]["warning_codes"] + + +def test_one_gap_per_band_refuses_rather_than_returning_nulls(tmp_path, regional_grid): + """85 of 90 faces carry data and every band mean is still undefined.""" + grid_file, n_face = regional_grid + values = _field(n_face) + for band in range(n_face // FACES_PER_BAND): + values[band * FACES_PER_BAND] = NAN + data_file = _write(tmp_path, "poisoned", values) + result = _analyze(grid_file, data_file) + + assert result["outcome"] == "input_required" + assert "stats" not in result + failed = result["refusal"]["failed_checks"] + assert [check["id"] for check in failed] == ["anomaly_coverage_nonzero"] + assert "85 faces carried data" in failed[0]["detail"] + # The bands are not the thing to move here, so the repair must not say so + # first: no band can be placed to avoid a gap that is in every band. + assert "missing" in failed[0]["repair"] + + +def test_an_entirely_missing_variable_is_refused_with_its_own_repair( + tmp_path, regional_grid +): + grid_file, n_face = regional_grid + data_file = _write(tmp_path, "allnan", np.full(n_face, NAN)) + result = _analyze(grid_file, data_file) + + assert result["outcome"] == "input_required" + repair = result["refusal"]["failed_checks"][0]["repair"] + # Nothing was measurable, so pointing at lat_spec would be a wrong lead. + assert "lat_spec" not in repair + assert "no usable values" in repair + + +def test_an_ordinary_masked_field_neither_refuses_nor_warns(tmp_path, regional_grid): + """A band left entirely empty is not a defect: it simply has no faces.""" + grid_file, n_face = regional_grid + values = _field(n_face) + values[:FACES_PER_BAND] = NAN + data_file = _write(tmp_path, "masked", values) + result = _analyze(grid_file, data_file) + + coverage = result["anomaly_coverage"] + assert coverage["n_face_with_data"] == n_face - FACES_PER_BAND + assert coverage["n_face_data_lost"] == 0 + assert coverage["cause"] == "missing_input" + assert result["outcome"] == "complete" + assert result["scientific_status"]["warning_codes"] == [] + + +def test_the_override_returns_the_number_without_claiming_it(tmp_path, regional_grid): + grid_file, n_face = regional_grid + data_file = _write(tmp_path, "allnan_override", np.full(n_face, NAN)) + result = _analyze(grid_file, data_file, acknowledge=OVERRIDE_TOKEN) + + assert result["outcome"] == "complete" + assert result["preconditions"]["status"] == "overridden" + assert result["preconditions"]["override_used"] is True + assert result["scientific_status"]["physically_interpretable"] is False + # There is no number to hand back, and the override does not invent one. + assert result["stats"] == {"min": None, "max": None, "mean": None, "std": None}