uxarray-mcp 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/PKG-INFO +2 -2
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/pyproject.toml +4 -2
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/pyproject.toml.orig +11 -2
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/__init__.py +1 -1
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/__init__.py +12 -1
- uxarray_mcp-0.3.1/src/uxarray_mcp/domain/profile_coverage.py +90 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/remap_coverage.py +44 -2
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/vector_calc.py +12 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/zonal.py +5 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/preconditions.py +265 -22
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/compute_functions.py +46 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/advanced.py +318 -26
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/frontdoor.py +89 -7
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/typed_results.py +82 -6
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/LICENSE +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/README.md +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/__main__.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/app.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/cli.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/content_blocks.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/area.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/dims.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/mesh.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/plotting.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/variable.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/next_steps.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/postconditions.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/provenance.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/py.typed +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/registry.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/__init__.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/agent.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/config.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/health.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/response_contract.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/state.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/__init__.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/capabilities.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/catalog.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/contracts.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/execution_control.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/inspection.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/orchestration.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/plotting.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/remote_tools.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/scientific_agent.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/stateful.py +0 -0
- {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/vector_calc.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: uxarray-mcp
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: MCP server for analyzing unstructured meshes with UXarray
|
|
5
5
|
Keywords: uxarray,mcp,unstructured grids,scientific computing,globus compute
|
|
6
6
|
Author: Rajeev Jain, Dayan Abdulla
|
|
@@ -222,7 +222,7 @@ Requires-Dist: mcp>=1.24,<3
|
|
|
222
222
|
Requires-Dist: holoviews>=1.19.0
|
|
223
223
|
Requires-Dist: matplotlib>=3.9.0
|
|
224
224
|
Requires-Dist: pyyaml>=6.0
|
|
225
|
-
Requires-Dist: uxarray>=2026.8.
|
|
225
|
+
Requires-Dist: uxarray>=2026.8.1
|
|
226
226
|
Requires-Dist: sphinx>=7.0 ; extra == 'docs'
|
|
227
227
|
Requires-Dist: sphinx-book-theme>=1.1.0 ; extra == 'docs'
|
|
228
228
|
Requires-Dist: myst-parser>=3.0 ; extra == 'docs'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uxarray-mcp"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "MCP server for analyzing unstructured meshes with UXarray"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
keywords = [
|
|
@@ -29,7 +29,7 @@ dependencies = [
|
|
|
29
29
|
"holoviews>=1.19.0",
|
|
30
30
|
"matplotlib>=3.9.0",
|
|
31
31
|
"pyyaml>=6.0",
|
|
32
|
-
"uxarray>=2026.8.
|
|
32
|
+
"uxarray>=2026.8.1",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[[project.authors]]
|
|
@@ -65,7 +65,9 @@ Tracker = "https://github.com/UXARRAY/uxarray-mcp-server/issues"
|
|
|
65
65
|
|
|
66
66
|
[dependency-groups]
|
|
67
67
|
dev = [
|
|
68
|
+
"jsonschema>=4.20.0",
|
|
68
69
|
"mypy>=1.10.0",
|
|
70
|
+
"packaging>=23.0",
|
|
69
71
|
"pre-commit>=4.3.0",
|
|
70
72
|
"pytest>=9.0.2",
|
|
71
73
|
"pytest-asyncio>=1.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uxarray-mcp"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "MCP server for analyzing unstructured meshes with UXarray"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
keywords = ["uxarray", "mcp", "unstructured grids", "scientific computing", "globus compute"]
|
|
@@ -49,7 +49,7 @@ dependencies = [
|
|
|
49
49
|
"holoviews>=1.19.0",
|
|
50
50
|
"matplotlib>=3.9.0",
|
|
51
51
|
"pyyaml>=6.0",
|
|
52
|
-
"uxarray>=2026.8.
|
|
52
|
+
"uxarray>=2026.8.1",
|
|
53
53
|
]
|
|
54
54
|
|
|
55
55
|
[project.optional-dependencies]
|
|
@@ -76,7 +76,16 @@ Tracker = "https://github.com/UXARRAY/uxarray-mcp-server/issues"
|
|
|
76
76
|
|
|
77
77
|
[dependency-groups]
|
|
78
78
|
dev = [
|
|
79
|
+
# Declared so the outputSchema conformance tests cannot silently skip.
|
|
80
|
+
# jsonschema arrives transitively today, which means a resolver change
|
|
81
|
+
# would turn the checks that hold every result to its published envelope
|
|
82
|
+
# into a green no-op.
|
|
83
|
+
"jsonschema>=4.20.0",
|
|
79
84
|
"mypy>=1.10.0",
|
|
85
|
+
# Same reason as jsonschema: the suite-integrity check reads the declared
|
|
86
|
+
# uxarray floor out of the packaging metadata so it cannot drift from this
|
|
87
|
+
# file, and it needs a specifier parser to do that.
|
|
88
|
+
"packaging>=23.0",
|
|
80
89
|
"pre-commit>=4.3.0",
|
|
81
90
|
"pytest>=9.0.2",
|
|
82
91
|
"pytest-asyncio>=1.0.0",
|
|
@@ -6,7 +6,15 @@ Functions here contain the pure domain logic used by both local tools
|
|
|
6
6
|
|
|
7
7
|
from .area import compute_area_stats
|
|
8
8
|
from .mesh import is_healpix_spec, load_dataset, load_grid, parse_healpix_zoom
|
|
9
|
-
from .
|
|
9
|
+
from .profile_coverage import (
|
|
10
|
+
compute_profile_coverage,
|
|
11
|
+
profile_coverage_warning_codes,
|
|
12
|
+
)
|
|
13
|
+
from .remap_coverage import (
|
|
14
|
+
compute_scattered_coverage,
|
|
15
|
+
compute_target_coverage,
|
|
16
|
+
method_is_conservative,
|
|
17
|
+
)
|
|
10
18
|
from .variable import compute_variable_info
|
|
11
19
|
from .vector_calc import (
|
|
12
20
|
compute_azimuthal_mean,
|
|
@@ -22,7 +30,10 @@ __all__ = [
|
|
|
22
30
|
"is_healpix_spec",
|
|
23
31
|
"parse_healpix_zoom",
|
|
24
32
|
"compute_area_stats",
|
|
33
|
+
"compute_profile_coverage",
|
|
34
|
+
"profile_coverage_warning_codes",
|
|
25
35
|
"compute_target_coverage",
|
|
36
|
+
"compute_scattered_coverage",
|
|
26
37
|
"method_is_conservative",
|
|
27
38
|
"compute_variable_info",
|
|
28
39
|
"compute_zonal_mean_stats",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""How much of a binned profile the mesh actually filled.
|
|
2
|
+
|
|
3
|
+
``calculate_zonal_mean`` and ``azimuthal_mean`` both reduce a field onto bins
|
|
4
|
+
the caller chooses -- latitude bands, or rings of great-circle distance from a
|
|
5
|
+
centre. Nothing forces those bins to intersect the mesh. A regional mesh asked
|
|
6
|
+
for southern-hemisphere bands, or a radial profile centred a hundred degrees
|
|
7
|
+
away, returns a profile of the requested length made entirely of NaN, and a
|
|
8
|
+
profile of the right shape is indistinguishable from one carrying an answer
|
|
9
|
+
unless somebody counts.
|
|
10
|
+
|
|
11
|
+
The count here is deliberately indirect. Re-deriving which faces land in which
|
|
12
|
+
bin would duplicate UXarray's own binning and could disagree with it, so this
|
|
13
|
+
measures the profile that came back instead. An empty bin is NaN -- but so is a
|
|
14
|
+
bin whose faces all held missing data, and the two mean different things. The
|
|
15
|
+
source field is therefore checked as well: when it is entirely finite, a NaN
|
|
16
|
+
bin is unambiguously an empty one; when it is not, the cause is reported as
|
|
17
|
+
ambiguous rather than guessed at.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Sequence
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_profile_coverage(
|
|
28
|
+
values: Sequence[float],
|
|
29
|
+
*,
|
|
30
|
+
source: Any = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Report how many bins of a profile carry a value.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
values
|
|
37
|
+
The profile as returned by the operation, one entry per bin.
|
|
38
|
+
source
|
|
39
|
+
The field the profile was reduced from, if available. Used only to
|
|
40
|
+
decide whether an empty bin can be attributed to the bins missing the
|
|
41
|
+
mesh, or whether missing data in the field could explain it too.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
dict
|
|
46
|
+
``n_bins``, ``n_bins_filled``, ``source_has_missing`` (``None`` when
|
|
47
|
+
the field was not supplied) and ``cause``, which is
|
|
48
|
+
``"bins_miss_mesh"`` only when the source is known to be complete.
|
|
49
|
+
No fraction: two integers carry it, and this block rides on every
|
|
50
|
+
profile result under a byte budget.
|
|
51
|
+
"""
|
|
52
|
+
profile = np.asarray(values, dtype=float)
|
|
53
|
+
n_bins = int(profile.size)
|
|
54
|
+
n_filled = int(np.isfinite(profile).sum())
|
|
55
|
+
|
|
56
|
+
source_has_missing: bool | None = None
|
|
57
|
+
if source is not None:
|
|
58
|
+
source_values = np.asarray(getattr(source, "values", source), dtype=float)
|
|
59
|
+
source_has_missing = bool(source_values.size) and bool(
|
|
60
|
+
(~np.isfinite(source_values)).any()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if n_filled == n_bins:
|
|
64
|
+
cause = "none"
|
|
65
|
+
elif source_has_missing is False:
|
|
66
|
+
cause = "bins_miss_mesh"
|
|
67
|
+
else:
|
|
68
|
+
# Either the field carries missing data or nobody looked, so an empty
|
|
69
|
+
# bin has two possible explanations and this does not pick one.
|
|
70
|
+
cause = "ambiguous"
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"n_bins": n_bins,
|
|
74
|
+
"n_bins_filled": n_filled,
|
|
75
|
+
"source_has_missing": source_has_missing,
|
|
76
|
+
"cause": cause,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def profile_coverage_warning_codes(coverage: dict[str, Any]) -> list[str]:
|
|
81
|
+
"""Stable codes for a partly or wholly unfilled profile."""
|
|
82
|
+
n_bins = coverage.get("n_bins", 0)
|
|
83
|
+
if not n_bins:
|
|
84
|
+
return []
|
|
85
|
+
filled = coverage.get("n_bins_filled", 0)
|
|
86
|
+
if filled == 0:
|
|
87
|
+
return ["PROFILE_COVERAGE_ZERO"]
|
|
88
|
+
if filled < n_bins:
|
|
89
|
+
return ["PROFILE_COVERAGE_PARTIAL"]
|
|
90
|
+
return []
|
|
@@ -67,7 +67,9 @@ def compute_target_coverage(
|
|
|
67
67
|
Source mesh the data lives on.
|
|
68
68
|
target_lon, target_lat : sequence of float
|
|
69
69
|
1-D target coordinate arrays in degrees; the target is their
|
|
70
|
-
cartesian product, as for a rectilinear grid.
|
|
70
|
+
cartesian product, as for a rectilinear grid. For a target that is
|
|
71
|
+
itself an unstructured mesh, whose coordinates pair off rather than
|
|
72
|
+
multiply out, use :func:`compute_scattered_coverage`.
|
|
71
73
|
method : str, optional
|
|
72
74
|
Remap method name, used only to report its conservation property.
|
|
73
75
|
|
|
@@ -81,7 +83,47 @@ def compute_target_coverage(
|
|
|
81
83
|
lon = _wrap_lon(np.asarray(list(target_lon), dtype=float))
|
|
82
84
|
lat = np.asarray(list(target_lat), dtype=float)
|
|
83
85
|
mesh_lon, mesh_lat = np.meshgrid(lon, lat)
|
|
84
|
-
|
|
86
|
+
return _coverage_of_points(
|
|
87
|
+
grid, mesh_lon.ravel(), mesh_lat.ravel(), method=method, wrap=False
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute_scattered_coverage(
|
|
92
|
+
grid: Any,
|
|
93
|
+
target_lon: Sequence[float],
|
|
94
|
+
target_lat: Sequence[float],
|
|
95
|
+
*,
|
|
96
|
+
method: str | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Coverage for a target whose coordinates are paired, not a product.
|
|
99
|
+
|
|
100
|
+
An unstructured target grid supplies one longitude and one latitude per
|
|
101
|
+
point, so taking their cartesian product would invent points the target
|
|
102
|
+
does not have and report coverage for a mesh nobody asked about. Same
|
|
103
|
+
measurement, same keys as :func:`compute_target_coverage`.
|
|
104
|
+
"""
|
|
105
|
+
return _coverage_of_points(grid, target_lon, target_lat, method=method)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _coverage_of_points(
|
|
109
|
+
grid: Any,
|
|
110
|
+
target_lon: Sequence[float],
|
|
111
|
+
target_lat: Sequence[float],
|
|
112
|
+
*,
|
|
113
|
+
method: str | None = None,
|
|
114
|
+
wrap: bool = True,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Count how many of the given points lie inside the source mesh."""
|
|
117
|
+
lon = np.asarray(list(target_lon), dtype=float)
|
|
118
|
+
if wrap:
|
|
119
|
+
lon = _wrap_lon(lon)
|
|
120
|
+
lat = np.asarray(list(target_lat), dtype=float)
|
|
121
|
+
if lon.shape != lat.shape:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"target_lon and target_lat must have the same shape when the "
|
|
124
|
+
f"points are paired; got {lon.shape} and {lat.shape}."
|
|
125
|
+
)
|
|
126
|
+
points = np.column_stack([lon, lat])
|
|
85
127
|
n_points = int(points.shape[0])
|
|
86
128
|
|
|
87
129
|
bbox = source_bbox(grid)
|
|
@@ -6,6 +6,7 @@ import warnings as _warnings_module
|
|
|
6
6
|
from typing import Any, Callable, TypeVar
|
|
7
7
|
|
|
8
8
|
from uxarray_mcp.domain.dims import face_slice_selection
|
|
9
|
+
from uxarray_mcp.domain.profile_coverage import compute_profile_coverage
|
|
9
10
|
from uxarray_mcp.domain.zonal import extract_profile
|
|
10
11
|
|
|
11
12
|
_T = TypeVar("_T")
|
|
@@ -271,6 +272,14 @@ def compute_gradient(
|
|
|
271
272
|
result,
|
|
272
273
|
warnings=uxarray_warnings,
|
|
273
274
|
warning_codes=["SPHERE_RADIUS_UNAVAILABLE"] if uxarray_warnings else [],
|
|
275
|
+
extra={
|
|
276
|
+
# Requested and applied are different facts, and only the second
|
|
277
|
+
# one decides whether the derivative is per metre or per radian.
|
|
278
|
+
# ``curl``/``divergence`` already report both; reporting them here
|
|
279
|
+
# too is what lets the front door gate all three the same way.
|
|
280
|
+
"physical_scaling_requested": bool(scale_by_radius),
|
|
281
|
+
"physical_scaling_applied": bool(scale_by_radius and not uxarray_warnings),
|
|
282
|
+
},
|
|
274
283
|
)
|
|
275
284
|
|
|
276
285
|
|
|
@@ -586,4 +595,7 @@ def compute_azimuthal_mean(
|
|
|
586
595
|
"azimuthal_mean_values": values,
|
|
587
596
|
"reduced_dims": reduced_dims,
|
|
588
597
|
"n_face": int(uxds.uxgrid.n_face),
|
|
598
|
+
# A centre the mesh does not reach still produces a profile of the
|
|
599
|
+
# requested length, every ring of it NaN.
|
|
600
|
+
"profile_coverage": compute_profile_coverage(values, source=var),
|
|
589
601
|
}
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
7
|
from uxarray_mcp.domain.dims import face_slice_selection
|
|
8
|
+
from uxarray_mcp.domain.profile_coverage import compute_profile_coverage
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def compute_zonal_mean_stats(
|
|
@@ -72,6 +73,10 @@ def compute_zonal_mean_stats(
|
|
|
72
73
|
"conservative": conservative,
|
|
73
74
|
"reduced_dims": reduced_dims,
|
|
74
75
|
"grid_info": grid_info,
|
|
76
|
+
# Bands the caller asked for need not touch the mesh. A regional mesh
|
|
77
|
+
# asked for bands it does not span returns a profile of the right
|
|
78
|
+
# length made of NaN, which reads as an answer unless it is counted.
|
|
79
|
+
"profile_coverage": compute_profile_coverage(zonal_mean_values, source=var),
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
|
|
@@ -17,7 +17,7 @@ request (MRTR) flow. ``InputRequiredResult`` lets a server answer a
|
|
|
17
17
|
``request_state`` the client echoes back on retry. We mirror that shape
|
|
18
18
|
in the tool result:
|
|
19
19
|
|
|
20
|
-
- ``
|
|
20
|
+
- ``outcome`` is ``"input_required"`` rather than ``"complete"``
|
|
21
21
|
- ``input_requests`` carries an ``elicitation/create`` form request
|
|
22
22
|
describing the acknowledgment we need
|
|
23
23
|
- ``request_state`` is an opaque token the caller passes back verbatim
|
|
@@ -30,6 +30,15 @@ than reaching the transport as an elicitation. Building the payload in
|
|
|
30
30
|
the spec's shape now means the day the adapter grows MRTR support this
|
|
31
31
|
becomes a passthrough rather than a redesign.
|
|
32
32
|
|
|
33
|
+
The mirrored field is named ``outcome`` and not ``result_type``, which
|
|
34
|
+
is what it was called first. The SDK stamps its own ``resultType`` onto
|
|
35
|
+
every JSON-RPC result object, always ``"complete"`` because from the
|
|
36
|
+
protocol's point of view the call did return. A refusal therefore put
|
|
37
|
+
two fields with the same name and contradicting values on one wire,
|
|
38
|
+
separated only by camelCase. ``outcome`` is ours and says which of the
|
|
39
|
+
two payload shapes this is; ``resultType`` is the protocol's and says
|
|
40
|
+
the call completed. Both are true at once now.
|
|
41
|
+
|
|
33
42
|
Note that ``not_evaluated`` (#84) and a failed precondition are
|
|
34
43
|
different states and stay distinct: one is "we did not check," the other
|
|
35
44
|
is "we checked and it fails."
|
|
@@ -47,8 +56,8 @@ from typing import Any
|
|
|
47
56
|
OVERRIDE_TOKEN = "i-understand-this-result-may-not-be-physical"
|
|
48
57
|
|
|
49
58
|
#: Marks a result the caller must act on before the computation will run.
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
OUTCOME_INPUT_REQUIRED = "input_required"
|
|
60
|
+
OUTCOME_COMPLETE = "complete"
|
|
52
61
|
|
|
53
62
|
|
|
54
63
|
class PreconditionRefusal(Exception):
|
|
@@ -85,22 +94,40 @@ def evaluate_vector_preconditions(
|
|
|
85
94
|
v_variable: str,
|
|
86
95
|
evidence: dict[str, Any],
|
|
87
96
|
scale_by_radius: bool | None,
|
|
97
|
+
scaling_applied: bool | None = None,
|
|
88
98
|
) -> list[dict[str, Any]]:
|
|
89
|
-
"""Declare what must hold for
|
|
99
|
+
"""Declare what must hold for a vector-calculus result to be physical.
|
|
90
100
|
|
|
91
|
-
Three conditions
|
|
92
|
-
server already reads:
|
|
101
|
+
Three conditions apply to the two-component operations, each
|
|
102
|
+
independently checkable from metadata the server already reads:
|
|
93
103
|
|
|
94
104
|
1. The two components are distinct fields.
|
|
95
105
|
2. Both carry velocity-like units.
|
|
96
106
|
3. Their direction identity (eastward/northward) is resolvable from
|
|
97
107
|
``standard_name`` or ``long_name``.
|
|
98
108
|
|
|
99
|
-
|
|
100
|
-
result is a unit-sphere quantity
|
|
109
|
+
Every differential operator additionally requires radius scaling,
|
|
110
|
+
without which the result is a unit-sphere quantity -- a vorticity per
|
|
111
|
+
radian rather than in s^-1, a gradient per radian rather than per
|
|
112
|
+
metre.
|
|
113
|
+
|
|
114
|
+
``scaling_applied`` is what that last check actually reads. Asking
|
|
115
|
+
only whether scaling was *requested* let the gate pass on any grid
|
|
116
|
+
without a ``sphere_radius`` attribute: UXarray honours the request as
|
|
117
|
+
far as it can, warns that the result is left on the unit sphere, and
|
|
118
|
+
returns it. The number came back marked uninterpretable but came back
|
|
119
|
+
all the same, which is the warning-beside-a-number state #86 exists to
|
|
120
|
+
end. Pass ``None`` when the caller cannot tell applied from requested
|
|
121
|
+
and the requested flag is used as before.
|
|
101
122
|
"""
|
|
102
123
|
u_ev = evidence.get("u", {}) or {}
|
|
103
124
|
v_ev = evidence.get("v", {}) or {}
|
|
125
|
+
if operation == "gradient":
|
|
126
|
+
# A gradient is taken of one field, so the component checks below --
|
|
127
|
+
# distinctness, velocity units, eastward/northward identity -- have
|
|
128
|
+
# nothing to read. Radius scaling is the one condition that carries
|
|
129
|
+
# over, and it is the one that decides the units of the answer.
|
|
130
|
+
return [_radius_scaling_check(scale_by_radius, scaling_applied)]
|
|
104
131
|
checks = [
|
|
105
132
|
_check(
|
|
106
133
|
# Unnamed components cannot be compared, so this check abstains
|
|
@@ -133,18 +160,48 @@ def evaluate_vector_preconditions(
|
|
|
133
160
|
),
|
|
134
161
|
]
|
|
135
162
|
if operation in {"curl", "divergence"}:
|
|
136
|
-
checks.append(
|
|
137
|
-
_check(
|
|
138
|
-
"radius_scaling",
|
|
139
|
-
bool(scale_by_radius),
|
|
140
|
-
f"scale_by_radius={bool(scale_by_radius)}.",
|
|
141
|
-
"Set scale_by_radius=True so the result carries physical "
|
|
142
|
-
"units instead of unit-sphere units.",
|
|
143
|
-
)
|
|
144
|
-
)
|
|
163
|
+
checks.append(_radius_scaling_check(scale_by_radius, scaling_applied))
|
|
145
164
|
return checks
|
|
146
165
|
|
|
147
166
|
|
|
167
|
+
def _radius_scaling_check(
|
|
168
|
+
scale_by_radius: bool | None,
|
|
169
|
+
scaling_applied: bool | None,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""The condition that decides whether a derivative has physical units."""
|
|
172
|
+
requested = bool(scale_by_radius)
|
|
173
|
+
if not requested:
|
|
174
|
+
return _check(
|
|
175
|
+
"radius_scaling",
|
|
176
|
+
False,
|
|
177
|
+
"scale_by_radius=False.",
|
|
178
|
+
"Set scale_by_radius=True so the result carries physical units "
|
|
179
|
+
"instead of unit-sphere units.",
|
|
180
|
+
)
|
|
181
|
+
if scaling_applied is False:
|
|
182
|
+
# Requested and refused by the data, not by the caller. Naming the
|
|
183
|
+
# missing attribute is the repair; telling them to set the flag they
|
|
184
|
+
# already set would send them in a circle.
|
|
185
|
+
return _check(
|
|
186
|
+
"radius_scaling",
|
|
187
|
+
False,
|
|
188
|
+
"scale_by_radius=True was requested but the grid carries no "
|
|
189
|
+
"'sphere_radius' attribute, so UXarray left the result on the "
|
|
190
|
+
"unit sphere.",
|
|
191
|
+
"Set uxgrid.sphere_radius on the source grid (6371000.0 m for "
|
|
192
|
+
"Earth) so the scaling can be applied, or pass "
|
|
193
|
+
"scale_by_radius=False and read the result as a per-radian "
|
|
194
|
+
"quantity.",
|
|
195
|
+
)
|
|
196
|
+
return _check(
|
|
197
|
+
"radius_scaling",
|
|
198
|
+
True,
|
|
199
|
+
f"scale_by_radius={requested}, applied={scaling_applied}.",
|
|
200
|
+
"Set scale_by_radius=True so the result carries physical units "
|
|
201
|
+
"instead of unit-sphere units.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
148
205
|
def evaluate_validation_preconditions(result: dict[str, Any]) -> list[dict[str, Any]]:
|
|
149
206
|
"""Declare that a dataset must validate before it is analyzed further.
|
|
150
207
|
|
|
@@ -165,7 +222,10 @@ def evaluate_validation_preconditions(result: dict[str, Any]) -> list[dict[str,
|
|
|
165
222
|
]
|
|
166
223
|
|
|
167
224
|
|
|
168
|
-
def evaluate_remap_preconditions(
|
|
225
|
+
def evaluate_remap_preconditions(
|
|
226
|
+
coverage: dict[str, Any],
|
|
227
|
+
operation: str = "remap_to_rectilinear",
|
|
228
|
+
) -> list[dict[str, Any]]:
|
|
169
229
|
"""Declare that a remap target must overlap its source mesh.
|
|
170
230
|
|
|
171
231
|
#85 shipped ``REMAP_COVERAGE_ZERO`` as a warning printed beside the
|
|
@@ -188,15 +248,198 @@ def evaluate_remap_preconditions(coverage: dict[str, Any]) -> list[dict[str, Any
|
|
|
188
248
|
)
|
|
189
249
|
else:
|
|
190
250
|
extent = "unknown (source bounding box not reported)"
|
|
251
|
+
# A repair has to name something the caller can actually change. The
|
|
252
|
+
# rectilinear operation is handed coordinate ranges; the other two are
|
|
253
|
+
# handed a target grid file, and telling their caller to adjust
|
|
254
|
+
# target_lon/target_lat would name arguments they never passed.
|
|
255
|
+
if operation == "remap_to_rectilinear":
|
|
256
|
+
repair = (
|
|
257
|
+
"Choose target_lon/target_lat ranges that overlap the source "
|
|
258
|
+
"mesh extent reported above, so at least some target points "
|
|
259
|
+
"are interpolated rather than extrapolated."
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
repair = (
|
|
263
|
+
"Pass a target_grid_path whose mesh overlaps the source extent "
|
|
264
|
+
"reported above, so at least some target points are interpolated "
|
|
265
|
+
"rather than extrapolated. If the two grids are meant to overlap, "
|
|
266
|
+
"check that both use the same longitude convention "
|
|
267
|
+
"(-180..180 against 0..360)."
|
|
268
|
+
)
|
|
191
269
|
return [
|
|
192
270
|
_check(
|
|
193
271
|
"remap_coverage_nonzero",
|
|
194
272
|
"REMAP_COVERAGE_ZERO" not in codes,
|
|
195
273
|
f"{n_in} of {n_total} target points fall inside the source mesh; "
|
|
196
274
|
f"the source covers {extent}.",
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
275
|
+
repair,
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
#: Spellings of the same unit that CF-conforming files disagree about. Only
|
|
281
|
+
#: pairs that are exactly interchangeable belong here -- this table decides
|
|
282
|
+
#: whether two fields may be subtracted, so a wrong entry produces a wrong
|
|
283
|
+
#: number rather than a refusal.
|
|
284
|
+
_UNIT_SYNONYMS = {
|
|
285
|
+
"kelvin": "k",
|
|
286
|
+
"degrees_kelvin": "k",
|
|
287
|
+
"degk": "k",
|
|
288
|
+
"celsius": "degc",
|
|
289
|
+
"degrees_celsius": "degc",
|
|
290
|
+
"degree_celsius": "degc",
|
|
291
|
+
"c": "degc",
|
|
292
|
+
"m/s": "m s-1",
|
|
293
|
+
"m s^-1": "m s-1",
|
|
294
|
+
"meter/second": "m s-1",
|
|
295
|
+
"metre/second": "m s-1",
|
|
296
|
+
"percent": "%",
|
|
297
|
+
"1": "",
|
|
298
|
+
"dimensionless": "",
|
|
299
|
+
"none": "",
|
|
300
|
+
"unitless": "",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def normalize_units(units: str | None) -> str | None:
|
|
305
|
+
"""Fold a units string to a comparable form, or ``None`` if undeclared.
|
|
306
|
+
|
|
307
|
+
Deliberately shallow: it collapses whitespace and case and resolves a
|
|
308
|
+
fixed synonym table. It does not parse unit algebra, so it will call
|
|
309
|
+
``mm day-1`` and ``kg m-2 s-1`` different even though they measure the
|
|
310
|
+
same thing. That direction of error costs a caller one explicit
|
|
311
|
+
acknowledgement; the other direction silently subtracts incompatible
|
|
312
|
+
fields.
|
|
313
|
+
"""
|
|
314
|
+
if units is None:
|
|
315
|
+
return None
|
|
316
|
+
folded = " ".join(str(units).strip().lower().split())
|
|
317
|
+
if not folded:
|
|
318
|
+
return None
|
|
319
|
+
return _UNIT_SYNONYMS.get(folded, folded)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def evaluate_comparison_preconditions(
|
|
323
|
+
variable_name: str,
|
|
324
|
+
units_a: str | None,
|
|
325
|
+
units_b: str | None,
|
|
326
|
+
) -> list[dict[str, Any]]:
|
|
327
|
+
"""Declare that two fields must be in the same units to be differenced.
|
|
328
|
+
|
|
329
|
+
``bias``, ``rmse`` and the difference field are all built from ``a - b``,
|
|
330
|
+
which is only a physical quantity when both sides measure the same thing
|
|
331
|
+
in the same scale. Subtracting a field in K from one in degC returns
|
|
332
|
+
273.15 with no hint that the offset is the unit difference and not the
|
|
333
|
+
model error -- a number that looks like an answer and is not one.
|
|
334
|
+
|
|
335
|
+
An undeclared unit is a gap in the metadata rather than a contradiction,
|
|
336
|
+
so it does not refuse here; ``compare_fields`` reports it through
|
|
337
|
+
``scientific_status`` instead. Only a declared, unresolvable disagreement
|
|
338
|
+
is refusable.
|
|
339
|
+
"""
|
|
340
|
+
left, right = normalize_units(units_a), normalize_units(units_b)
|
|
341
|
+
both_declared = left is not None and right is not None
|
|
342
|
+
return [
|
|
343
|
+
_check(
|
|
344
|
+
"units_comparable",
|
|
345
|
+
not both_declared or left == right,
|
|
346
|
+
f"{variable_name}: units_a={units_a or 'unset'!r}, "
|
|
347
|
+
f"units_b={units_b or 'unset'!r}.",
|
|
348
|
+
"Convert one field so both carry the same units before "
|
|
349
|
+
"differencing them. If the units are already equivalent and only "
|
|
350
|
+
"spelled differently, the server does not parse unit algebra -- "
|
|
351
|
+
"align the strings or acknowledge to proceed.",
|
|
352
|
+
)
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def evaluate_ensemble_preconditions(
|
|
357
|
+
operation: str,
|
|
358
|
+
variable_name: str,
|
|
359
|
+
evidence: dict[str, Any],
|
|
360
|
+
) -> list[dict[str, Any]]:
|
|
361
|
+
"""Conditions an ensemble statistic needs across its members.
|
|
362
|
+
|
|
363
|
+
Averaging members is the same arithmetic as differencing two fields and
|
|
364
|
+
fails the same way: a member in K averaged with one in degC returns a
|
|
365
|
+
number that is in neither scale and looks exactly like a valid mean.
|
|
366
|
+
Undeclared units stay a warning for the reason they do in comparisons --
|
|
367
|
+
a gap in metadata is not a contradiction.
|
|
368
|
+
|
|
369
|
+
The mesh check is separate and weaker on purpose. Members are opened as
|
|
370
|
+
plain datasets, so identity rests on whatever coordinates the files
|
|
371
|
+
carry. When they carry none, matching dimension lengths is all there is,
|
|
372
|
+
and two unrelated meshes with the same face count would satisfy that --
|
|
373
|
+
so the result says the mesh is unverified rather than claiming it agrees.
|
|
374
|
+
"""
|
|
375
|
+
units = evidence.get("member_units") or []
|
|
376
|
+
spelled = ", ".join(repr(u) if u else "unset" for u in units)
|
|
377
|
+
checks = [
|
|
378
|
+
_check(
|
|
379
|
+
"ensemble_units_consistent",
|
|
380
|
+
evidence.get("units_consistent") is not False,
|
|
381
|
+
f"{variable_name}: member units are {spelled}.",
|
|
382
|
+
"Convert the members onto one scale before combining them. The "
|
|
383
|
+
"server does not parse unit algebra, so equivalent units spelled "
|
|
384
|
+
"differently also have to be aligned -- or acknowledge to proceed.",
|
|
385
|
+
)
|
|
386
|
+
]
|
|
387
|
+
if evidence.get("grids_consistent") is False:
|
|
388
|
+
checks.append(
|
|
389
|
+
_check(
|
|
390
|
+
"ensemble_grids_consistent",
|
|
391
|
+
False,
|
|
392
|
+
f"{operation}: the members carry coordinates that do not "
|
|
393
|
+
"match, so their values are not on a common mesh.",
|
|
394
|
+
"Supply members written on the same mesh, or remap them onto "
|
|
395
|
+
"a common grid first. Combining values cell-by-cell across "
|
|
396
|
+
"different meshes averages unrelated locations.",
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
return checks
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def evaluate_profile_preconditions(
|
|
403
|
+
operation: str,
|
|
404
|
+
coverage: dict[str, Any],
|
|
405
|
+
) -> list[dict[str, Any]]:
|
|
406
|
+
"""Declare that a binned profile must have at least one filled bin.
|
|
407
|
+
|
|
408
|
+
A profile whose bins all miss the mesh comes back the requested length
|
|
409
|
+
and entirely NaN, which is shaped exactly like an answer. That is the
|
|
410
|
+
same state ``remap_coverage_nonzero`` refuses over and for the same
|
|
411
|
+
reason: nothing in the returned array was measured.
|
|
412
|
+
|
|
413
|
+
Partly filled stays a warning. A regional mesh averaged over global
|
|
414
|
+
bands legitimately fills only the bands it spans, and refusing there
|
|
415
|
+
would make ordinary regional profiles unusable.
|
|
416
|
+
"""
|
|
417
|
+
n_bins = coverage.get("n_bins", 0)
|
|
418
|
+
filled = coverage.get("n_bins_filled", 0)
|
|
419
|
+
if operation == "azimuthal_mean":
|
|
420
|
+
repair = (
|
|
421
|
+
"Move center_lon/center_lat onto the mesh, or widen outer_radius "
|
|
422
|
+
"so the rings reach it. Check the longitude convention too "
|
|
423
|
+
"(-180..180 against 0..360)."
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
repair = (
|
|
427
|
+
"Choose lat_spec bands that fall within the latitudes the mesh "
|
|
428
|
+
"spans, so at least one band contains faces."
|
|
429
|
+
)
|
|
430
|
+
if coverage.get("cause") == "ambiguous":
|
|
431
|
+
# The field itself carries missing data, so an empty bin has two
|
|
432
|
+
# explanations and the repair should not assert only one of them.
|
|
433
|
+
repair += (
|
|
434
|
+
" The field also contains missing values, so empty bins may come "
|
|
435
|
+
"from the data rather than from the bins missing the mesh."
|
|
436
|
+
)
|
|
437
|
+
return [
|
|
438
|
+
_check(
|
|
439
|
+
"profile_coverage_nonzero",
|
|
440
|
+
filled > 0,
|
|
441
|
+
f"{operation}: {filled} of {n_bins} bins contain a value.",
|
|
442
|
+
repair,
|
|
200
443
|
)
|
|
201
444
|
]
|
|
202
445
|
|
|
@@ -288,7 +531,7 @@ def enforce(
|
|
|
288
531
|
)
|
|
289
532
|
raise PreconditionRefusal(
|
|
290
533
|
{
|
|
291
|
-
"
|
|
534
|
+
"outcome": OUTCOME_INPUT_REQUIRED,
|
|
292
535
|
"operation": operation,
|
|
293
536
|
"refusal": {
|
|
294
537
|
"summary": (
|