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
6 changes: 3 additions & 3 deletions .github/workflows/monthly-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ jobs:
if: steps.prep.outputs.release_needed == 'true'
uses: astral-sh/setup-uv@v5

- name: Set up Python 3.13
- name: Set up Python 3.12
if: steps.prep.outputs.release_needed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.12"

- name: Run release checks
if: steps.prep.outputs.release_needed == 'true'
Expand All @@ -78,7 +78,7 @@ jobs:
run: |
uv build
uvx twine check dist/*
uv venv /tmp/uxarray-mcp-wheel --python 3.13
uv venv /tmp/uxarray-mcp-wheel --python 3.12
uv pip install --python /tmp/uxarray-mcp-wheel/bin/python dist/uxarray_mcp-*.whl
/tmp/uxarray-mcp-wheel/bin/uxarray-mcp --help

Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ uses Semantic Versioning for public releases.

## Unreleased

### Breaking
- `gradient` and `curl` now default `scale_by_radius=True`, matching UXarray's
public API. Pass `scale_by_radius=False` explicitly to preserve the previous
MCP unit-sphere behavior. This default alignment requires a minor release.

### Added
- Vector-calculus results now include a machine-actionable
`scientific_status` with `status`, `physically_interpretable`, stable warning
codes, and warning text.
- `get_capabilities` now returns variable units, standard names, dimensions,
and a `scientific_contracts.vector_calculus` block that separates structural
applicability from semantic suitability.
- Persistent JSON and NetCDF artifacts are written atomically under a process
lock so concurrent tool calls cannot expose partial result files.
- `gradient`, `curl`, and `divergence` (via `run_analysis` and the
`calculate_gradient`/`calculate_curl`/`calculate_divergence` tools) now
accept `time_index`/`level_index` to select a single time/level slice
Expand All @@ -27,6 +40,14 @@ uses Semantic Versioning for public releases.
reading the tool's structured JSON result.

### Fixed
- `analyze_dataset` and `run_scientific_agent` now actually skip zonal
statistics after failed validation; `analyze_dataset` also skips variable
plotting and records the validation summary in provenance.
- `analyze_dataset` derives aggregate execution venue from completed stage
provenance instead of labeling a fallback-local run as HPC solely because
`use_remote=True` was requested.
- The monthly release workflow now uses supported Python 3.12 instead of Python
3.13, which conflicts with the package's `requires-python` constraint.
- `calculate_area` (local and remote) silently defaulted `area_units` to
`"m^2"` whenever a grid's `face_areas` carried no `units` attribute at
all, fabricating a label the source file never provided. It now reports
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,16 @@ auditable and the server actively flags common scientific pitfalls:
- **Derivative unit convention is never hidden.** `gradient`, `curl`, and
`divergence` echo `scale_by_radius` in both the result and provenance, so a
unit-sphere result can never be mistaken for a physical (per-metre) one.
Gradient and curl default to physical scaling, matching UXarray; pass
`scale_by_radius=False` explicitly for unit-sphere output.
- **Vector-calculus sanity guard.** `curl`/`divergence` warn (without blocking)
when the two inputs are the same field, or when neither carries a
velocity/flux-like `units` attribute — the classic "vorticity from two random
scalars" mistake now surfaces a warning in `_provenance.warnings`.
scalars" mistake now surfaces a warning in `_provenance.warnings` and a
machine-actionable `scientific_status` with stable warning codes.
- **Applicability is not suitability.** `get_capabilities` reports whether
vector operations are structurally computable separately from whether
metadata supports physical interpretation.
- **Local/remote version drift is surfaced.** Remote results record the
worker's *actual* UXarray version (`remote_uxarray_version`) and emit a
warning when it differs from the local version, so silent numerical
Expand Down
14 changes: 10 additions & 4 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,20 @@ missing.

`gradient`, `curl`, and `divergence` echo the `scale_by_radius` convention in
their result and provenance. `gradient` and `curl` accept `scale_by_radius`
(default `False`). When `False`, results stay on the unit sphere (the historical
behavior). Set it to `True` to divide by `uxgrid.sphere_radius` for physical
units; the grid must define `sphere_radius`.
(default `True`, matching UXarray). When `True`, results are divided by
`uxgrid.sphere_radius` for physical units; the grid must define
`sphere_radius`. Pass `False` explicitly to keep unit-sphere results.

`curl` and `divergence` also emit **vector-component warnings**: if the two
inputs are the same field, or neither carries a velocity/flux-like `units`
attribute, a warning is added to `_provenance.warnings`. The computation still
runs (the math is valid), but the result is flagged as possibly non-physical.
runs (the math is valid), but `scientific_status` marks the result as a warning,
sets `physically_interpretable` to false, and includes stable warning codes.

`get_capabilities` distinguishes **structural applicability** from **semantic
suitability** for vector operations. Two face-centered arrays make curl and
divergence computable, but physical interpretation remains `unverified` unless
metadata supplies vector-like units or standard names.

`zonal_anomaly` and `remap_to_rectilinear` are backed by
`UxDataArray.zonal_anomaly` and `UxDataArray.remap.to_rectilinear`, available in
Expand Down
55 changes: 41 additions & 14 deletions src/uxarray_mcp/domain/vector_calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _vector_component_warnings(
u: Any,
v: Any,
operation: str,
) -> list[str]:
) -> tuple[list[str], list[str]]:
"""Return soft warnings when (u, v) do not look like real vector components.

curl and divergence are only physically meaningful when ``u`` and ``v`` are
Expand All @@ -118,8 +118,10 @@ def _vector_component_warnings(
scientist — can see that the inputs were suspicious.
"""
warnings: list[str] = []
warning_codes: list[str] = []

if u_variable == v_variable:
warning_codes.append("VECTOR_COMPONENTS_IDENTICAL")
warnings.append(
f"{operation}: u_variable and v_variable are the same field "
f"('{u_variable}'). {operation} is only physically meaningful for a "
Expand All @@ -138,6 +140,7 @@ def _looks_velocity(unit: str) -> bool:
return any(hint in unit for hint in _VELOCITY_LIKE_UNIT_HINTS)

if not (_looks_velocity(u_unit) or _looks_velocity(v_unit)):
warning_codes.append("VECTOR_UNITS_UNVERIFIED")
seen = ", ".join(
f"{name}='{unit or 'unset'}'"
for name, unit in ((u_variable, u_unit), (v_variable, v_unit))
Expand All @@ -149,13 +152,13 @@ def _looks_velocity(unit: str) -> bool:
f"{operation} physically."
)

return warnings
return warnings, warning_codes


def compute_gradient(
uxds: Any,
variable_name: str,
scale_by_radius: bool = False,
scale_by_radius: bool = True,
time_index: int = 0,
level_index: int = 0,
) -> dict:
Expand All @@ -171,10 +174,10 @@ def compute_gradient(
Loaded UXarray dataset.
variable_name : str
Face-centered scalar variable to differentiate.
scale_by_radius : bool, default False
scale_by_radius : bool, default True
When ``True``, divide the unit-sphere derivatives by
``uxgrid.sphere_radius`` to return physical units (requires a grid with
``sphere_radius``). The default ``False`` keeps the unit-sphere result.
``sphere_radius``). Pass ``False`` to keep the unit-sphere result.
time_index : int, default 0
Time index to select if the variable carries a leading time
dimension. Ignored if there is no time dimension.
Expand Down Expand Up @@ -229,7 +232,7 @@ def _stats(arr: Any) -> dict:

components = {name: _stats(grad[name]) for name in comp_names}

return {
result = {
"variable_name": variable_name,
"components": comp_names,
"component_stats": components,
Expand All @@ -238,13 +241,20 @@ def _stats(arr: Any) -> dict:
"interpretation": "zonal (∂/∂x) and meridional (∂/∂y) components of the gradient",
"component_warnings": uxarray_warnings,
}
from uxarray_mcp.provenance import attach_scientific_status

return attach_scientific_status(
result,
warnings=uxarray_warnings,
warning_codes=["SPHERE_RADIUS_UNAVAILABLE"] if uxarray_warnings else [],
)


def compute_curl(
uxds: Any,
u_variable: str,
v_variable: str,
scale_by_radius: bool = False,
scale_by_radius: bool = True,
time_index: int = 0,
level_index: int = 0,
) -> dict:
Expand All @@ -263,10 +273,10 @@ def compute_curl(
Zonal (east–west) component variable name.
v_variable : str
Meridional (north–south) component variable name.
scale_by_radius : bool, default False
scale_by_radius : bool, default True
When ``True``, divide the unit-sphere result by ``uxgrid.sphere_radius``
to return physical units (``1/s`` for wind in ``m/s``; requires a grid
with ``sphere_radius``). The default ``False`` keeps the unit sphere.
with ``sphere_radius``). Pass ``False`` to keep the unit sphere.
time_index : int, default 0
Time index to select if the components carry a leading time
dimension. Ignored if there is no time dimension.
Expand Down Expand Up @@ -298,7 +308,7 @@ def compute_curl(

import numpy as np

component_warnings = _vector_component_warnings(
component_warnings, warning_codes = _vector_component_warnings(
u_variable, v_variable, u, v, "curl"
)

Expand All @@ -316,6 +326,8 @@ def compute_curl(
seen.add(w)
deduped_uxarray_warnings.append(w)
component_warnings = component_warnings + deduped_uxarray_warnings
if deduped_uxarray_warnings:
warning_codes.append("SPHERE_RADIUS_UNAVAILABLE")
vals = result.values
finite = vals[np.isfinite(vals)]

Expand All @@ -330,7 +342,7 @@ def compute_curl(
else:
stats = {"min": None, "max": None, "mean": None, "std": None}

return {
result = {
"u_variable": u_variable,
"v_variable": v_variable,
"interpretation": "relative vorticity ζ = ∂v/∂x − ∂u/∂y",
Expand All @@ -339,6 +351,11 @@ def compute_curl(
"stats": stats,
"component_warnings": component_warnings,
}
from uxarray_mcp.provenance import attach_scientific_status

return attach_scientific_status(
result, warnings=component_warnings, warning_codes=warning_codes
)


def compute_divergence(
Expand Down Expand Up @@ -395,11 +412,16 @@ def compute_divergence(

import numpy as np

component_warnings = _vector_component_warnings(
component_warnings, warning_codes = _vector_component_warnings(
u_variable, v_variable, u, v, "divergence"
)

result = u.divergence(v)
result, uxarray_warnings = _call_capturing_warnings(lambda: u.divergence(v))
for warning in uxarray_warnings:
if warning not in component_warnings:
component_warnings.append(warning)
if uxarray_warnings:
warning_codes.append("SPHERE_RADIUS_UNAVAILABLE")
vals = result.values
finite = vals[np.isfinite(vals)]

Expand All @@ -414,14 +436,19 @@ def compute_divergence(
else:
stats = {"min": None, "max": None, "mean": None, "std": None}

return {
output = {
"u_variable": u_variable,
"v_variable": v_variable,
"interpretation": "horizontal divergence ∂u/∂x + ∂v/∂y",
"n_face": int(uxds.uxgrid.n_face),
"stats": stats,
"component_warnings": component_warnings,
}
from uxarray_mcp.provenance import attach_scientific_status

return attach_scientific_status(
output, warnings=component_warnings, warning_codes=warning_codes
)


def compute_azimuthal_mean(
Expand Down
17 changes: 17 additions & 0 deletions src/uxarray_mcp/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,20 @@ def attach_provenance(
provenance["validation_summary"] = validation_summary
result["_provenance"] = provenance
return result


def attach_scientific_status(
result: dict[str, Any],
*,
warnings: list[str] | None = None,
warning_codes: list[str] | None = None,
) -> dict[str, Any]:
"""Attach a machine-actionable scientific interpretation status."""
messages = warnings or []
result["scientific_status"] = {
"status": "warning" if messages else "complete",
"physically_interpretable": not messages,
"warning_codes": warning_codes or [],
"warnings": messages,
}
return result
4 changes: 2 additions & 2 deletions src/uxarray_mcp/remote/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ async def calculate_gradient_remote(
grid_path: str,
data_path: str,
variable_name: str,
scale_by_radius: bool = False,
scale_by_radius: bool = True,
time_index: int = 0,
level_index: int = 0,
) -> Dict[str, Any]:
Expand All @@ -240,7 +240,7 @@ async def calculate_curl_remote(
data_path: str,
u_variable: str,
v_variable: str,
scale_by_radius: bool = False,
scale_by_radius: bool = True,
time_index: int = 0,
level_index: int = 0,
) -> Dict[str, Any]:
Expand Down
Loading
Loading