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.
- {planetmodel-1.3.2 → planetmodel-1.4.0}/PKG-INFO +6 -4
- {planetmodel-1.3.2 → planetmodel-1.4.0}/README.md +5 -3
- {planetmodel-1.3.2 → planetmodel-1.4.0}/pyproject.toml +1 -1
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/__init__.py +1 -1
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/harmonics.py +111 -2
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/__init__.py +9 -4
- planetmodel-1.4.0/src/planetmodel/plotting/cutaways.py +270 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/basis.py +42 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/operator.py +25 -5
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/slices.py +474 -7
- {planetmodel-1.3.2 → planetmodel-1.4.0}/LICENSE +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/behaviours.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/catalogue.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/character.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/__init__.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/building.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/laws.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/lithosphere.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/parameters.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/profiles.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/prony.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/conversion/tables.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/__init__.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/building.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/coefficients.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/decks.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/entries.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/fetch.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/grids.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/models.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/processing.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/projections.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/registry.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/sph.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/strata.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/datasets/surfaces.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/deck.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/displacement.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/expansions.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/fields.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/frames.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/geometry.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/gridded.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/lateral.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/layerfunction.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mapping.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/materials.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/means.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/__init__.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/gll.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/gravity.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh1d/mesh.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/__init__.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_geometry.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_orient.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_session.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_sizing.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_tagging.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_validate.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/_writer.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/export.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/layered.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/manifest.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/offset.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/mesh3d/spec.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/model.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/modelfile.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/perturbation.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/maps.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/profiles.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/sections.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/spectra.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/style.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/plotting/vtk.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/pushforward.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/__init__.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/fields.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/randomfield/mesh.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/rheology.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/sampling.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/skeleton.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/surfacefile.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/testing.py +0 -0
- {planetmodel-1.3.2 → planetmodel-1.4.0}/src/planetmodel/units.py +0 -0
- {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
|
+
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/`:
|
|
158
|
-
fields, deck files
|
|
159
|
-
a `# %%` script that
|
|
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/`:
|
|
122
|
-
fields, deck files
|
|
123
|
-
a `# %%` script that
|
|
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
|
+
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.
|
|
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", "
|
|
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); `
|
|
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", "
|
|
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)
|
|
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
|
|
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:
|