Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions src/uxarray_mcp/domain/anomaly_coverage.py
Original file line number Diff line number Diff line change
@@ -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 []
2 changes: 2 additions & 0 deletions src/uxarray_mcp/domain/zonal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
54 changes: 54 additions & 0 deletions src/uxarray_mcp/preconditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions src/uxarray_mcp/tools/frontdoor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +21,7 @@
OUTCOME_COMPLETE,
PreconditionRefusal,
enforce,
evaluate_anomaly_preconditions,
evaluate_comparison_preconditions,
evaluate_ensemble_preconditions,
evaluate_profile_preconditions,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading