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
181 changes: 181 additions & 0 deletions src/uxarray_mcp/domain/uxarray_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""What of UXarray this server reaches, and what it deliberately does not.

The server is intent-shaped rather than a binding layer, so it was never
going to expose every UXarray method and should not try. The risk is not
incompleteness; it is incompleteness nobody decided on. A method appears
upstream, no one notices, and the gap is discovered by a user whose
notebook does the obvious thing.

:data:`SURFACE` is that decision written down. Every UXarray-specific
science method is listed once, with either the operation that reaches it
or the reason it is out of scope. ``tests/test_uxarray_surface.py`` holds
the list to the installed library: a new method upstream fails the test
until someone records a verdict, and a renamed one fails it the day the
rename lands rather than the next time a user tries it.

Deliberately *not* a count. A test asserting "we expose N of M methods"
goes red on every upstream release for reasons nobody can act on, and the
fix is to edit the number -- which teaches people to stop reading it.
"""

from __future__ import annotations

from typing import Literal

#: Why a method is not reachable. Narrow on purpose: if a gap does not fit
#: one of these, it probably has not been thought about yet.
Verdict = Literal[
"exposed",
# Reachable, but the server spells it differently -- usually because
# the intent, not the method, is what a caller names.
"exposed-aliased",
# A deliberate omission, with the reason in the second field.
"out-of-scope",
# Known gap we intend to close. Kept distinct from out-of-scope so the
# backlog is visible rather than quietly accepted.
"gap",
]

#: method -> (verdict, how it is reached, or why it is not)
#:
#: Keys are attribute names on ``UxDataArray``/``UxDataset`` (minus what
#: they inherit from xarray) and on the ``subset`` accessor. The xarray
#: surface is excluded on purpose: ~150 inherited methods would swamp the
#: list and none of them are UXarray's to change.
SURFACE: dict[str, tuple[Verdict, str]] = {
# -- selection ---------------------------------------------------
"subset": (
"out-of-scope",
"the accessor object itself; its methods are listed individually",
),
"subset.bounding_box": ("exposed", "run_analysis(operation='subset_bbox')"),
"subset.bounding_circle": (
"exposed",
"subset_advanced(selection='circle')",
),
"subset.nearest_neighbor": (
"exposed",
"subset_advanced(selection='nearest')",
),
"subset.constant_latitude": (
"exposed-aliased",
"run_analysis(operation='cross_section', latitude=...)",
),
"subset.constant_longitude": (
"exposed-aliased",
"run_analysis(operation='cross_section', longitude=...)",
),
"subset.constant_latitude_interval": (
"gap",
(
"a latitude band; subset_bbox spanning all longitudes is close but "
"selects by face bounds rather than by band membership"
),
),
"subset.constant_longitude_interval": (
"gap",
"a longitude band; same reasoning as the latitude interval",
),
# -- reductions and calculus -------------------------------------
"zonal_mean": ("exposed", "run_analysis(operation='calculate_zonal_mean')"),
"zonal_average": ("exposed-aliased", "alias of zonal_mean upstream"),
"zonal_anomaly": ("exposed", "run_analysis(operation='zonal_anomaly')"),
"azimuthal_mean": ("exposed", "run_analysis(operation='azimuthal_mean')"),
"azimuthal_average": ("exposed-aliased", "alias of azimuthal_mean upstream"),
"gradient": ("exposed", "run_analysis(operation='gradient')"),
"curl": ("exposed", "run_analysis(operation='curl')"),
"divergence": ("exposed", "run_analysis(operation='divergence')"),
"difference": ("exposed-aliased", "run_analysis(operation='compare_fields')"),
"remap": ("exposed", "run_analysis(operation='remap_variable', method=...)"),
"cross_section": ("exposed", "run_analysis(operation='cross_section')"),
# -- node/edge/face transfer -------------------------------------
"topological_mean": (
"exposed",
(
"regrid_elements(statistic='mean'); also the repair named when a "
"face-centered operation is handed node-centered data"
),
),
"topological_min": ("exposed", "regrid_elements(statistic='min')"),
"topological_max": ("exposed", "regrid_elements(statistic='max')"),
"topological_sum": ("exposed", "regrid_elements(statistic='sum')"),
"topological_std": ("exposed", "regrid_elements(statistic='std')"),
"topological_var": ("exposed", "regrid_elements(statistic='var')"),
"topological_median": ("exposed", "regrid_elements(statistic='median')"),
"topological_prod": (
"out-of-scope",
(
"a product over neighbouring elements has no physical reading for "
"a geophysical field; the other reductions do"
),
),
"topological_any": (
"out-of-scope",
"boolean reduction; the server's operations are all on float fields",
),
"topological_all": (
"out-of-scope",
"boolean reduction; see topological_any",
),
# -- known gaps ---------------------------------------------------
"weighted_mean": (
"gap",
(
"area-weighted field mean. calculate_area reports the areas and "
"several operations weight internally, but a caller cannot ask for "
"the weighted mean of a field on its own"
),
),
"neighborhood": (
"gap",
(
"neighbourhood filtering, used by the cyclone-tracking notebook in "
"UXARRAY/uxarray-community"
),
),
"to_raster": (
"gap",
(
"rasterization. plot_dataset renders images; this returns the array "
"behind one, which is a different request"
),
),
# -- out of scope -------------------------------------------------
"get_dual": (
"out-of-scope",
(
"constructs the dual mesh -- a mesh-authoring operation, not an "
"analysis of a field the caller already has"
),
),
"scalardotgradient": (
"out-of-scope",
(
"a composition of gradient and a dot product; both parts are "
"reachable and the composition is the caller's to make"
),
),
# -- plumbing, never exposed -------------------------------------
"uxgrid": ("out-of-scope", "attribute access, not an operation"),
"to_xarray": ("out-of-scope", "type conversion"),
"from_xarray": ("out-of-scope", "type conversion"),
"from_healpix": ("out-of-scope", "constructor; grid_path='healpix:<zoom>'"),
"from_structured": ("out-of-scope", "constructor"),
"to_geodataframe": ("out-of-scope", "type conversion"),
"to_polycollection": ("out-of-scope", "type conversion, matplotlib-specific"),
"data_mapping": ("out-of-scope", "attribute access"),
"source_datasets": ("out-of-scope", "attribute access"),
}

#: Verdicts that mean a caller can actually run the thing.
REACHABLE: frozenset[str] = frozenset({"exposed", "exposed-aliased"})


def unreachable() -> dict[str, tuple[Verdict, str]]:
"""The part of UXarray a caller cannot get to through this server."""
return {k: v for k, v in SURFACE.items() if v[0] not in REACHABLE}


def gaps() -> dict[str, str]:
"""Known gaps we mean to close, as ``name -> reason``."""
return {k: v[1] for k, v in SURFACE.items() if v[0] == "gap"}
18 changes: 15 additions & 3 deletions src/uxarray_mcp/domain/vector_calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
from uxarray_mcp.domain.profile_coverage import compute_profile_coverage
from uxarray_mcp.domain.zonal import extract_profile

#: Appended to every "not face-centered" refusal.
#:
#: Refusing was right; refusing without a way forward was not. Node-centred
#: vorticity is ordinary model output, and the standard move is
#: ``topological_mean("face")`` -- which the server could not run at all
#: until ``regrid_elements`` existed, so the message had nothing to offer.
#: Found by replaying the UXARRAY/uxarray-community cyclone notebook, which
#: does exactly this before computing anything.
_REGRID_REPAIR = (
"Move it onto faces first with regrid_elements(destination='face'), then retry."
)

_T = TypeVar("_T")


Expand Down Expand Up @@ -217,7 +229,7 @@ def compute_gradient(
if "n_face" not in var.dims and "nCells" not in var.dims:
raise ValueError(
f"Variable '{variable_name}' is not face-centered. "
"Gradient requires face-centered data."
"Gradient requires face-centered data. " + _REGRID_REPAIR
)
var, reduced_dims = reduce_to_face(
var, time_index=time_index, level_index=level_index
Expand Down Expand Up @@ -332,7 +344,7 @@ def compute_curl(
if "n_face" not in var.dims and "nCells" not in var.dims:
raise ValueError(
f"Variable '{name}' is not face-centered. "
"Curl requires face-centered vector components."
"Curl requires face-centered vector components. " + _REGRID_REPAIR
)
u, u_reduced = reduce_to_face(u, time_index=time_index, level_index=level_index)
v, v_reduced = reduce_to_face(v, time_index=time_index, level_index=level_index)
Expand Down Expand Up @@ -462,7 +474,7 @@ def compute_divergence(
if "n_face" not in var.dims and "nCells" not in var.dims:
raise ValueError(
f"Variable '{name}' is not face-centered. "
"Divergence requires face-centered vector components."
"Divergence requires face-centered vector components. " + _REGRID_REPAIR
)
u, u_reduced = reduce_to_face(u, time_index=time_index, level_index=level_index)
v, v_reduced = reduce_to_face(v, time_index=time_index, level_index=level_index)
Expand Down
10 changes: 10 additions & 0 deletions src/uxarray_mcp/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@
"subset_bbox",
"subset_polygon",
"extract_cross_section",
# Selection near a point, which the bounding box cannot express:
# a radius, or the k closest faces. Both appear in the
# uxarray-community notebooks.
"subset_advanced",
# Moves a field between nodes, edges and faces. Kept with the
# shape tools because it changes where values live, not what
# they mean.
"regrid_elements",
"remap_variable",
"regrid_dataset",
"remap_to_rectilinear",
Expand Down Expand Up @@ -795,6 +803,8 @@ def _apply_output_schema(tool: object, raw_name: str) -> None:
"compare_fields": "diff two datasets verification",
"calculate_area": "face cell surface area",
"subset_bbox": "longitude latitude bounding box region",
"subset_advanced": "circle radius nearest neighbor closest faces point",
"regrid_elements": "node edge face centered topological mean interpolate",
"subset_polygon": "polygon region of interest mask",
"extract_cross_section": "transect slice latitude longitude",
"remap_variable": "interpolation target grid",
Expand Down
6 changes: 6 additions & 0 deletions src/uxarray_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
resume_workflow,
run_workflow,
)
from .surface_extras import (
regrid_elements,
subset_advanced,
)
from .transfer_tools import (
transfer_get,
transfer_ls,
Expand Down Expand Up @@ -109,6 +113,8 @@
"plot_variable",
"plot_zonal_mean",
"subset_bbox",
"subset_advanced",
"regrid_elements",
"subset_polygon",
"extract_cross_section",
"compare_fields",
Expand Down
17 changes: 17 additions & 0 deletions src/uxarray_mcp/tools/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,23 @@ def get_capabilities(
"configured_endpoints": config.endpoint_names,
},
"uxarray_capabilities": uxarray_capabilities,
# What the list above is, and is not. Every string in it is a real
# UXarray call valid for this mesh, but the server does not execute
# all of them -- `grid.get_ball_tree()` and `var.integrate()` have no
# operation behind them and never did. Without saying so, a model
# reading this plans a call that does not exist, which is the same
# defect as a wrong tool annotation: the server stating something
# about itself that is not true. `mcp_server_tools` above is the
# executable surface; this one is advice for code the caller writes.
"uxarray_capabilities_are_advisory": {
"meaning": (
"Native UXarray calls valid for this mesh, listed so you can "
"write them yourself. Not all are executable through this "
"server."
),
"executable_surface": "mcp_server_tools",
"inventory": "uxarray_mcp.domain.uxarray_surface.SURFACE",
},
"scientific_contracts": {
"vector_calculus": {
"structurally_applicable": len(face_centered_var_names) >= 2,
Expand Down
Loading
Loading