pygeoinf 1.3.4__py3-none-any.whl → 1.3.6__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.
pygeoinf/subspaces.py ADDED
@@ -0,0 +1,403 @@
1
+ """
2
+ Defines classes for representing affine and linear subspaces.
3
+
4
+ The primary abstraction is the `AffineSubspace`, which represents a subset of
5
+ a Hilbert space defined by a translation and a closed linear tangent space.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from typing import List, Optional, Any, Callable, TYPE_CHECKING
10
+ import numpy as np
11
+ import warnings
12
+
13
+ from .linear_operators import LinearOperator
14
+ from .hilbert_space import HilbertSpace, Vector, EuclideanSpace
15
+ from .linear_solvers import LinearSolver, CholeskySolver, IterativeLinearSolver
16
+
17
+ if TYPE_CHECKING:
18
+ from .gaussian_measure import GaussianMeasure
19
+
20
+
21
+ class OrthogonalProjector(LinearOperator):
22
+ """
23
+ Internal engine for subspace projections.
24
+ Represents an orthogonal projection operator P = P* = P^2.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ domain: HilbertSpace,
30
+ mapping: Callable[[Any], Any],
31
+ complement_projector: Optional[LinearOperator] = None,
32
+ ) -> None:
33
+ super().__init__(domain, domain, mapping, adjoint_mapping=mapping)
34
+ self._complement_projector = complement_projector
35
+
36
+ @property
37
+ def complement(self) -> LinearOperator:
38
+ """Returns the projector onto the orthogonal complement (I - P)."""
39
+ if self._complement_projector is None:
40
+ identity = self.domain.identity_operator()
41
+ self._complement_projector = identity - self
42
+ return self._complement_projector
43
+
44
+ @classmethod
45
+ def from_basis(
46
+ cls,
47
+ domain: HilbertSpace,
48
+ basis_vectors: List[Vector],
49
+ orthonormalize: bool = True,
50
+ ) -> OrthogonalProjector:
51
+ """Constructs a projector P onto the span of the provided basis vectors."""
52
+ if not basis_vectors:
53
+ return domain.zero_operator(domain)
54
+
55
+ if orthonormalize:
56
+ e_vectors = domain.gram_schmidt(basis_vectors)
57
+ else:
58
+ e_vectors = basis_vectors
59
+
60
+ # P = sum (v_i x v_i)
61
+ tensor_op = LinearOperator.self_adjoint_from_tensor_product(domain, e_vectors)
62
+ return cls(domain, tensor_op)
63
+
64
+
65
+ class AffineSubspace:
66
+ """
67
+ Represents an affine subspace A = x0 + V.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ projector: OrthogonalProjector,
73
+ translation: Optional[Vector] = None,
74
+ constraint_operator: Optional[LinearOperator] = None,
75
+ constraint_value: Optional[Vector] = None,
76
+ solver: Optional[LinearSolver] = None,
77
+ preconditioner: Optional[LinearOperator] = None,
78
+ ) -> None:
79
+ """
80
+ Initializes the AffineSubspace.
81
+ """
82
+ self._projector = projector
83
+
84
+ if translation is None:
85
+ self._translation = projector.domain.zero
86
+ else:
87
+ if not projector.domain.is_element(translation):
88
+ raise ValueError("Translation vector not in domain.")
89
+ self._translation = translation
90
+
91
+ self._constraint_operator = constraint_operator
92
+ self._constraint_value = constraint_value
93
+
94
+ # Logic: If explicit equation exists, default to Cholesky.
95
+ # If implicit, leave None (requires robust solver from user).
96
+ if self._constraint_operator is not None and solver is None:
97
+ self._solver = CholeskySolver(galerkin=True)
98
+ else:
99
+ self._solver = solver
100
+
101
+ self._preconditioner = preconditioner
102
+
103
+ @property
104
+ def domain(self) -> HilbertSpace:
105
+ return self._projector.domain
106
+
107
+ @property
108
+ def translation(self) -> Vector:
109
+ return self._translation
110
+
111
+ @property
112
+ def projector(self) -> OrthogonalProjector:
113
+ return self._projector
114
+
115
+ @property
116
+ def tangent_space(self) -> LinearSubspace:
117
+ return LinearSubspace(self._projector)
118
+
119
+ @property
120
+ def has_explicit_equation(self) -> bool:
121
+ """True if defined by B(u)=w, False if defined only by geometry."""
122
+ return self._constraint_operator is not None
123
+
124
+ @property
125
+ def constraint_operator(self) -> LinearOperator:
126
+ """
127
+ Returns B for {u | B(u)=w}.
128
+ Falls back to (I - P) if no explicit operator exists.
129
+ """
130
+ if self._constraint_operator is None:
131
+ return self._projector.complement
132
+ return self._constraint_operator
133
+
134
+ @property
135
+ def constraint_value(self) -> Vector:
136
+ """
137
+ Returns w for {u | B(u)=w}.
138
+ Falls back to (I - P)x0 if no explicit operator exists.
139
+ """
140
+ if self._constraint_value is None:
141
+ complement = self._projector.complement
142
+ return complement(self._translation)
143
+ return self._constraint_value
144
+
145
+ def project(self, x: Vector) -> Vector:
146
+ """Orthogonally projects x onto the affine subspace."""
147
+ diff = self.domain.subtract(x, self.translation)
148
+ proj_diff = self.projector(diff)
149
+ return self.domain.add(self.translation, proj_diff)
150
+
151
+ def is_element(self, x: Vector, rtol: float = 1e-6) -> bool:
152
+ """Returns True if x lies in the subspace."""
153
+ proj = self.project(x)
154
+ diff = self.domain.subtract(x, proj)
155
+ norm_diff = self.domain.norm(diff)
156
+ norm_x = self.domain.norm(x)
157
+ scale = norm_x if norm_x > 1e-12 else 1.0
158
+ return norm_diff <= rtol * scale
159
+
160
+ def condition_gaussian_measure(
161
+ self, prior: GaussianMeasure, geometric: bool = False
162
+ ) -> GaussianMeasure:
163
+ """
164
+ Conditions a Gaussian measure on this subspace.
165
+ """
166
+ if geometric:
167
+ # Geometric Projection: u -> P(u - x0) + x0
168
+ # Affine Map: u -> P(u) + (I-P)x0
169
+ shift = self.domain.subtract(
170
+ self.translation, self.projector(self.translation)
171
+ )
172
+ return prior.affine_mapping(operator=self.projector, translation=shift)
173
+
174
+ else:
175
+ # Bayesian Conditioning: u | B(u)=w
176
+
177
+ # Check for singular implicit operator usage
178
+ if not self.has_explicit_equation and self._solver is None:
179
+ raise ValueError(
180
+ "This subspace defines the constraint implicitly as (I-P)u = (I-P)x0. "
181
+ "The operator (I-P) is singular. You must provide a solver "
182
+ "capable of handling singular systems (e.g. MinRes) to the "
183
+ "AffineSubspace constructor."
184
+ )
185
+
186
+ # Local imports
187
+ from .forward_problem import LinearForwardProblem
188
+ from .linear_bayesian import LinearBayesianInversion
189
+
190
+ solver = self._solver
191
+ preconditioner = self._preconditioner
192
+
193
+ constraint_problem = LinearForwardProblem(self.constraint_operator)
194
+ constraint_inversion = LinearBayesianInversion(constraint_problem, prior)
195
+
196
+ return constraint_inversion.model_posterior_measure(
197
+ self.constraint_value, solver, preconditioner=preconditioner
198
+ )
199
+
200
+ @classmethod
201
+ def from_linear_equation(
202
+ cls,
203
+ operator: LinearOperator,
204
+ value: Vector,
205
+ solver: Optional[LinearSolver] = None,
206
+ preconditioner: Optional[LinearOperator] = None,
207
+ ) -> AffineSubspace:
208
+ """Constructs subspace from B(u)=w."""
209
+ domain = operator.domain
210
+ G = operator @ operator.adjoint
211
+
212
+ if solver is None:
213
+ solver = CholeskySolver(galerkin=True)
214
+
215
+ if isinstance(solver, IterativeLinearSolver):
216
+ G_inv = solver(G, preconditioner=preconditioner)
217
+ else:
218
+ G_inv = solver(G)
219
+
220
+ intermediate = G_inv(value)
221
+ translation = operator.adjoint(intermediate)
222
+ P_perp_op = operator.adjoint @ G_inv @ operator
223
+
224
+ def mapping(x: Any) -> Any:
225
+ return domain.subtract(x, P_perp_op(x))
226
+
227
+ projector = OrthogonalProjector(domain, mapping, complement_projector=P_perp_op)
228
+
229
+ return cls(
230
+ projector,
231
+ translation,
232
+ constraint_operator=operator,
233
+ constraint_value=value,
234
+ solver=solver,
235
+ preconditioner=preconditioner,
236
+ )
237
+
238
+ @classmethod
239
+ def from_tangent_basis(
240
+ cls,
241
+ domain: HilbertSpace,
242
+ basis_vectors: List[Vector],
243
+ translation: Optional[Vector] = None,
244
+ orthonormalize: bool = True,
245
+ solver: Optional[LinearSolver] = None,
246
+ preconditioner: Optional[LinearOperator] = None,
247
+ ) -> AffineSubspace:
248
+ """
249
+ Constructs an affine subspace from a translation and a basis for the tangent space.
250
+
251
+ This method defines the subspace geometrically. The constraint is implicit:
252
+ (I - P)u = (I - P)x0.
253
+
254
+ Args:
255
+ domain: The Hilbert space.
256
+ basis_vectors: Basis vectors for the tangent space V.
257
+ translation: A point x0 in the subspace.
258
+ orthonormalize: If True, orthonormalizes the basis.
259
+ solver: A linear solver capable of handling the singular operator (I-P).
260
+ Required if you intend to use this subspace for Bayesian conditioning.
261
+ preconditioner: Optional preconditioner for the solver.
262
+ """
263
+ if solver is None:
264
+ warnings.warn(
265
+ "Constructing a subspace from a tangent basis without a solver. "
266
+ "This defines an implicit constraint with a singular operator. "
267
+ "Bayesian conditioning will fail; geometric projection remains available.",
268
+ UserWarning,
269
+ stacklevel=2,
270
+ )
271
+
272
+ projector = OrthogonalProjector.from_basis(
273
+ domain, basis_vectors, orthonormalize=orthonormalize
274
+ )
275
+
276
+ return cls(projector, translation, solver=solver, preconditioner=preconditioner)
277
+
278
+ @classmethod
279
+ def from_complement_basis(
280
+ cls,
281
+ domain: HilbertSpace,
282
+ basis_vectors: List[Vector],
283
+ translation: Optional[Vector] = None,
284
+ orthonormalize: bool = True,
285
+ ) -> AffineSubspace:
286
+ """
287
+ Constructs subspace from complement basis.
288
+ Constraint is explicit: <u, e_i> = <x0, e_i>.
289
+ """
290
+ if orthonormalize:
291
+ e_vectors = domain.gram_schmidt(basis_vectors)
292
+ else:
293
+ e_vectors = basis_vectors
294
+
295
+ complement_projector = OrthogonalProjector.from_basis(
296
+ domain, e_vectors, orthonormalize=False
297
+ )
298
+
299
+ def mapping(x: Any) -> Any:
300
+ return domain.subtract(x, complement_projector(x))
301
+
302
+ projector = OrthogonalProjector(
303
+ domain, mapping, complement_projector=complement_projector
304
+ )
305
+
306
+ codomain = EuclideanSpace(len(e_vectors))
307
+
308
+ def constraint_mapping(u: Vector) -> np.ndarray:
309
+ return np.array([domain.inner_product(e, u) for e in e_vectors])
310
+
311
+ def constraint_adjoint(c: np.ndarray) -> Vector:
312
+ res = domain.zero
313
+ for i, e in enumerate(e_vectors):
314
+ domain.axpy(c[i], e, res)
315
+ return res
316
+
317
+ B = LinearOperator(
318
+ domain, codomain, constraint_mapping, adjoint_mapping=constraint_adjoint
319
+ )
320
+
321
+ if translation is None:
322
+ _translation = domain.zero
323
+ w = codomain.zero
324
+ else:
325
+ _translation = translation
326
+ w = B(_translation)
327
+
328
+ solver = CholeskySolver(galerkin=True)
329
+
330
+ return cls(
331
+ projector,
332
+ _translation,
333
+ constraint_operator=B,
334
+ constraint_value=w,
335
+ solver=solver,
336
+ )
337
+
338
+
339
+ class LinearSubspace(AffineSubspace):
340
+ """
341
+ Represents a linear subspace (an affine subspace passing through the origin).
342
+ """
343
+
344
+ def __init__(self, projector: OrthogonalProjector) -> None:
345
+ super().__init__(projector, translation=None)
346
+
347
+ @property
348
+ def complement(self) -> LinearSubspace:
349
+ op_perp = self.projector.complement
350
+ if isinstance(op_perp, OrthogonalProjector):
351
+ return LinearSubspace(op_perp)
352
+ p_perp = OrthogonalProjector(self.domain, op_perp._mapping)
353
+ return LinearSubspace(p_perp)
354
+
355
+ @classmethod
356
+ def from_kernel(
357
+ cls,
358
+ operator: LinearOperator,
359
+ solver: Optional[LinearSolver] = None,
360
+ preconditioner: Optional[LinearOperator] = None,
361
+ ) -> LinearSubspace:
362
+ affine = AffineSubspace.from_linear_equation(
363
+ operator, operator.codomain.zero, solver, preconditioner
364
+ )
365
+ instance = cls(affine.projector)
366
+ instance._constraint_operator = operator
367
+ instance._constraint_value = operator.codomain.zero
368
+ instance._solver = affine._solver
369
+ instance._preconditioner = preconditioner
370
+ return instance
371
+
372
+ @classmethod
373
+ def from_basis(
374
+ cls,
375
+ domain: HilbertSpace,
376
+ basis_vectors: List[Vector],
377
+ orthonormalize: bool = True,
378
+ solver: Optional[LinearSolver] = None,
379
+ preconditioner: Optional[LinearOperator] = None,
380
+ ) -> LinearSubspace:
381
+ projector = OrthogonalProjector.from_basis(
382
+ domain, basis_vectors, orthonormalize=orthonormalize
383
+ )
384
+ instance = cls(projector)
385
+ instance._solver = solver
386
+ instance._preconditioner = preconditioner
387
+ return instance
388
+
389
+ @classmethod
390
+ def from_complement_basis(
391
+ cls,
392
+ domain: HilbertSpace,
393
+ basis_vectors: List[Vector],
394
+ orthonormalize: bool = True,
395
+ ) -> LinearSubspace:
396
+ affine = AffineSubspace.from_complement_basis(
397
+ domain, basis_vectors, translation=None, orthonormalize=orthonormalize
398
+ )
399
+ instance = cls(affine.projector)
400
+ instance._constraint_operator = affine.constraint_operator
401
+ instance._constraint_value = affine.constraint_value
402
+ instance._solver = affine._solver
403
+ return instance
@@ -0,0 +1,107 @@
1
+ """
2
+ Module collecting some small helper functions or classes.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+
9
+
10
+ class SHVectorConverter:
11
+ """
12
+ Handles conversion between pyshtools 3D coefficient arrays and 1D vectors.
13
+
14
+ This class bridges the gap between the `pyshtools` 3D array format
15
+ (shape `[2, lmax+1, lmax+1]`) and the flat 1D vector format used in
16
+ linear algebra.
17
+
18
+ **Vector Layout:**
19
+ The output vector is ordered first by degree $l$ (ascending from `lmin` to `lmax`),
20
+ and then by order $m$ (ascending from $-l$ to $+l$).
21
+
22
+ The sequence of coefficients is:
23
+
24
+ .. math::
25
+ [u_{l_{min}, -l_{min}}, \dots, u_{l_{min}, l_{min}}, \quad
26
+ u_{l_{min}+1, -(l_{min}+1)}, \dots, u_{l_{min}+1, l_{min}+1}, \quad \dots]
27
+
28
+ **Example (lmin=0):**
29
+
30
+ .. math::
31
+ [u_{0,0}, \quad u_{1,-1}, u_{1,0}, u_{1,1}, \quad u_{2,-2}, u_{2,-1}, u_{2,0}, u_{2,1}, u_{2,2}, \dots]
32
+
33
+ Args:
34
+ lmax (int): The maximum spherical harmonic degree to include.
35
+ lmin (int): The minimum spherical harmonic degree to include. Defaults to 0.
36
+ """
37
+
38
+ def __init__(self, lmax: int, lmin: int = 0):
39
+ if not isinstance(lmax, int) or not isinstance(lmin, int):
40
+ raise TypeError("lmax and lmin must be integers.")
41
+ if lmin > lmax:
42
+ raise ValueError("lmin cannot be greater than lmax.")
43
+
44
+ self.lmax = lmax
45
+ self.lmin = lmin
46
+ self.vector_size = (self.lmax + 1) ** 2 - self.lmin**2
47
+
48
+ def to_vector(self, coeffs: np.ndarray) -> np.ndarray:
49
+ """Converts a pyshtools 3D coefficient array to a 1D vector.
50
+
51
+ If the input coefficients have a smaller lmax than the converter,
52
+ the missing high-degree coefficients in the output vector will be zero.
53
+
54
+ Args:
55
+ coeffs (np.ndarray): A pyshtools-compatible coefficient array
56
+ of shape (2, l_in+1, l_in+1).
57
+
58
+ Returns:
59
+ np.ndarray: A 1D vector of the coefficients from lmin to lmax.
60
+ """
61
+ lmax_in = coeffs.shape[1] - 1
62
+ vec = np.zeros(self.vector_size)
63
+ loop_lmax = min(self.lmax, lmax_in)
64
+
65
+ for l in range(self.lmin, loop_lmax + 1):
66
+ start_idx = l**2 - self.lmin**2
67
+ sin_part = coeffs[1, l, 1 : l + 1][::-1]
68
+ cos_part = coeffs[0, l, 0 : l + 1]
69
+ vec[start_idx : start_idx + l] = sin_part
70
+ vec[start_idx + l : start_idx + 2 * l + 1] = cos_part
71
+
72
+ return vec
73
+
74
+ def from_vector(
75
+ self, vec: np.ndarray, output_lmax: Optional[int] = None
76
+ ) -> np.ndarray:
77
+ """Converts a 1D vector back to a pyshtools 3D coefficient array.
78
+
79
+ This method can create an array that is larger (zero-padding) or
80
+ smaller (truncating) than the lmax of the converter.
81
+
82
+ Args:
83
+ vec (np.ndarray): A 1D vector of coefficients.
84
+ output_lmax (Optional[int]): The desired lmax for the output array.
85
+ If None, defaults to the converter's lmax.
86
+
87
+ Returns:
88
+ np.ndarray: A pyshtools-compatible coefficient array.
89
+ """
90
+ if vec.size != self.vector_size:
91
+ raise ValueError("Input vector has incorrect size.")
92
+
93
+ # If output_lmax is not specified, default to the converter's lmax
94
+ lmax_out = output_lmax if output_lmax is not None else self.lmax
95
+
96
+ # Create the output array of the desired size, initialized to zeros
97
+ coeffs = np.zeros((2, lmax_out + 1, lmax_out + 1))
98
+
99
+ # Determine the loop range: iterate up to the minimum of the two lmax values
100
+ loop_lmax = min(self.lmax, lmax_out)
101
+
102
+ for l in range(self.lmin, loop_lmax + 1):
103
+ start_idx = l**2 - self.lmin**2
104
+ coeffs[1, l, 1 : l + 1] = vec[start_idx : start_idx + l][::-1]
105
+ coeffs[0, l, 0 : l + 1] = vec[start_idx + l : start_idx + 2 * l + 1]
106
+
107
+ return coeffs
@@ -41,6 +41,7 @@ except ImportError:
41
41
  )
42
42
 
43
43
  from pygeoinf.hilbert_space import (
44
+ EuclideanSpace,
44
45
  HilbertModule,
45
46
  MassWeightedHilbertModule,
46
47
  )
@@ -50,12 +51,14 @@ from .symmetric_space import (
50
51
  AbstractInvariantLebesgueSpace,
51
52
  AbstractInvariantSobolevSpace,
52
53
  )
54
+ from .sh_tools import SHVectorConverter
53
55
 
54
56
 
55
57
  if TYPE_CHECKING:
56
58
  from matplotlib.figure import Figure
57
59
  from cartopy.mpl.geoaxes import GeoAxes
58
60
  from cartopy.crs import Projection
61
+ from pyshtools import SHGrid
59
62
 
60
63
 
61
64
  class SphereHelper:
@@ -528,6 +531,87 @@ class Lebesgue(SphereHelper, HilbertModule, AbstractInvariantLebesgueSpace):
528
531
  f"extend={self.extend}"
529
532
  )
530
533
 
534
+ def to_coefficient_operator(self, lmax: int, lmin: int = 0):
535
+ r"""
536
+ Returns a LinearOperator mapping a function to its spherical harmonic coefficients.
537
+
538
+ The operator maps an element of the Hilbert space to a vector in $\mathbb{R}^k$.
539
+ The coefficients in the output vector are ordered by degree $l$ (major)
540
+ and order $m$ (minor), from $-l$ to $+l$.
541
+
542
+ **Ordering:**
543
+
544
+ .. math::
545
+ u = [u_{0,0}, \quad u_{1,-1}, u_{1,0}, u_{1,1}, \quad u_{2,-2}, \dots, u_{2,2}, \quad \dots]
546
+
547
+ (assuming `lmin=0`).
548
+
549
+ Args:
550
+ lmax: The maximum spherical harmonic degree to include in the output.
551
+ lmin: The minimum spherical harmonic degree to include. Defaults to 0.
552
+
553
+ Returns:
554
+ A LinearOperator mapping `SHGrid` -> `numpy.ndarray`.
555
+ """
556
+
557
+ converter = SHVectorConverter(lmax, lmin)
558
+ codomain = EuclideanSpace(converter.vector_size)
559
+
560
+ def mapping(u: SHGrid) -> np.ndarray:
561
+ ulm = self.to_coefficients(u)
562
+ return converter.to_vector(ulm.coeffs)
563
+
564
+ def adjoint_mapping(data: np.ndarray) -> SHGrid:
565
+ coeffs = converter.from_vector(data, output_lmax=self.lmax)
566
+ ulm = sh.SHCoeffs.from_array(
567
+ coeffs,
568
+ normalization=self.normalization,
569
+ csphase=self.csphase,
570
+ )
571
+ return self.from_coefficients(ulm) / self.radius**2
572
+
573
+ return LinearOperator(self, codomain, mapping, adjoint_mapping=adjoint_mapping)
574
+
575
+ def from_coefficient_operator(self, lmax: int, lmin: int = 0):
576
+ r"""
577
+ Returns a LinearOperator mapping a vector of coefficients to a function.
578
+
579
+ The operator maps a vector in $\mathbb{R}^k$ to an element of the Hilbert space.
580
+ The input vector must follow the standard $l$-major, $m$-minor ordering.
581
+
582
+ **Ordering:**
583
+
584
+ .. math::
585
+ v = [u_{0,0}, \quad u_{1,-1}, u_{1,0}, u_{1,1}, \quad u_{2,-2}, \dots, u_{2,2}, \quad \dots]
586
+
587
+ (assuming `lmin=0`).
588
+
589
+ Args:
590
+ lmax: The maximum spherical harmonic degree expected in the input.
591
+ lmin: The minimum spherical harmonic degree expected. Defaults to 0.
592
+
593
+ Returns:
594
+ A LinearOperator mapping `numpy.ndarray` -> `SHGrid`.
595
+ """
596
+
597
+ converter = SHVectorConverter(lmax, lmin)
598
+ domain = EuclideanSpace(converter.vector_size)
599
+
600
+ def mapping(data: np.ndarray) -> SHGrid:
601
+ coeffs = converter.from_vector(data, output_lmax=self.lmax)
602
+ ulm = sh.SHCoeffs.from_array(
603
+ coeffs,
604
+ normalization=self.normalization,
605
+ csphase=self.csphase,
606
+ )
607
+ return self.from_coefficients(ulm)
608
+
609
+ def adjoint_mapping(u: SHGrid) -> np.ndarray:
610
+ ulm = self.to_coefficients(u)
611
+ return converter.to_vector(ulm.coeffs) * self.radius**2
612
+
613
+ return LinearOperator(domain, self, mapping, adjoint_mapping=adjoint_mapping)
614
+
531
615
 
532
616
  class Sobolev(SphereHelper, MassWeightedHilbertModule, AbstractInvariantSobolevSpace):
533
617
  """
@@ -691,3 +775,58 @@ class Sobolev(SphereHelper, MassWeightedHilbertModule, AbstractInvariantSobolevS
691
775
  f"grid={self.grid}\n"
692
776
  f"extend={self.extend}"
693
777
  )
778
+
779
+ def to_coefficient_operator(self, lmax: int, lmin: int = 0):
780
+ r"""
781
+ Returns a LinearOperator mapping a function to its spherical harmonic coefficients.
782
+
783
+ The operator maps an element of the Hilbert space to a vector in $\mathbb{R}^k$.
784
+ The coefficients in the output vector are ordered by degree $l$ (major)
785
+ and order $m$ (minor), from $-l$ to $+l$.
786
+
787
+ **Ordering:**
788
+
789
+ .. math::
790
+ u = [u_{0,0}, \quad u_{1,-1}, u_{1,0}, u_{1,1}, \quad u_{2,-2}, \dots, u_{2,2}, \quad \dots]
791
+
792
+ (assuming `lmin=0`).
793
+
794
+ Args:
795
+ lmax: The maximum spherical harmonic degree to include in the output.
796
+ lmin: The minimum spherical harmonic degree to include. Defaults to 0.
797
+
798
+ Returns:
799
+ A LinearOperator mapping `SHGrid` -> `numpy.ndarray`.
800
+ """
801
+
802
+ l2_operator = self.underlying_space.to_coefficient_operator(lmax, lmin)
803
+
804
+ return LinearOperator.from_formal_adjoint(
805
+ self, l2_operator.codomain, l2_operator
806
+ )
807
+
808
+ def from_coefficient_operator(self, lmax: int, lmin: int = 0):
809
+ r"""
810
+ Returns a LinearOperator mapping a vector of coefficients to a function.
811
+
812
+ The operator maps a vector in $\mathbb{R}^k$ to an element of the Hilbert space.
813
+ The input vector must follow the standard $l$-major, $m$-minor ordering.
814
+
815
+ **Ordering:**
816
+
817
+ .. math::
818
+ v = [u_{0,0}, \quad u_{1,-1}, u_{1,0}, u_{1,1}, \quad u_{2,-2}, \dots, u_{2,2}, \quad \dots]
819
+
820
+ (assuming `lmin=0`).
821
+
822
+ Args:
823
+ lmax: The maximum spherical harmonic degree expected in the input.
824
+ lmin: The minimum spherical harmonic degree expected. Defaults to 0.
825
+
826
+ Returns:
827
+ A LinearOperator mapping `numpy.ndarray` -> `SHGrid`.
828
+ """
829
+
830
+ l2_operator = self.underlying_space.from_coefficient_operator(lmax, lmin)
831
+
832
+ return LinearOperator.from_formal_adjoint(l2_operator.domain, self, l2_operator)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeoinf
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: A package for solving geophysical inference and inverse problems
5
5
  License: BSD-3-Clause
6
6
  License-File: LICENSE