planetmodel 1.3.2__tar.gz → 1.4.0__tar.gz

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.
Files changed (85) hide show
  1. {planetmodel-1.3.2 → planetmodel-1.4.0}/PKG-INFO +6 -4
  2. {planetmodel-1.3.2 → planetmodel-1.4.0}/README.md +5 -3
  3. {planetmodel-1.3.2 → planetmodel-1.4.0}/pyproject.toml +1 -1
  4. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/__init__.py +1 -1
  5. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/harmonics.py +111 -2
  6. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/__init__.py +9 -4
  7. planetmodel-1.4.0/src/planetmodel/plotting/cutaways.py +270 -0
  8. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/basis.py +42 -0
  9. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/operator.py +25 -5
  10. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/slices.py +474 -7
  11. {planetmodel-1.3.2 → planetmodel-1.4.0}/LICENSE +0 -0
  12. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/behaviours.py +0 -0
  13. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/catalogue.py +0 -0
  14. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/character.py +0 -0
  15. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/__init__.py +0 -0
  16. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/building.py +0 -0
  17. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/laws.py +0 -0
  18. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/lithosphere.py +0 -0
  19. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/parameters.py +0 -0
  20. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/profiles.py +0 -0
  21. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/prony.py +0 -0
  22. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/tables.py +0 -0
  23. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/__init__.py +0 -0
  24. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/building.py +0 -0
  25. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/coefficients.py +0 -0
  26. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/decks.py +0 -0
  27. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/entries.py +0 -0
  28. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/fetch.py +0 -0
  29. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/grids.py +0 -0
  30. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/models.py +0 -0
  31. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/processing.py +0 -0
  32. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/projections.py +0 -0
  33. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/registry.py +0 -0
  34. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/sph.py +0 -0
  35. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/strata.py +0 -0
  36. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/surfaces.py +0 -0
  37. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/deck.py +0 -0
  38. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/displacement.py +0 -0
  39. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/expansions.py +0 -0
  40. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/fields.py +0 -0
  41. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/frames.py +0 -0
  42. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/geometry.py +0 -0
  43. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/gridded.py +0 -0
  44. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/lateral.py +0 -0
  45. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/layerfunction.py +0 -0
  46. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mapping.py +0 -0
  47. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/materials.py +0 -0
  48. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/means.py +0 -0
  49. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/__init__.py +0 -0
  50. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/gll.py +0 -0
  51. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/gravity.py +0 -0
  52. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/mesh.py +0 -0
  53. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/__init__.py +0 -0
  54. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_geometry.py +0 -0
  55. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_orient.py +0 -0
  56. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_session.py +0 -0
  57. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_sizing.py +0 -0
  58. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_tagging.py +0 -0
  59. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_validate.py +0 -0
  60. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_writer.py +0 -0
  61. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/export.py +0 -0
  62. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/layered.py +0 -0
  63. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/manifest.py +0 -0
  64. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/offset.py +0 -0
  65. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/spec.py +0 -0
  66. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/model.py +0 -0
  67. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/modelfile.py +0 -0
  68. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/perturbation.py +0 -0
  69. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/maps.py +0 -0
  70. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/profiles.py +0 -0
  71. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/sections.py +0 -0
  72. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/spectra.py +0 -0
  73. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/style.py +0 -0
  74. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/vtk.py +0 -0
  75. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/pushforward.py +0 -0
  76. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/__init__.py +0 -0
  77. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/fields.py +0 -0
  78. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/mesh.py +0 -0
  79. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/rheology.py +0 -0
  80. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/sampling.py +0 -0
  81. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/skeleton.py +0 -0
  82. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/surfacefile.py +0 -0
  83. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/testing.py +0 -0
  84. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/units.py +0 -0
  85. {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/vocabulary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planetmodel
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: Spherically layered planetary models: reference bodies, fields, mappings and meshes
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -154,9 +154,11 @@ print(mesh.nodal(nd, "rho").shape) # density at every mesh node
154
154
 
155
155
  ## Where to go next
156
156
 
157
- - `examples/tutorials/`: twelve walkthroughs, from a skeleton to random
158
- fields, deck files and a laterally varying model in a model file, each
159
- a `# %%` script that runs headless.
157
+ - `examples/tutorials/`: fifteen walkthroughs, from a skeleton to random
158
+ fields, deck files, a laterally varying model in a model file, pictures,
159
+ published models and sensitivity kernels, each a `# %%` script that
160
+ runs headless and opens as a notebook, in Google Colab from the badges
161
+ in `examples/README.md`.
160
162
  - `docs/source/formats/`: the model file and the manifest beside every
161
163
  mesh, described for the codes that read them (also on Read the Docs).
162
164
  - `CONTRIBUTING.md`: the development setup, the hooks, the test
@@ -118,9 +118,11 @@ print(mesh.nodal(nd, "rho").shape) # density at every mesh node
118
118
 
119
119
  ## Where to go next
120
120
 
121
- - `examples/tutorials/`: twelve walkthroughs, from a skeleton to random
122
- fields, deck files and a laterally varying model in a model file, each
123
- a `# %%` script that runs headless.
121
+ - `examples/tutorials/`: fifteen walkthroughs, from a skeleton to random
122
+ fields, deck files, a laterally varying model in a model file, pictures,
123
+ published models and sensitivity kernels, each a `# %%` script that
124
+ runs headless and opens as a notebook, in Google Colab from the badges
125
+ in `examples/README.md`.
124
126
  - `docs/source/formats/`: the model file and the manifest beside every
125
127
  mesh, described for the codes that read them (also on Read the Docs).
126
128
  - `CONTRIBUTING.md`: the development setup, the hooks, the test
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "planetmodel"
3
- version = "1.3.2"
3
+ version = "1.4.0"
4
4
  description = "Spherically layered planetary models: reference bodies, fields, mappings and meshes"
5
5
  keywords = ["geophysics", "seismology", "planetary models", "PREM", "spectral elements", "meshing"]
6
6
  classifiers = [
@@ -129,7 +129,7 @@ from .units import EARTH_MEAN_DENSITY, G_SI, Dimensions, Scales
129
129
  from .vocabulary import CONSTANTS, VOCABULARY, Constant, FieldSpec
130
130
  from . import testing # noqa: F401
131
131
 
132
- __version__ = "1.3.2"
132
+ __version__ = "1.4.0"
133
133
 
134
134
  __all__ = [
135
135
  "Skeleton",
@@ -39,6 +39,16 @@ with the Condon-Shortley phase; the real ones here are
39
39
  and that is the whole of the conversion. ducc0 is imported where a
40
40
  transform is made, so that `import planetmodel` does not load it.
41
41
 
42
+ **Many shells at once.** A consumer holding a field on radial nodes
43
+ times a grid -- a hundred shells, one band -- crosses into ducc0 once
44
+ per shell. `ShellTransform` instead tabulates the basis on one
45
+ Gauss-Legendre grid's meridian, once per grid, and carries every shell
46
+ together through two matrix products each way, agreeing with
47
+ `synthesise_grid` and `analyse_grid` to rounding. Its table is
48
+ (lmax + 1)^3 numbers, so the grid transforms remain the route for a
49
+ single field, a grid of another kind, or degrees where the table would
50
+ not fit.
51
+
42
52
  **Other conventions.** Published coefficients come in other
43
53
  normalisations and with or without the Condon-Shortley phase;
44
54
  `from_convention` gives the library's coefficients from them and
@@ -75,8 +85,8 @@ if TYPE_CHECKING:
75
85
  from .sampling import AngularGrid
76
86
 
77
87
  __all__ = ["real_harmonics", "synthesise", "synthesise_gradient", "synthesise_grid",
78
- "analyse_grid", "packing", "from_convention", "to_convention",
79
- "NORMALIZATIONS", "POINT_EPSILON"]
88
+ "analyse_grid", "ShellTransform", "packing", "from_convention",
89
+ "to_convention", "NORMALIZATIONS", "POINT_EPSILON"]
80
90
 
81
91
  #: The relative accuracy asked of ducc0's synthesis at arbitrary points.
82
92
  POINT_EPSILON = 1e-13
@@ -393,3 +403,102 @@ def real_harmonics(lmax: int, theta: ArrayLike, phi: ArrayLike) -> np.ndarray:
393
403
  lam * (root2 * np.sin(angle))[None]])
394
404
  out[1, :, 0] = 0.0
395
405
  return out.reshape((2, lmax + 1, lmax + 1) + theta.shape)
406
+
407
+
408
+ # -- many shells at once ----------------------------------------------------------
409
+
410
+ class ShellTransform:
411
+ """Synthesis and analysis on one Gauss-Legendre grid, for any number
412
+ of radial shells at once.
413
+
414
+ The transform is separable: the basis on the prime meridian -- the
415
+ normalised Legendre functions, tabulated once through
416
+ `real_harmonics` -- contracts the degrees of every shell in one
417
+ matrix product, and the longitudes in a second; nothing loops over
418
+ shells. Construction costs the table, (lmax + 1)^3 numbers; each
419
+ call after that is the matmuls.
420
+
421
+ The grid is the minimal Gauss-Legendre one of its band,
422
+ `gauss_legendre(lmax)`: lmax + 1 colatitudes and 2 lmax + 1
423
+ longitudes, from any phi0. Any other grid is refused. On it,
424
+ `synthesise` and `analyse` agree with `synthesise_grid` and
425
+ `analyse_grid` to rounding: exact for a field band-limited to the
426
+ grid's degree, a projection otherwise.
427
+ """
428
+
429
+ def __init__(self, grid: AngularGrid) -> None:
430
+ """Tabulate the basis on the meridian of `grid`."""
431
+ if (getattr(grid, "kind", None) != "gauss_legendre" or grid.lmax is None
432
+ or grid.weights is None):
433
+ raise ValueError(
434
+ f"ShellTransform tabulates the basis of a Gauss-Legendre grid "
435
+ f"and integrates with its weights; got a "
436
+ f"{getattr(grid, 'kind', None)!r} grid")
437
+ _rings(grid) # the nodes are genuinely the kind's own
438
+ lmax = int(grid.lmax)
439
+ if grid.ntheta != lmax + 1 or grid.nphi != 2 * lmax + 1:
440
+ raise ValueError(
441
+ f"ShellTransform takes the minimal Gauss-Legendre grid of its "
442
+ f"band, gauss_legendre({lmax}): {lmax + 1} colatitudes and "
443
+ f"{2 * lmax + 1} longitudes, not {grid.ntheta} x {grid.nphi}")
444
+ self._lmax = lmax
445
+ # The basis on the prime meridian: the cosine harmonics there are the
446
+ # normalised Legendre functions, and the sine ones share them.
447
+ meridian = real_harmonics(lmax, grid.colatitudes, 0.0)[0]
448
+ # (order, colatitude, degree), so that a matmul contracts the degree.
449
+ self._legendre = np.ascontiguousarray(meridian.transpose(1, 2, 0))
450
+ # (order, degree, colatitude), the quadrature weights folded in.
451
+ self._weighted = np.ascontiguousarray(
452
+ (meridian * grid.weights).transpose(1, 0, 2))
453
+ angle = np.arange(lmax + 1)[:, None] * grid.longitudes[None, :]
454
+ self._cosines = np.cos(angle) # (order, longitude)
455
+ self._sines = np.sin(angle)
456
+ self._cell = 2.0 * np.pi / grid.nphi
457
+
458
+ @property
459
+ def lmax(self) -> int:
460
+ """The grid's band: the degree the table holds."""
461
+ return self._lmax
462
+
463
+ def synthesise(self, coefficients: ArrayLike) -> np.ndarray:
464
+ """Values on every shell from coefficient functions.
465
+
466
+ `coefficients` has shape (2, lmax + 1, lmax + 1, shells), a
467
+ coefficient array with one radial axis; the result has shape
468
+ (shells, ntheta, nphi), the layout of a sample.
469
+ """
470
+ c = np.asarray(coefficients, dtype=float)
471
+ n = self._lmax + 1
472
+ if c.ndim != 4 or c.shape[:3] != (2, n, n):
473
+ raise ValueError(f"coefficients must have shape (2, {n}, {n}, "
474
+ f"shells), got {c.shape}")
475
+ shells = c.shape[3]
476
+ # (order, degree, shells) -> (order, colatitude, shells)
477
+ even = np.matmul(self._legendre, c[0].transpose(1, 0, 2))
478
+ odd = np.matmul(self._legendre, c[1].transpose(1, 0, 2))
479
+ # (longitude, colatitude * shells)
480
+ flat = self._cosines.T @ even.reshape(n, -1)
481
+ flat += self._sines.T @ odd.reshape(n, -1)
482
+ return np.ascontiguousarray(flat.reshape(-1, n, shells).transpose(2, 1, 0))
483
+
484
+ def analyse(self, values: ArrayLike) -> np.ndarray:
485
+ """Coefficient functions from values on every shell, by the grid's
486
+ own quadrature.
487
+
488
+ `values` has shape (shells, ntheta, nphi); the result has shape
489
+ (2, lmax + 1, lmax + 1, shells), zero where no harmonic is.
490
+ """
491
+ v = np.asarray(values, dtype=float)
492
+ n = self._lmax + 1
493
+ if v.ndim != 3 or v.shape[1:] != (n, 2 * self._lmax + 1):
494
+ raise ValueError(f"values must have shape (shells, {n}, "
495
+ f"{2 * self._lmax + 1}), got {v.shape}")
496
+ shells = v.shape[0]
497
+ flat = v.transpose(2, 1, 0).reshape(v.shape[2], -1) # (longitude, ...)
498
+ out = np.empty((2, n, n, shells))
499
+ for part, waves in enumerate((self._cosines, self._sines)):
500
+ fourier = (self._cell * waves) @ flat # (order, colatitude * shells)
501
+ fourier = fourier.reshape(n, n, shells)
502
+ # (order, degree, colatitude) @ (order, colatitude, shells)
503
+ out[part] = np.matmul(self._weighted, fourier).transpose(1, 0, 2)
504
+ return out
@@ -10,7 +10,11 @@ the figure to the caller. Nothing here is particular to the Earth.
10
10
  upward, a step at every interface (`profiles`); `section` draws a
11
11
  great-circle section as a wedge of the annulus or a disc, or in the
12
12
  body's deformed shape (`sections`); `sphere_map` draws a sphere slice on
13
- cartopy's projections (`maps`, the `maps` extra); `spectrum`,
13
+ cartopy's projections (`maps`, the `maps` extra); `cutaway` draws the
14
+ body with a wedge cut from it, the field on the faces of the cut and
15
+ the surface as the field, a plain colour, Natural Earth's land and sea
16
+ or its shaded relief, with coastlines (`cutaways`, cartopy where the
17
+ Earth's surface is drawn); `spectrum`,
14
18
  `correlation` and `correlation_total` draw spectra and correlations by
15
19
  degree and radius (`spectra`); `write_vtk` writes a model as VTK files
16
20
  for ParaView (`vtk`, no extra needed); `style` holds the look they
@@ -20,6 +24,7 @@ divided by its largest value with `normalised=True`, on maps and
20
24
  sections alike. matplotlib is not imported here: the axes are the caller's,
21
25
  made with the `plot` extra, and cartopy only where a map is drawn.
22
26
  """
27
+ from .cutaways import COASTLINE_STYLE, LAND, LIGHT, SEA, cutaway
23
28
  from .maps import cell_edges, sphere_map
24
29
  from .profiles import profile, radial_profile
25
30
  from .sections import section
@@ -28,6 +33,6 @@ from .style import BOUNDARY_STYLE, DIVERGING, SEQUENTIAL, colour_extend, colour_
28
33
  from .vtk import write_vtk
29
34
 
30
35
  __all__ = ["profile", "radial_profile", "section", "sphere_map", "cell_edges",
31
- "spectrum", "correlation", "correlation_total", "write_vtk",
32
- "BOUNDARY_STYLE", "DIVERGING", "SEQUENTIAL", "colour_scale",
33
- "colour_extend"]
36
+ "cutaway", "spectrum", "correlation", "correlation_total", "write_vtk",
37
+ "BOUNDARY_STYLE", "DIVERGING", "SEQUENTIAL", "LIGHT", "LAND", "SEA",
38
+ "COASTLINE_STYLE", "colour_scale", "colour_extend"]
@@ -0,0 +1,270 @@
1
+ """Cutaways: the body with a wedge cut from it, or discs through it, the
2
+ field on the faces.
3
+
4
+ `cutaway` draws a `slices.CutawaySlice`, of `slices.cutaway` or of
5
+ `slices.discs`, on plain matplotlib axes as one image, the pixels of the
6
+ slice's orthographic view coloured by what each looks at: the faces of
7
+ the cut, or the discs, always show the field; the sphere left standing
8
+ in a cut shows the field or a plain colour (`inner=`);
9
+ and the surface shows the field (`surface="field"`), a plain colour, the
10
+ land and the sea in two colours from Natural Earth's land polygons
11
+ (`"land"`), Natural Earth's shaded relief, the raster cartopy ships
12
+ (`"image"`), or any image of the whole surface given as an array (rows
13
+ from the north pole to the south, columns from 180 degrees west
14
+ eastward, RGB or RGBA). Plain colours and images are shaded by the
15
+ light of `LIGHT`, so that the sphere reads as one; the field's colours
16
+ are never shaded. `coastlines=True` draws Natural Earth's coastlines
17
+ where they are seen, through `CutawaySlice.visible`, and
18
+ `interfaces=True` the model's interfaces as faint arcs on the faces
19
+ (`style.BOUNDARY_STYLE`); a slice showing a sphere within the body
20
+ wears its dressing and coastlines on that sphere, the faces running
21
+ past it. The edge of the body is antialiased by the
22
+ slice's coverage, a pixel of the edge whose centre misses the body
23
+ taking the colour of its nearest neighbour that sees it. The colour
24
+ scale is `style.colour_scale`'s over every pixel that shows the field,
25
+ chosen with `diverging=` and `clip=` as a map's is; `normalised=True`
26
+ draws the slice divided by its largest absolute value. cartopy (the
27
+ `maps` extra) is imported where land, relief or coastlines are drawn,
28
+ and only then.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ import numpy as np
35
+
36
+ from ..frames import cartesian_points, from_geographic, geographic
37
+ from ..slices import CutawaySlice, normalised as _normalised
38
+ from .style import BOUNDARY_STYLE, colour_extend, colour_scale
39
+
40
+ __all__ = ["cutaway", "LIGHT", "LAND", "SEA", "COASTLINE_STYLE"]
41
+
42
+ #: The direction the light comes from, in the picture's frame (right, up,
43
+ #: toward the viewer), for shading plain colours and images.
44
+ LIGHT = (-0.3, 0.5, 1.0)
45
+
46
+ #: The depth of the shading: a plain colour is multiplied by 1 - SHADE at
47
+ #: a grazing surface and by 1 where the light falls square on it.
48
+ _SHADE = 0.45
49
+
50
+ #: The colours of the land and the sea of `surface="land"`.
51
+ LAND = "#d8ceb1"
52
+ SEA = "#c9dcea"
53
+
54
+ #: The style of a coastline.
55
+ COASTLINE_STYLE = {"color": "0.15", "lw": 0.5}
56
+
57
+ #: The points along each interface arc drawn on a face.
58
+ _ARC_POINTS = 181
59
+
60
+
61
+ def _rgb(colour: Any) -> np.ndarray:
62
+ """A matplotlib colour as RGBA floats."""
63
+ from matplotlib.colors import to_rgba
64
+ return np.asarray(to_rgba(colour), dtype=float)
65
+
66
+
67
+ def _shading(slice_: CutawaySlice, points: np.ndarray, normals: np.ndarray
68
+ ) -> np.ndarray:
69
+ """The factor (...,) a plain colour at `points` is multiplied by for
70
+ the light of `LIGHT` on a surface of outward `normals`."""
71
+ light = np.asarray(LIGHT, dtype=float) @ slice_.view
72
+ light /= np.linalg.norm(light)
73
+ cos = np.clip(normals @ light, 0.0, 1.0)
74
+ return 1.0 - _SHADE * (1.0 - cos)
75
+
76
+
77
+ def _sample_image(image: np.ndarray, theta: np.ndarray, phi: np.ndarray
78
+ ) -> np.ndarray:
79
+ """The pixels of an image of the whole surface, rows from the north
80
+ pole, columns from 180 degrees west, nearest to (theta, phi)."""
81
+ lat, lon = geographic(theta, phi)
82
+ rows, cols = image.shape[:2]
83
+ i = np.clip(((90.0 - lat) / 180.0 * rows).astype(int), 0, rows - 1)
84
+ j = np.clip(((lon + 180.0) % 360.0 / 360.0 * cols).astype(int), 0, cols - 1)
85
+ out = image[i, j]
86
+ if out.shape[-1] == 3:
87
+ out = np.concatenate([out, np.ones(out.shape[:-1] + (1,))], axis=-1)
88
+ return out
89
+
90
+
91
+ def _natural_earth_image() -> np.ndarray:
92
+ """Natural Earth's shaded relief, the raster cartopy ships, as floats."""
93
+ import os
94
+ import cartopy
95
+ from matplotlib.image import imread
96
+ path = os.path.join(cartopy.config["repo_data_dir"], "raster", "natural_earth",
97
+ "50-natural-earth-1-downsampled.png")
98
+ return np.asarray(imread(path), dtype=float)
99
+
100
+
101
+ def _on_land(theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
102
+ """Whether points lie on Natural Earth's land, at 110 m."""
103
+ import shapely
104
+ from cartopy.feature import NaturalEarthFeature
105
+ lat, lon = geographic(theta, phi)
106
+ lon = (lon + 180.0) % 360.0 - 180.0
107
+ out = np.zeros(lat.shape, dtype=bool)
108
+ for geometry in NaturalEarthFeature("physical", "land", "110m").geometries():
109
+ shapely.prepare(geometry)
110
+ out |= shapely.contains_xy(geometry, lon, lat)
111
+ return out
112
+
113
+
114
+ def _coastlines(slice_: CutawaySlice, scale: float) -> tuple[np.ndarray, np.ndarray]:
115
+ """Natural Earth's coastlines at 110 m in the picture, the runs that
116
+ are seen separated by NaN."""
117
+ from cartopy.feature import NaturalEarthFeature
118
+ xs, ys = [], []
119
+ for geometry in NaturalEarthFeature("physical", "coastline",
120
+ "110m").geometries():
121
+ lines = getattr(geometry, "geoms", [geometry])
122
+ for line in lines:
123
+ lon, lat = np.asarray(line.coords, dtype=float).T
124
+ theta, phi = from_geographic(lat, lon)
125
+ X = cartesian_points(slice_.outer, theta, phi)
126
+ seen = slice_.visible(X)
127
+ if not np.any(seen):
128
+ continue
129
+ x, y = slice_.project(X)
130
+ xs.append(np.where(seen, x * scale, np.nan))
131
+ ys.append(np.where(seen, y * scale, np.nan))
132
+ xs.append([np.nan])
133
+ ys.append([np.nan])
134
+ if not xs:
135
+ return np.array([]), np.array([])
136
+ return np.concatenate(xs), np.concatenate(ys)
137
+
138
+
139
+ def _interface_arcs(slice_: CutawaySlice, scale: float
140
+ ) -> tuple[np.ndarray, np.ndarray]:
141
+ """The model's interfaces between the cut's radii as arcs on the two
142
+ faces, in the picture, the runs that are seen separated by NaN."""
143
+ xs, ys = [], []
144
+ for radius in slice_.boundaries:
145
+ if not slice_.inner < radius < slice_.top:
146
+ continue
147
+ for centre, a, b, start, end in slice_.arcs:
148
+ s = np.linspace(start, end, _ARC_POINTS)[:, None]
149
+ X = radius * (centre + a * np.cos(s) + b * np.sin(s))
150
+ seen = slice_.visible(X)
151
+ if not np.any(seen):
152
+ continue
153
+ x, y = slice_.project(X)
154
+ xs += [np.where(seen, x * scale, np.nan), [np.nan]]
155
+ ys += [np.where(seen, y * scale, np.nan), [np.nan]]
156
+ if not xs:
157
+ return np.array([]), np.array([])
158
+ return np.concatenate(xs), np.concatenate(ys)
159
+
160
+
161
+ def _surface_colours(slice_: CutawaySlice, surface: Any, hit: np.ndarray
162
+ ) -> np.ndarray | None:
163
+ """The RGBA of the surface pixels `hit`, or None for the field."""
164
+ if isinstance(surface, str) and surface == "field":
165
+ return None
166
+ X = slice_.reference[hit]
167
+ r = np.linalg.norm(X, axis=-1)
168
+ theta = np.arccos(np.clip(X[:, 2] / r, -1.0, 1.0))
169
+ phi = np.arctan2(X[:, 1], X[:, 0])
170
+ if isinstance(surface, str) and surface == "image":
171
+ rgba = _sample_image(_natural_earth_image(), theta, phi)
172
+ elif isinstance(surface, str) and surface == "land":
173
+ rgba = np.where(_on_land(theta, phi)[:, None], _rgb(LAND), _rgb(SEA))
174
+ elif isinstance(surface, np.ndarray) and surface.ndim == 3:
175
+ image = surface.astype(float)
176
+ if surface.dtype.kind in "ui":
177
+ image = image / 255.0
178
+ if image.shape[2] not in (3, 4):
179
+ raise ValueError("an image of the surface has RGB or RGBA pixels, "
180
+ f"got shape {surface.shape}")
181
+ rgba = _sample_image(image, theta, phi)
182
+ else:
183
+ try:
184
+ rgba = np.broadcast_to(_rgb(surface), (X.shape[0], 4)).copy()
185
+ except ValueError:
186
+ raise ValueError("surface is 'field', 'image', 'land', an RGB(A) "
187
+ f"image of the surface or a colour, got {surface!r}"
188
+ ) from None
189
+ rgba = np.array(rgba, dtype=float)
190
+ rgba[:, :3] *= _shading(slice_, X, X / r[:, None])[:, None]
191
+ return rgba
192
+
193
+
194
+ def cutaway(ax: Any, slice_: CutawaySlice, *, surface: Any = "field",
195
+ inner: Any = "field", coastlines: bool = False,
196
+ interfaces: bool = True, scale: float = 1.0, cmap: str | None = None,
197
+ vmin: float | None = None, vmax: float | None = None,
198
+ diverging: bool = False, clip: float | None = None,
199
+ normalised: bool = False, colorbar: bool = False, **style: Any) -> Any:
200
+ """Draw a cutaway on plain axes by the rules of the module docstring.
201
+
202
+ `surface` is `"field"`, `"image"`, `"land"`, an RGB(A) image of the
203
+ whole surface, or a colour; `inner` `"field"` or a colour for the
204
+ sphere left standing in the cut; `coastlines` draws the coastlines;
205
+ `interfaces` the interfaces as faint arcs on the faces; positions
206
+ are multiplied by `scale`; `cmap`, `vmin` and `vmax` override the
207
+ colour scale, `diverging` and `clip` choose it, `normalised` divides
208
+ by the largest absolute value; `colorbar` adds a colour bar labelled
209
+ with what the slice shows, with arrows at the ends the values run
210
+ beyond; `style` goes to `imshow`. Returns the image drawn.
211
+ """
212
+ from matplotlib.cm import ScalarMappable
213
+ from matplotlib.colors import Normalize
214
+ import matplotlib.pyplot as plt
215
+ if (getattr(ax, "projection", None) is not None
216
+ or getattr(ax, "name", "") == "polar"):
217
+ raise ValueError("a cutaway is drawn on plain axes")
218
+ if normalised:
219
+ slice_ = _normalised(slice_)
220
+ field = slice_.face > 0
221
+ if not (isinstance(surface, str) and surface == "field"):
222
+ field &= ~slice_.mask("surface")
223
+ if not (isinstance(inner, str) and inner == "field") and "inner" in slice_.faces:
224
+ field &= ~slice_.mask("inner")
225
+ values = slice_.values[field]
226
+ cmap, lo, hi = colour_scale([values], slice_.shown, cmap=cmap, vmin=vmin,
227
+ vmax=vmax, diverging=diverging, clip=clip)
228
+ norm = Normalize(lo, hi)
229
+ colours = plt.get_cmap(cmap)
230
+ n = slice_.n
231
+ rgba = np.zeros((n, n, 4))
232
+ rgba[field] = colours(norm(values))
233
+ top = _surface_colours(slice_, surface, slice_.mask("surface"))
234
+ if top is not None:
235
+ rgba[slice_.mask("surface")] = top
236
+ if not (isinstance(inner, str) and inner == "field") and "inner" in slice_.faces:
237
+ core = slice_.mask("inner")
238
+ X = slice_.reference[core]
239
+ r = np.linalg.norm(X, axis=-1)
240
+ plain = np.broadcast_to(_rgb(inner), (X.shape[0], 4)).copy()
241
+ plain[:, :3] *= _shading(slice_, X, X / r[:, None])[:, None]
242
+ rgba[core] = plain
243
+ # a pixel of the edge whose centre misses the body takes the colour of its
244
+ # nearest neighbour that sees it, at its coverage
245
+ seen = slice_.face > 0
246
+ partial = ~seen & (slice_.coverage > 0.0)
247
+ if np.any(partial):
248
+ from scipy.ndimage import distance_transform_edt
249
+ _, (rows, cols) = distance_transform_edt(~seen, return_indices=True)
250
+ rgba[partial] = rgba[rows[partial], cols[partial]]
251
+ rgba[..., 3] *= slice_.coverage
252
+ e = slice_.extent * scale
253
+ image = ax.imshow(rgba, extent=(-e, e, -e, e), origin="upper",
254
+ interpolation="antialiased", **style)
255
+ if interfaces:
256
+ x, y = _interface_arcs(slice_, scale)
257
+ if x.size:
258
+ ax.plot(x, y, **BOUNDARY_STYLE)
259
+ if coastlines:
260
+ x, y = _coastlines(slice_, scale)
261
+ if x.size:
262
+ ax.plot(x, y, **COASTLINE_STYLE)
263
+ ax.set_aspect("equal")
264
+ ax.set_xlim(-e, e)
265
+ ax.set_ylim(-e, e)
266
+ ax.set_axis_off()
267
+ if colorbar:
268
+ ax.figure.colorbar(ScalarMappable(norm=norm, cmap=cmap), ax=ax,
269
+ label=slice_.label, extend=colour_extend([values], lo, hi))
270
+ return image
@@ -224,6 +224,48 @@ class SpectralBasis:
224
224
  Phi = self._modes[self.restriction.nodes]
225
225
  return (Phi ** 2) @ self.theta ** (-float(power))
226
226
 
227
+ def pointwise_variance_of(self, covariance: ArrayLike) -> np.ndarray:
228
+ """diag(Phi C Phi^T) at the physical nodes: the variance there of
229
+ a field of this degree whose coefficients have the covariance C,
230
+ a matrix (nmodes, nmodes). `pointwise_variance(power)` is the
231
+ case C = diag(theta^-power)."""
232
+ C = np.asarray(covariance, dtype=float)
233
+ n = self.nmodes
234
+ if C.shape != (n, n):
235
+ raise ValueError(f"the covariance is a matrix of shape ({n}, {n}), "
236
+ f"got {C.shape}")
237
+ Phi = self._modes[self.restriction.nodes]
238
+ return np.einsum("ij,ij->i", Phi @ C, Phi)
239
+
240
+ def galerkin(self, family: RadialOperatorFamily) -> np.ndarray:
241
+ """The matrix (nmodes, nmodes) of `family`'s member of this basis's
242
+ degree in the kept modes: Phi^T K'_l Phi, the bilinear form of A'_l
243
+ (u^T K'_l v, with K'_l its banded pencil) between pairs of the
244
+ M-orthonormal modes, so that for this basis's own family it is
245
+ diag(theta), and in general the Galerkin block of A'_l on the
246
+ span of the modes. `family` must be on this basis's mesh with
247
+ the same weight. The product is symmetrised explicitly,
248
+ (B + B^T) / 2, which removes its rounding asymmetry."""
249
+ own = self.family
250
+ if not isinstance(family, RadialOperatorFamily):
251
+ raise TypeError("family must be a RadialOperatorFamily")
252
+ if family.weight != own.weight:
253
+ raise ValueError(f"the family's weight is {family.weight!r}; this "
254
+ f"basis's is {own.weight!r}")
255
+ mesh, other = own.mesh, family.mesh
256
+ if other is not mesh and (
257
+ other.nglob != mesh.nglob or other.ngll != mesh.ngll
258
+ or not np.array_equal(other.rglob, mesh.rglob)):
259
+ raise ValueError(f"the family's mesh ({other.nspec} elements of "
260
+ f"{other.ngll} nodes on [{other.rglob[0]:g}, "
261
+ f"{other.rglob[-1]:g}]) is not this basis's "
262
+ f"({mesh.nspec} elements of {mesh.ngll} nodes on "
263
+ f"[{mesh.rglob[0]:g}, {mesh.rglob[-1]:g}])")
264
+ l = self.degree
265
+ mass = family.mass(l)
266
+ B = self._Phi.T @ (mass[:, None] * family.apply(l, self._Phi))
267
+ return 0.5 * (B + B.T)
268
+
227
269
  def white_noise(self, *, rng: Any = None, size: int | None = None) -> np.ndarray:
228
270
  """Coefficients of white noise of the space, independent standard
229
271
  normals of shape (nmodes,) or (nmodes, size), column j of a batch
@@ -24,7 +24,10 @@ LAPACK's banded symmetric drivers, with select-by-value for spectral
24
24
  truncation. Since A_l = M + K_l with K_l >= 0, every eigenvalue
25
25
  satisfies theta >= 1, so negative fractional powers of the family are
26
26
  uniformly well behaved. Eigenvectors are M-orthonormal
27
- (Phi^T M Phi = I), so A_l^s = Phi Theta^s Phi^T M on nodal vectors and
27
+ (Phi^T M Phi = I) and of a fixed sign, the first component above
28
+ `SIGN_RTOL` of the largest positive, so that the same eigenfunction
29
+ discretised on two meshes has one sign; A_l^s = Phi Theta^s Phi^T M on
30
+ nodal vectors and
28
31
  <f, A_l^s g> with the L^2(w dr) pairing is the H^s inner product of the
29
32
  Sobolev scale built on A. White noise of the space is M^-1/2 z with
30
33
  z standard normal, whose covariance is M^-1.
@@ -59,7 +62,11 @@ from scipy.linalg import eig_banded, eigvals_banded, solveh_banded
59
62
 
60
63
  from ..mesh1d import Mesh1D
61
64
 
62
- __all__ = ["RadialOperatorFamily", "Coefficient", "Robin", "WEIGHTS"]
65
+ __all__ = ["RadialOperatorFamily", "Coefficient", "Robin", "WEIGHTS", "SIGN_RTOL"]
66
+
67
+ #: The size, relative to an eigenvector's largest component, from which a
68
+ #: component counts as nonzero when the vector's sign is fixed.
69
+ SIGN_RTOL = 1e-8
63
70
 
64
71
  #: The measures a family may carry: "r2" for r^2 dr, the radial part of the
65
72
  #: volume element of an annulus or ball, and "one" for plain dr on an interval.
@@ -93,6 +100,16 @@ def _nodal_coefficient(mesh: Mesh1D, c: Coefficient) -> np.ndarray:
93
100
  return out
94
101
 
95
102
 
103
+ def _signed(Phi: np.ndarray) -> np.ndarray:
104
+ """The columns of Phi, each with its first component larger in size
105
+ than `SIGN_RTOL` of its largest made positive."""
106
+ size = np.abs(Phi)
107
+ big = size > SIGN_RTOL * size.max(axis=0, keepdims=True)
108
+ first = np.argmax(big, axis=0)
109
+ lead = Phi[first, np.arange(Phi.shape[1])]
110
+ return Phi * np.where(lead < 0.0, -1.0, 1.0)
111
+
112
+
96
113
  def _parse_robin(robin: Robin) -> tuple[float, float]:
97
114
  """(gamma_inner, gamma_outer) from None, one number, or a pair with
98
115
  None entries meaning Neumann."""
@@ -309,7 +326,10 @@ class RadialOperatorFamily:
309
326
  """Eigenpairs (theta, Phi) of A_l Phi = M Phi Theta, cached.
310
327
 
311
328
  Phi has the active-dof eigenvectors in its columns, ascending in
312
- theta and M-orthonormal. With `theta_max`, only the eigenvalues
329
+ theta, M-orthonormal and each of a fixed sign: its first component
330
+ above `SIGN_RTOL` of its largest is positive, so that the same
331
+ eigenfunction on two meshes has the same sign (LAPACK's is
332
+ arbitrary). With `theta_max`, only the eigenvalues
313
333
  theta <= theta_max are computed; with `count`, the `count`
314
334
  smallest, selected by index so that none is lost to the rounding
315
335
  of a bound. The cache keeps the widest range requested so far
@@ -334,7 +354,7 @@ class RadialOperatorFamily:
334
354
  theta, Y = eig_banded(S, lower=False, select="i",
335
355
  select_range=(0, count - 1))
336
356
  have = float(theta[-1])
337
- self._pairs[l] = (theta, s[:, None] * Y, have)
357
+ self._pairs[l] = (theta, _signed(s[:, None] * Y), have)
338
358
  theta, Phi, _ = self._pairs[l]
339
359
  return theta[:count], Phi[:, :count]
340
360
  want = np.inf if theta_max is None else float(theta_max)
@@ -348,7 +368,7 @@ class RadialOperatorFamily:
348
368
  else:
349
369
  theta, Y = eig_banded(S, lower=False, select="v",
350
370
  select_range=(0.0, want))
351
- Phi = s[:, None] * Y
371
+ Phi = _signed(s[:, None] * Y)
352
372
  self._pairs[l] = (theta, Phi, want)
353
373
  theta, Phi, have = self._pairs[l]
354
374
  if want < have: