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.
@@ -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 - 4 * sigma, mu + 4 * sigma, 200)
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.5 * sigma_j,
280
- mean_2d[0] + 3.5 * sigma_j, 100)
281
- y_range = np.linspace(mean_2d[1] - 3.5 * sigma_i,
282
- mean_2d[1] + 3.5 * sigma_i, 100)
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