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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 156 additions & 0 deletions src/uxarray_mcp/domain/temporal_coverage.py
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions src/uxarray_mcp/preconditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions src/uxarray_mcp/tools/advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/uxarray_mcp/tools/frontdoor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading