diff --git a/src/uxarray_mcp/domain/uxarray_surface.py b/src/uxarray_mcp/domain/uxarray_surface.py new file mode 100644 index 0000000..b563452 --- /dev/null +++ b/src/uxarray_mcp/domain/uxarray_surface.py @@ -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:'"), + "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"} diff --git a/src/uxarray_mcp/domain/vector_calc.py b/src/uxarray_mcp/domain/vector_calc.py index 809d4cc..85e89ca 100644 --- a/src/uxarray_mcp/domain/vector_calc.py +++ b/src/uxarray_mcp/domain/vector_calc.py @@ -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") @@ -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 @@ -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) @@ -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) diff --git a/src/uxarray_mcp/registry.py b/src/uxarray_mcp/registry.py index 40b130d..727b9ae 100644 --- a/src/uxarray_mcp/registry.py +++ b/src/uxarray_mcp/registry.py @@ -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", @@ -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", diff --git a/src/uxarray_mcp/tools/__init__.py b/src/uxarray_mcp/tools/__init__.py index ee2d124..83b97ee 100644 --- a/src/uxarray_mcp/tools/__init__.py +++ b/src/uxarray_mcp/tools/__init__.py @@ -68,6 +68,10 @@ resume_workflow, run_workflow, ) +from .surface_extras import ( + regrid_elements, + subset_advanced, +) from .transfer_tools import ( transfer_get, transfer_ls, @@ -109,6 +113,8 @@ "plot_variable", "plot_zonal_mean", "subset_bbox", + "subset_advanced", + "regrid_elements", "subset_polygon", "extract_cross_section", "compare_fields", diff --git a/src/uxarray_mcp/tools/capabilities.py b/src/uxarray_mcp/tools/capabilities.py index 70c09d3..9f3abf5 100644 --- a/src/uxarray_mcp/tools/capabilities.py +++ b/src/uxarray_mcp/tools/capabilities.py @@ -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, diff --git a/src/uxarray_mcp/tools/surface_extras.py b/src/uxarray_mcp/tools/surface_extras.py new file mode 100644 index 0000000..b8462a3 --- /dev/null +++ b/src/uxarray_mcp/tools/surface_extras.py @@ -0,0 +1,326 @@ +"""Selection and element-transfer operations that ``run_analysis`` lacked. + +Two tools, kept out of ``run_analysis`` on purpose. That front door is +already 46 parameters across 25 operations and #89 tracks splitting it; +adding a radius, a neighbour count, a centre and a destination element to +it would have made the measured problem worse to fix a different one. +Each tool here takes only the arguments its own operation can use. + +Both were found by replaying the notebooks in UXARRAY/uxarray-community +through the server: ``subset.bounding_circle`` and +``subset.nearest_neighbor`` appear four times each, and +``topological_mean("face")`` three times -- the last one in the step that +moves node-centred vorticity onto faces before anything else can be done +with it. Before this, a caller handed node-centred data was told +"Variable 't_node' is not face-centered", which is true, and given no way +to act on it. +""" + +from __future__ import annotations + +from typing import Any + +from uxarray_mcp.domain.mesh import load_grid +from uxarray_mcp.domain.subset_coverage import compute_subset_coverage, mesh_extent +from uxarray_mcp.provenance import attach_provenance +from uxarray_mcp.state import ( + OperationTracker, + summarize_array, + summarize_grid, +) +from uxarray_mcp.tools.advanced import ( + _load_dataarray, + _persist_dataarray_result, + _persist_grid_result, + _resolve_paths, +) + +#: ``topological_*`` reductions a caller may ask for by name. +#: +#: ``prod`` and the boolean pair are left out: a product over neighbouring +#: elements has no physical reading for a geophysical field, and the +#: server's operations are all on float fields. See +#: ``domain/uxarray_surface.py`` for the full record. +TOPOLOGICAL_STATISTICS: tuple[str, ...] = ( + "mean", + "min", + "max", + "sum", + "std", + "var", + "median", +) + +#: Mesh elements a field can be carried onto. +ELEMENTS: tuple[str, ...] = ("node", "edge", "face") + + +def subset_advanced( + selection: str, + center_lon: float, + center_lat: float, + radius: float | None = None, + k: int | None = None, + grid_path: str | None = None, + data_path: str | None = None, + variable_name: str | None = None, + session_id: str | None = None, + dataset_handle: str | None = None, + result_name: str | None = None, +) -> dict[str, Any]: + """Select faces near a point, by radius or by neighbour count. + + Args: + selection: ``"circle"`` keeps every face within ``radius`` degrees + of the centre. ``"nearest"`` keeps the ``k`` closest faces + regardless of how far away they are. + center_lon: Centre longitude in degrees. + center_lat: Centre latitude in degrees. + radius: Great-circle radius in degrees. Required for ``"circle"``. + k: Number of faces to keep. Required for ``"nearest"``. + grid_path: Mesh file, or ``healpix:``. + data_path: Optional data file; without it the mesh alone is subset. + variable_name: Field to carry through the selection. + session_id: Session to track this operation under. + dataset_handle: Registered dataset to use instead of paths. + result_name: Name for the persisted result. + + Returns: + The selected grid or field, with ``subset_coverage`` reporting how + much of the source mesh was kept. + + Raises: + ValueError: Unknown ``selection``, a missing or non-positive + ``radius``/``k``, or a selection that kept nothing. + """ + if selection not in ("circle", "nearest"): + raise ValueError( + f"Unknown selection {selection!r}; expected 'circle' or 'nearest'." + ) + if selection == "circle": + if radius is None: + raise ValueError("selection='circle' requires radius (in degrees).") + if radius <= 0: + raise ValueError(f"radius must be positive, got {radius}.") + else: + if k is None: + raise ValueError("selection='nearest' requires k (number of faces).") + if k < 1: + raise ValueError(f"k must be at least 1, got {k}.") + + tracker = OperationTracker("subset_advanced", session_id=session_id) + tracker.stage("loading", "Loading grid and optional dataset.") + resolved_grid, resolved_data = _resolve_paths( + session_id=session_id, + dataset_handle=dataset_handle, + grid_path=grid_path, + data_path=data_path, + ) + grid = load_grid(resolved_grid) + center = (center_lon, center_lat) + rule = "face_center_within_radius" if selection == "circle" else "k_nearest_faces" + + def _apply(obj): + if selection == "circle": + return obj.subset.bounding_circle(center_coord=center, r=radius) + return obj.subset.nearest_neighbor(center_coord=center, k=k) + + try: + subset_grid = _apply(grid) + except ValueError as exc: + # UXarray raises when a circle finds nothing. Reported the same way + # an empty bounding box is, so the caller is told where the mesh + # actually is rather than only that the circle was empty -- the + # raw message says the first and not the second. + if "No elements found" not in str(exc) and "not enough" not in str(exc).lower(): + raise + raise ValueError( + f"Selection kept no faces: {selection} centred at " + f"({center_lon}, {center_lat})" + + (f" with radius {radius} degrees" if radius is not None else "") + + f". The mesh covers {mesh_extent(grid)}. Move the centre inside " + "that extent, or widen the radius." + ) from exc + + result_handle = None + variable_summary = None + selected = None + if resolved_data is not None: + _, uxda, selected = _load_dataarray(resolved_grid, resolved_data, variable_name) + subset_data = _apply(uxda) + variable_summary = summarize_array(subset_data.to_xarray()) + result_handle = _persist_dataarray_result( + data=subset_data, + session_id=session_id, + name=result_name or f"{selection}:{selected}", + kind="subset_advanced", + summary=variable_summary, + metadata={ + "selection": selection, + "center": {"lon": center_lon, "lat": center_lat}, + "radius": radius, + "k": k, + "variable_name": selected, + }, + ) + else: + result_handle = _persist_grid_result( + grid=subset_grid, + session_id=session_id, + name=result_name or f"{selection}_subset", + kind="subset_advanced_grid", + summary=summarize_grid(subset_grid), + ) + + tracker.succeed(f"Selected {subset_grid.n_face} faces.") + result: dict[str, Any] = { + "selection": selection, + "center": {"lon": center_lon, "lat": center_lat}, + "radius_deg": radius, + "k": k, + "subset_grid": summarize_grid(subset_grid), + "variable_name": selected, + "variable_summary": variable_summary, + "result_handle": result_handle, + "subset_coverage": compute_subset_coverage( + grid.n_face, + subset_grid.n_face, + extent=mesh_extent(grid), + selection_rule=rule, + ), + } + result = attach_provenance( + result, + tool="subset_advanced", + inputs={ + "selection": selection, + "center_lon": center_lon, + "center_lat": center_lat, + "radius": radius, + "k": k, + "grid_path": grid_path, + "data_path": data_path, + "variable_name": variable_name, + "session_id": session_id, + "dataset_handle": dataset_handle, + }, + ) + result["_provenance"]["operation_id"] = tracker.operation_id + return result + + +def regrid_elements( + destination: str, + grid_path: str | None = None, + data_path: str | None = None, + variable_name: str | None = None, + statistic: str = "mean", + session_id: str | None = None, + dataset_handle: str | None = None, + result_name: str | None = None, +) -> dict[str, Any]: + """Carry a field between nodes, edges and faces. + + Most of this server's operations need face-centred data, and model + output is frequently node-centred -- vorticity especially. This is the + step that makes such a field usable, and the repair named when one of + those operations refuses. + + This changes where the values live, not what they mean: each + destination element takes the chosen statistic over the source + elements touching it. ``mean`` is the usual choice; ``min``/``max`` + are for extremes, which averaging would hide. + + Args: + destination: ``"node"``, ``"edge"`` or ``"face"``. + grid_path: Mesh file, or ``healpix:``. + data_path: Data file holding the field. + variable_name: Field to move. + statistic: One of ``mean``, ``min``, ``max``, ``sum``, ``std``, + ``var``, ``median``. + session_id: Session to track this operation under. + dataset_handle: Registered dataset to use instead of paths. + result_name: Name for the persisted result. + + Returns: + The field on its new element, with the source and destination + dimensions both named so the move is auditable. + + Raises: + ValueError: Unknown destination or statistic, no data supplied, or + a field already on the requested element. + """ + if destination not in ELEMENTS: + raise ValueError( + f"Unknown destination {destination!r}; expected one of " + f"{', '.join(ELEMENTS)}." + ) + if statistic not in TOPOLOGICAL_STATISTICS: + raise ValueError( + f"Unknown statistic {statistic!r}; expected one of " + f"{', '.join(TOPOLOGICAL_STATISTICS)}." + ) + + tracker = OperationTracker("regrid_elements", session_id=session_id) + resolved_grid, resolved_data = _resolve_paths( + session_id=session_id, + dataset_handle=dataset_handle, + grid_path=grid_path, + data_path=data_path, + ) + if resolved_data is None: + raise ValueError( + "regrid_elements moves a field between elements and so needs " + "data_path (or a dataset_handle that carries one)." + ) + + _, uxda, selected = _load_dataarray(resolved_grid, resolved_data, variable_name) + source_dims = list(uxda.dims) + target_dim = f"n_{destination}" + if target_dim in source_dims: + raise ValueError( + f"Variable {selected!r} is already on {destination} elements " + f"(dims {source_dims}); nothing to move." + ) + + moved = getattr(uxda, f"topological_{statistic}")(destination) + summary = summarize_array(moved.to_xarray()) + result_handle = _persist_dataarray_result( + data=moved, + session_id=session_id, + name=result_name or f"{selected}_on_{destination}", + kind="regrid_elements", + summary=summary, + metadata={ + "destination": destination, + "statistic": statistic, + "variable_name": selected, + "source_dims": source_dims, + }, + ) + tracker.succeed(f"Moved {selected!r} onto {destination} elements.") + + result: dict[str, Any] = { + "variable_name": selected, + "destination": destination, + "statistic": statistic, + "source_dims": source_dims, + "result_dims": list(moved.dims), + "variable_summary": summary, + "result_handle": result_handle, + } + result = attach_provenance( + result, + tool="regrid_elements", + inputs={ + "destination": destination, + "statistic": statistic, + "grid_path": grid_path, + "data_path": data_path, + "variable_name": variable_name, + "session_id": session_id, + "dataset_handle": dataset_handle, + }, + ) + result["_provenance"]["operation_id"] = tracker.operation_id + return result diff --git a/tests/test_server.py b/tests/test_server.py index 2ed5805..5ce42c9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -29,7 +29,11 @@ EXPECTED_CORE_EXTRA = 3 # list_datasets + 2 response-contract tools EXPECTED_PROMPTS = 7 # first_look, vorticity_analysis, cyclone_structure, # eddy_activity, model_evaluation, climatology_anomaly, hpc_diagnose -EXPECTED_DEFERRED = 33 # +zonal_anomaly, +remap_to_rectilinear, +check_remote_yac +EXPECTED_DEFERRED = 35 # +zonal_anomaly, +remap_to_rectilinear, +check_remote_yac, +# +subset_advanced and +regrid_elements, which close the bounding_circle / +# nearest_neighbor / topological_mean gaps found by replaying the +# uxarray-community notebooks. Deferred rather than front-door on purpose: +# #89 already measures run_analysis as too large to describe itself. @pytest.fixture(autouse=True) diff --git a/tests/test_surface_extras.py b/tests/test_surface_extras.py new file mode 100644 index 0000000..012b461 --- /dev/null +++ b/tests/test_surface_extras.py @@ -0,0 +1,260 @@ +"""``subset_advanced`` and ``regrid_elements``. + +Both close gaps found by replaying the UXARRAY/uxarray-community +notebooks through the server: a selection near a point (four uses each of +``bounding_circle`` and ``nearest_neighbor``) and node-to-face transfer +(three uses of ``topological_mean``). + +The selection tests lean on the same rule as ``test_subset_coverage.py``: +a selection that kept nothing must not come back shaped like a selection +that kept something. +""" + +from __future__ import annotations + +import pytest + +from uxarray_mcp.tools.surface_extras import ( + TOPOLOGICAL_STATISTICS, + regrid_elements, + subset_advanced, +) + + +@pytest.fixture +def global_mesh(tmp_path): + """A global structured mesh plus a face-centred field.""" + import numpy as np + import uxarray as ux + import xarray as xr + + grid = ux.Grid.from_structured( + lon=np.arange(-180, 180, 20.0), lat=np.arange(-80, 81, 20.0) + ) + gf = tmp_path / "g.nc" + df = tmp_path / "d.nc" + grid.to_xarray().to_netcdf(gf) + rng = np.random.default_rng(3) + xr.Dataset( + {"temperature": (["n_face"], 280 + 20 * rng.random(grid.n_face))} + ).to_netcdf(df) + return str(gf), str(df) + + +@pytest.fixture +def node_data_file(global_mesh, tmp_path): + """A node-centred field on the same mesh. + + Node-centred output is the case the face-centred refusals exist for, + and no shared fixture had one. + """ + import numpy as np + import uxarray as ux + import xarray as xr + + gf, _ = global_mesh + n_node = ux.open_grid(gf).n_node + nf = tmp_path / "node.nc" + xr.Dataset({"t_node": (["n_node"], np.linspace(250.0, 300.0, n_node))}).to_netcdf( + nf + ) + return str(nf) + + +class TestSelectingNearAPoint: + def test_a_circle_keeps_faces_within_the_radius(self, global_mesh): + global_grid_file, _ = global_mesh + out = subset_advanced( + selection="circle", + center_lon=0.0, + center_lat=0.0, + radius=30.0, + grid_path=global_grid_file, + ) + assert out["selection"] == "circle" + assert out["subset_grid"]["n_face"] > 0 + assert out["subset_coverage"]["selection_rule"] == "face_center_within_radius" + + def test_nearest_keeps_exactly_k_faces(self, global_mesh): + global_grid_file, _ = global_mesh + """The point of ``nearest``: a count, not a distance.""" + out = subset_advanced( + selection="nearest", + center_lon=0.0, + center_lat=0.0, + k=12, + grid_path=global_grid_file, + ) + assert out["subset_grid"]["n_face"] == 12 + + def test_a_circle_that_finds_nothing_says_where_the_mesh_is( + self, regional_mesh_files + ): + regional_grid_file, _ = regional_mesh_files + """UXarray says the circle was empty. That is true and not useful. + + The caller needs to know where a circle *would* find something, + which is what the mesh extent carries. + """ + with pytest.raises(ValueError) as err: + subset_advanced( + selection="circle", + center_lon=-170.0, + center_lat=-80.0, + radius=1.0, + grid_path=regional_grid_file, + ) + msg = str(err.value) + assert "kept no faces" in msg + assert "mesh covers" in msg + + def test_a_field_follows_the_selection(self, global_mesh): + global_grid_file, global_data_file = global_mesh + out = subset_advanced( + selection="circle", + center_lon=0.0, + center_lat=0.0, + radius=40.0, + grid_path=global_grid_file, + data_path=global_data_file, + variable_name="temperature", + ) + assert out["variable_name"] == "temperature" + assert out["variable_summary"] is not None + assert out["result_handle"] is not None + + +class TestTheArgumentsMustMatchTheSelection: + """Each selection needs its own argument and cannot borrow the other's. + + A circle with no radius and a nearest with no k are the two mistakes + the schema cannot prevent, since both parameters are optional. + """ + + def test_circle_without_a_radius_is_refused(self, global_mesh): + global_grid_file, _ = global_mesh + with pytest.raises(ValueError, match="requires radius"): + subset_advanced( + selection="circle", + center_lon=0.0, + center_lat=0.0, + grid_path=global_grid_file, + ) + + def test_nearest_without_k_is_refused(self, global_mesh): + global_grid_file, _ = global_mesh + with pytest.raises(ValueError, match="requires k"): + subset_advanced( + selection="nearest", + center_lon=0.0, + center_lat=0.0, + grid_path=global_grid_file, + ) + + def test_a_nonpositive_radius_is_refused(self, global_mesh): + global_grid_file, _ = global_mesh + with pytest.raises(ValueError, match="must be positive"): + subset_advanced( + selection="circle", + center_lon=0.0, + center_lat=0.0, + radius=0.0, + grid_path=global_grid_file, + ) + + def test_an_unknown_selection_names_the_two_that_exist(self, global_mesh): + global_grid_file, _ = global_mesh + with pytest.raises(ValueError, match="circle.*nearest"): + subset_advanced( + selection="square", + center_lon=0.0, + center_lat=0.0, + grid_path=global_grid_file, + ) + + +class TestMovingAFieldBetweenElements: + def test_node_data_lands_on_faces(self, global_mesh, node_data_file): + global_grid_file, _ = global_mesh + out = regrid_elements( + destination="face", + grid_path=global_grid_file, + data_path=node_data_file, + variable_name="t_node", + ) + assert out["source_dims"] == ["n_node"] + assert out["result_dims"] == ["n_face"] + assert out["statistic"] == "mean" + + @pytest.mark.parametrize("stat", TOPOLOGICAL_STATISTICS) + def test_every_offered_statistic_runs(self, stat, global_mesh, node_data_file): + global_grid_file, _ = global_mesh + """Offering a statistic the library cannot apply would be worse + than not offering it: the failure arrives after the caller has + chosen. + """ + out = regrid_elements( + destination="face", + statistic=stat, + grid_path=global_grid_file, + data_path=node_data_file, + variable_name="t_node", + ) + assert out["result_dims"] == ["n_face"] + + def test_a_field_already_on_the_destination_is_refused(self, global_mesh): + global_grid_file, global_data_file = global_mesh + """Nothing to do is not the same as done.""" + with pytest.raises(ValueError, match="already on face"): + regrid_elements( + destination="face", + grid_path=global_grid_file, + data_path=global_data_file, + variable_name="temperature", + ) + + def test_an_unknown_statistic_is_refused(self, global_mesh, node_data_file): + global_grid_file, _ = global_mesh + with pytest.raises(ValueError, match="Unknown statistic"): + regrid_elements( + destination="face", + statistic="kurtosis", + grid_path=global_grid_file, + data_path=node_data_file, + variable_name="t_node", + ) + + def test_without_data_it_says_so(self, global_mesh): + global_grid_file, _ = global_mesh + """A mesh alone has no field to move.""" + with pytest.raises(ValueError, match="needs data_path"): + regrid_elements(destination="face", grid_path=global_grid_file) + + +class TestTheRepairIsReachable: + """The face-centred refusals now name ``regrid_elements``. + + That advice has to work, or it is a worse dead end than saying + nothing: the caller follows it and lands on a second error. + """ + + def test_the_advice_in_the_gradient_refusal_actually_fixes_it( + self, global_mesh, node_data_file + ): + global_grid_file, _ = global_mesh + import uxarray as ux + + from uxarray_mcp.domain.vector_calc import compute_gradient + + uxds = ux.open_dataset(global_grid_file, node_data_file) + with pytest.raises(ValueError) as err: + compute_gradient(uxds, "t_node") + assert "regrid_elements(destination='face')" in str(err.value) + + moved = regrid_elements( + destination="face", + grid_path=global_grid_file, + data_path=node_data_file, + variable_name="t_node", + ) + assert moved["result_dims"] == ["n_face"] diff --git a/tests/test_uxarray_surface.py b/tests/test_uxarray_surface.py new file mode 100644 index 0000000..a72291b --- /dev/null +++ b/tests/test_uxarray_surface.py @@ -0,0 +1,126 @@ +"""The inventory in ``domain/uxarray_surface.py`` against the real library. + +Three failure modes, each of which happened or nearly happened: + +1. UXarray grows a method, nobody notices, and a user's notebook finds the + gap before we do. ``subset.bounding_circle`` and ``topological_mean`` + were found exactly that way, in the uxarray-community notebooks. +2. UXarray renames or drops a method we advertise in ``get_capabilities``, + and the server keeps recommending a call that no longer exists. +3. The inventory itself drifts -- it claims an operation reaches something + when the operation was never wired up. + +None of these are caught by a count. A test asserting "N of M exposed" +fails on every release for reasons nobody can act on. These assert that +every method has a *verdict*, which is a decision someone made. +""" + +from __future__ import annotations + +import uxarray as ux +import xarray as xr + +from uxarray_mcp.domain.uxarray_surface import REACHABLE, SURFACE, gaps + + +def _uxarray_only(cls, base) -> set[str]: + """Public names ``cls`` adds on top of its xarray base. + + The inherited xarray surface is deliberately excluded: ~150 methods + that are not UXarray's to change and would drown the signal. + """ + own = {n for n in dir(cls) if not n.startswith("_")} + inherited = {n for n in dir(base) if not n.startswith("_")} + return own - inherited + + +def _installed_surface() -> set[str]: + from uxarray.subset import DataArraySubsetAccessor + + names = _uxarray_only(ux.UxDataArray, xr.DataArray) + names |= _uxarray_only(ux.UxDataset, xr.Dataset) + names |= { + f"subset.{n}" for n in dir(DataArraySubsetAccessor) if not n.startswith("_") + } + return names + + +class TestEveryUpstreamMethodHasAVerdict: + def test_nothing_upstream_is_unaccounted_for(self): + """A new UXarray method must be given a verdict, not ignored. + + If this fails, UXarray added something. Decide what it is -- + `exposed`, `gap`, or `out-of-scope` with a reason -- and add it to + SURFACE. Deciding "we are not doing this" is a fine answer; not + noticing is not. + """ + missing = sorted(_installed_surface() - set(SURFACE)) + assert not missing, ( + f"uxarray {ux.__version__} has methods with no verdict in " + f"domain/uxarray_surface.py: {missing}" + ) + + def test_the_inventory_does_not_name_methods_that_vanished(self): + """A verdict about a method that no longer exists is stale. + + Catches an upstream rename the day it lands rather than the next + time a user follows our advice into a method that is gone. + """ + installed = _installed_surface() + stale = sorted(k for k in SURFACE if k not in installed) + assert not stale, ( + f"domain/uxarray_surface.py names methods absent from uxarray " + f"{ux.__version__}: {stale}. Renamed or removed upstream?" + ) + + +class TestTheInventoryIsInternallyHonest: + def test_every_entry_carries_a_reason(self): + for name, (verdict, reason) in SURFACE.items(): + assert reason.strip(), f"{name} has verdict {verdict} and no reason" + + def test_every_exposed_entry_names_how_to_reach_it(self): + """ "exposed" with no route is the drift this file exists to stop.""" + for name, (verdict, reason) in SURFACE.items(): + if verdict in REACHABLE: + assert "(" in reason or "alias" in reason, ( + f"{name} is {verdict} but its note does not name a call: {reason!r}" + ) + + def test_the_operations_named_as_routes_actually_exist(self): + """An inventory claiming a route that was never wired up is worse + than an inventory with a gap in it: the gap is at least visible. + """ + import re + + from uxarray_mcp.app import make_registry + from uxarray_mcp.tools.frontdoor import SUPPORTED_OPERATIONS + + tools = set(make_registry(profile="deferred-full").list_tools()) + bare_tools = {t.split("-")[-1] for t in tools} + ops = set(SUPPORTED_OPERATIONS) + + for name, (verdict, reason) in SURFACE.items(): + if verdict not in REACHABLE: + continue + for op in re.findall(r"operation='([a-z_]+)'", reason): + assert op in ops, f"{name} names operation {op!r}, which does not exist" + for tool in re.findall(r"\b([a-z_]+)\(", reason): + if tool in {"operation", "run_analysis"}: + continue + assert tool in bare_tools or tool in ops, ( + f"{name} names {tool!r}, which is neither a tool nor an operation" + ) + + +class TestKnownGapsAreTrackedNotForgotten: + def test_the_gap_list_is_the_backlog(self): + """Gaps are allowed; silent gaps are not. + + This asserts the shape of the record, not its length -- closing a + gap should not require editing a number somewhere else. + """ + for name, reason in gaps().items(): + assert len(reason) > 20, ( + f"gap {name} needs a reason someone can act on, got {reason!r}" + )