pygeoinf 1.0.8__py3-none-any.whl → 1.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.
@@ -1,5 +1,24 @@
1
1
  """
2
- Module for Sobolev spaces on the two-sphere.
2
+ Provides concrete implementations of function spaces on the two-sphere (S²).
3
+
4
+ This module uses the abstract framework from the symmetric space module to create
5
+ fully-featured `Lebesgue` (L²) and `Sobolev` (Hˢ) Hilbert spaces for functions
6
+ defined on the surface of a sphere.
7
+
8
+ It utilizes the `pyshtools` library for highly efficient and accurate spherical
9
+ harmonic transforms. Following a compositional design, this module first
10
+ defines a base `Lebesgue` space and then constructs the `Sobolev` space as a
11
+ `MassWeightedHilbertSpace` over it. The module also includes powerful plotting
12
+ utilities built on `cartopy` for professional-quality geospatial visualization.
13
+
14
+ Key Classes
15
+ -----------
16
+ - `SphereHelper`: A mixin class providing the core geometry, spherical harmonic
17
+ transform machinery, and `cartopy`-based plotting utilities.
18
+ - `Lebesgue`: A concrete implementation of the L²(S²) space of square-integrable
19
+ functions on the sphere.
20
+ - `Sobolev`: A concrete implementation of the Hˢ(S²) space of functions with a
21
+ specified degree of smoothness.
3
22
  """
4
23
 
5
24
  from __future__ import annotations
@@ -7,185 +26,83 @@ from typing import Callable, Any, List, Optional, Tuple, TYPE_CHECKING
7
26
 
8
27
  import matplotlib.pyplot as plt
9
28
  import matplotlib.ticker as mticker
10
-
11
29
  import numpy as np
12
-
13
30
  from scipy.sparse import diags, coo_array
14
31
 
15
32
  import pyshtools as sh
16
-
17
33
  import cartopy.crs as ccrs
18
34
  import cartopy.feature as cfeature
19
35
  from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
20
36
 
21
- from pygeoinf.hilbert_space import EuclideanSpace
37
+ from pygeoinf.hilbert_space import (
38
+ HilbertModule,
39
+ MassWeightedHilbertModule,
40
+ )
22
41
  from pygeoinf.operators import LinearOperator
23
- from pygeoinf.symmetric_space.symmetric_space import SymmetricSpaceSobolev
24
- from pygeoinf.gaussian_measure import GaussianMeasure
42
+ from pygeoinf.linear_forms import LinearForm
43
+ from .symmetric_space import (
44
+ AbstractInvariantLebesgueSpace,
45
+ AbstractInvariantSobolevSpace,
46
+ )
47
+
25
48
 
26
- # This block only runs for type checkers, not at runtime
27
49
  if TYPE_CHECKING:
28
50
  from matplotlib.figure import Figure
29
- from matplotlib.axes import Axes
30
51
  from cartopy.mpl.geoaxes import GeoAxes
31
52
  from cartopy.crs import Projection
32
- from pygeoinf.forms import LinearForm
33
53
 
34
54
 
35
- class Sobolev(SymmetricSpaceSobolev):
55
+ class SphereHelper:
36
56
  """
37
- Implements Sobolev spaces H^s on a two-sphere.
57
+ A mixin providing common functionality for function spaces on the sphere.
38
58
 
39
- This class uses `pyshtools` for spherical harmonic transforms. Vectors in the
40
- space can be represented either as `pyshtools.SHGrid` objects (spatial
41
- domain) or `pyshtools.SHCoeffs` objects (spectral domain), controlled by a flag.
59
+ This helper is not intended for direct instantiation. It provides the core
60
+ geometry (radius, grid type), the spherical harmonic transform machinery via
61
+ `pyshtools`, and `cartopy`-based plotting utilities that are shared by the
62
+ `Lebesgue` and `Sobolev` space classes.
42
63
  """
43
64
 
44
65
  def __init__(
45
66
  self,
46
67
  lmax: int,
47
- order: float,
48
- scale: float,
49
- /,
50
- *,
51
- vector_as_SHGrid: bool = True,
52
- radius: float = 1.0,
53
- grid: str = "DH",
54
- ) -> None:
68
+ radius: float,
69
+ grid: str,
70
+ extend: bool,
71
+ ):
55
72
  """
56
73
  Args:
57
- lmax: The maximum spherical harmonic degree for truncation.
58
- order: The order of the Sobolev space, controlling smoothness.
59
- scale: The non-dimensional length-scale for the space.
60
- vector_as_SHGrid: If True (default), elements of the space are
61
- `pyshtools.SHGrid` objects. Otherwise, they are `pyshtools.SHCoeffs`.
62
- radius: The radius of the sphere. Defaults to 1.0.
63
- grid: The `pyshtools` grid type (e.g., 'DH', 'DH2', 'GLQ').
64
- Defaults to 'DH'.
74
+ lmax: The maximum spherical harmonic degree to be represented.
75
+ radius: Radius of the sphere.
76
+ grid: The `pyshtools` grid type.
77
+ extend: If True, the spatial grid includes both 0 and 360-degree longitudes.
65
78
  """
66
-
67
79
  self._lmax: int = lmax
68
80
  self._radius: float = radius
69
- self._grid: str = grid
70
- self._sampling: int = 2 if self.grid == "DH2" else 1
71
- self._extend: bool = True
72
- self._normalization: str = "ortho"
73
- self._csphase: int = 1
74
- self._sparse_coeffs_to_component: coo_array = (
75
- self._coefficient_to_component_mapping()
76
- )
77
81
 
78
- dim: int = (lmax + 1) ** 2
79
-
80
- self._vector_as_SHGrid: bool = vector_as_SHGrid
81
- if vector_as_SHGrid:
82
- super().__init__(
83
- order,
84
- scale,
85
- dim,
86
- self._to_components_from_SHGrid,
87
- self._from_components_to_SHGrid,
88
- self._inner_product_impl,
89
- self._to_dual_impl,
90
- self._from_dual_impl,
91
- ax=self._ax_impl,
92
- axpy=self._axpy_impl,
93
- vector_multiply=self._vector_multiply_impl,
94
- )
82
+ if grid == "DH2":
83
+ self._grid = "DH"
84
+ self._sampling = 2
95
85
  else:
96
- super().__init__(
97
- order,
98
- scale,
99
- dim,
100
- self._to_components_from_SHCoeffs,
101
- self._from_components_to_SHCoeffs,
102
- self._inner_product_impl,
103
- self._to_dual_impl,
104
- self._from_dual_impl,
105
- ax=self._ax_impl,
106
- axpy=self._axpy_impl,
107
- vector_multiply=self._vector_multiply_impl,
108
- )
109
-
110
- self._metric_tensor: diags = self._degree_dependent_scaling_to_diagonal_matrix(
111
- self._sobolev_function
112
- )
113
- self._inverse_metric_tensor: diags = (
114
- self._degree_dependent_scaling_to_diagonal_matrix(
115
- lambda l: 1.0 / self._sobolev_function(l)
116
- )
117
- )
118
-
119
- # ===============================================#
120
- # Static methods #
121
- # ===============================================#
122
-
123
- @staticmethod
124
- def from_sobolev_parameters(
125
- order: float,
126
- scale: float,
127
- /,
128
- *,
129
- radius: float = 1.0,
130
- vector_as_SHGrid: bool = True,
131
- grid: str = "DH",
132
- rtol: float = 1e-8,
133
- power_of_two: bool = False,
134
- ) -> "Sobolev":
135
- """
136
- Creates an instance with `lmax` chosen based on the Sobolev parameters.
137
-
138
- This factory method estimates the spherical harmonic truncation degree
139
- (`lmax`) required to represent the space while meeting a specified
140
- relative tolerance for the truncation error. This is useful when the
141
- required `lmax` is not known a priori.
142
-
143
- Args:
144
- order: The order of the Sobolev space, controlling smoothness.
145
- scale: The non-dimensional length-scale for the space.
146
- radius: The radius of the sphere. Defaults to 1.0.
147
- vector_as_SHGrid: If True (default), elements are `SHGrid` objects.
148
- grid: The `pyshtools` grid type (e.g., 'DH'). Defaults to 'DH'.
149
- rtol: The relative tolerance used to determine the `lmax`.
150
- power_of_two: If True, `lmax` is set to the next power of two.
151
-
152
- Returns:
153
- An instance of the Sobolev class with a calculated `lmax`.
154
- """
155
- if order <= 1.0:
156
- raise ValueError("This method is only applicable for orders > 1.0")
86
+ self._grid = grid
87
+ self._sampling = 1
157
88
 
158
- summation = 1.0
159
- l = 0
160
- err = 1.0
161
- sobolev_func = lambda deg: (1.0 + scale**2 * deg * (deg + 1)) ** order
89
+ self._extend: bool = extend
162
90
 
163
- while err > rtol:
164
- l += 1
165
- term = (2 * l + 1) / sobolev_func(l)
166
- summation += term
167
- err = term / summation
168
- print(l, err)
169
- if l > 10000:
170
- raise RuntimeError("Failed to converge on a stable lmax.")
171
-
172
- if power_of_two:
173
- n = int(np.log2(l))
174
- l = 2 ** (n + 1)
91
+ # SH coefficient options fixed internally
92
+ self._normalization: str = "ortho"
93
+ self._csphase: int = 1
175
94
 
176
- lmax = l
177
- return Sobolev(
178
- lmax,
179
- order,
180
- scale,
181
- vector_as_SHGrid=vector_as_SHGrid,
182
- radius=radius,
183
- grid=grid,
95
+ # Set up sparse matrix that maps SHCoeff data arrrays into reduced form
96
+ self._sparse_coeffs_to_component: coo_array = (
97
+ self._coefficient_to_component_mapping()
184
98
  )
185
99
 
186
- # ===============================================#
187
- # Properties #
188
- # ===============================================#
100
+ def orthonormalised(self) -> bool:
101
+ """The space is orthonormalised."""
102
+ return True
103
+
104
+ def _space(self):
105
+ return self
189
106
 
190
107
  @property
191
108
  def lmax(self) -> int:
@@ -202,6 +119,11 @@ class Sobolev(SymmetricSpaceSobolev):
202
119
  """The `pyshtools` grid type used for spatial representations."""
203
120
  return self._grid
204
121
 
122
+ @property
123
+ def sampling(self) -> int:
124
+ """The sampling factor used for spatial representations."""
125
+ return self._sampling
126
+
205
127
  @property
206
128
  def extend(self) -> bool:
207
129
  """True if the spatial grid includes both 0 and 360-degree longitudes."""
@@ -217,181 +139,70 @@ class Sobolev(SymmetricSpaceSobolev):
217
139
  """The Condon-Shortley phase convention used (1)."""
218
140
  return self._csphase
219
141
 
220
- # ==============================================#
221
- # Public methods #
222
- # ==============================================#
142
+ @property
143
+ def spatial_dimension(self) -> int:
144
+ """The dimension of the space."""
145
+ return 2
223
146
 
224
147
  def random_point(self) -> List[float]:
225
148
  """Returns a random point as `[latitude, longitude]`."""
226
- latitude = np.random.uniform(-90.0, 90.0)
149
+ latitude = np.rad2deg(np.arcsin(np.random.uniform(-1.0, 1.0)))
227
150
  longitude = np.random.uniform(0.0, 360.0)
228
151
  return [latitude, longitude]
229
152
 
230
- def low_degree_projection(
231
- self,
232
- truncation_degree: int,
233
- /,
234
- *,
235
- smoother: Optional[Callable[[int], float]] = None,
236
- ) -> "LinearOperator":
153
+ def laplacian_eigenvalue(self, k: [int, int]) -> float:
237
154
  """
238
- Returns an operator that projects onto a lower-degree space.
239
-
240
- This can be used for truncating or smoothing a field in the spherical
241
- harmonic domain.
155
+ Returns the (l.m)-th eigenvalue of the Laplacian.
242
156
 
243
157
  Args:
244
- truncation_degree: The new maximum degree `lmax`.
245
- smoother: An optional callable `f(l)` that applies a degree-dependent
246
- weighting factor during projection.
247
-
248
- Returns:
249
- A `LinearOperator` that maps from this space to a new, lower-degree
250
- `Sobolev` space.
158
+ k = (l,m): The index of the eigenvalue to return.
251
159
  """
252
- truncation_degree = (
253
- truncation_degree if truncation_degree <= self.lmax else self.lmax
254
- )
255
- # Default smoother is an identity mapping.
256
- f: Callable[[int], float] = smoother if smoother is not None else lambda l: 1.0
257
-
258
- row_dim = (truncation_degree + 1) ** 2
259
- col_dim = (self.lmax + 1) ** 2
260
-
261
- # Construct the sparse matrix that performs the coordinate projection.
262
- row, col = 0, 0
263
- rows, cols, data = [], [], []
264
- for l in range(self.lmax + 1):
265
- fac = f(l)
266
- for _ in range(l + 1):
267
- if l <= truncation_degree:
268
- rows.append(row)
269
- row += 1
270
- cols.append(col)
271
- data.append(fac)
272
- col += 1
273
-
274
- for l in range(truncation_degree + 1):
275
- fac = f(l)
276
- for _ in range(1, l + 1):
277
- rows.append(row)
278
- row += 1
279
- cols.append(col)
280
- data.append(fac)
281
- col += 1
282
-
283
- smat = coo_array(
284
- (data, (rows, cols)), shape=(row_dim, col_dim), dtype=float
285
- ).tocsc()
286
-
287
- codomain = Sobolev(
288
- truncation_degree,
289
- self.order,
290
- self.scale,
291
- vector_as_SHGrid=self._vector_as_SHGrid,
292
- radius=self.radius,
293
- grid=self._grid,
294
- )
295
-
296
- def mapping(u: Any) -> Any:
297
- uc = self.to_components(u)
298
- vc = smat @ uc
299
- return codomain.from_components(vc)
300
-
301
- def adjoint_mapping(v: Any) -> Any:
302
- vc = codomain.to_components(v)
303
- uc = smat.T @ vc
304
- return self.from_components(uc)
160
+ l = k[0]
161
+ return l * (l + 1) / self.radius**2
305
162
 
306
- return LinearOperator(self, codomain, mapping, adjoint_mapping=adjoint_mapping)
307
-
308
- def dirac(self, point: List[float]) -> "LinearForm":
163
+ def trace_of_invariant_automorphism(self, f: Callable[[float], float]) -> float:
309
164
  """
310
- Returns the linear functional for point evaluation (Dirac measure).
165
+ Returns the trace of the automorphism of the form f(Δ) with f a function
166
+ that is well-defined on the spectrum of the Laplacian.
311
167
 
312
168
  Args:
313
- point: A list containing `[latitude, longitude]`.
169
+ f: A real-valued function that is well-defined on the spectrum
170
+ of the Laplacian.
314
171
  """
315
- latitude, longitude = point
316
- colatitude = 90.0 - latitude
317
-
318
- coeffs = sh.expand.spharm(
319
- self.lmax,
320
- colatitude,
321
- longitude,
322
- normalization=self.normalization,
323
- degrees=True,
324
- )
325
- c = self._to_components_from_coeffs(coeffs)
326
- # Note: The user confirmed the logic for this return statement is correct
327
- # for their use case, despite the base class returning a LinearForm.
328
- return self.dual.from_components(c)
329
-
330
- def invariant_automorphism(self, f: Callable[[float], float]) -> "LinearOperator":
331
- matrix = self._degree_dependent_scaling_to_diagonal_matrix(f)
332
-
333
- def mapping(x: Any) -> Any:
334
- return self.from_components(matrix @ self.to_components(x))
335
-
336
- return LinearOperator.self_adjoint(self, mapping)
172
+ trace = 0
173
+ for l in range(self.lmax + 1):
174
+ for m in range(-l, l + 1):
175
+ trace += f(self.laplacian_eigenvalue((l, m)))
176
+ return trace
337
177
 
338
- def invariant_gaussian_measure(
339
- self, f: Callable[[float], float], /, *, expectation: Optional[Any] = None
340
- ) -> "GaussianMeasure":
178
+ def project_function(self, f: Callable[[(float, float)], float]) -> np.ndarray:
341
179
  """
342
- Returns a Gaussian measure with a covariance of the form `f(Delta)`.
180
+ Returns an element of the space by projecting a given function.
343
181
 
344
182
  Args:
345
- f: The scalar function `f(l(l+1))` defining the covariance.
346
- expectation: The mean of the measure. Defaults to zero.
183
+ f: A function that takes a point `(lat, lon)` and returns a value.
347
184
  """
348
-
349
- def g(l: int) -> float:
350
- return np.sqrt(f(l) / (self.radius**2 * self._sobolev_function(l)))
351
-
352
- def h(l: int) -> float:
353
- return np.sqrt(self.radius**2 * self._sobolev_function(l) * f(l))
354
-
355
- matrix = self._degree_dependent_scaling_to_diagonal_matrix(g)
356
- adjoint_matrix = self._degree_dependent_scaling_to_diagonal_matrix(h)
357
- domain = EuclideanSpace(self.dim)
358
-
359
- def mapping(c: np.ndarray) -> Any:
360
- return self.from_components(matrix @ c)
361
-
362
- def adjoint_mapping(u: Any) -> np.ndarray:
363
- return adjoint_matrix @ self.to_components(u)
364
-
365
- inverse_matrix = self._degree_dependent_scaling_to_diagonal_matrix(
366
- lambda l: 1.0 / g(l)
367
- )
368
- inverse_adjoint_matrix = self._degree_dependent_scaling_to_diagonal_matrix(
369
- lambda l: 1.0 / h(l)
185
+ u = sh.SHGrid.from_zeros(
186
+ self.lmax, grid=self.grid, extend=self.extend, sampling=self._sampling
370
187
  )
188
+ for j, lon in enumerate(u.lons()):
189
+ for i, lat in enumerate(u.lats()):
190
+ u.data[i, j] = f((lat, lon))
371
191
 
372
- def inverse_mapping(u: Any) -> np.ndarray:
373
- return inverse_matrix @ self.to_components(u)
192
+ return u
374
193
 
375
- def inverse_adjoint_mapping(c: np.ndarray) -> Any:
376
- # Note: This is a formal adjoint mapping.
377
- return self.from_components(inverse_adjoint_matrix @ c)
194
+ def to_coefficient(self, u: sh.SHGrid) -> sh.SHCoeffs:
195
+ """Maps a function vector to its spherical harmonic coefficients."""
196
+ return u.expand(normalization=self.normalization, csphase=self.csphase)
378
197
 
379
- covariance_factor = LinearOperator(
380
- domain, self, mapping, adjoint_mapping=adjoint_mapping
381
- )
382
- inverse_covariance_factor = LinearOperator(
383
- self, domain, inverse_mapping, adjoint_mapping=inverse_adjoint_mapping
384
- )
385
-
386
- return GaussianMeasure(
387
- covariance_factor=covariance_factor,
388
- inverse_covariance_factor=inverse_covariance_factor,
389
- expectation=expectation,
390
- )
198
+ def from_coefficient(self, ulm: sh.SHCoeffs) -> sh.SHGrid:
199
+ """Maps spherical harmonic coefficients to a function vector."""
200
+ grid = self.grid if self._sampling == 1 else "DH2"
201
+ return ulm.expand(grid=grid, extend=self.extend)
391
202
 
392
203
  def plot(
393
204
  self,
394
- f: Any,
205
+ u: sh.SHGrid,
395
206
  /,
396
207
  *,
397
208
  projection: "Projection" = ccrs.PlateCarree(),
@@ -409,7 +220,7 @@ class Sobolev(SymmetricSpaceSobolev):
409
220
  Creates a map plot of a function on the sphere using `cartopy`.
410
221
 
411
222
  Args:
412
- f: The function to be plotted (either an `SHGrid` or `SHCoeffs` object).
223
+ u: The element to be plotted.
413
224
  projection: A `cartopy.crs` projection. Defaults to `PlateCarree`.
414
225
  contour: If True, creates a filled contour plot. Otherwise, a `pcolormesh` plot.
415
226
  cmap: The colormap name.
@@ -425,14 +236,9 @@ class Sobolev(SymmetricSpaceSobolev):
425
236
  Returns:
426
237
  A tuple `(figure, axes, image)` containing the Matplotlib and Cartopy objects.
427
238
  """
428
- field: sh.SHGrid = (
429
- f
430
- if self._vector_as_SHGrid
431
- else f.expand(normalization=self.normalization, csphase=self.csphase)
432
- )
433
239
 
434
- lons = field.lons()
435
- lats = field.lats()
240
+ lons = u.lons()
241
+ lats = u.lats()
436
242
 
437
243
  figsize: Tuple[int, int] = kwargs.pop("figsize", (10, 8))
438
244
  fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": projection})
@@ -448,7 +254,7 @@ class Sobolev(SymmetricSpaceSobolev):
448
254
 
449
255
  kwargs.setdefault("cmap", cmap)
450
256
  if symmetric:
451
- data_max = 1.2 * np.nanmax(np.abs(f.data))
257
+ data_max = 1.2 * np.nanmax(np.abs(u.data))
452
258
  kwargs.setdefault("vmin", -data_max)
453
259
  kwargs.setdefault("vmax", data_max)
454
260
 
@@ -458,14 +264,14 @@ class Sobolev(SymmetricSpaceSobolev):
458
264
  im = ax.contourf(
459
265
  lons,
460
266
  lats,
461
- field.data,
267
+ u.data,
462
268
  transform=ccrs.PlateCarree(),
463
269
  levels=levels,
464
270
  **kwargs,
465
271
  )
466
272
  else:
467
273
  im = ax.pcolormesh(
468
- lons, lats, field.data, transform=ccrs.PlateCarree(), **kwargs
274
+ lons, lats, u.data, transform=ccrs.PlateCarree(), **kwargs
469
275
  )
470
276
 
471
277
  if gridlines:
@@ -485,28 +291,12 @@ class Sobolev(SymmetricSpaceSobolev):
485
291
 
486
292
  return fig, ax, im
487
293
 
488
- def __eq__(self, other: object) -> bool:
489
- """
490
- Checks for mathematical equality with another Sobolev space on a sphere.
294
+ # --------------------------------------------------------------- #
295
+ # private methods #
296
+ # ----------------------------------------------------------------#
491
297
 
492
- Two spaces are considered equal if they are of the same type and have
493
- the same defining parameters.
494
- """
495
- if not isinstance(other, Sobolev):
496
- return NotImplemented
497
-
498
- return (
499
- self.lmax == other.lmax
500
- and self.order == other.order
501
- and self.scale == other.scale
502
- and self.radius == other.radius
503
- and self.grid == other.grid
504
- and self._vector_as_SHGrid == other._vector_as_SHGrid
505
- )
506
-
507
- # ==============================================#
508
- # Private methods #
509
- # ==============================================#
298
+ def _grid_name(self):
299
+ return self.grid if self._sampling == 1 else "DH2"
510
300
 
511
301
  def _coefficient_to_component_mapping(self) -> coo_array:
512
302
  """Builds a sparse matrix to map `pyshtools` coeffs to component vectors."""
@@ -536,38 +326,10 @@ class Sobolev(SymmetricSpaceSobolev):
536
326
  (data, (rows, cols)), shape=(row_dim, col_dim), dtype=float
537
327
  ).tocsc()
538
328
 
539
- def _to_components_from_coeffs(self, coeffs: np.ndarray) -> np.ndarray:
540
- """Returns a component vector from a `pyshtools` coefficient array."""
541
- f = coeffs.flatten(order="C")
542
- return self._sparse_coeffs_to_component @ f
543
-
544
- def _to_components_from_SHCoeffs(self, ulm: sh.SHCoeffs) -> np.ndarray:
545
- """Returns a component vector from an `SHCoeffs` object."""
546
- return self._to_components_from_coeffs(ulm.coeffs)
547
-
548
- def _to_components_from_SHGrid(self, u: sh.SHGrid) -> np.ndarray:
549
- """Returns a component vector from an `SHGrid` object."""
550
- ulm = u.expand(normalization=self.normalization, csphase=self.csphase)
551
- return self._to_components_from_SHCoeffs(ulm)
552
-
553
- def _from_components_to_SHCoeffs(self, c: np.ndarray) -> sh.SHCoeffs:
554
- """Returns an `SHCoeffs` object from its component vector."""
555
- f = self._sparse_coeffs_to_component.T @ c
556
- coeffs = f.reshape((2, self.lmax + 1, self.lmax + 1))
557
- return sh.SHCoeffs.from_array(
558
- coeffs, normalization=self.normalization, csphase=self.csphase
559
- )
560
-
561
- def _from_components_to_SHGrid(self, c: np.ndarray) -> sh.SHGrid:
562
- """Returns an `SHGrid` object from its component vector."""
563
- ulm = self._from_components_to_SHCoeffs(c)
564
- return ulm.expand(grid=self.grid, extend=self.extend)
565
-
566
- def _degree_dependent_scaling_to_diagonal_matrix(
567
- self, f: Callable[[int], float]
568
- ) -> diags:
329
+ def _degree_dependent_scaling_values(self, f: Callable[[int], float]) -> diags:
569
330
  """Creates a diagonal sparse matrix from a function of degree `l`."""
570
- values = np.zeros(self.dim)
331
+ dim = (self.lmax + 1) ** 2
332
+ values = np.zeros(dim)
571
333
  i = 0
572
334
  for l in range(self.lmax + 1):
573
335
  j = i + l + 1
@@ -577,124 +339,262 @@ class Sobolev(SymmetricSpaceSobolev):
577
339
  j = i + l
578
340
  values[i:j] = f(l)
579
341
  i = j
580
- return diags([values], [0])
342
+ return values
581
343
 
582
- def _sobolev_function(self, l: int) -> float:
583
- """The degree-dependent scaling that defines the Sobolev inner product."""
584
- return (1.0 + self.scale**2 * l * (l + 1)) ** self.order
344
+ def _coefficient_to_component(self, ulm: sh.SHCoeffs) -> np.ndarray:
345
+ """Maps spherical harmonic coefficients to a component vector."""
346
+ flat_coeffs = ulm.coeffs.flatten(order="C")
347
+ return self._sparse_coeffs_to_component @ flat_coeffs
585
348
 
586
- def _inner_product_impl(self, u: Any, v: Any) -> float:
587
- """Implements the Sobolev inner product in the spectral domain."""
588
- return self.radius**2 * np.dot(
589
- self._metric_tensor @ self.to_components(u), self.to_components(v)
349
+ def _component_to_coefficient(self, c: np.ndarray) -> sh.SHCoeffs:
350
+ """Maps a component vector to spherical harmonic coefficients."""
351
+ flat_coeffs = self._sparse_coeffs_to_component.T @ c
352
+ coeffs = flat_coeffs.reshape((2, self.lmax + 1, self.lmax + 1))
353
+ return sh.SHCoeffs.from_array(
354
+ coeffs, normalization=self.normalization, csphase=self.csphase
590
355
  )
591
356
 
592
- def _to_dual_impl(self, u: Any) -> "LinearForm":
593
- """Implements the mapping to the dual space."""
594
- c = self._metric_tensor @ self.to_components(u) * self.radius**2
595
- return self.dual.from_components(c)
596
357
 
597
- def _from_dual_impl(self, up: "LinearForm") -> Any:
598
- """Implements the mapping from the dual space."""
599
- c = self._inverse_metric_tensor @ self.dual.to_components(up) / self.radius**2
600
- return self.from_components(c)
358
+ class Lebesgue(SphereHelper, HilbertModule, AbstractInvariantLebesgueSpace):
359
+ """
360
+ Implementation of the Lebesgue space on the sphere.
361
+
362
+ This class represents square-integrable functions on a sphere. A function is
363
+ represented by a `pyshtools.SHGrid` object, which stores its values on a
364
+ regular grid in latitude and longitude. The L² inner product is defined
365
+ in the spherical harmonic domain.
366
+ """
367
+
368
+ def __init__(
369
+ self,
370
+ lmax: int,
371
+ /,
372
+ *,
373
+ radius: float = 1,
374
+ grid: str = "DH",
375
+ extend: bool = True,
376
+ ):
377
+
378
+ if lmax < 0:
379
+ raise ValueError("lmax must be non-negative")
380
+
381
+ self._dim = (lmax + 1) ** 2
382
+
383
+ SphereHelper.__init__(self, lmax, radius, grid, extend)
384
+
385
+ @property
386
+ def dim(self) -> int:
387
+ """The dimension of the space."""
388
+ return self._dim
389
+
390
+ def to_components(self, u: sh.SHGrid) -> np.ndarray:
391
+ coeff = self.to_coefficient(u)
392
+ return self._coefficient_to_component(coeff)
393
+
394
+ def from_components(self, c: np.ndarray) -> sh.SHGrid:
395
+ coeff = self._component_to_coefficient(c)
396
+ return self.from_coefficient(coeff)
601
397
 
602
- def _ax_impl(self, a: float, x: Any) -> None:
398
+ def to_dual(self, u: sh.SHGrid) -> LinearForm:
399
+ coeff = self.to_coefficient(u)
400
+ cp = self._coefficient_to_component(coeff) * self.radius**2
401
+ return self.dual.from_components(cp)
402
+
403
+ def from_dual(self, up: LinearForm) -> sh.SHGrid:
404
+ cp = self.dual.to_components(up) / self.radius**2
405
+ coeff = self._component_to_coefficient(cp)
406
+ return self.from_coefficient(coeff)
407
+
408
+ def ax(self, a: float, x: sh.SHGrid) -> None:
603
409
  """
604
410
  Custom in-place ax implementation for pyshtools objects.
605
411
  x := a*x
606
412
  """
607
- if self._vector_as_SHGrid:
608
- # For SHGrid objects, modify the .data array
609
- x.data *= a
610
- else:
611
- # For SHCoeffs objects, modify the .coeffs array
612
- x.coeffs *= a
413
+ x.data *= a
613
414
 
614
- def _axpy_impl(self, a: float, x: Any, y: Any) -> None:
415
+ def axpy(self, a: float, x: sh.SHGrid, y: sh.SHGrid) -> None:
615
416
  """
616
417
  Custom in-place axpy implementation for pyshtools objects.
617
418
  y := a*x + y
618
419
  """
619
- if self._vector_as_SHGrid:
620
- # For SHGrid objects, modify the .data array
621
- y.data += a * x.data
622
- else:
623
- # For SHCoeffs objects, modify the .coeffs array
624
- y.coeffs += a * x.coeffs
420
+ y.data += a * x.data
625
421
 
626
- def _vector_multiply_impl(self, u1: Any, u2: Any) -> Any:
627
- """Implements element-wise multiplication of two fields."""
628
- if self._vector_as_SHGrid:
629
- return u1 * u2
630
- else:
631
- u1_field = u1.expand(grid=self.grid, extend=self.extend)
632
- u2_field = u2.expand(grid=self.grid, extend=self.extend)
633
- u3_field = u1_field * u2_field
634
- return u3_field.expand(
635
- normalization=self.normalization, csphase=self.csphase
636
- )
422
+ def vector_multiply(self, x1: sh.SHGrid, x2: sh.SHGrid) -> sh.SHGrid:
423
+ """
424
+ Computes the pointwise product of two functions.
425
+ """
426
+ return x1 * x2
637
427
 
428
+ def eigenfunction_norms(self) -> np.ndarray:
429
+ """Returns a list of the norms of the eigenfunctions."""
430
+ return np.fromiter(
431
+ [self.radius for i in range(self.dim)],
432
+ dtype=float,
433
+ )
638
434
 
639
- class Lebesgue(Sobolev):
435
+ def invariant_automorphism(self, f: Callable[[float], float]) -> LinearOperator:
436
+ values = self._degree_dependent_scaling_values(
437
+ lambda l: f(self.laplacian_eigenvalue((l, 0)))
438
+ )
439
+ matrix = diags([values], [0])
440
+
441
+ def mapping(u):
442
+ c = matrix @ (self.to_components(u))
443
+ coeff = self._component_to_coefficient(c)
444
+ return self.from_coefficient(coeff)
445
+
446
+ return LinearOperator.self_adjoint(self, mapping)
447
+
448
+ # ================================================================ #
449
+ # Private methods #
450
+ # ================================================================ #
451
+
452
+
453
+ class Sobolev(SphereHelper, MassWeightedHilbertModule, AbstractInvariantSobolevSpace):
640
454
  """
641
- Implements the L^2 space on the two-sphere.
455
+ Implementation of the Sobolev space on the sphere.
642
456
 
643
- This is a special case of the `Sobolev` class with `order = 0`.
457
+ This class represents functions with a specified degree of smoothness. It is
458
+ constructed as a `MassWeightedHilbertModule` over the `Lebesgue` space, where
459
+ the mass operator weights the spherical harmonic coefficients to enforce
460
+ smoothness. This is the primary class for defining smooth, random function
461
+ fields (e.g., for geophysics or climate science).
644
462
  """
645
463
 
646
464
  def __init__(
647
465
  self,
648
466
  lmax: int,
467
+ order: float,
468
+ scale: float,
469
+ /,
470
+ radius: float = 1,
471
+ grid: str = "DH",
472
+ extend: bool = True,
473
+ ):
474
+
475
+ if lmax < 0:
476
+ raise ValueError("lmax must be non-negative")
477
+
478
+ SphereHelper.__init__(self, lmax, radius, grid, extend)
479
+ AbstractInvariantSobolevSpace.__init__(self, order, scale)
480
+
481
+ lebesgue = Lebesgue(lmax, radius=radius, grid=grid, extend=extend)
482
+
483
+ mass_operator = lebesgue.invariant_automorphism(self.sobolev_function)
484
+ inverse_mass_operator = lebesgue.invariant_automorphism(
485
+ lambda k: 1.0 / self.sobolev_function(k)
486
+ )
487
+
488
+ MassWeightedHilbertModule.__init__(
489
+ self, lebesgue, mass_operator, inverse_mass_operator
490
+ )
491
+
492
+ @staticmethod
493
+ def from_sobolev_parameters(
494
+ order: float,
495
+ scale: float,
649
496
  /,
650
497
  *,
651
- vector_as_SHGrid: bool = True,
652
498
  radius: float = 1.0,
499
+ vector_as_SHGrid: bool = True,
653
500
  grid: str = "DH",
654
- ) -> None:
501
+ rtol: float = 1e-8,
502
+ power_of_two: bool = False,
503
+ ) -> "Sobolev":
655
504
  """
505
+ Creates an instance with `lmax` chosen based on the Sobolev parameters.
506
+
507
+ This factory method estimates the spherical harmonic truncation degree
508
+ (`lmax`) required to represent the space while meeting a specified
509
+ relative tolerance for the truncation error. This is useful when the
510
+ required `lmax` is not known a priori.
511
+
656
512
  Args:
657
- lmax: The maximum spherical harmonic degree for truncation.
658
- vector_as_SHGrid: If True, elements are `SHGrid` objects. Otherwise,
659
- they are `SHCoeffs` objects.
660
- radius: The radius of the sphere.
661
- grid: The `pyshtools` grid type.
513
+ order: The order of the Sobolev space, controlling smoothness.
514
+ scale: The non-dimensional length-scale for the space.
515
+ radius: The radius of the sphere. Defaults to 1.0.
516
+ grid: The `pyshtools` grid type (e.g., 'DH'). Defaults to 'DH'.
517
+ rtol: The relative tolerance used to determine the `lmax`.
518
+ power_of_two: If True, `lmax` is set to the next power of two.
519
+
520
+ Returns:
521
+ An instance of the Sobolev class with a calculated `lmax`.
662
522
  """
663
- super().__init__(
523
+ if order <= 1.0:
524
+ raise ValueError("This method is only applicable for orders > 1.0")
525
+
526
+ summation = 1.0
527
+ l = 0
528
+ err = 1.0
529
+ sobolev_func = lambda deg: (1.0 + scale**2 * deg * (deg + 1)) ** order
530
+
531
+ while err > rtol:
532
+ l += 1
533
+ term = 1 / sobolev_func(l)
534
+ summation += term
535
+ err = term / summation
536
+ if l > 10000:
537
+ raise RuntimeError("Failed to converge on a stable lmax.")
538
+
539
+ if power_of_two:
540
+ n = int(np.log2(l))
541
+ l = 2 ** (n + 1)
542
+
543
+ lmax = l
544
+ return Sobolev(
664
545
  lmax,
665
- 0.0,
666
- 1.0,
667
- vector_as_SHGrid=vector_as_SHGrid,
546
+ order,
547
+ scale,
668
548
  radius=radius,
669
549
  grid=grid,
670
550
  )
671
551
 
552
+ def __eq__(self, other: object) -> bool:
553
+ """
554
+ Checks for mathematical equality with another Sobolev space on a sphere.
672
555
 
673
- class LowPassFilter:
674
- """
675
- Implements a simple Hann-type low-pass filter in the spherical harmonic domain.
676
- """
556
+ Two spaces are considered equal if they are of the same type and have
557
+ the same defining parameters (kmax, order, scale, and radius).
558
+ """
559
+ if not isinstance(other, Sobolev):
560
+ return NotImplemented
561
+
562
+ return (
563
+ self.lmax == other.lmax
564
+ and self.radius == other.radius
565
+ and self.order == other.order
566
+ and self.scale == other.scale
567
+ )
568
+
569
+ def eigenfunction_norms(self) -> np.ndarray:
570
+ """Returns a list of the norms of the eigenfunctions."""
571
+ values = self._degree_dependent_scaling_values(
572
+ lambda l: np.sqrt(self.sobolev_function(self.laplacian_eigenvalue((l, 0))))
573
+ )
574
+ return self.radius * np.fromiter(values, dtype=float)
677
575
 
678
- def __init__(self, lower_degree: int, upper_degree: int) -> None:
576
+ def dirac(self, point: (float, float)) -> LinearForm:
679
577
  """
578
+ Returns the linear functional for point evaluation (Dirac measure).
579
+
680
580
  Args:
681
- lower_degree: Below this degree `l`, the filter gain is 1.
682
- upper_degree: Above this degree `l`, the filter gain is 0.
581
+ point: A tuple containing `(latitude, longitude)`.
683
582
  """
684
- self._lower_degree: int = lower_degree
685
- self._upper_degree: int = upper_degree
686
-
687
- def __call__(self, l: int) -> float:
688
- if l <= self._lower_degree:
689
- return 1.0
690
- elif self._lower_degree <= l <= self._upper_degree:
691
- return 0.5 * (
692
- 1.0
693
- - np.cos(
694
- np.pi
695
- * (self._upper_degree - l)
696
- / (self._upper_degree - self._lower_degree)
697
- )
698
- )
699
- else:
700
- return 0.0
583
+ latitude, longitude = point
584
+ colatitude = 90.0 - latitude
585
+
586
+ coeffs = sh.expand.spharm(
587
+ self.lmax,
588
+ colatitude,
589
+ longitude,
590
+ normalization=self.normalization,
591
+ degrees=True,
592
+ )
593
+ ulm = sh.SHCoeffs.from_array(
594
+ coeffs,
595
+ normalization=self.normalization,
596
+ csphase=self.csphase,
597
+ )
598
+
599
+ c = self._coefficient_to_component(ulm)
600
+ return self.dual.from_components(c)