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.
@@ -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
- dim = (self.lmax + 1) ** 2
382
- values = np.zeros(dim)
383
- i = 0
384
- for l in range(self.lmax + 1):
385
- j = i + l + 1
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
- flat_coeffs = ulm.coeffs.flatten(order="C")
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
- flat_coeffs = self._sparse_coeffs_to_component.T @ c
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