pygeoinf 1.0.9__py3-none-any.whl → 1.1.1__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
- )
86
+ self._grid = grid
87
+ self._sampling = 1
118
88
 
119
- # ===============================================#
120
- # Static methods #
121
- # ===============================================#
89
+ self._extend: bool = extend
122
90
 
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")
157
-
158
- summation = 1.0
159
- l = 0
160
- err = 1.0
161
- sobolev_func = lambda deg: (1.0 + scale**2 * deg * (deg + 1)) ** order
162
-
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,194 +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
- # ==============================================#
223
-
224
- def project_function(self, f: Callable[[(float, float)], float]) -> np.ndarray:
225
- """
226
- Returns an element of the space by projecting a given function.
227
-
228
- Args:
229
- f: A function that takes a point `(lat, lon)` and returns a value.
230
- """
231
- u = self.zero
232
- for j, lon in enumerate(u.lons()):
233
- for i, lat in enumerate(u.lats()):
234
- u.data[i, j] = f((lat, lon))
235
- return u
142
+ @property
143
+ def spatial_dimension(self) -> int:
144
+ """The dimension of the space."""
145
+ return 2
236
146
 
237
147
  def random_point(self) -> List[float]:
238
148
  """Returns a random point as `[latitude, longitude]`."""
239
- latitude = np.random.uniform(-90.0, 90.0)
149
+ latitude = np.rad2deg(np.arcsin(np.random.uniform(-1.0, 1.0)))
240
150
  longitude = np.random.uniform(0.0, 360.0)
241
151
  return [latitude, longitude]
242
152
 
243
- def low_degree_projection(
244
- self,
245
- truncation_degree: int,
246
- /,
247
- *,
248
- smoother: Optional[Callable[[int], float]] = None,
249
- ) -> "LinearOperator":
153
+ def laplacian_eigenvalue(self, k: [int, int]) -> float:
250
154
  """
251
- Returns an operator that projects onto a lower-degree space.
252
-
253
- This can be used for truncating or smoothing a field in the spherical
254
- harmonic domain.
155
+ Returns the (l.m)-th eigenvalue of the Laplacian.
255
156
 
256
157
  Args:
257
- truncation_degree: The new maximum degree `lmax`.
258
- smoother: An optional callable `f(l)` that applies a degree-dependent
259
- weighting factor during projection.
260
-
261
- Returns:
262
- A `LinearOperator` that maps from this space to a new, lower-degree
263
- `Sobolev` space.
158
+ k = (l,m): The index of the eigenvalue to return.
264
159
  """
265
- truncation_degree = (
266
- truncation_degree if truncation_degree <= self.lmax else self.lmax
267
- )
268
- # Default smoother is an identity mapping.
269
- f: Callable[[int], float] = smoother if smoother is not None else lambda l: 1.0
270
-
271
- row_dim = (truncation_degree + 1) ** 2
272
- col_dim = (self.lmax + 1) ** 2
273
-
274
- # Construct the sparse matrix that performs the coordinate projection.
275
- row, col = 0, 0
276
- rows, cols, data = [], [], []
277
- for l in range(self.lmax + 1):
278
- fac = f(l)
279
- for _ in range(l + 1):
280
- if l <= truncation_degree:
281
- rows.append(row)
282
- row += 1
283
- cols.append(col)
284
- data.append(fac)
285
- col += 1
286
-
287
- for l in range(truncation_degree + 1):
288
- fac = f(l)
289
- for _ in range(1, l + 1):
290
- rows.append(row)
291
- row += 1
292
- cols.append(col)
293
- data.append(fac)
294
- col += 1
295
-
296
- smat = coo_array(
297
- (data, (rows, cols)), shape=(row_dim, col_dim), dtype=float
298
- ).tocsc()
299
-
300
- codomain = Sobolev(
301
- truncation_degree,
302
- self.order,
303
- self.scale,
304
- vector_as_SHGrid=self._vector_as_SHGrid,
305
- radius=self.radius,
306
- grid=self._grid,
307
- )
308
-
309
- def mapping(u: Any) -> Any:
310
- uc = self.to_components(u)
311
- vc = smat @ uc
312
- return codomain.from_components(vc)
313
-
314
- def adjoint_mapping(v: Any) -> Any:
315
- vc = codomain.to_components(v)
316
- uc = smat.T @ vc
317
- return self.from_components(uc)
160
+ l = k[0]
161
+ return l * (l + 1) / self.radius**2
318
162
 
319
- return LinearOperator(self, codomain, mapping, adjoint_mapping=adjoint_mapping)
320
-
321
- def dirac(self, point: List[float]) -> "LinearForm":
163
+ def trace_of_invariant_automorphism(self, f: Callable[[float], float]) -> float:
322
164
  """
323
- 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.
324
167
 
325
168
  Args:
326
- point: A list containing `[latitude, longitude]`.
169
+ f: A real-valued function that is well-defined on the spectrum
170
+ of the Laplacian.
327
171
  """
328
- latitude, longitude = point
329
- colatitude = 90.0 - latitude
330
-
331
- coeffs = sh.expand.spharm(
332
- self.lmax,
333
- colatitude,
334
- longitude,
335
- normalization=self.normalization,
336
- degrees=True,
337
- )
338
- c = self._to_components_from_coeffs(coeffs)
339
- # Note: The user confirmed the logic for this return statement is correct
340
- # for their use case, despite the base class returning a LinearForm.
341
- return self.dual.from_components(c)
342
-
343
- def invariant_automorphism(self, f: Callable[[float], float]) -> "LinearOperator":
344
- matrix = self._degree_dependent_scaling_to_diagonal_matrix(f)
345
-
346
- def mapping(x: Any) -> Any:
347
- return self.from_components(matrix @ self.to_components(x))
348
-
349
- 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
350
177
 
351
- def invariant_gaussian_measure(
352
- self, f: Callable[[float], float], /, *, expectation: Optional[Any] = None
353
- ) -> "GaussianMeasure":
178
+ def project_function(self, f: Callable[[(float, float)], float]) -> np.ndarray:
354
179
  """
355
- Returns a Gaussian measure with a covariance of the form `f(Delta)`.
180
+ Returns an element of the space by projecting a given function.
356
181
 
357
182
  Args:
358
- f: The scalar function `f(l(l+1))` defining the covariance.
359
- expectation: The mean of the measure. Defaults to zero.
183
+ f: A function that takes a point `(lat, lon)` and returns a value.
360
184
  """
361
-
362
- def g(l: int) -> float:
363
- return np.sqrt(f(l) / (self.radius**2 * self._sobolev_function(l)))
364
-
365
- def h(l: int) -> float:
366
- return np.sqrt(self.radius**2 * self._sobolev_function(l) * f(l))
367
-
368
- matrix = self._degree_dependent_scaling_to_diagonal_matrix(g)
369
- adjoint_matrix = self._degree_dependent_scaling_to_diagonal_matrix(h)
370
- domain = EuclideanSpace(self.dim)
371
-
372
- def mapping(c: np.ndarray) -> Any:
373
- return self.from_components(matrix @ c)
374
-
375
- def adjoint_mapping(u: Any) -> np.ndarray:
376
- return adjoint_matrix @ self.to_components(u)
377
-
378
- inverse_matrix = self._degree_dependent_scaling_to_diagonal_matrix(
379
- lambda l: 1.0 / g(l)
185
+ u = sh.SHGrid.from_zeros(
186
+ self.lmax, grid=self.grid, extend=self.extend, sampling=self._sampling
380
187
  )
381
- inverse_adjoint_matrix = self._degree_dependent_scaling_to_diagonal_matrix(
382
- lambda l: 1.0 / h(l)
383
- )
384
-
385
- def inverse_mapping(u: Any) -> np.ndarray:
386
- return inverse_matrix @ self.to_components(u)
188
+ for j, lon in enumerate(u.lons()):
189
+ for i, lat in enumerate(u.lats()):
190
+ u.data[i, j] = f((lat, lon))
387
191
 
388
- def inverse_adjoint_mapping(c: np.ndarray) -> Any:
389
- # Note: This is a formal adjoint mapping.
390
- return self.from_components(inverse_adjoint_matrix @ c)
192
+ return u
391
193
 
392
- covariance_factor = LinearOperator(
393
- domain, self, mapping, adjoint_mapping=adjoint_mapping
394
- )
395
- inverse_covariance_factor = LinearOperator(
396
- self, domain, inverse_mapping, adjoint_mapping=inverse_adjoint_mapping
397
- )
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)
398
197
 
399
- return GaussianMeasure(
400
- covariance_factor=covariance_factor,
401
- inverse_covariance_factor=inverse_covariance_factor,
402
- expectation=expectation,
403
- )
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)
404
202
 
405
203
  def plot(
406
204
  self,
407
- f: Any,
205
+ u: sh.SHGrid,
408
206
  /,
409
207
  *,
410
208
  projection: "Projection" = ccrs.PlateCarree(),
@@ -422,7 +220,7 @@ class Sobolev(SymmetricSpaceSobolev):
422
220
  Creates a map plot of a function on the sphere using `cartopy`.
423
221
 
424
222
  Args:
425
- f: The function to be plotted (either an `SHGrid` or `SHCoeffs` object).
223
+ u: The element to be plotted.
426
224
  projection: A `cartopy.crs` projection. Defaults to `PlateCarree`.
427
225
  contour: If True, creates a filled contour plot. Otherwise, a `pcolormesh` plot.
428
226
  cmap: The colormap name.
@@ -438,14 +236,9 @@ class Sobolev(SymmetricSpaceSobolev):
438
236
  Returns:
439
237
  A tuple `(figure, axes, image)` containing the Matplotlib and Cartopy objects.
440
238
  """
441
- field: sh.SHGrid = (
442
- f
443
- if self._vector_as_SHGrid
444
- else f.expand(normalization=self.normalization, csphase=self.csphase)
445
- )
446
239
 
447
- lons = field.lons()
448
- lats = field.lats()
240
+ lons = u.lons()
241
+ lats = u.lats()
449
242
 
450
243
  figsize: Tuple[int, int] = kwargs.pop("figsize", (10, 8))
451
244
  fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": projection})
@@ -461,7 +254,7 @@ class Sobolev(SymmetricSpaceSobolev):
461
254
 
462
255
  kwargs.setdefault("cmap", cmap)
463
256
  if symmetric:
464
- data_max = 1.2 * np.nanmax(np.abs(f.data))
257
+ data_max = 1.2 * np.nanmax(np.abs(u.data))
465
258
  kwargs.setdefault("vmin", -data_max)
466
259
  kwargs.setdefault("vmax", data_max)
467
260
 
@@ -471,14 +264,14 @@ class Sobolev(SymmetricSpaceSobolev):
471
264
  im = ax.contourf(
472
265
  lons,
473
266
  lats,
474
- field.data,
267
+ u.data,
475
268
  transform=ccrs.PlateCarree(),
476
269
  levels=levels,
477
270
  **kwargs,
478
271
  )
479
272
  else:
480
273
  im = ax.pcolormesh(
481
- lons, lats, field.data, transform=ccrs.PlateCarree(), **kwargs
274
+ lons, lats, u.data, transform=ccrs.PlateCarree(), **kwargs
482
275
  )
483
276
 
484
277
  if gridlines:
@@ -498,28 +291,12 @@ class Sobolev(SymmetricSpaceSobolev):
498
291
 
499
292
  return fig, ax, im
500
293
 
501
- def __eq__(self, other: object) -> bool:
502
- """
503
- Checks for mathematical equality with another Sobolev space on a sphere.
294
+ # --------------------------------------------------------------- #
295
+ # private methods #
296
+ # ----------------------------------------------------------------#
504
297
 
505
- Two spaces are considered equal if they are of the same type and have
506
- the same defining parameters.
507
- """
508
- if not isinstance(other, Sobolev):
509
- return NotImplemented
510
-
511
- return (
512
- self.lmax == other.lmax
513
- and self.order == other.order
514
- and self.scale == other.scale
515
- and self.radius == other.radius
516
- and self.grid == other.grid
517
- and self._vector_as_SHGrid == other._vector_as_SHGrid
518
- )
519
-
520
- # ==============================================#
521
- # Private methods #
522
- # ==============================================#
298
+ def _grid_name(self):
299
+ return self.grid if self._sampling == 1 else "DH2"
523
300
 
524
301
  def _coefficient_to_component_mapping(self) -> coo_array:
525
302
  """Builds a sparse matrix to map `pyshtools` coeffs to component vectors."""
@@ -549,38 +326,10 @@ class Sobolev(SymmetricSpaceSobolev):
549
326
  (data, (rows, cols)), shape=(row_dim, col_dim), dtype=float
550
327
  ).tocsc()
551
328
 
552
- def _to_components_from_coeffs(self, coeffs: np.ndarray) -> np.ndarray:
553
- """Returns a component vector from a `pyshtools` coefficient array."""
554
- f = coeffs.flatten(order="C")
555
- return self._sparse_coeffs_to_component @ f
556
-
557
- def _to_components_from_SHCoeffs(self, ulm: sh.SHCoeffs) -> np.ndarray:
558
- """Returns a component vector from an `SHCoeffs` object."""
559
- return self._to_components_from_coeffs(ulm.coeffs)
560
-
561
- def _to_components_from_SHGrid(self, u: sh.SHGrid) -> np.ndarray:
562
- """Returns a component vector from an `SHGrid` object."""
563
- ulm = u.expand(normalization=self.normalization, csphase=self.csphase)
564
- return self._to_components_from_SHCoeffs(ulm)
565
-
566
- def _from_components_to_SHCoeffs(self, c: np.ndarray) -> sh.SHCoeffs:
567
- """Returns an `SHCoeffs` object from its component vector."""
568
- f = self._sparse_coeffs_to_component.T @ c
569
- coeffs = f.reshape((2, self.lmax + 1, self.lmax + 1))
570
- return sh.SHCoeffs.from_array(
571
- coeffs, normalization=self.normalization, csphase=self.csphase
572
- )
573
-
574
- def _from_components_to_SHGrid(self, c: np.ndarray) -> sh.SHGrid:
575
- """Returns an `SHGrid` object from its component vector."""
576
- ulm = self._from_components_to_SHCoeffs(c)
577
- return ulm.expand(grid=self.grid, extend=self.extend)
578
-
579
- def _degree_dependent_scaling_to_diagonal_matrix(
580
- self, f: Callable[[int], float]
581
- ) -> diags:
329
+ def _degree_dependent_scaling_values(self, f: Callable[[int], float]) -> diags:
582
330
  """Creates a diagonal sparse matrix from a function of degree `l`."""
583
- values = np.zeros(self.dim)
331
+ dim = (self.lmax + 1) ** 2
332
+ values = np.zeros(dim)
584
333
  i = 0
585
334
  for l in range(self.lmax + 1):
586
335
  j = i + l + 1
@@ -590,124 +339,262 @@ class Sobolev(SymmetricSpaceSobolev):
590
339
  j = i + l
591
340
  values[i:j] = f(l)
592
341
  i = j
593
- return diags([values], [0])
342
+ return values
594
343
 
595
- def _sobolev_function(self, l: int) -> float:
596
- """The degree-dependent scaling that defines the Sobolev inner product."""
597
- 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
598
348
 
599
- def _inner_product_impl(self, u: Any, v: Any) -> float:
600
- """Implements the Sobolev inner product in the spectral domain."""
601
- return self.radius**2 * np.dot(
602
- 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
603
355
  )
604
356
 
605
- def _to_dual_impl(self, u: Any) -> "LinearForm":
606
- """Implements the mapping to the dual space."""
607
- c = self._metric_tensor @ self.to_components(u) * self.radius**2
608
- return self.dual.from_components(c)
609
357
 
610
- def _from_dual_impl(self, up: "LinearForm") -> Any:
611
- """Implements the mapping from the dual space."""
612
- c = self._inverse_metric_tensor @ self.dual.to_components(up) / self.radius**2
613
- 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
+ ):
614
377
 
615
- def _ax_impl(self, a: float, x: Any) -> None:
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)
397
+
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:
616
409
  """
617
410
  Custom in-place ax implementation for pyshtools objects.
618
411
  x := a*x
619
412
  """
620
- if self._vector_as_SHGrid:
621
- # For SHGrid objects, modify the .data array
622
- x.data *= a
623
- else:
624
- # For SHCoeffs objects, modify the .coeffs array
625
- x.coeffs *= a
413
+ x.data *= a
626
414
 
627
- def _axpy_impl(self, a: float, x: Any, y: Any) -> None:
415
+ def axpy(self, a: float, x: sh.SHGrid, y: sh.SHGrid) -> None:
628
416
  """
629
417
  Custom in-place axpy implementation for pyshtools objects.
630
418
  y := a*x + y
631
419
  """
632
- if self._vector_as_SHGrid:
633
- # For SHGrid objects, modify the .data array
634
- y.data += a * x.data
635
- else:
636
- # For SHCoeffs objects, modify the .coeffs array
637
- y.coeffs += a * x.coeffs
420
+ y.data += a * x.data
638
421
 
639
- def _vector_multiply_impl(self, u1: Any, u2: Any) -> Any:
640
- """Implements element-wise multiplication of two fields."""
641
- if self._vector_as_SHGrid:
642
- return u1 * u2
643
- else:
644
- u1_field = u1.expand(grid=self.grid, extend=self.extend)
645
- u2_field = u2.expand(grid=self.grid, extend=self.extend)
646
- u3_field = u1_field * u2_field
647
- return u3_field.expand(
648
- normalization=self.normalization, csphase=self.csphase
649
- )
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
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
+ )
434
+
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
+ # ================================================================ #
650
451
 
651
452
 
652
- class Lebesgue(Sobolev):
453
+ class Sobolev(SphereHelper, MassWeightedHilbertModule, AbstractInvariantSobolevSpace):
653
454
  """
654
- Implements the L^2 space on the two-sphere.
455
+ Implementation of the Sobolev space on the sphere.
655
456
 
656
- 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).
657
462
  """
658
463
 
659
464
  def __init__(
660
465
  self,
661
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,
662
496
  /,
663
497
  *,
664
- vector_as_SHGrid: bool = True,
665
498
  radius: float = 1.0,
499
+ vector_as_SHGrid: bool = True,
666
500
  grid: str = "DH",
667
- ) -> None:
501
+ rtol: float = 1e-8,
502
+ power_of_two: bool = False,
503
+ ) -> "Sobolev":
668
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
+
669
512
  Args:
670
- lmax: The maximum spherical harmonic degree for truncation.
671
- vector_as_SHGrid: If True, elements are `SHGrid` objects. Otherwise,
672
- they are `SHCoeffs` objects.
673
- radius: The radius of the sphere.
674
- 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`.
675
522
  """
676
- 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(
677
545
  lmax,
678
- 0.0,
679
- 1.0,
680
- vector_as_SHGrid=vector_as_SHGrid,
546
+ order,
547
+ scale,
681
548
  radius=radius,
682
549
  grid=grid,
683
550
  )
684
551
 
552
+ def __eq__(self, other: object) -> bool:
553
+ """
554
+ Checks for mathematical equality with another Sobolev space on a sphere.
685
555
 
686
- class LowPassFilter:
687
- """
688
- Implements a simple Hann-type low-pass filter in the spherical harmonic domain.
689
- """
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
+ )
690
568
 
691
- def __init__(self, lower_degree: int, upper_degree: int) -> None:
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)
575
+
576
+ def dirac(self, point: (float, float)) -> LinearForm:
692
577
  """
578
+ Returns the linear functional for point evaluation (Dirac measure).
579
+
693
580
  Args:
694
- lower_degree: Below this degree `l`, the filter gain is 1.
695
- upper_degree: Above this degree `l`, the filter gain is 0.
581
+ point: A tuple containing `(latitude, longitude)`.
696
582
  """
697
- self._lower_degree: int = lower_degree
698
- self._upper_degree: int = upper_degree
699
-
700
- def __call__(self, l: int) -> float:
701
- if l <= self._lower_degree:
702
- return 1.0
703
- elif self._lower_degree <= l <= self._upper_degree:
704
- return 0.5 * (
705
- 1.0
706
- - np.cos(
707
- np.pi
708
- * (self._upper_degree - l)
709
- / (self._upper_degree - self._lower_degree)
710
- )
711
- )
712
- else:
713
- 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)