spharmgrid 0.1.0__py3-none-any.whl

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.
spharmgrid/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """xarray-first spherical-harmonic atmospheric operations using DUCC0.
2
+
3
+ Importing :mod:`spharmgrid` registers the ``.sg`` accessors on xarray
4
+ ``DataArray`` and ``Dataset`` objects. DUCC0 supplies all numerical
5
+ spherical-harmonic transforms; spharmgrid supplies the GL/CC, xarray, and CF
6
+ operations layer around them.
7
+ """
8
+
9
+ # Import solely for xarray accessor registration after direct functions exist.
10
+ from . import accessors as _accessors # noqa: F401
11
+ from .grids import Grid, clenshaw_curtis_grid, detect_grid, gaussian_grid
12
+ from .kinematics import (
13
+ divergence,
14
+ divergent_wind,
15
+ helmholtz,
16
+ inverse_vector_laplacian,
17
+ kinematics,
18
+ potentials,
19
+ rotational_wind,
20
+ streamfunction,
21
+ vector_laplacian,
22
+ velocity_potential,
23
+ vorticity,
24
+ wind,
25
+ )
26
+ from .operators import (
27
+ EARTH_RADIUS_M,
28
+ gradient,
29
+ inverse_gradient,
30
+ inverse_laplacian,
31
+ laplacian,
32
+ )
33
+ from .regrid import regrid, regrid_vector
34
+ from .spectral import SpectralRange, filter, parse_spectral
35
+
36
+ __all__ = [
37
+ "EARTH_RADIUS_M",
38
+ "Grid",
39
+ "SpectralRange",
40
+ "clenshaw_curtis_grid",
41
+ "detect_grid",
42
+ "divergent_wind",
43
+ "divergence",
44
+ "filter",
45
+ "gaussian_grid",
46
+ "gradient",
47
+ "helmholtz",
48
+ "inverse_gradient",
49
+ "inverse_laplacian",
50
+ "inverse_vector_laplacian",
51
+ "kinematics",
52
+ "laplacian",
53
+ "parse_spectral",
54
+ "potentials",
55
+ "regrid",
56
+ "regrid_vector",
57
+ "rotational_wind",
58
+ "streamfunction",
59
+ "velocity_potential",
60
+ "vector_laplacian",
61
+ "vorticity",
62
+ "wind",
63
+ ]
spharmgrid/_ducc.py ADDED
@@ -0,0 +1,202 @@
1
+ """Small internal adapters around DUCC0's spherical-harmonic functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import cache
7
+ from typing import Literal, cast
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from .grids import Grid
13
+
14
+ Geometry = Literal["CC", "GL"]
15
+
16
+ DUCC_THREADS = 4
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class TransformSpec:
21
+ """A scalar/vector transform bandwidth supported by its source geometry."""
22
+
23
+ lmax: int
24
+ mmax: int
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.lmax < 0 or self.mmax < 0 or self.mmax > self.lmax:
28
+ raise ValueError("transform limits must satisfy 0 <= mmax <= lmax")
29
+
30
+
31
+ def geometry_for(grid: Grid) -> Geometry:
32
+ """Map the public lowercase grid label to DUCC's geometry label."""
33
+ return "GL" if grid.kind == "gl" else "CC"
34
+
35
+
36
+ @cache
37
+ def alm_degrees(lmax: int, mmax: int) -> NDArray[np.int64]:
38
+ """Return total degree for each contiguous healpy-ordered coefficient."""
39
+ degrees = np.concatenate(
40
+ [np.arange(m, lmax + 1, dtype=np.int64) for m in range(mmax + 1)]
41
+ )
42
+ degrees.setflags(write=False)
43
+ return degrees
44
+
45
+
46
+ def scalar_analysis(
47
+ frame: NDArray[np.generic],
48
+ *,
49
+ spec: TransformSpec,
50
+ geometry: Geometry,
51
+ phi0: float,
52
+ ) -> NDArray[np.complex128]:
53
+ """Analyze one north-to-south, cyclic-eastward scalar map."""
54
+ _require_frame(frame)
55
+ import ducc0
56
+
57
+ values = np.ascontiguousarray(frame, dtype=np.float64)
58
+ result = ducc0.sht.analysis_2d(
59
+ map=values[np.newaxis, :, :],
60
+ spin=0,
61
+ lmax=spec.lmax,
62
+ mmax=spec.mmax,
63
+ geometry=geometry,
64
+ phi0=phi0,
65
+ nthreads=DUCC_THREADS,
66
+ )
67
+ return cast(NDArray[np.complex128], result)
68
+
69
+
70
+ def scalar_synthesis(
71
+ alm: NDArray[np.complexfloating],
72
+ *,
73
+ spec: TransformSpec,
74
+ geometry: Geometry,
75
+ ntheta: int,
76
+ nphi: int,
77
+ phi0: float,
78
+ ) -> NDArray[np.float64]:
79
+ """Synthesize one scalar map on a north-to-south DUCC geometry."""
80
+ import ducc0
81
+
82
+ values = np.ascontiguousarray(alm)
83
+ result = ducc0.sht.synthesis_2d(
84
+ alm=values,
85
+ spin=0,
86
+ lmax=spec.lmax,
87
+ mmax=spec.mmax,
88
+ geometry=geometry,
89
+ ntheta=ntheta,
90
+ nphi=nphi,
91
+ phi0=phi0,
92
+ nthreads=DUCC_THREADS,
93
+ )
94
+ return cast(NDArray[np.float64], result[0])
95
+
96
+
97
+ def scalar_derivative_synthesis(
98
+ alm: NDArray[np.complexfloating],
99
+ *,
100
+ spec: TransformSpec,
101
+ geometry: Geometry,
102
+ ntheta: int,
103
+ nphi: int,
104
+ phi0: float,
105
+ ) -> NDArray[np.float64]:
106
+ """Synthesize theta and eastward angular derivatives of a scalar field.
107
+
108
+ DUCC's ``DERIV1`` mode returns ``(d/dtheta, (1/sin(theta)) d/dphi)``.
109
+ The caller maps the first component from southward theta to northward
110
+ latitude and applies the physical-radius factor.
111
+ """
112
+ import ducc0
113
+
114
+ result = ducc0.sht.synthesis_2d(
115
+ alm=np.ascontiguousarray(alm),
116
+ spin=1,
117
+ lmax=spec.lmax,
118
+ mmax=spec.mmax,
119
+ geometry=geometry,
120
+ ntheta=ntheta,
121
+ nphi=nphi,
122
+ phi0=phi0,
123
+ nthreads=DUCC_THREADS,
124
+ mode="DERIV1",
125
+ )
126
+ return cast(NDArray[np.float64], result)
127
+
128
+
129
+ def vector_analysis(
130
+ u: NDArray[np.generic],
131
+ v: NDArray[np.generic],
132
+ *,
133
+ spec: TransformSpec,
134
+ geometry: Geometry,
135
+ phi0: float,
136
+ ) -> NDArray[np.complex128]:
137
+ """Analyze geographic eastward/northward wind into DUCC E/B coefficients.
138
+
139
+ DUCC's spin-1 map components are ``(v_theta, v_phi)``. Geographic wind
140
+ uses northward ``v``, while increasing theta points south, so spharmgrid
141
+ maps the components as ``(-v, u)``.
142
+ """
143
+ _require_frame(u)
144
+ _require_frame(v)
145
+ if u.shape != v.shape:
146
+ raise ValueError(
147
+ f"wind frames have incompatible shapes: {u.shape} and {v.shape}"
148
+ )
149
+ import ducc0
150
+
151
+ eastward = np.asarray(u, dtype=np.float64)
152
+ northward = np.asarray(v, dtype=np.float64)
153
+ vector_map = np.stack((-northward, eastward), axis=0)
154
+ result = ducc0.sht.analysis_2d(
155
+ map=np.ascontiguousarray(vector_map),
156
+ spin=1,
157
+ lmax=spec.lmax,
158
+ mmax=spec.mmax,
159
+ geometry=geometry,
160
+ phi0=phi0,
161
+ nthreads=DUCC_THREADS,
162
+ )
163
+ return cast(NDArray[np.complex128], result)
164
+
165
+
166
+ def vector_synthesis(
167
+ alm_e: NDArray[np.complexfloating],
168
+ alm_b: NDArray[np.complexfloating],
169
+ *,
170
+ spec: TransformSpec,
171
+ geometry: Geometry,
172
+ ntheta: int,
173
+ nphi: int,
174
+ phi0: float,
175
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
176
+ """Synthesize DUCC E/B coefficients into geographic eastward/northward wind."""
177
+ import ducc0
178
+
179
+ coefficients = np.stack((alm_e, alm_b), axis=0)
180
+ result = ducc0.sht.synthesis_2d(
181
+ alm=np.ascontiguousarray(coefficients),
182
+ spin=1,
183
+ lmax=spec.lmax,
184
+ mmax=spec.mmax,
185
+ geometry=geometry,
186
+ ntheta=ntheta,
187
+ nphi=nphi,
188
+ phi0=phi0,
189
+ nthreads=DUCC_THREADS,
190
+ )
191
+ # This is the inverse of ``(-v, u)`` in ``vector_analysis``.
192
+ return (
193
+ cast(NDArray[np.float64], result[1]),
194
+ cast(NDArray[np.float64], -result[0]),
195
+ )
196
+
197
+
198
+ def _require_frame(frame: NDArray[np.generic]) -> None:
199
+ if frame.ndim != 2:
200
+ raise ValueError(
201
+ f"DUCC kernel requires a two-dimensional frame, got {frame.shape}"
202
+ )
spharmgrid/_vector.py ADDED
@@ -0,0 +1,248 @@
1
+ """Private xarray adapters for paired tangent-vector SHT operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import cast
7
+
8
+ import numpy as np
9
+ import xarray as xr
10
+ from numpy.typing import NDArray
11
+
12
+ from ._xarray import (
13
+ FieldLayout,
14
+ apply_ufunc_options,
15
+ exact_align,
16
+ field_layout,
17
+ require_dataarray,
18
+ restore_output,
19
+ )
20
+ from .grids import grids_equivalent
21
+
22
+ VectorScalarKernel = Callable[
23
+ [NDArray[np.generic], NDArray[np.generic]], NDArray[np.float64]
24
+ ]
25
+ VectorPairKernel = Callable[
26
+ [NDArray[np.generic], NDArray[np.generic]],
27
+ tuple[NDArray[np.float64], NDArray[np.float64]],
28
+ ]
29
+ VectorQuadKernel = Callable[
30
+ [NDArray[np.generic], NDArray[np.generic]],
31
+ tuple[
32
+ NDArray[np.float64],
33
+ NDArray[np.float64],
34
+ NDArray[np.float64],
35
+ NDArray[np.float64],
36
+ ],
37
+ ]
38
+
39
+
40
+ def vector_inputs(
41
+ u: xr.DataArray, v: xr.DataArray
42
+ ) -> tuple[FieldLayout, xr.DataArray, xr.DataArray]:
43
+ """Validate, canonicalize, and exactly align a geographic vector pair."""
44
+ u = require_dataarray(u, "u")
45
+ v = require_dataarray(v, "v")
46
+ layout = field_layout(u)
47
+ v_layout = field_layout(v)
48
+ _require_matching_layouts(layout, v_layout, "u", "v")
49
+ canonical_u = layout.canonicalize(u)
50
+ canonical_v = v_layout.canonicalize(v)
51
+ return layout, *exact_align(canonical_u, canonical_v, names=("u", "v"))
52
+
53
+
54
+ def vector_scalar_transform(
55
+ original: xr.DataArray,
56
+ source: FieldLayout,
57
+ canonical_u: xr.DataArray,
58
+ canonical_v: xr.DataArray,
59
+ transform: VectorScalarKernel,
60
+ ) -> xr.DataArray:
61
+ """Apply one paired-vector kernel that returns a scalar map."""
62
+ dask_field = _dask_input(canonical_u, canonical_v)
63
+
64
+ def kernel(
65
+ frame_u: NDArray[np.generic], frame_v: NDArray[np.generic]
66
+ ) -> NDArray[np.float64]:
67
+ return transform(frame_u, frame_v)
68
+
69
+ output = xr.apply_ufunc(
70
+ kernel,
71
+ canonical_u,
72
+ canonical_v,
73
+ input_core_dims=[
74
+ [source.latitude_dim, source.longitude_dim],
75
+ [source.latitude_dim, source.longitude_dim],
76
+ ],
77
+ output_core_dims=[[source.latitude_dim, source.longitude_dim]],
78
+ vectorize=True,
79
+ output_dtypes=[np.float64],
80
+ **apply_ufunc_options(dask_field),
81
+ )
82
+ return restore_output(
83
+ cast(xr.DataArray, output),
84
+ source=source,
85
+ target=source,
86
+ original_dims=original.dims,
87
+ )
88
+
89
+
90
+ def vector_pair_transform(
91
+ original: xr.DataArray,
92
+ source: FieldLayout,
93
+ target: FieldLayout,
94
+ canonical_u: xr.DataArray,
95
+ canonical_v: xr.DataArray,
96
+ transform: VectorPairKernel,
97
+ ) -> tuple[xr.DataArray, xr.DataArray]:
98
+ """Apply one paired-vector kernel that returns two target-grid maps."""
99
+ dask_field = _dask_input(canonical_u, canonical_v)
100
+ output_changes_shape = _output_changes_shape(source, target)
101
+ output_sizes = (
102
+ {target.latitude_dim: target.grid.nlat, target.longitude_dim: target.grid.nlon}
103
+ if output_changes_shape
104
+ else None
105
+ )
106
+
107
+ def kernel(
108
+ frame_u: NDArray[np.generic], frame_v: NDArray[np.generic]
109
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
110
+ return transform(frame_u, frame_v)
111
+
112
+ outputs = xr.apply_ufunc(
113
+ kernel,
114
+ canonical_u,
115
+ canonical_v,
116
+ input_core_dims=[
117
+ [source.latitude_dim, source.longitude_dim],
118
+ [source.latitude_dim, source.longitude_dim],
119
+ ],
120
+ output_core_dims=[
121
+ [target.latitude_dim, target.longitude_dim],
122
+ [target.latitude_dim, target.longitude_dim],
123
+ ],
124
+ vectorize=True,
125
+ output_dtypes=[np.float64, np.float64],
126
+ exclude_dims=(
127
+ {source.latitude_dim, source.longitude_dim}
128
+ if output_changes_shape
129
+ else set()
130
+ ),
131
+ **apply_ufunc_options(dask_field, output_sizes=output_sizes),
132
+ )
133
+ first, second = cast(tuple[xr.DataArray, xr.DataArray], outputs)
134
+ return (
135
+ restore_output(
136
+ first,
137
+ source=source,
138
+ target=target,
139
+ original_dims=original.dims,
140
+ ),
141
+ restore_output(
142
+ second,
143
+ source=source,
144
+ target=target,
145
+ original_dims=original.dims,
146
+ ),
147
+ )
148
+
149
+
150
+ def vector_quad_transform(
151
+ original: xr.DataArray,
152
+ source: FieldLayout,
153
+ canonical_u: xr.DataArray,
154
+ canonical_v: xr.DataArray,
155
+ transform: VectorQuadKernel,
156
+ ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray]:
157
+ """Apply one paired-vector kernel that returns four source-grid maps."""
158
+ dask_field = _dask_input(canonical_u, canonical_v)
159
+
160
+ def kernel(
161
+ frame_u: NDArray[np.generic], frame_v: NDArray[np.generic]
162
+ ) -> tuple[
163
+ NDArray[np.float64],
164
+ NDArray[np.float64],
165
+ NDArray[np.float64],
166
+ NDArray[np.float64],
167
+ ]:
168
+ return transform(frame_u, frame_v)
169
+
170
+ outputs = xr.apply_ufunc(
171
+ kernel,
172
+ canonical_u,
173
+ canonical_v,
174
+ input_core_dims=[
175
+ [source.latitude_dim, source.longitude_dim],
176
+ [source.latitude_dim, source.longitude_dim],
177
+ ],
178
+ output_core_dims=[
179
+ [source.latitude_dim, source.longitude_dim],
180
+ [source.latitude_dim, source.longitude_dim],
181
+ [source.latitude_dim, source.longitude_dim],
182
+ [source.latitude_dim, source.longitude_dim],
183
+ ],
184
+ vectorize=True,
185
+ output_dtypes=[np.float64, np.float64, np.float64, np.float64],
186
+ **apply_ufunc_options(dask_field),
187
+ )
188
+ first, second, third, fourth = cast(
189
+ tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray], outputs
190
+ )
191
+ return (
192
+ restore_output(
193
+ first,
194
+ source=source,
195
+ target=source,
196
+ original_dims=original.dims,
197
+ ),
198
+ restore_output(
199
+ second,
200
+ source=source,
201
+ target=source,
202
+ original_dims=original.dims,
203
+ ),
204
+ restore_output(
205
+ third,
206
+ source=source,
207
+ target=source,
208
+ original_dims=original.dims,
209
+ ),
210
+ restore_output(
211
+ fourth,
212
+ source=source,
213
+ target=source,
214
+ original_dims=original.dims,
215
+ ),
216
+ )
217
+
218
+
219
+ def _dask_input(first: xr.DataArray, second: xr.DataArray) -> xr.DataArray:
220
+ return first if first.chunks is not None else second
221
+
222
+
223
+ def _output_changes_shape(source: FieldLayout, target: FieldLayout) -> bool:
224
+ return (
225
+ source.latitude_dim != target.latitude_dim
226
+ or source.longitude_dim != target.longitude_dim
227
+ or source.grid.nlat != target.grid.nlat
228
+ or source.grid.nlon != target.grid.nlon
229
+ )
230
+
231
+
232
+ def _require_matching_layouts(
233
+ first: FieldLayout, second: FieldLayout, first_name: str, second_name: str
234
+ ) -> None:
235
+ if (
236
+ first.latitude_dim != second.latitude_dim
237
+ or first.longitude_dim != second.longitude_dim
238
+ or first.coordinates.latitude_name != second.coordinates.latitude_name
239
+ or first.coordinates.longitude_name != second.coordinates.longitude_name
240
+ ):
241
+ raise ValueError(
242
+ f"{first_name} and {second_name} must use the same horizontal "
243
+ "coordinate names and dimensions"
244
+ )
245
+ if not grids_equivalent(first.grid, second.grid):
246
+ raise ValueError(
247
+ f"{first_name} and {second_name} must describe equivalent supported grids"
248
+ )