From acb720d9e601f5672c03a15c73f75f55674b1780 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 23 Oct 2026 00:13:48 -0500 Subject: [PATCH] Record what of UXarray we reach, and close three gaps Replaying the notebooks in UXARRAY/uxarray-community through the server found capabilities a user reaches for that we could not run. Looking for the rest of them found twelve, and no record anywhere of which absences were decided and which were merely unnoticed. domain/uxarray_surface.py is that record. Every UXarray-specific method on UxDataArray, UxDataset and the subset accessor appears once, with either the operation that reaches it or the reason it is out of scope. The xarray-inherited surface is excluded: ~150 methods that are not UXarray's to change and would drown the signal. tests/test_uxarray_surface.py holds the list to the installed library. A new method upstream fails until someone writes a verdict; a renamed one fails the day the rename lands rather than the next time a user follows our advice into a method that is gone. It also checks that every route the inventory claims actually exists, because an inventory claiming a route that was never wired is worse than one with a visible gap. Deliberately not a count. "We expose N of M methods" goes red on every upstream release for reasons nobody can act on, and the repair is to edit the number -- which teaches people to stop reading the test. The weekly floor job spent six Mondays red for that reason. Three gaps closed, in two new tools rather than in run_analysis: - subset_advanced -- selection near a point, by radius (bounding_circle) or by neighbour count (nearest_neighbor). Four uses each in the notebooks. An empty selection is refused with the mesh extent, the same rule test_subset_coverage.py already enforces for bounding boxes; UXarray's own message says the circle was empty without saying where a circle would not be. - regrid_elements -- carries a field between nodes, edges and faces via the topological_* reductions. Three uses, all in the step that moves node-centred vorticity onto faces before anything else can touch it. They are separate tools, and deferred, because #89 measures run_analysis at 46 parameters over 25 operations and names that as the problem to fix. Adding a radius, a neighbour count, a centre and a destination element to it would have made the measured problem worse in order to fix a different one. Each new tool takes only what its own operation uses. Two honesty fixes fall out of the same work: - get_capabilities lists native UXarray calls, several of which the server cannot execute -- grid.get_ball_tree(), var.integrate(). All true, none runnable here, and nothing said so. A model reading that plans a call that does not exist. The result now carries uxarray_capabilities_are_advisory, naming mcp_server_tools as the executable surface. - The face-centred refusals were a dead end. "Variable 't_node' is not face-centered" is correct, and the standard repair is topological_mean("face"), which the server could not run, so the message offered nothing. It now names regrid_elements, and a test follows that advice to confirm it works rather than landing on a second error. EXPECTED_DEFERRED moves 33 -> 35. That counter guards the namespace plan against drift, which two new deferred tools genuinely changes. 1538 passed, 1 skipped, 44 deselected, 1 xfailed. Verified over stdio on the deferred-full profile: both tools run through call_deferred, and the empty-circle refusal carries the mesh extent. --- src/uxarray_mcp/domain/uxarray_surface.py | 181 ++++++++++++ src/uxarray_mcp/domain/vector_calc.py | 18 +- src/uxarray_mcp/registry.py | 10 + src/uxarray_mcp/tools/__init__.py | 6 + src/uxarray_mcp/tools/capabilities.py | 17 ++ src/uxarray_mcp/tools/surface_extras.py | 326 ++++++++++++++++++++++ tests/test_server.py | 6 +- tests/test_surface_extras.py | 260 +++++++++++++++++ tests/test_uxarray_surface.py | 126 +++++++++ 9 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 src/uxarray_mcp/domain/uxarray_surface.py create mode 100644 src/uxarray_mcp/tools/surface_extras.py create mode 100644 tests/test_surface_extras.py create mode 100644 tests/test_uxarray_surface.py 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}" + )