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.
Files changed (48) hide show
  1. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/PKG-INFO +2 -2
  2. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/pyproject.toml +4 -2
  3. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/pyproject.toml.orig +11 -2
  4. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/__init__.py +1 -1
  5. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/__init__.py +12 -1
  6. uxarray_mcp-0.3.1/src/uxarray_mcp/domain/profile_coverage.py +90 -0
  7. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/remap_coverage.py +44 -2
  8. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/vector_calc.py +12 -0
  9. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/zonal.py +5 -0
  10. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/preconditions.py +265 -22
  11. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/compute_functions.py +46 -0
  12. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/advanced.py +318 -26
  13. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/frontdoor.py +89 -7
  14. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/typed_results.py +82 -6
  15. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/LICENSE +0 -0
  16. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/README.md +0 -0
  17. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/__main__.py +0 -0
  18. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/app.py +0 -0
  19. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/cli.py +0 -0
  20. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/content_blocks.py +0 -0
  21. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/area.py +0 -0
  22. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/dims.py +0 -0
  23. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/mesh.py +0 -0
  24. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/plotting.py +0 -0
  25. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/domain/variable.py +0 -0
  26. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/next_steps.py +0 -0
  27. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/postconditions.py +0 -0
  28. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/provenance.py +0 -0
  29. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/py.typed +0 -0
  30. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/registry.py +0 -0
  31. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/__init__.py +0 -0
  32. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/agent.py +0 -0
  33. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/config.py +0 -0
  34. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/remote/health.py +0 -0
  35. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/response_contract.py +0 -0
  36. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/state.py +0 -0
  37. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/__init__.py +0 -0
  38. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/capabilities.py +0 -0
  39. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/catalog.py +0 -0
  40. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/contracts.py +0 -0
  41. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/execution_control.py +0 -0
  42. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/inspection.py +0 -0
  43. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/orchestration.py +0 -0
  44. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/plotting.py +0 -0
  45. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/remote_tools.py +0 -0
  46. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/scientific_agent.py +0 -0
  47. {uxarray_mcp-0.3.0 → uxarray_mcp-0.3.1}/src/uxarray_mcp/tools/stateful.py +0 -0
  48. {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.0
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.0
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.0"
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.0",
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.0"
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.0",
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",
@@ -3,4 +3,4 @@
3
3
  from uxarray_mcp.tools import inspect_mesh
4
4
 
5
5
  __all__ = ["inspect_mesh"]
6
- __version__ = "0.3.0"
6
+ __version__ = "0.3.1"
@@ -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 .remap_coverage import compute_target_coverage, method_is_conservative
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
- points = np.column_stack([mesh_lon.ravel(), mesh_lat.ravel()])
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
- - ``result_type`` is ``"input_required"`` rather than ``"complete"``
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
- RESULT_TYPE_INPUT_REQUIRED = "input_required"
51
- RESULT_TYPE_COMPLETE = "complete"
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 ``curl``/``divergence`` to be physical.
99
+ """Declare what must hold for a vector-calculus result to be physical.
90
100
 
91
- Three conditions, each independently checkable from metadata the
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
- ``curl`` additionally requires radius scaling, without which the
100
- result is a unit-sphere quantity rather than a vorticity in s^-1.
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(coverage: dict[str, Any]) -> list[dict[str, Any]]:
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
- "Choose target_lon/target_lat ranges that overlap the source "
198
- "mesh extent reported above, so at least some target points "
199
- "are interpolated rather than extrapolated.",
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
- "result_type": RESULT_TYPE_INPUT_REQUIRED,
534
+ "outcome": OUTCOME_INPUT_REQUIRED,
292
535
  "operation": operation,
293
536
  "refusal": {
294
537
  "summary": (