pygeoinf 1.3.3__py3-none-any.whl → 1.3.5__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/checks/hilbert_space.py +1 -1
- pygeoinf/checks/linear_operators.py +1 -1
- pygeoinf/checks/nonlinear_operators.py +1 -1
- pygeoinf/hilbert_space.py +45 -0
- pygeoinf/linear_bayesian.py +181 -123
- pygeoinf/linear_optimisation.py +218 -0
- pygeoinf/plot.py +5 -5
- pygeoinf/subspaces.py +311 -0
- pygeoinf/symmetric_space/sh_tools.py +95 -0
- pygeoinf/symmetric_space/sphere.py +151 -0
- {pygeoinf-1.3.3.dist-info → pygeoinf-1.3.5.dist-info}/METADATA +1 -1
- {pygeoinf-1.3.3.dist-info → pygeoinf-1.3.5.dist-info}/RECORD +16 -13
- {pygeoinf-1.3.3.dist-info → pygeoinf-1.3.5.dist-info}/WHEEL +0 -0
- {pygeoinf-1.3.3.dist-info → pygeoinf-1.3.5.dist-info}/licenses/LICENSE +0 -0
pygeoinf/linear_optimisation.py
CHANGED
|
@@ -16,6 +16,8 @@ Key Classes
|
|
|
16
16
|
- `LinearMinimumNormInversion`: Finds the model with the smallest norm that
|
|
17
17
|
fits the data to a statistically acceptable degree using the discrepancy
|
|
18
18
|
principle.
|
|
19
|
+
- `ConstrainedLinearLeastSquaresInversion`: Solves a linear inverse problem
|
|
20
|
+
subject to an affine subspace constraint.
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
from __future__ import annotations
|
|
@@ -30,6 +32,7 @@ from .forward_problem import LinearForwardProblem
|
|
|
30
32
|
from .linear_operators import LinearOperator
|
|
31
33
|
from .linear_solvers import LinearSolver, IterativeLinearSolver
|
|
32
34
|
from .hilbert_space import Vector
|
|
35
|
+
from .subspaces import AffineSubspace
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
class LinearLeastSquaresInversion(LinearInversion):
|
|
@@ -160,6 +163,113 @@ class LinearLeastSquaresInversion(LinearInversion):
|
|
|
160
163
|
return inverse_normal_operator @ forward_operator.adjoint
|
|
161
164
|
|
|
162
165
|
|
|
166
|
+
class ConstrainedLinearLeastSquaresInversion(LinearInversion):
|
|
167
|
+
"""
|
|
168
|
+
Solves a linear inverse problem subject to an affine subspace constraint.
|
|
169
|
+
|
|
170
|
+
Problem:
|
|
171
|
+
Minimize J(u) = || A(u) - d ||_D^2 + alpha * || u ||_M^2
|
|
172
|
+
Subject to u in A (Affine Subspace)
|
|
173
|
+
|
|
174
|
+
Method:
|
|
175
|
+
The problem is reduced to an unconstrained minimization in the subspace.
|
|
176
|
+
We decompose the model as u = u_base + w, where u_base is the element
|
|
177
|
+
of the affine subspace closest to the origin (orthogonal to the tangent space),
|
|
178
|
+
and w is a perturbation in the tangent space.
|
|
179
|
+
|
|
180
|
+
The cost function separates (due to orthogonality) into:
|
|
181
|
+
J(w) = || A(w) - (d - A(u_base)) ||^2 + alpha * || w ||^2 + (alpha * ||u_base||^2)
|
|
182
|
+
|
|
183
|
+
This is solved using the standard LinearLeastSquaresInversion on a
|
|
184
|
+
reduced forward problem.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self, forward_problem: LinearForwardProblem, constraint: AffineSubspace
|
|
189
|
+
) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Args:
|
|
192
|
+
forward_problem: The original unconstrained forward problem.
|
|
193
|
+
constraint: The affine subspace A where the solution must lie.
|
|
194
|
+
"""
|
|
195
|
+
super().__init__(forward_problem)
|
|
196
|
+
self._constraint = constraint
|
|
197
|
+
|
|
198
|
+
# 1. Compute the Orthogonal Base Vector (u_base)
|
|
199
|
+
# u_base = (I - P) * translation
|
|
200
|
+
# This is the unique vector in the affine space that is orthogonal to the tangent space.
|
|
201
|
+
# It ensures ||u||^2 = ||u_base||^2 + ||w||^2, decoupling the regularization.
|
|
202
|
+
self._u_base = constraint.domain.subtract(
|
|
203
|
+
constraint.translation, constraint.projector(constraint.translation)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# 2. Construct Reduced Forward Problem
|
|
207
|
+
# Operator: A_tilde = A @ P
|
|
208
|
+
reduced_operator = forward_problem.forward_operator @ constraint.projector
|
|
209
|
+
|
|
210
|
+
# The error measure on the data remains valid for the reduced problem
|
|
211
|
+
# because the noise model is additive and independent of the model parameters.
|
|
212
|
+
self._reduced_forward_problem = LinearForwardProblem(
|
|
213
|
+
reduced_operator,
|
|
214
|
+
data_error_measure=(
|
|
215
|
+
forward_problem.data_error_measure
|
|
216
|
+
if forward_problem.data_error_measure_set
|
|
217
|
+
else None
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# 3. Initialize the internal unconstrained solver
|
|
222
|
+
self._unconstrained_inversion = LinearLeastSquaresInversion(
|
|
223
|
+
self._reduced_forward_problem
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def least_squares_operator(
|
|
227
|
+
self,
|
|
228
|
+
damping: float,
|
|
229
|
+
solver: LinearSolver,
|
|
230
|
+
/,
|
|
231
|
+
**kwargs,
|
|
232
|
+
) -> NonLinearOperator:
|
|
233
|
+
"""
|
|
234
|
+
Returns an operator that maps data to the constrained least-squares solution.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
damping: The Tikhonov damping parameter.
|
|
238
|
+
solver: The linear solver for the reduced normal equations.
|
|
239
|
+
**kwargs: Additional arguments passed to the solver (e.g., preconditioner).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A NonLinearOperator mapping d -> u_constrained.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# Get the operator L_tilde such that w = L_tilde(d_tilde)
|
|
246
|
+
reduced_op = self._unconstrained_inversion.least_squares_operator(
|
|
247
|
+
damping, solver, **kwargs
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Precompute A(u_base) to shift the data efficiently
|
|
251
|
+
# This represents the data predicted by the "base" model.
|
|
252
|
+
data_offset = self.forward_problem.forward_operator(self._u_base)
|
|
253
|
+
|
|
254
|
+
domain = self.data_space
|
|
255
|
+
codomain = self.model_space
|
|
256
|
+
|
|
257
|
+
def mapping(d: Vector) -> Vector:
|
|
258
|
+
# 1. Shift Data: d_tilde = d - A(u_base)
|
|
259
|
+
d_tilde = domain.subtract(d, data_offset)
|
|
260
|
+
|
|
261
|
+
# 2. Solve for perturbation w in the tangent space
|
|
262
|
+
# w = (P A* A P + alpha I)^-1 P A* d_tilde
|
|
263
|
+
w = reduced_op(d_tilde)
|
|
264
|
+
|
|
265
|
+
# 3. Reconstruct full model: u = u_base + w
|
|
266
|
+
# Note: w is guaranteed to be in the tangent space (Range of P)
|
|
267
|
+
# because of the structure of the reduced normal equations.
|
|
268
|
+
return codomain.add(self._u_base, w)
|
|
269
|
+
|
|
270
|
+
return NonLinearOperator(domain, codomain, mapping)
|
|
271
|
+
|
|
272
|
+
|
|
163
273
|
class LinearMinimumNormInversion(LinearInversion):
|
|
164
274
|
"""
|
|
165
275
|
Finds a regularized solution using the discrepancy principle.
|
|
@@ -309,3 +419,111 @@ class LinearMinimumNormInversion(LinearInversion):
|
|
|
309
419
|
normal_operator = forward_operator @ forward_operator.adjoint
|
|
310
420
|
inverse_normal_operator = solver(normal_operator)
|
|
311
421
|
return forward_operator.adjoint @ inverse_normal_operator
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class ConstrainedLinearMinimumNormInversion(LinearInversion):
|
|
425
|
+
"""
|
|
426
|
+
Finds the minimum-norm solution subject to an affine subspace constraint
|
|
427
|
+
using the discrepancy principle.
|
|
428
|
+
|
|
429
|
+
Problem:
|
|
430
|
+
Minimize ||u||
|
|
431
|
+
Subject to u in A (Affine Subspace)
|
|
432
|
+
And chi_squared(u, d) <= critical_value
|
|
433
|
+
|
|
434
|
+
Method:
|
|
435
|
+
We decompose the model as u = u_base + w, where u_base is the element
|
|
436
|
+
of the affine subspace with the smallest norm (orthogonal to the tangent
|
|
437
|
+
space), and w is a perturbation in the tangent space.
|
|
438
|
+
|
|
439
|
+
Because u_base and w are orthogonal, ||u||^2 = ||u_base||^2 + ||w||^2.
|
|
440
|
+
Minimizing ||u|| is therefore equivalent to minimizing ||w||.
|
|
441
|
+
|
|
442
|
+
The problem reduces to finding the minimum norm w such that:
|
|
443
|
+
|| A(w) - (d - A(u_base)) ||_D^2 <= critical_value
|
|
444
|
+
|
|
445
|
+
This is solved using the standard LinearMinimumNormInversion on a
|
|
446
|
+
reduced forward problem.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
forward_problem: LinearForwardProblem,
|
|
452
|
+
constraint: AffineSubspace,
|
|
453
|
+
) -> None:
|
|
454
|
+
"""
|
|
455
|
+
Args:
|
|
456
|
+
forward_problem: The original unconstrained forward problem.
|
|
457
|
+
constraint: The affine subspace A where the solution must lie.
|
|
458
|
+
"""
|
|
459
|
+
super().__init__(forward_problem)
|
|
460
|
+
if self.forward_problem.data_error_measure_set:
|
|
461
|
+
self.assert_inverse_data_covariance()
|
|
462
|
+
|
|
463
|
+
self._constraint = constraint
|
|
464
|
+
|
|
465
|
+
# 1. Compute the Orthogonal Base Vector (u_base)
|
|
466
|
+
# u_base = (I - P) * translation
|
|
467
|
+
# This is the vector in the affine space closest to the origin.
|
|
468
|
+
self._u_base = constraint.domain.subtract(
|
|
469
|
+
constraint.translation, constraint.projector(constraint.translation)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# 2. Construct Reduced Forward Problem
|
|
473
|
+
# Operator: A_tilde = A @ P
|
|
474
|
+
reduced_operator = forward_problem.forward_operator @ constraint.projector
|
|
475
|
+
|
|
476
|
+
self._reduced_forward_problem = LinearForwardProblem(
|
|
477
|
+
reduced_operator,
|
|
478
|
+
data_error_measure=(
|
|
479
|
+
forward_problem.data_error_measure
|
|
480
|
+
if forward_problem.data_error_measure_set
|
|
481
|
+
else None
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# 3. Initialize the internal unconstrained solver
|
|
486
|
+
self._unconstrained_inversion = LinearMinimumNormInversion(
|
|
487
|
+
self._reduced_forward_problem
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
def minimum_norm_operator(
|
|
491
|
+
self,
|
|
492
|
+
solver: LinearSolver,
|
|
493
|
+
/,
|
|
494
|
+
**kwargs,
|
|
495
|
+
) -> NonLinearOperator:
|
|
496
|
+
"""
|
|
497
|
+
Returns an operator that maps data to the constrained minimum-norm solution.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
solver: The linear solver for the reduced normal equations.
|
|
501
|
+
**kwargs: Arguments passed to LinearMinimumNormInversion (e.g.,
|
|
502
|
+
significance_level, rtol, maxiter).
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
A NonLinearOperator mapping d -> u_constrained.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
# Get the operator L_tilde such that w = L_tilde(d_tilde)
|
|
509
|
+
reduced_op = self._unconstrained_inversion.minimum_norm_operator(
|
|
510
|
+
solver, **kwargs
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Precompute A(u_base) to shift the data
|
|
514
|
+
data_offset = self.forward_problem.forward_operator(self._u_base)
|
|
515
|
+
|
|
516
|
+
domain = self.data_space
|
|
517
|
+
codomain = self.model_space
|
|
518
|
+
|
|
519
|
+
def mapping(d: Vector) -> Vector:
|
|
520
|
+
# 1. Shift Data: d_tilde = d - A(u_base)
|
|
521
|
+
d_tilde = domain.subtract(d, data_offset)
|
|
522
|
+
|
|
523
|
+
# 2. Solve for perturbation w in the tangent space
|
|
524
|
+
w = reduced_op(d_tilde)
|
|
525
|
+
|
|
526
|
+
# 3. Reconstruct full model: u = u_base + w
|
|
527
|
+
return codomain.add(self._u_base, w)
|
|
528
|
+
|
|
529
|
+
return NonLinearOperator(domain, codomain, mapping)
|
pygeoinf/plot.py
CHANGED
|
@@ -247,7 +247,7 @@ def plot_corner_distributions(
|
|
|
247
247
|
sigma = np.sqrt(cov_posterior[i, i])
|
|
248
248
|
|
|
249
249
|
# Create x-axis range
|
|
250
|
-
x = np.linspace(mu -
|
|
250
|
+
x = np.linspace(mu - 3.75 * sigma, mu + 3.75 * sigma, 200)
|
|
251
251
|
pdf = stats.norm.pdf(x, mu, sigma)
|
|
252
252
|
|
|
253
253
|
# Plot the PDF
|
|
@@ -276,10 +276,10 @@ def plot_corner_distributions(
|
|
|
276
276
|
sigma_j = np.sqrt(cov_posterior[j, j])
|
|
277
277
|
sigma_i = np.sqrt(cov_posterior[i, i])
|
|
278
278
|
|
|
279
|
-
x_range = np.linspace(mean_2d[0] - 3.
|
|
280
|
-
mean_2d[0] + 3.
|
|
281
|
-
y_range = np.linspace(mean_2d[1] - 3.
|
|
282
|
-
mean_2d[1] + 3.
|
|
279
|
+
x_range = np.linspace(mean_2d[0] - 3.75 * sigma_j,
|
|
280
|
+
mean_2d[0] + 3.75 * sigma_j, 100)
|
|
281
|
+
y_range = np.linspace(mean_2d[1] - 3.75 * sigma_i,
|
|
282
|
+
mean_2d[1] + 3.75 * sigma_i, 100)
|
|
283
283
|
|
|
284
284
|
X, Y = np.meshgrid(x_range, y_range)
|
|
285
285
|
pos = np.dstack((X, Y))
|
pygeoinf/subspaces.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
`LinearSubspace` is a specialization where the translation is zero.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from typing import List, Optional, Any, Callable, TYPE_CHECKING
|
|
11
|
+
import numpy as np
|
|
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
|
+
# Avoid circular imports for type checking
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrthogonalProjector(LinearOperator):
|
|
23
|
+
"""
|
|
24
|
+
Internal engine for subspace projections.
|
|
25
|
+
|
|
26
|
+
Represents an orthogonal projection operator P = P* = P^2.
|
|
27
|
+
While this class can be used directly, it is generally recommended to use
|
|
28
|
+
`AffineSubspace` or `LinearSubspace` for high-level problem definitions.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
domain: HilbertSpace,
|
|
34
|
+
mapping: Callable[[Any], Any],
|
|
35
|
+
complement_projector: Optional[LinearOperator] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(domain, domain, mapping, adjoint_mapping=mapping)
|
|
38
|
+
self._complement_projector = complement_projector
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def complement(self) -> LinearOperator:
|
|
42
|
+
"""Returns the projector onto the orthogonal complement (I - P)."""
|
|
43
|
+
if self._complement_projector is None:
|
|
44
|
+
identity = self.domain.identity_operator()
|
|
45
|
+
self._complement_projector = identity - self
|
|
46
|
+
return self._complement_projector
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_basis(
|
|
50
|
+
cls,
|
|
51
|
+
domain: HilbertSpace,
|
|
52
|
+
basis_vectors: List[Vector],
|
|
53
|
+
orthonormalize: bool = True,
|
|
54
|
+
) -> OrthogonalProjector:
|
|
55
|
+
"""Constructs P from a basis spanning the range."""
|
|
56
|
+
if not basis_vectors:
|
|
57
|
+
# Return zero operator if basis is empty
|
|
58
|
+
return domain.zero_operator(domain)
|
|
59
|
+
|
|
60
|
+
if orthonormalize:
|
|
61
|
+
e_vectors = domain.gram_schmidt(basis_vectors)
|
|
62
|
+
else:
|
|
63
|
+
e_vectors = basis_vectors
|
|
64
|
+
|
|
65
|
+
# P = sum (v_i x v_i)
|
|
66
|
+
tensor_op = LinearOperator.self_adjoint_from_tensor_product(domain, e_vectors)
|
|
67
|
+
return cls(domain, tensor_op)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AffineSubspace:
|
|
71
|
+
"""
|
|
72
|
+
Represents an affine subspace A = x0 + V.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
projector: OrthogonalProjector,
|
|
78
|
+
translation: Optional[Vector] = None,
|
|
79
|
+
constraint_operator: Optional[LinearOperator] = None,
|
|
80
|
+
constraint_value: Optional[Vector] = None,
|
|
81
|
+
) -> None:
|
|
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
|
+
@property
|
|
95
|
+
def domain(self) -> HilbertSpace:
|
|
96
|
+
return self._projector.domain
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def translation(self) -> Vector:
|
|
100
|
+
return self._translation
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def projector(self) -> OrthogonalProjector:
|
|
104
|
+
return self._projector
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def tangent_space(self) -> LinearSubspace:
|
|
108
|
+
return LinearSubspace(self._projector)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def has_constraint_equation(self) -> bool:
|
|
112
|
+
return self._constraint_operator is not None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def constraint_operator(self) -> LinearOperator:
|
|
116
|
+
if self._constraint_operator is None:
|
|
117
|
+
raise AttributeError("This subspace is not defined by a linear equation.")
|
|
118
|
+
return self._constraint_operator
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def constraint_value(self) -> Vector:
|
|
122
|
+
if self._constraint_value is None:
|
|
123
|
+
raise AttributeError("This subspace is not defined by a linear equation.")
|
|
124
|
+
return self._constraint_value
|
|
125
|
+
|
|
126
|
+
def project(self, x: Vector) -> Vector:
|
|
127
|
+
diff = self.domain.subtract(x, self.translation)
|
|
128
|
+
proj_diff = self.projector(diff)
|
|
129
|
+
return self.domain.add(self.translation, proj_diff)
|
|
130
|
+
|
|
131
|
+
def is_element(self, x: Vector, rtol: float = 1e-6) -> bool:
|
|
132
|
+
proj = self.project(x)
|
|
133
|
+
diff = self.domain.subtract(x, proj)
|
|
134
|
+
norm_diff = self.domain.norm(diff)
|
|
135
|
+
norm_x = self.domain.norm(x)
|
|
136
|
+
scale = norm_x if norm_x > 1e-12 else 1.0
|
|
137
|
+
return norm_diff <= rtol * scale
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_linear_equation(
|
|
141
|
+
cls,
|
|
142
|
+
operator: LinearOperator,
|
|
143
|
+
value: Vector,
|
|
144
|
+
solver: Optional[LinearSolver] = None,
|
|
145
|
+
preconditioner: Optional[LinearOperator] = None,
|
|
146
|
+
) -> AffineSubspace:
|
|
147
|
+
"""Constructs the subspace {u | B(u) = w}."""
|
|
148
|
+
domain = operator.domain
|
|
149
|
+
G = operator @ operator.adjoint
|
|
150
|
+
|
|
151
|
+
if solver is None:
|
|
152
|
+
solver = CholeskySolver(galerkin=True)
|
|
153
|
+
|
|
154
|
+
if isinstance(solver, IterativeLinearSolver):
|
|
155
|
+
G_inv = solver(G, preconditioner=preconditioner)
|
|
156
|
+
else:
|
|
157
|
+
G_inv = solver(G)
|
|
158
|
+
|
|
159
|
+
intermediate = G_inv(value)
|
|
160
|
+
translation = operator.adjoint(intermediate)
|
|
161
|
+
P_perp_op = operator.adjoint @ G_inv @ operator
|
|
162
|
+
|
|
163
|
+
def mapping(x: Any) -> Any:
|
|
164
|
+
return domain.subtract(x, P_perp_op(x))
|
|
165
|
+
|
|
166
|
+
projector = OrthogonalProjector(domain, mapping, complement_projector=P_perp_op)
|
|
167
|
+
|
|
168
|
+
return cls(
|
|
169
|
+
projector, translation, constraint_operator=operator, constraint_value=value
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_tangent_basis(
|
|
174
|
+
cls,
|
|
175
|
+
domain: HilbertSpace,
|
|
176
|
+
basis_vectors: List[Vector],
|
|
177
|
+
translation: Optional[Vector] = None,
|
|
178
|
+
orthonormalize: bool = True,
|
|
179
|
+
) -> AffineSubspace:
|
|
180
|
+
"""
|
|
181
|
+
Constructs the subspace passing through 'translation' with the given
|
|
182
|
+
tangent basis.
|
|
183
|
+
|
|
184
|
+
Note: This does not define a constraint equation B(u)=w, so it cannot
|
|
185
|
+
be used directly with ConstrainedLinearBayesianInversion.
|
|
186
|
+
"""
|
|
187
|
+
projector = OrthogonalProjector.from_basis(
|
|
188
|
+
domain, basis_vectors, orthonormalize=orthonormalize
|
|
189
|
+
)
|
|
190
|
+
return cls(projector, translation)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_complement_basis(
|
|
194
|
+
cls,
|
|
195
|
+
domain: HilbertSpace,
|
|
196
|
+
basis_vectors: List[Vector],
|
|
197
|
+
translation: Optional[Vector] = None,
|
|
198
|
+
orthonormalize: bool = True,
|
|
199
|
+
) -> AffineSubspace:
|
|
200
|
+
"""
|
|
201
|
+
Constructs the subspace orthogonal to the given basis, passing through
|
|
202
|
+
'translation'.
|
|
203
|
+
|
|
204
|
+
This automatically constructs the constraint operator B such that
|
|
205
|
+
the subspace is {u | B(u) = B(translation)}.
|
|
206
|
+
"""
|
|
207
|
+
# 1. Orthonormalize basis for stability
|
|
208
|
+
if orthonormalize:
|
|
209
|
+
e_vectors = domain.gram_schmidt(basis_vectors)
|
|
210
|
+
else:
|
|
211
|
+
e_vectors = basis_vectors
|
|
212
|
+
|
|
213
|
+
# 2. Construct Projector P_perp
|
|
214
|
+
complement_projector = OrthogonalProjector.from_basis(
|
|
215
|
+
domain, e_vectors, orthonormalize=False # Already done
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# 3. Construct Projector P = I - P_perp
|
|
219
|
+
def mapping(x: Any) -> Any:
|
|
220
|
+
return domain.subtract(x, complement_projector(x))
|
|
221
|
+
|
|
222
|
+
projector = OrthogonalProjector(
|
|
223
|
+
domain, mapping, complement_projector=complement_projector
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 4. Construct Constraint Operator B implicitly defined by the basis
|
|
227
|
+
# B: E -> R^k, u -> [<e_1, u>, ..., <e_k, u>]
|
|
228
|
+
# Since e_i are orthonormal, BB* = I, which is perfect for solvers.
|
|
229
|
+
codomain = EuclideanSpace(len(e_vectors))
|
|
230
|
+
|
|
231
|
+
def constraint_mapping(u: Vector) -> np.ndarray:
|
|
232
|
+
return np.array([domain.inner_product(e, u) for e in e_vectors])
|
|
233
|
+
|
|
234
|
+
def constraint_adjoint(c: np.ndarray) -> Vector:
|
|
235
|
+
# sum c_i e_i
|
|
236
|
+
res = domain.zero
|
|
237
|
+
for i, e in enumerate(e_vectors):
|
|
238
|
+
domain.axpy(c[i], e, res)
|
|
239
|
+
return res
|
|
240
|
+
|
|
241
|
+
B = LinearOperator(
|
|
242
|
+
domain, codomain, constraint_mapping, adjoint_mapping=constraint_adjoint
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# 5. Determine Constraint Value w = B(translation)
|
|
246
|
+
# If translation is None (zero), w is zero.
|
|
247
|
+
if translation is None:
|
|
248
|
+
_translation = domain.zero
|
|
249
|
+
w = codomain.zero
|
|
250
|
+
else:
|
|
251
|
+
_translation = translation
|
|
252
|
+
w = B(_translation)
|
|
253
|
+
|
|
254
|
+
return cls(projector, _translation, constraint_operator=B, constraint_value=w)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class LinearSubspace(AffineSubspace):
|
|
258
|
+
"""
|
|
259
|
+
Represents a linear subspace (an affine subspace passing through zero).
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, projector: OrthogonalProjector) -> None:
|
|
263
|
+
super().__init__(projector, translation=None)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def complement(self) -> LinearSubspace:
|
|
267
|
+
op_perp = self.projector.complement
|
|
268
|
+
if isinstance(op_perp, OrthogonalProjector):
|
|
269
|
+
return LinearSubspace(op_perp)
|
|
270
|
+
p_perp = OrthogonalProjector(self.domain, op_perp._mapping)
|
|
271
|
+
return LinearSubspace(p_perp)
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def from_kernel(
|
|
275
|
+
cls, operator: LinearOperator, solver: Optional[LinearSolver] = None
|
|
276
|
+
) -> LinearSubspace:
|
|
277
|
+
affine = AffineSubspace.from_linear_equation(
|
|
278
|
+
operator, operator.codomain.zero, solver
|
|
279
|
+
)
|
|
280
|
+
instance = cls(affine.projector)
|
|
281
|
+
instance._constraint_operator = operator
|
|
282
|
+
instance._constraint_value = operator.codomain.zero
|
|
283
|
+
return instance
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def from_basis(
|
|
287
|
+
cls,
|
|
288
|
+
domain: HilbertSpace,
|
|
289
|
+
basis_vectors: List[Vector],
|
|
290
|
+
orthonormalize: bool = True,
|
|
291
|
+
) -> LinearSubspace:
|
|
292
|
+
projector = OrthogonalProjector.from_basis(
|
|
293
|
+
domain, basis_vectors, orthonormalize=orthonormalize
|
|
294
|
+
)
|
|
295
|
+
return cls(projector)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def from_complement_basis(
|
|
299
|
+
cls,
|
|
300
|
+
domain: HilbertSpace,
|
|
301
|
+
basis_vectors: List[Vector],
|
|
302
|
+
orthonormalize: bool = True,
|
|
303
|
+
) -> LinearSubspace:
|
|
304
|
+
affine = AffineSubspace.from_complement_basis(
|
|
305
|
+
domain, basis_vectors, translation=None, orthonormalize=orthonormalize
|
|
306
|
+
)
|
|
307
|
+
# Copy constraint info from the affine instance
|
|
308
|
+
instance = cls(affine.projector)
|
|
309
|
+
instance._constraint_operator = affine.constraint_operator
|
|
310
|
+
instance._constraint_value = affine.constraint_value
|
|
311
|
+
return instance
|
|
@@ -0,0 +1,95 @@
|
|
|
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 provides a bridge between the pyshtools data structure for
|
|
15
|
+
spherical harmonic coefficients, a 3D array of shape (2, lmax+1, lmax+1),
|
|
16
|
+
and the 1D vector format often used in linear algebra and inverse problems.
|
|
17
|
+
|
|
18
|
+
The vector is ordered by degree l, and within each degree, by order m,
|
|
19
|
+
from -l to +l.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
lmax (int): The maximum spherical harmonic degree to include.
|
|
23
|
+
lmin (int): The minimum spherical harmonic degree to include. Defaults to 2.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, lmax: int, lmin: int = 0):
|
|
27
|
+
if not isinstance(lmax, int) or not isinstance(lmin, int):
|
|
28
|
+
raise TypeError("lmax and lmin must be integers.")
|
|
29
|
+
if lmin > lmax:
|
|
30
|
+
raise ValueError("lmin cannot be greater than lmax.")
|
|
31
|
+
|
|
32
|
+
self.lmax = lmax
|
|
33
|
+
self.lmin = lmin
|
|
34
|
+
self.vector_size = (self.lmax + 1) ** 2 - self.lmin**2
|
|
35
|
+
|
|
36
|
+
def to_vector(self, coeffs: np.ndarray) -> np.ndarray:
|
|
37
|
+
"""Converts a pyshtools 3D coefficient array to a 1D vector.
|
|
38
|
+
|
|
39
|
+
If the input coefficients have a smaller lmax than the converter,
|
|
40
|
+
the missing high-degree coefficients in the output vector will be zero.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
coeffs (np.ndarray): A pyshtools-compatible coefficient array
|
|
44
|
+
of shape (2, l_in+1, l_in+1).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
np.ndarray: A 1D vector of the coefficients from lmin to lmax.
|
|
48
|
+
"""
|
|
49
|
+
lmax_in = coeffs.shape[1] - 1
|
|
50
|
+
vec = np.zeros(self.vector_size)
|
|
51
|
+
loop_lmax = min(self.lmax, lmax_in)
|
|
52
|
+
|
|
53
|
+
for l in range(self.lmin, loop_lmax + 1):
|
|
54
|
+
start_idx = l**2 - self.lmin**2
|
|
55
|
+
sin_part = coeffs[1, l, 1 : l + 1][::-1]
|
|
56
|
+
cos_part = coeffs[0, l, 0 : l + 1]
|
|
57
|
+
vec[start_idx : start_idx + l] = sin_part
|
|
58
|
+
vec[start_idx + l : start_idx + 2 * l + 1] = cos_part
|
|
59
|
+
|
|
60
|
+
return vec
|
|
61
|
+
|
|
62
|
+
def from_vector(
|
|
63
|
+
self, vec: np.ndarray, output_lmax: Optional[int] = None
|
|
64
|
+
) -> np.ndarray:
|
|
65
|
+
"""Converts a 1D vector back to a pyshtools 3D coefficient array.
|
|
66
|
+
|
|
67
|
+
This method can create an array that is larger (zero-padding) or
|
|
68
|
+
smaller (truncating) than the lmax of the converter.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
vec (np.ndarray): A 1D vector of coefficients.
|
|
72
|
+
output_lmax (Optional[int]): The desired lmax for the output array.
|
|
73
|
+
If None, defaults to the converter's lmax.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
np.ndarray: A pyshtools-compatible coefficient array.
|
|
77
|
+
"""
|
|
78
|
+
if vec.size != self.vector_size:
|
|
79
|
+
raise ValueError("Input vector has incorrect size.")
|
|
80
|
+
|
|
81
|
+
# If output_lmax is not specified, default to the converter's lmax
|
|
82
|
+
lmax_out = output_lmax if output_lmax is not None else self.lmax
|
|
83
|
+
|
|
84
|
+
# Create the output array of the desired size, initialized to zeros
|
|
85
|
+
coeffs = np.zeros((2, lmax_out + 1, lmax_out + 1))
|
|
86
|
+
|
|
87
|
+
# Determine the loop range: iterate up to the minimum of the two lmax values
|
|
88
|
+
loop_lmax = min(self.lmax, lmax_out)
|
|
89
|
+
|
|
90
|
+
for l in range(self.lmin, loop_lmax + 1):
|
|
91
|
+
start_idx = l**2 - self.lmin**2
|
|
92
|
+
coeffs[1, l, 1 : l + 1] = vec[start_idx : start_idx + l][::-1]
|
|
93
|
+
coeffs[0, l, 0 : l + 1] = vec[start_idx + l : start_idx + 2 * l + 1]
|
|
94
|
+
|
|
95
|
+
return coeffs
|