pygeoinf 1.3.8__py3-none-any.whl → 1.4.0__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 +36 -0
- pygeoinf/gaussian_measure.py +79 -13
- pygeoinf/hilbert_space.py +15 -0
- pygeoinf/linear_operators.py +32 -0
- pygeoinf/plot.py +7 -1
- pygeoinf/preconditioners.py +1 -1
- pygeoinf/subsets.py +845 -0
- pygeoinf/subspaces.py +173 -23
- pygeoinf/symmetric_space/circle.py +41 -1
- pygeoinf/symmetric_space/sphere.py +231 -22
- pygeoinf/symmetric_space/symmetric_space.py +167 -12
- pygeoinf/symmetric_space/wigner.py +284 -0
- pygeoinf/utils.py +15 -0
- {pygeoinf-1.3.8.dist-info → pygeoinf-1.4.0.dist-info}/METADATA +3 -1
- {pygeoinf-1.3.8.dist-info → pygeoinf-1.4.0.dist-info}/RECORD +17 -14
- {pygeoinf-1.3.8.dist-info → pygeoinf-1.4.0.dist-info}/WHEEL +1 -1
- {pygeoinf-1.3.8.dist-info → pygeoinf-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -101,11 +101,6 @@ class SphereHelper:
|
|
|
101
101
|
self._normalization: str = "ortho"
|
|
102
102
|
self._csphase: int = 1
|
|
103
103
|
|
|
104
|
-
# Set up sparse matrix that maps SHCoeff data arrrays into reduced form
|
|
105
|
-
self._sparse_coeffs_to_component: coo_array = (
|
|
106
|
-
self._coefficient_to_component_mapping()
|
|
107
|
-
)
|
|
108
|
-
|
|
109
104
|
def orthonormalised(self) -> bool:
|
|
110
105
|
"""The space is orthonormalised."""
|
|
111
106
|
return True
|
|
@@ -341,6 +336,221 @@ class SphereHelper:
|
|
|
341
336
|
|
|
342
337
|
return fig, ax, im
|
|
343
338
|
|
|
339
|
+
def plot_geodesic(
|
|
340
|
+
self,
|
|
341
|
+
p1: Tuple[float, float],
|
|
342
|
+
p2: Tuple[float, float],
|
|
343
|
+
ax: Optional["GeoAxes"] = None,
|
|
344
|
+
n_points: int = 100,
|
|
345
|
+
**kwargs,
|
|
346
|
+
) -> Tuple["Figure", "GeoAxes"]:
|
|
347
|
+
"""
|
|
348
|
+
Plots a geodesic curve onto a Cartopy map.
|
|
349
|
+
"""
|
|
350
|
+
# Generate points via our quadrature logic (returns lats, lons)
|
|
351
|
+
points, _ = self.geodesic_quadrature(p1, p2, n_points=n_points)
|
|
352
|
+
lats, lons = zip(*points)
|
|
353
|
+
|
|
354
|
+
# 2. Get/Create Axes
|
|
355
|
+
if ax is None:
|
|
356
|
+
fig, ax = plt.subplots(
|
|
357
|
+
figsize=kwargs.pop("figsize", (10, 8)),
|
|
358
|
+
subplot_kw={"projection": ccrs.PlateCarree()},
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
fig = ax.get_figure()
|
|
362
|
+
|
|
363
|
+
# 3. Plot with the Geodetic transform
|
|
364
|
+
# This 'transform' handles the conversion to whatever projection 'ax' uses.
|
|
365
|
+
kwargs.setdefault("color", "black")
|
|
366
|
+
kwargs.setdefault("linewidth", 2)
|
|
367
|
+
|
|
368
|
+
# We use Geodetic() here because our points were generated along a great circle
|
|
369
|
+
ax.plot(lons, lats, transform=ccrs.Geodetic(), **kwargs)
|
|
370
|
+
|
|
371
|
+
return fig, ax
|
|
372
|
+
|
|
373
|
+
def plot_geodesic_network(
|
|
374
|
+
self,
|
|
375
|
+
paths: List[Tuple[Tuple[float, float], Tuple[float, float]]],
|
|
376
|
+
ax: Optional["GeoAxes"] = None,
|
|
377
|
+
n_points: int = 50,
|
|
378
|
+
**kwargs,
|
|
379
|
+
) -> Tuple["Figure", "GeoAxes"]:
|
|
380
|
+
"""
|
|
381
|
+
Plots a network of geodesic paths onto a Cartopy map.
|
|
382
|
+
|
|
383
|
+
This method iterates through a list of source-receiver pairs and renders
|
|
384
|
+
each as a great-circle arc. It is useful for visualizing the spatial
|
|
385
|
+
coverage of a tomographic survey.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
paths: A list of ((lat1, lon1), (lat2, lon2)) tuples.
|
|
389
|
+
ax: An existing cartopy GeoAxes object. If None, a new figure is created.
|
|
390
|
+
n_points: Number of points used to render each curve. A lower value
|
|
391
|
+
(e.g., 50) is often sufficient for batch plotting many lines.
|
|
392
|
+
**kwargs: Keyword arguments passed to the underlying plot calls
|
|
393
|
+
(e.g., color, alpha, linewidth).
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
A tuple (figure, axes) containing the plot objects.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
# Setup/Verify Axes
|
|
400
|
+
if ax is None:
|
|
401
|
+
figsize = kwargs.pop("figsize", (12, 10))
|
|
402
|
+
fig, ax = plt.subplots(
|
|
403
|
+
figsize=figsize, subplot_kw={"projection": ccrs.PlateCarree()}
|
|
404
|
+
)
|
|
405
|
+
ax.set_global()
|
|
406
|
+
ax.coastlines()
|
|
407
|
+
else:
|
|
408
|
+
fig = ax.get_figure()
|
|
409
|
+
|
|
410
|
+
# Set default styling for a "network" look
|
|
411
|
+
# Using a lower alpha and thinner lines helps prevent clutter
|
|
412
|
+
# when many paths overlap.
|
|
413
|
+
kwargs.setdefault("color", "black")
|
|
414
|
+
kwargs.setdefault("linewidth", 0.8)
|
|
415
|
+
kwargs.setdefault("alpha", 0.5)
|
|
416
|
+
|
|
417
|
+
# Batch plot all geodesics
|
|
418
|
+
for p1, p2 in paths:
|
|
419
|
+
self.plot_geodesic(p1, p2, ax=ax, n_points=n_points, **kwargs)
|
|
420
|
+
|
|
421
|
+
# Extract unique sources and receivers for marking
|
|
422
|
+
sources = list(set([tuple(p[0]) for p in paths]))
|
|
423
|
+
receivers = list(set([tuple(p[1]) for p in paths]))
|
|
424
|
+
|
|
425
|
+
src_lats, src_lons = zip(*sources)
|
|
426
|
+
rec_lats, rec_lons = zip(*receivers)
|
|
427
|
+
|
|
428
|
+
# Plot Sources (Stars)
|
|
429
|
+
src_style = kwargs.pop("source_kwargs", {})
|
|
430
|
+
src_style.setdefault("marker", "*")
|
|
431
|
+
src_style.setdefault("color", "gold")
|
|
432
|
+
src_style.setdefault("s", 150)
|
|
433
|
+
src_style.setdefault("edgecolor", "black")
|
|
434
|
+
src_style.setdefault("zorder", 5) # Ensure markers are on top
|
|
435
|
+
|
|
436
|
+
ax.scatter(src_lons, src_lats, transform=ccrs.Geodetic(), **src_style)
|
|
437
|
+
|
|
438
|
+
# Plot Receivers (Dots)
|
|
439
|
+
rec_style = kwargs.pop("receiver_kwargs", {})
|
|
440
|
+
rec_style.setdefault("marker", "o")
|
|
441
|
+
rec_style.setdefault("color", "red")
|
|
442
|
+
rec_style.setdefault("s", 50)
|
|
443
|
+
rec_style.setdefault("edgecolor", "white")
|
|
444
|
+
rec_style.setdefault("zorder", 5)
|
|
445
|
+
|
|
446
|
+
ax.scatter(rec_lons, rec_lats, transform=ccrs.Geodetic(), **rec_style)
|
|
447
|
+
|
|
448
|
+
return fig, ax
|
|
449
|
+
|
|
450
|
+
def sample_power_measure(
|
|
451
|
+
self,
|
|
452
|
+
measure,
|
|
453
|
+
n_samples,
|
|
454
|
+
/,
|
|
455
|
+
*,
|
|
456
|
+
lmin=None,
|
|
457
|
+
lmax=None,
|
|
458
|
+
parallel: bool = False,
|
|
459
|
+
n_jobs: int = -1,
|
|
460
|
+
):
|
|
461
|
+
"""
|
|
462
|
+
Takes in a Gaussian measure on the space, draws n_samples from
|
|
463
|
+
and returns samples for the spherical harmonic power at degrees in
|
|
464
|
+
the indicated range.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
lmin = 0 if lmin is None else lmin
|
|
468
|
+
lmax = self.lmax if lmax is None else min(self.lmax, lmax)
|
|
469
|
+
|
|
470
|
+
samples = measure.samples(n_samples, parallel=parallel, n_jobs=n_jobs)
|
|
471
|
+
|
|
472
|
+
powers = []
|
|
473
|
+
for u in samples:
|
|
474
|
+
ulm = self.to_coefficients(u)
|
|
475
|
+
powers.append(ulm.spectrum(lmax=lmax, convention="power")[lmin:])
|
|
476
|
+
|
|
477
|
+
return powers
|
|
478
|
+
|
|
479
|
+
def geodesic_quadrature(
|
|
480
|
+
self, p1: Tuple[float, float], p2: Tuple[float, float], n_points: int
|
|
481
|
+
) -> Tuple[List[Tuple[float, float]], np.ndarray]:
|
|
482
|
+
"""
|
|
483
|
+
Generates Gauss-Legendre quadrature points and weights along a great-circle arc.
|
|
484
|
+
|
|
485
|
+
This implementation converts the start and end latitudes and longitudes into
|
|
486
|
+
unit vectors, calculates the central angle (omega), and interpolates the
|
|
487
|
+
geodesic path using SLERP.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
p1: Start point as (latitude, longitude) in degrees.
|
|
491
|
+
p2: End point as (latitude, longitude) in degrees.
|
|
492
|
+
n_points: Number of quadrature points to generate.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
points: A list of (lat, lon) tuples in degrees along the geodesic.
|
|
496
|
+
weights: Integration weights scaled by the total arc length (R * omega).
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
# Coordinate Transforms (Degrees -> Radians -> Unit Vectors)
|
|
500
|
+
def to_vector(lat, lon):
|
|
501
|
+
lat_rad, lon_rad = np.radians(lat), np.radians(lon)
|
|
502
|
+
return np.array(
|
|
503
|
+
[
|
|
504
|
+
np.cos(lat_rad) * np.cos(lon_rad),
|
|
505
|
+
np.cos(lat_rad) * np.sin(lon_rad),
|
|
506
|
+
np.sin(lat_rad),
|
|
507
|
+
]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def to_latlon(vec):
|
|
511
|
+
# Normalize for numerical stability before converting back
|
|
512
|
+
vec = vec / np.linalg.norm(vec)
|
|
513
|
+
lat_rad = np.arcsin(vec[2])
|
|
514
|
+
lon_rad = np.arctan2(vec[1], vec[0])
|
|
515
|
+
return (np.degrees(lat_rad), np.degrees(lon_rad))
|
|
516
|
+
|
|
517
|
+
v1, v2 = to_vector(*p1), to_vector(*p2)
|
|
518
|
+
|
|
519
|
+
# Calculate Central Angle (omega)
|
|
520
|
+
dot_product = np.clip(np.dot(v1, v2), -1.0, 1.0)
|
|
521
|
+
omega = np.arccos(dot_product)
|
|
522
|
+
|
|
523
|
+
# Handle identical points edge case
|
|
524
|
+
if omega < 1e-10:
|
|
525
|
+
return [p1] * n_points, np.zeros(n_points)
|
|
526
|
+
|
|
527
|
+
# Handle antipodal points (non-unique path)
|
|
528
|
+
if np.abs(omega - np.pi) < 1e-10:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
"Points are antipodal; the great circle path is not unique."
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Generate Gauss-Legendre Nodes and Weights
|
|
534
|
+
x, w = np.polynomial.legendre.leggauss(n_points)
|
|
535
|
+
|
|
536
|
+
# Map Nodes to Path Parameter t in [0, 1] and scale weights
|
|
537
|
+
# t = (x + 1) / 2 maps [-1, 1] to [0, 1]
|
|
538
|
+
# Weights are scaled by (total_arc_length / 2)
|
|
539
|
+
t_vals = (x + 1) / 2.0
|
|
540
|
+
scaled_weights = w * (self.radius * omega / 2.0)
|
|
541
|
+
|
|
542
|
+
# Spherical Linear Interpolation (SLERP) for each node
|
|
543
|
+
sin_omega = np.sin(omega)
|
|
544
|
+
points = []
|
|
545
|
+
|
|
546
|
+
for t in t_vals:
|
|
547
|
+
coeff1 = np.sin((1 - t) * omega) / sin_omega
|
|
548
|
+
coeff2 = np.sin(t * omega) / sin_omega
|
|
549
|
+
v_interp = coeff1 * v1 + coeff2 * v2
|
|
550
|
+
points.append(to_latlon(v_interp))
|
|
551
|
+
|
|
552
|
+
return points, scaled_weights
|
|
553
|
+
|
|
344
554
|
# --------------------------------------------------------------- #
|
|
345
555
|
# private methods #
|
|
346
556
|
# ----------------------------------------------------------------#
|
|
@@ -378,28 +588,19 @@ class SphereHelper:
|
|
|
378
588
|
|
|
379
589
|
def _degree_dependent_scaling_values(self, f: Callable[[int], float]) -> diags:
|
|
380
590
|
"""Creates a diagonal sparse matrix from a function of degree `l`."""
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
values[i:j] = f(l)
|
|
387
|
-
i = j
|
|
388
|
-
for l in range(1, self.lmax + 1):
|
|
389
|
-
j = i + l
|
|
390
|
-
values[i:j] = f(l)
|
|
391
|
-
i = j
|
|
392
|
-
return values
|
|
591
|
+
ls = np.arange(self.lmax + 1)
|
|
592
|
+
f_vectorized = np.vectorize(f)
|
|
593
|
+
values = f_vectorized(ls)
|
|
594
|
+
counts = 2 * ls + 1
|
|
595
|
+
return np.repeat(values, counts)
|
|
393
596
|
|
|
394
597
|
def _coefficient_to_component(self, ulm: sh.SHCoeffs) -> np.ndarray:
|
|
395
598
|
"""Maps spherical harmonic coefficients to a component vector."""
|
|
396
|
-
|
|
397
|
-
return self._sparse_coeffs_to_component @ flat_coeffs
|
|
599
|
+
return sh.shio.SHCilmToVector(ulm.coeffs)
|
|
398
600
|
|
|
399
601
|
def _component_to_coefficients(self, c: np.ndarray) -> sh.SHCoeffs:
|
|
400
602
|
"""Maps a component vector to spherical harmonic coefficients."""
|
|
401
|
-
|
|
402
|
-
coeffs = flat_coeffs.reshape((2, self.lmax + 1, self.lmax + 1))
|
|
603
|
+
coeffs = sh.shio.SHVectorToCilm(c)
|
|
403
604
|
return sh.SHCoeffs.from_array(
|
|
404
605
|
coeffs, normalization=self.normalization, csphase=self.csphase
|
|
405
606
|
)
|
|
@@ -475,6 +676,14 @@ class Lebesgue(SphereHelper, HilbertModule, AbstractInvariantLebesgueSpace):
|
|
|
475
676
|
"""
|
|
476
677
|
return x1 * x2
|
|
477
678
|
|
|
679
|
+
def vector_sqrt(self, x: sh.SHGrid) -> sh.SHGrid:
|
|
680
|
+
"""
|
|
681
|
+
Returns the pointwise square root of a function.
|
|
682
|
+
"""
|
|
683
|
+
y = x.copy()
|
|
684
|
+
y.data = np.sqrt(x.data)
|
|
685
|
+
return y
|
|
686
|
+
|
|
478
687
|
def __eq__(self, other: object) -> bool:
|
|
479
688
|
"""
|
|
480
689
|
Checks for mathematical equality with another Sobolev space on a sphere.
|
|
@@ -691,7 +900,7 @@ class Sobolev(SphereHelper, MassWeightedHilbertModule, AbstractInvariantSobolevS
|
|
|
691
900
|
err = 1.0
|
|
692
901
|
|
|
693
902
|
def sobolev_func(deg):
|
|
694
|
-
return (1.0 + scale**2 * deg * (deg + 1)) ** order
|
|
903
|
+
return (1.0 + (scale / radius) ** 2 * deg * (deg + 1)) ** order
|
|
695
904
|
|
|
696
905
|
while err > rtol:
|
|
697
906
|
l += 1
|
|
@@ -29,7 +29,7 @@ AbstractInvariantSobolevSpace
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
from abc import ABC, abstractmethod
|
|
32
|
-
from typing import Callable, Any, List
|
|
32
|
+
from typing import Callable, Any, List, Tuple, Optional
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
import numpy as np
|
|
@@ -120,6 +120,29 @@ class AbstractInvariantLebesgueSpace(ABC):
|
|
|
120
120
|
g: A function that takes an eigenvalue index and returns a real value.
|
|
121
121
|
"""
|
|
122
122
|
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def trace_of_invariant_automorphism(self, f: Callable[[float], float]) -> float:
|
|
125
|
+
"""
|
|
126
|
+
Returns the trace of the automorphism of the form f(Δ) with f a function
|
|
127
|
+
that is well-defined on the spectrum of the Laplacian.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
f: A real-valued function that is well-defined on the spectrum
|
|
131
|
+
of the Laplacian.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def geodesic_quadrature(
|
|
136
|
+
self, p1: Any, p2: Any, n_points: int
|
|
137
|
+
) -> Tuple[List[Any], np.ndarray]:
|
|
138
|
+
"""
|
|
139
|
+
Returns quadrature points and weights for a geodesic between p1 and p2.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
points: List of manifold coordinates.
|
|
143
|
+
weights: Integration weights scaled by the line element.
|
|
144
|
+
"""
|
|
145
|
+
|
|
123
146
|
def invariant_automorphism(self, f: Callable[[float], float]) -> LinearOperator:
|
|
124
147
|
"""
|
|
125
148
|
Returns an automorphism of the form f(Δ) with f a function
|
|
@@ -143,17 +166,6 @@ class AbstractInvariantLebesgueSpace(ABC):
|
|
|
143
166
|
lambda k: f(self.laplacian_eigenvalue(k))
|
|
144
167
|
)
|
|
145
168
|
|
|
146
|
-
@abstractmethod
|
|
147
|
-
def trace_of_invariant_automorphism(self, f: Callable[[float], float]) -> float:
|
|
148
|
-
"""
|
|
149
|
-
Returns the trace of the automorphism of the form f(Δ) with f a function
|
|
150
|
-
that is well-defined on the spectrum of the Laplacian.
|
|
151
|
-
|
|
152
|
-
Args:
|
|
153
|
-
f: A real-valued function that is well-defined on the spectrum
|
|
154
|
-
of the Laplacian.
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
169
|
def invariant_gaussian_measure(
|
|
158
170
|
self,
|
|
159
171
|
f: Callable[[float], float],
|
|
@@ -469,3 +481,146 @@ class AbstractInvariantSobolevSpace(AbstractInvariantLebesgueSpace):
|
|
|
469
481
|
return self.point_value_scaled_invariant_gaussian_measure(
|
|
470
482
|
lambda k: np.exp(-(scale**2) * k), amplitude
|
|
471
483
|
)
|
|
484
|
+
|
|
485
|
+
def geodesic_integral(
|
|
486
|
+
self, p1: Any, p2: Any, n_points: Optional[int] = None
|
|
487
|
+
) -> LinearForm:
|
|
488
|
+
"""
|
|
489
|
+
Returns a linear functional representing the line integral of a function
|
|
490
|
+
along a geodesic path.
|
|
491
|
+
|
|
492
|
+
This method approximates the integral :math:`\\int_{\\gamma} u(s) ds`, where
|
|
493
|
+
:math:`\\gamma` is the shortest path (geodesic) connecting points `p1` and `p2`.
|
|
494
|
+
The integral is represented as a :class:`LinearForm` in the dual space,
|
|
495
|
+
constructed by summing weighted point evaluations (Dirac measures) along
|
|
496
|
+
the path.
|
|
497
|
+
|
|
498
|
+
For Hilbert spaces with a specified :attr:`scale`, the method can
|
|
499
|
+
automatically determine the required quadrature density to resolve the
|
|
500
|
+
smooth features of the space's sensitivity kernels.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
p1 (Any): The starting point of the geodesic. The type is manifold-dependent
|
|
504
|
+
(e.g., float for :class:`Circle`, tuple for :class:`Sphere`).
|
|
505
|
+
p2 (Any): The end point of the geodesic.
|
|
506
|
+
n_points (int, optional): The number of Gauss-Legendre quadrature points.
|
|
507
|
+
If None, it is heuristically determined as:
|
|
508
|
+
:math:`n = \\lceil (\\text{arc\\_length} / \\text{scale}) \\times 2 \\rceil`.
|
|
509
|
+
This ensures at least two points per characteristic length-scale,
|
|
510
|
+
providing stable sampling of the sensitivity kernel. Defaults to None.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
LinearForm: A linear functional whose action on a vector `u` computes
|
|
514
|
+
the approximated line integral.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
NotImplementedError: If the Sobolev order :math:`s` is less than or
|
|
518
|
+
equal to half the spatial dimension :math:`n/2`.
|
|
519
|
+
"""
|
|
520
|
+
if self.order <= self.spatial_dimension / 2:
|
|
521
|
+
raise NotImplementedError(
|
|
522
|
+
f"Order {self.order} is too low for point evaluation on a "
|
|
523
|
+
f"{self.spatial_dimension}D manifold."
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Heuristic quadrature density determination
|
|
527
|
+
if n_points is None:
|
|
528
|
+
# Perform a minimal call to determine the total arc length via weights
|
|
529
|
+
_, temp_weights = self.geodesic_quadrature(p1, p2, n_points=2)
|
|
530
|
+
arc_length = np.sum(temp_weights)
|
|
531
|
+
|
|
532
|
+
# Scale-based heuristic (Nyquist-like sampling)
|
|
533
|
+
n_points = int(np.ceil((arc_length / self.scale) * 2.0))
|
|
534
|
+
n_points = max(2, n_points)
|
|
535
|
+
|
|
536
|
+
# Retrieve final manifold-specific points and weights
|
|
537
|
+
points, weights = self.geodesic_quadrature(p1, p2, n_points)
|
|
538
|
+
|
|
539
|
+
# Aggregate weighted components into the dual space representation
|
|
540
|
+
# The components of a LinearForm represent the functional in the dual basis
|
|
541
|
+
total_components = np.zeros(self.dim)
|
|
542
|
+
for pt, weight in zip(points, weights):
|
|
543
|
+
# Accumulate the weighted Riesz representation of each Dirac delta
|
|
544
|
+
total_components += weight * self.dirac(pt).components
|
|
545
|
+
|
|
546
|
+
return LinearForm(self, components=total_components)
|
|
547
|
+
|
|
548
|
+
def geodesic_integral_representation(
|
|
549
|
+
self, p1: Any, p2: Any, n_points: Optional[int] = None
|
|
550
|
+
) -> Any:
|
|
551
|
+
"""
|
|
552
|
+
Returns the Riesz representation (sensitivity kernel) of the line integral.
|
|
553
|
+
|
|
554
|
+
This maps the LinearForm (the integral functional) back into the
|
|
555
|
+
primal Hilbert space. Visualizing this vector reveals the "sensitivity"
|
|
556
|
+
of the line integral to perturbations at different locations in the domain.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
p1, p2: Start and end points of the geodesic.
|
|
560
|
+
n_points: Number of quadrature points.
|
|
561
|
+
"""
|
|
562
|
+
# Create the functional and map it to a vector in the space
|
|
563
|
+
integral_form = self.geodesic_integral(p1, p2, n_points)
|
|
564
|
+
return self.from_dual(integral_form)
|
|
565
|
+
|
|
566
|
+
def path_average_operator(self, paths, n_points=None):
|
|
567
|
+
"""
|
|
568
|
+
Constructs a tomographic operator mapping a function field to its
|
|
569
|
+
line integrals along a set of geodesic paths.
|
|
570
|
+
|
|
571
|
+
Note: Despite the name, this operator returns the line integral
|
|
572
|
+
(the dual pairing of the function with the path functional) rather
|
|
573
|
+
than a normalized average, unless the user manually scales the forms.
|
|
574
|
+
This corresponds to the 'path average' convention often used in
|
|
575
|
+
seismic and atmospheric tomography.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
paths (List[Tuple[Any, Any]]): A list of start and end point pairs
|
|
579
|
+
defining the geodesics.
|
|
580
|
+
n_points (int, optional): The number of quadrature points per path.
|
|
581
|
+
If None, the heuristic based on the Sobolev scale is used.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
LinearOperator: An operator mapping Space -> EuclideanSpace(len(paths)).
|
|
585
|
+
The adjoint of this operator performs the 'back-projection'
|
|
586
|
+
mapping data residuals into the function space.
|
|
587
|
+
"""
|
|
588
|
+
# Generate the set of linear functionals representing each path integral
|
|
589
|
+
# The integral logic is handled by the Abstract Geodesic Integral method
|
|
590
|
+
path_forms = [
|
|
591
|
+
self.geodesic_integral(p1, p2, n_points=n_points) for p1, p2 in paths
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
# Convert the list of forms into a single LinearOperator mapping
|
|
595
|
+
return LinearOperator.from_linear_forms(path_forms)
|
|
596
|
+
|
|
597
|
+
def random_source_receiver_paths(
|
|
598
|
+
self, n_sources: int, n_receivers: int
|
|
599
|
+
) -> List[Tuple[Any, Any]]:
|
|
600
|
+
"""
|
|
601
|
+
Generates a list of source-receiver pairs by connecting every source to
|
|
602
|
+
every receiver.
|
|
603
|
+
|
|
604
|
+
This method uses the existing :meth:`random_points` logic to generate
|
|
605
|
+
coordinates appropriate for the specific symmetric space. For a set
|
|
606
|
+
of S sources and R receivers, this returns a list of S*R paths.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
n_sources: The number of random source locations to generate.
|
|
610
|
+
n_receivers: The number of random receiver locations to generate.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
List[Tuple[Any, Any]]: A list of tuples, where each tuple contains
|
|
614
|
+
a (source, receiver) pair.
|
|
615
|
+
"""
|
|
616
|
+
# Generate the points using the existing base class method
|
|
617
|
+
sources = self.random_points(n_sources)
|
|
618
|
+
receivers = self.random_points(n_receivers)
|
|
619
|
+
|
|
620
|
+
# Create the full-mesh network
|
|
621
|
+
paths = []
|
|
622
|
+
for src in sources:
|
|
623
|
+
for rec in receivers:
|
|
624
|
+
paths.append((src, rec))
|
|
625
|
+
|
|
626
|
+
return paths
|