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/__init__.py +15 -2
- pygeoinf/checks/__init__.py +0 -0
- pygeoinf/linear_bayesian.py +121 -121
- pygeoinf/linear_optimisation.py +218 -0
- pygeoinf/subspaces.py +403 -0
- pygeoinf/symmetric_space/sh_tools.py +107 -0
- pygeoinf/symmetric_space/sphere.py +139 -0
- {pygeoinf-1.3.4.dist-info → pygeoinf-1.3.6.dist-info}/METADATA +1 -1
- {pygeoinf-1.3.4.dist-info → pygeoinf-1.3.6.dist-info}/RECORD +11 -8
- {pygeoinf-1.3.4.dist-info → pygeoinf-1.3.6.dist-info}/WHEEL +0 -0
- {pygeoinf-1.3.4.dist-info → pygeoinf-1.3.6.dist-info}/licenses/LICENSE +0 -0
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)
|