planetmodel 1.2.1__tar.gz → 1.2.4__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.2.4/PKG-INFO +166 -0
- planetmodel-1.2.4/README.md +130 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/pyproject.toml +1 -1
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/__init__.py +3 -2
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/behaviours.py +101 -6
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/catalogue.py +24 -12
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/deck.py +3 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/displacement.py +11 -1
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/fields.py +34 -5
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/geometry.py +7 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/layerfunction.py +24 -5
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mapping.py +26 -3
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/materials.py +2 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/__init__.py +8 -1
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/__init__.py +9 -6
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_geometry.py +1 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_sizing.py +50 -14
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/export.py +104 -9
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/layered.py +19 -8
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/manifest.py +146 -13
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/offset.py +13 -3
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/spec.py +304 -15
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/model.py +204 -6
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/pushforward.py +1 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/__init__.py +2 -2
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/sampling.py +2 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/testing.py +42 -1
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/units.py +2 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/vocabulary.py +4 -0
- planetmodel-1.2.1/PKG-INFO +0 -161
- planetmodel-1.2.1/README.md +0 -125
- {planetmodel-1.2.1 → planetmodel-1.2.4}/LICENSE +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/character.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/frames.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/harmonics.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/gll.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/gravity.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/mesh.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_orient.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_session.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_tagging.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_validate.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_writer.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/plotting.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/basis.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/fields.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/mesh.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/operator.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/rheology.py +0 -0
- {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/skeleton.py +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: planetmodel
|
|
3
|
+
Version: 1.2.4
|
|
4
|
+
Summary: Spherically layered planetary models: reference bodies, fields, mappings and meshes
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: geophysics,seismology,planetary models,PREM,spectral elements,meshing
|
|
8
|
+
Author: David Al-Attar
|
|
9
|
+
Author-email: da380@cam.ac.uk
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Provides-Extra: harmonics
|
|
18
|
+
Provides-Extra: meshing
|
|
19
|
+
Provides-Extra: mfem
|
|
20
|
+
Provides-Extra: netcdf
|
|
21
|
+
Provides-Extra: notebook
|
|
22
|
+
Provides-Extra: plot
|
|
23
|
+
Requires-Dist: ducc0 (>=0.41) ; extra == "harmonics"
|
|
24
|
+
Requires-Dist: gmsh (>=4.13) ; extra == "meshing"
|
|
25
|
+
Requires-Dist: ipykernel (>=6.29) ; extra == "notebook"
|
|
26
|
+
Requires-Dist: matplotlib (>=3.11,<4) ; extra == "plot"
|
|
27
|
+
Requires-Dist: mfem (>=4.8) ; extra == "mfem"
|
|
28
|
+
Requires-Dist: netCDF4 (>=1.7) ; extra == "netcdf"
|
|
29
|
+
Requires-Dist: numpy (>=2.0,<3)
|
|
30
|
+
Requires-Dist: pyshtools (>=4.14) ; extra == "harmonics"
|
|
31
|
+
Requires-Dist: scipy (>=1.15,<2)
|
|
32
|
+
Project-URL: Homepage, https://github.com/da380/planetmodel
|
|
33
|
+
Project-URL: Repository, https://github.com/da380/planetmodel
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# planetmodel
|
|
37
|
+
|
|
38
|
+
Spherically layered planetary models — a planet described as concentric
|
|
39
|
+
layers with physical properties on each — and the meshes that hand such
|
|
40
|
+
a model to a numerical solver.
|
|
41
|
+
|
|
42
|
+
With this library you can build a model (PREM out of the box, simple
|
|
43
|
+
layered models, any mineos deck file, or a model type of your own), ask
|
|
44
|
+
it for its properties with the discontinuities treated honestly, deform
|
|
45
|
+
it with ellipticity or surface topography, and mesh it: a
|
|
46
|
+
spectral-element mesh along the radius, or a 2D or 3D finite-element
|
|
47
|
+
mesh for [MFEM](https://mfem.org), refined where you need it. A
|
|
48
|
+
sub-package, `planetmodel.randomfield`, draws Matérn random fields on
|
|
49
|
+
balls, annuli and layers.
|
|
50
|
+
|
|
51
|
+
## Design
|
|
52
|
+
|
|
53
|
+
**A skeleton.** The list of boundary radii, increasing, possibly
|
|
54
|
+
starting above zero for a hollow shell. It answers geometric questions
|
|
55
|
+
(which layer a radius lies in, the intervals between boundaries) and
|
|
56
|
+
supports surgery: refine, truncate, hollow, extend, coarsen.
|
|
57
|
+
|
|
58
|
+
**A geometry.** A skeleton placed in the physical world by one
|
|
59
|
+
continuous mapping from the reference (spherical) body to the physical
|
|
60
|
+
one, plus names for the layers and interfaces. Ellipticity or surface
|
|
61
|
+
topography is therefore a property of the whole model, stated once,
|
|
62
|
+
rather than something each field has to know about. The shipped mapping
|
|
63
|
+
is the radial stretch `m(X) = (r + h) e_r` driven by any callable
|
|
64
|
+
`h(r, theta, phi)`; any object with `__call__`,
|
|
65
|
+
`deformation_gradient` and `jacobian` serves as a mapping, and a
|
|
66
|
+
geometry checks the invariants (orientation preserved, continuous,
|
|
67
|
+
kinked only on boundaries) when it is built.
|
|
68
|
+
|
|
69
|
+
**A field on one layer.** An interval, a name, a character (the tensor
|
|
70
|
+
rank and weight that say how the values transform), and
|
|
71
|
+
`evaluate(r, theta, phi, *, frame)` giving components in the local
|
|
72
|
+
spherical frame or in Cartesian ones. A discontinuity is simply two
|
|
73
|
+
layers: ask each side and get each side's answer. Radial fields sit on
|
|
74
|
+
an algebra that is exact on polynomials — PREM's moduli `rho v^2` are
|
|
75
|
+
exact polynomials, not fits — and fields may be complex-valued, which
|
|
76
|
+
is what a model frozen at a frequency holds.
|
|
77
|
+
|
|
78
|
+
**One model class.** A model is a geometry with named fields on every
|
|
79
|
+
layer; what a name like `"rho"` means comes from the shipped vocabulary
|
|
80
|
+
or from the specs the model is given. A model type (`PREM`,
|
|
81
|
+
`LayeredIsotropicElastic`, `MineosModel`, or your own) derives from
|
|
82
|
+
`Model` alone — there is no hierarchy of model types — and the mixins
|
|
83
|
+
of `planetmodel.behaviours` add the standard derivations: the Love
|
|
84
|
+
moduli beside the velocities, the elastic tensor and its Voigt average,
|
|
85
|
+
gravity, and the constant-Q and Maxwell rheologies frozen at a
|
|
86
|
+
frequency.
|
|
87
|
+
|
|
88
|
+
**Units in one place.** The model's `Scales` say what one stored unit
|
|
89
|
+
of length, mass and time is in SI, and `converted` re-expresses the
|
|
90
|
+
whole model, exactly for polynomials. Nothing else names a unit: radii
|
|
91
|
+
are numbers, tolerances are relative, and the meshers hand the
|
|
92
|
+
geometry's numbers to gmsh unchanged.
|
|
93
|
+
|
|
94
|
+
**Two meshers.** `planetmodel.mesh1d` lays Gauss–Lobatto–Legendre
|
|
95
|
+
elements along the radius with every boundary an element boundary,
|
|
96
|
+
evaluates a model's fields and gravity on the nodes, and samples a
|
|
97
|
+
model on an angular grid.
|
|
98
|
+
`planetmodel.mesh3d` meshes a geometry, full or hollow, in 2D or 3D,
|
|
99
|
+
with buffer shells outside it when a far-field condition needs room;
|
|
100
|
+
element sizes are controlled per interface, and refinements
|
|
101
|
+
(`NearPoints`, `near_curve`, or any size function of your own) make the
|
|
102
|
+
mesh finer towards a coastline, a source region or a station network
|
|
103
|
+
without coarsening anything elsewhere. Every mesh comes with a JSON
|
|
104
|
+
manifest saying what each attribute means, and exports to MFEM together
|
|
105
|
+
with the mapping's displacement and the model's fields.
|
|
106
|
+
|
|
107
|
+
**Executable contracts.** `planetmodel.testing` holds one `check_*`
|
|
108
|
+
function per protocol, and the shipped implementations and your own are
|
|
109
|
+
held to the same call.
|
|
110
|
+
|
|
111
|
+
## Installing
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
pip install planetmodel # numpy and scipy only
|
|
115
|
+
pip install 'planetmodel[meshing]' # 2D and 3D meshes via gmsh
|
|
116
|
+
pip install 'planetmodel[mfem]' # export to MFEM (PyMFEM)
|
|
117
|
+
pip install 'planetmodel[harmonics]' # grid transforms via pyshtools and ducc0
|
|
118
|
+
pip install 'planetmodel[plot]' # matplotlib, for the figures
|
|
119
|
+
pip install 'planetmodel[notebook]' # ipykernel, to run tutorials cell by cell
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Python 3.12 or later. Nothing optional is imported by `import
|
|
123
|
+
planetmodel`.
|
|
124
|
+
|
|
125
|
+
## A first look
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from planetmodel import PREM, RadialMesh, flattening, gravity
|
|
129
|
+
|
|
130
|
+
m = PREM() # exact polynomials, SI units
|
|
131
|
+
|
|
132
|
+
# Fields live on layers, so a discontinuity gives two honest answers,
|
|
133
|
+
# one from each side.
|
|
134
|
+
r_cmb = m.geometry.interface("cmb").radius
|
|
135
|
+
print(m.layer("outer_core")["rho"](r_cmb)) # density, core side
|
|
136
|
+
print(m.layer("lowermost_mantle")["rho"](r_cmb)) # density, mantle side
|
|
137
|
+
print(gravity(m, [r_cmb, 6371e3])) # gravity at the CMB and surface
|
|
138
|
+
|
|
139
|
+
# Reshape the model: non-dimensionalise it, flatten it by 1/300, and
|
|
140
|
+
# lay a radial spectral-element mesh over it.
|
|
141
|
+
nd = m.nondimensionalised().stretched(flattening(1 / 300, rmax=1.0))
|
|
142
|
+
mesh = RadialMesh(nd, ngll=5, drmax=0.05)
|
|
143
|
+
print(mesh.nodal(nd, "rho").shape) # density at every mesh node
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Where to go next
|
|
147
|
+
|
|
148
|
+
- `examples/tutorials/`: eleven walkthroughs, from a skeleton to random
|
|
149
|
+
fields and deck files, each a `# %%` script that runs headless.
|
|
150
|
+
- `src/planetmodel/mesh3d/manifest.py`: the manifest beside every mesh,
|
|
151
|
+
its schema described from the consumer's side.
|
|
152
|
+
- `CONTRIBUTING.md`: the development setup, the hooks, the test
|
|
153
|
+
selections and how a release is made.
|
|
154
|
+
|
|
155
|
+
## Tests
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
poetry run pytest # the fast suite
|
|
159
|
+
poetry run pytest -m "not slow" # with gmsh and MFEM
|
|
160
|
+
poetry run ruff check .
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Licence
|
|
164
|
+
|
|
165
|
+
BSD-3
|
|
166
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# planetmodel
|
|
2
|
+
|
|
3
|
+
Spherically layered planetary models — a planet described as concentric
|
|
4
|
+
layers with physical properties on each — and the meshes that hand such
|
|
5
|
+
a model to a numerical solver.
|
|
6
|
+
|
|
7
|
+
With this library you can build a model (PREM out of the box, simple
|
|
8
|
+
layered models, any mineos deck file, or a model type of your own), ask
|
|
9
|
+
it for its properties with the discontinuities treated honestly, deform
|
|
10
|
+
it with ellipticity or surface topography, and mesh it: a
|
|
11
|
+
spectral-element mesh along the radius, or a 2D or 3D finite-element
|
|
12
|
+
mesh for [MFEM](https://mfem.org), refined where you need it. A
|
|
13
|
+
sub-package, `planetmodel.randomfield`, draws Matérn random fields on
|
|
14
|
+
balls, annuli and layers.
|
|
15
|
+
|
|
16
|
+
## Design
|
|
17
|
+
|
|
18
|
+
**A skeleton.** The list of boundary radii, increasing, possibly
|
|
19
|
+
starting above zero for a hollow shell. It answers geometric questions
|
|
20
|
+
(which layer a radius lies in, the intervals between boundaries) and
|
|
21
|
+
supports surgery: refine, truncate, hollow, extend, coarsen.
|
|
22
|
+
|
|
23
|
+
**A geometry.** A skeleton placed in the physical world by one
|
|
24
|
+
continuous mapping from the reference (spherical) body to the physical
|
|
25
|
+
one, plus names for the layers and interfaces. Ellipticity or surface
|
|
26
|
+
topography is therefore a property of the whole model, stated once,
|
|
27
|
+
rather than something each field has to know about. The shipped mapping
|
|
28
|
+
is the radial stretch `m(X) = (r + h) e_r` driven by any callable
|
|
29
|
+
`h(r, theta, phi)`; any object with `__call__`,
|
|
30
|
+
`deformation_gradient` and `jacobian` serves as a mapping, and a
|
|
31
|
+
geometry checks the invariants (orientation preserved, continuous,
|
|
32
|
+
kinked only on boundaries) when it is built.
|
|
33
|
+
|
|
34
|
+
**A field on one layer.** An interval, a name, a character (the tensor
|
|
35
|
+
rank and weight that say how the values transform), and
|
|
36
|
+
`evaluate(r, theta, phi, *, frame)` giving components in the local
|
|
37
|
+
spherical frame or in Cartesian ones. A discontinuity is simply two
|
|
38
|
+
layers: ask each side and get each side's answer. Radial fields sit on
|
|
39
|
+
an algebra that is exact on polynomials — PREM's moduli `rho v^2` are
|
|
40
|
+
exact polynomials, not fits — and fields may be complex-valued, which
|
|
41
|
+
is what a model frozen at a frequency holds.
|
|
42
|
+
|
|
43
|
+
**One model class.** A model is a geometry with named fields on every
|
|
44
|
+
layer; what a name like `"rho"` means comes from the shipped vocabulary
|
|
45
|
+
or from the specs the model is given. A model type (`PREM`,
|
|
46
|
+
`LayeredIsotropicElastic`, `MineosModel`, or your own) derives from
|
|
47
|
+
`Model` alone — there is no hierarchy of model types — and the mixins
|
|
48
|
+
of `planetmodel.behaviours` add the standard derivations: the Love
|
|
49
|
+
moduli beside the velocities, the elastic tensor and its Voigt average,
|
|
50
|
+
gravity, and the constant-Q and Maxwell rheologies frozen at a
|
|
51
|
+
frequency.
|
|
52
|
+
|
|
53
|
+
**Units in one place.** The model's `Scales` say what one stored unit
|
|
54
|
+
of length, mass and time is in SI, and `converted` re-expresses the
|
|
55
|
+
whole model, exactly for polynomials. Nothing else names a unit: radii
|
|
56
|
+
are numbers, tolerances are relative, and the meshers hand the
|
|
57
|
+
geometry's numbers to gmsh unchanged.
|
|
58
|
+
|
|
59
|
+
**Two meshers.** `planetmodel.mesh1d` lays Gauss–Lobatto–Legendre
|
|
60
|
+
elements along the radius with every boundary an element boundary,
|
|
61
|
+
evaluates a model's fields and gravity on the nodes, and samples a
|
|
62
|
+
model on an angular grid.
|
|
63
|
+
`planetmodel.mesh3d` meshes a geometry, full or hollow, in 2D or 3D,
|
|
64
|
+
with buffer shells outside it when a far-field condition needs room;
|
|
65
|
+
element sizes are controlled per interface, and refinements
|
|
66
|
+
(`NearPoints`, `near_curve`, or any size function of your own) make the
|
|
67
|
+
mesh finer towards a coastline, a source region or a station network
|
|
68
|
+
without coarsening anything elsewhere. Every mesh comes with a JSON
|
|
69
|
+
manifest saying what each attribute means, and exports to MFEM together
|
|
70
|
+
with the mapping's displacement and the model's fields.
|
|
71
|
+
|
|
72
|
+
**Executable contracts.** `planetmodel.testing` holds one `check_*`
|
|
73
|
+
function per protocol, and the shipped implementations and your own are
|
|
74
|
+
held to the same call.
|
|
75
|
+
|
|
76
|
+
## Installing
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
pip install planetmodel # numpy and scipy only
|
|
80
|
+
pip install 'planetmodel[meshing]' # 2D and 3D meshes via gmsh
|
|
81
|
+
pip install 'planetmodel[mfem]' # export to MFEM (PyMFEM)
|
|
82
|
+
pip install 'planetmodel[harmonics]' # grid transforms via pyshtools and ducc0
|
|
83
|
+
pip install 'planetmodel[plot]' # matplotlib, for the figures
|
|
84
|
+
pip install 'planetmodel[notebook]' # ipykernel, to run tutorials cell by cell
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Python 3.12 or later. Nothing optional is imported by `import
|
|
88
|
+
planetmodel`.
|
|
89
|
+
|
|
90
|
+
## A first look
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from planetmodel import PREM, RadialMesh, flattening, gravity
|
|
94
|
+
|
|
95
|
+
m = PREM() # exact polynomials, SI units
|
|
96
|
+
|
|
97
|
+
# Fields live on layers, so a discontinuity gives two honest answers,
|
|
98
|
+
# one from each side.
|
|
99
|
+
r_cmb = m.geometry.interface("cmb").radius
|
|
100
|
+
print(m.layer("outer_core")["rho"](r_cmb)) # density, core side
|
|
101
|
+
print(m.layer("lowermost_mantle")["rho"](r_cmb)) # density, mantle side
|
|
102
|
+
print(gravity(m, [r_cmb, 6371e3])) # gravity at the CMB and surface
|
|
103
|
+
|
|
104
|
+
# Reshape the model: non-dimensionalise it, flatten it by 1/300, and
|
|
105
|
+
# lay a radial spectral-element mesh over it.
|
|
106
|
+
nd = m.nondimensionalised().stretched(flattening(1 / 300, rmax=1.0))
|
|
107
|
+
mesh = RadialMesh(nd, ngll=5, drmax=0.05)
|
|
108
|
+
print(mesh.nodal(nd, "rho").shape) # density at every mesh node
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Where to go next
|
|
112
|
+
|
|
113
|
+
- `examples/tutorials/`: eleven walkthroughs, from a skeleton to random
|
|
114
|
+
fields and deck files, each a `# %%` script that runs headless.
|
|
115
|
+
- `src/planetmodel/mesh3d/manifest.py`: the manifest beside every mesh,
|
|
116
|
+
its schema described from the consumer's side.
|
|
117
|
+
- `CONTRIBUTING.md`: the development setup, the hooks, the test
|
|
118
|
+
selections and how a release is made.
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
poetry run pytest # the fast suite
|
|
124
|
+
poetry run pytest -m "not slow" # with gmsh and MFEM
|
|
125
|
+
poetry run ruff check .
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Licence
|
|
129
|
+
|
|
130
|
+
BSD-3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "planetmodel"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.4"
|
|
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 = [
|
|
@@ -89,7 +89,7 @@ from .mapping import (
|
|
|
89
89
|
from .materials import ElasticField, elastic_moduli, is_fluid, kappa_mu, moduli
|
|
90
90
|
from .mesh1d import RadialMesh
|
|
91
91
|
from .mesh1d.gravity import gravity, gravity_fields, mass
|
|
92
|
-
from .model import Layer, Model
|
|
92
|
+
from .model import CoarseningFit, Layer, Model
|
|
93
93
|
from .pushforward import PulledBackField, PushedForwardField, pull_back, push_forward
|
|
94
94
|
from .rheology import (
|
|
95
95
|
dispersive_moduli,
|
|
@@ -104,12 +104,13 @@ from .units import EARTH_MEAN_DENSITY, G_SI, Dimensions, Scales
|
|
|
104
104
|
from .vocabulary import CONSTANTS, VOCABULARY, Constant, FieldSpec
|
|
105
105
|
from . import testing # noqa: F401
|
|
106
106
|
|
|
107
|
-
__version__ = "1.2.
|
|
107
|
+
__version__ = "1.2.4"
|
|
108
108
|
|
|
109
109
|
__all__ = [
|
|
110
110
|
"Skeleton",
|
|
111
111
|
"Location",
|
|
112
112
|
"CoarseningMap",
|
|
113
|
+
"CoarseningFit",
|
|
113
114
|
"Geometry",
|
|
114
115
|
"LayerInfo",
|
|
115
116
|
"InterfaceInfo",
|
|
@@ -31,18 +31,18 @@ type derived from `Model` alone. The transformations `isotropic`,
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
33
|
import functools
|
|
34
|
-
from collections.abc import Callable, Mapping
|
|
34
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
35
35
|
from typing import TYPE_CHECKING, Any
|
|
36
36
|
|
|
37
37
|
import numpy as np
|
|
38
38
|
|
|
39
39
|
from . import materials, rheology
|
|
40
|
-
from .character import SCALAR
|
|
40
|
+
from .character import DENSITY, SCALAR
|
|
41
41
|
from .fields import ComposedField, Field
|
|
42
42
|
from .mesh1d.gravity import gravity, gravity_fields, mass
|
|
43
43
|
|
|
44
44
|
if TYPE_CHECKING:
|
|
45
|
-
from .model import Model
|
|
45
|
+
from .model import CoarseningFit, Model
|
|
46
46
|
|
|
47
47
|
__all__ = ["layer_method", "with_moduli", "with_velocities", "Elastic", "ConstantQ",
|
|
48
48
|
"SelfGravitating", "Viscoelastic", "ELASTIC_FAMILIES", "elastic_family"]
|
|
@@ -69,6 +69,25 @@ def elastic_family(name: str) -> tuple[str, ...]:
|
|
|
69
69
|
f"{list(materials.ELASTIC_NAMES)}")
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def _description_family(model: Model, group: Sequence[int]) -> tuple[str, ...]:
|
|
73
|
+
"""The elastic names a merged group is refit through: the first
|
|
74
|
+
description every layer of the group holds, velocities preferred
|
|
75
|
+
since their products keep the derived moduli exact polynomials."""
|
|
76
|
+
held = set.intersection(*(set(model.layer(i).names) for i in group))
|
|
77
|
+
if "rho" in held and ({"vp", "vs"} <= held or {"vpv", "vsv"} <= held):
|
|
78
|
+
return tuple(nm for nm in VELOCITY_NAMES if nm in held)
|
|
79
|
+
five = materials.MODULI_NAMES[materials.Symmetry.VTI]
|
|
80
|
+
if set(five) <= held:
|
|
81
|
+
return five
|
|
82
|
+
iso = materials.MODULI_NAMES[materials.Symmetry.ISOTROPIC]
|
|
83
|
+
if set(iso) <= held:
|
|
84
|
+
return iso
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"merged layers {list(group)} share no complete elastic description "
|
|
87
|
+
"in velocities or moduli (perhaps only 'elastic_moduli'); keep the "
|
|
88
|
+
"interface, or re-describe the layers first")
|
|
89
|
+
|
|
90
|
+
|
|
72
91
|
def layer_method[T](fn: Callable[..., T]) -> Callable[..., T]:
|
|
73
92
|
"""`fn(layer, *args, **kwargs)` as a method `model.fn(which, *args, **kwargs)`.
|
|
74
93
|
|
|
@@ -191,6 +210,39 @@ class Elastic:
|
|
|
191
210
|
return out
|
|
192
211
|
return out.replaced(layers=layers)
|
|
193
212
|
|
|
213
|
+
def coarsened(self: Model, *, keep: Iterable[int | str] | None = None,
|
|
214
|
+
drop: Iterable[int | str] | None = None, degree: int = 3,
|
|
215
|
+
n: int = 64) -> tuple[Model, CoarseningFit]:
|
|
216
|
+
"""`Model.coarsened` with the elastic description kept consistent.
|
|
217
|
+
|
|
218
|
+
Each merged group is refit through its base description alone,
|
|
219
|
+
rho with the velocities where every merged layer holds them
|
|
220
|
+
(the five then follow as exact polynomial products of the
|
|
221
|
+
fits), else the five Love moduli, else kappa and mu; the other
|
|
222
|
+
elastic names are dropped before the fit and derived again
|
|
223
|
+
from the fitted description, so the coarse model satisfies the
|
|
224
|
+
elastic relations exactly. A group whose layers share no such
|
|
225
|
+
description (one speaking only through `elastic_moduli`, say)
|
|
226
|
+
is refused. Layers left whole are untouched.
|
|
227
|
+
"""
|
|
228
|
+
from .model import Model
|
|
229
|
+
_, cmap = self._coarsening(keep=keep, drop=drop)
|
|
230
|
+
layers = [dict(layer.fields) for layer in self.layers]
|
|
231
|
+
merged = [group for group in cmap.layers if len(group) > 1]
|
|
232
|
+
for group in merged:
|
|
233
|
+
family = _description_family(self, group)
|
|
234
|
+
for i in group:
|
|
235
|
+
layers[i] = {k: f for k, f in layers[i].items()
|
|
236
|
+
if k not in materials.ELASTIC_NAMES or k in family}
|
|
237
|
+
stripped = self.replaced(layers=layers, check=False)
|
|
238
|
+
coarse, fit = Model.coarsened(stripped, keep=keep, drop=drop,
|
|
239
|
+
degree=degree, n=n)
|
|
240
|
+
out = [dict(layer.fields) for layer in coarse.layers]
|
|
241
|
+
for j, group in enumerate(fit.map.layers):
|
|
242
|
+
if len(group) > 1:
|
|
243
|
+
out[j] = with_velocities(with_moduli(out[j]))
|
|
244
|
+
return coarse.replaced(layers=out), fit
|
|
245
|
+
|
|
194
246
|
def isotropic(self: Model) -> Model:
|
|
195
247
|
"""The model with every elastic description replaced by its Voigt
|
|
196
248
|
average: each layer keeps rho and everything that is not an
|
|
@@ -246,9 +298,10 @@ class SelfGravitating:
|
|
|
246
298
|
density on every layer: the reference body's, computed on each call
|
|
247
299
|
and never stored, as numbers by `gravity` and `mass`, as one radial
|
|
248
300
|
field per layer by `gravity_fields`, and as a copy of the model
|
|
249
|
-
holding that field under the vocabulary name `g` by `with_gravity
|
|
250
|
-
|
|
251
|
-
|
|
301
|
+
holding that field under the vocabulary name `g` by `with_gravity`;
|
|
302
|
+
`with_stratification` attaches d rho / d Phi_0 = rho'(r) / g(r) to
|
|
303
|
+
the fluid layers. A density that depends on direction is refused,
|
|
304
|
+
and the geometry's mapping does not enter (see `mesh1d.gravity`)."""
|
|
252
305
|
|
|
253
306
|
gravity = gravity
|
|
254
307
|
mass = mass
|
|
@@ -268,6 +321,48 @@ class SelfGravitating:
|
|
|
268
321
|
layers.append({**layer.fields, name: field})
|
|
269
322
|
return self.replaced(layers=layers)
|
|
270
323
|
|
|
324
|
+
def with_stratification(self: Model, *, name: str = "drho_dphi0",
|
|
325
|
+
replace: bool = False) -> Model:
|
|
326
|
+
"""The model with the stratification of its fluid layers,
|
|
327
|
+
d rho / d Phi_0 = rho'(r) / g(r), attached to each of them
|
|
328
|
+
under `name`; solid layers, and layers whose fluidity cannot
|
|
329
|
+
be read, are left alone, and a model with no fluid layer is
|
|
330
|
+
refused. rho' is exact where the density is polynomial; the
|
|
331
|
+
field is zero where g vanishes (the centre). A fluid layer
|
|
332
|
+
already holding `name` is refused unless `replace`."""
|
|
333
|
+
fields = gravity_fields(self)
|
|
334
|
+
layers = []
|
|
335
|
+
n_fluid = 0
|
|
336
|
+
for layer, g in zip(self.layers, fields):
|
|
337
|
+
try:
|
|
338
|
+
fluid = materials.is_fluid(layer)
|
|
339
|
+
except KeyError:
|
|
340
|
+
fluid = False
|
|
341
|
+
if not fluid:
|
|
342
|
+
layers.append(dict(layer.fields))
|
|
343
|
+
continue
|
|
344
|
+
if name in layer and not replace:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"layer {layer.index} ({layer.name!r}) already holds {name!r}; "
|
|
347
|
+
"pass replace=True to replace it")
|
|
348
|
+
rho = layer["rho"]
|
|
349
|
+
if not hasattr(rho, "derivative"):
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"layer {layer.index} ({layer.name!r}) holds a rho that "
|
|
352
|
+
"cannot state its radial derivative, so its "
|
|
353
|
+
"stratification cannot be formed")
|
|
354
|
+
n_fluid += 1
|
|
355
|
+
strat = ComposedField(
|
|
356
|
+
lambda d, a: np.where(a != 0.0, d, 0.0)
|
|
357
|
+
/ np.where(a != 0.0, a, 1.0),
|
|
358
|
+
(rho.derivative(nu=1), g), character=DENSITY, name=name)
|
|
359
|
+
layers.append({**layer.fields, name: strat})
|
|
360
|
+
if not n_fluid:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"no fluid layer: the stratification d rho / d Phi_0 is a "
|
|
363
|
+
"property of fluids, and no layer of this model is one")
|
|
364
|
+
return self.replaced(layers=layers)
|
|
365
|
+
|
|
271
366
|
|
|
272
367
|
class Viscoelastic:
|
|
273
368
|
"""The rheology read from the fields: a layer's viscoelasticity, the
|
|
@@ -21,8 +21,9 @@ velocity are isotropic and get vph = vpv, vsh = vsv and eta = 1 as
|
|
|
21
21
|
exact constants; the fluid outer core and ocean hold no qmu; the
|
|
22
22
|
elastic values are those at a reference period of 1 s.
|
|
23
23
|
|
|
24
|
-
`LayeredIsotropicElastic` builds an isotropic model of
|
|
25
|
-
|
|
24
|
+
`LayeredIsotropicElastic` builds an isotropic model of exact layers
|
|
25
|
+
from a few numbers per layer, a constant or the coefficients of a
|
|
26
|
+
polynomial in r / scale, a fluid layer being one with vs = 0; its
|
|
26
27
|
`homogeneous` classmethod is the one-layer case.
|
|
27
28
|
|
|
28
29
|
`MineosModel` is the model of a mineos deck, PREM's own tabulation
|
|
@@ -199,16 +200,20 @@ class PREM(Elastic, ConstantQ, SelfGravitating, Viscoelastic, Model):
|
|
|
199
200
|
|
|
200
201
|
|
|
201
202
|
class LayeredIsotropicElastic(Elastic, SelfGravitating, Model):
|
|
202
|
-
"""An isotropic model of
|
|
203
|
+
"""An isotropic model of exact polynomial layers between `boundaries`.
|
|
203
204
|
|
|
204
205
|
`boundaries` are the skeleton's, centre outward (an inner radius
|
|
205
206
|
above zero gives a hollow model); `rho`, `vp` and `vs` give one
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
entry per layer, a number for a constant or a sequence of ascending
|
|
208
|
+
polynomial coefficients in r / scale, and a layer with vs = 0
|
|
209
|
+
throughout is fluid. `name` is the model's.
|
|
208
210
|
"""
|
|
209
211
|
|
|
210
|
-
def __init__(self, boundaries: Sequence[float], *,
|
|
211
|
-
|
|
212
|
+
def __init__(self, boundaries: Sequence[float], *,
|
|
213
|
+
rho: Sequence[float | Sequence[float]],
|
|
214
|
+
vp: Sequence[float | Sequence[float]],
|
|
215
|
+
vs: Sequence[float | Sequence[float]],
|
|
216
|
+
scale: float = 1.0,
|
|
212
217
|
layer_names: Sequence[str | None] | None = None,
|
|
213
218
|
interface_names: Sequence[str | None] | None = None,
|
|
214
219
|
scales: Scales = Scales.SI, name: str | None = None) -> None:
|
|
@@ -222,11 +227,18 @@ class LayeredIsotropicElastic(Elastic, SelfGravitating, Model):
|
|
|
222
227
|
layers = []
|
|
223
228
|
for i in range(sk.nlayers):
|
|
224
229
|
iv = sk.interval(i)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
+
fields = {}
|
|
231
|
+
for key, character in (("rho", DENSITY), ("vp", SCALAR),
|
|
232
|
+
("vs", SCALAR)):
|
|
233
|
+
value = values[key][i]
|
|
234
|
+
if np.isscalar(value):
|
|
235
|
+
fields[key] = constant_field(iv, float(value),
|
|
236
|
+
character=character, name=key)
|
|
237
|
+
else:
|
|
238
|
+
fields[key] = RadialField(
|
|
239
|
+
iv, polynomial_layer(iv, value, scale=scale),
|
|
240
|
+
character=character, name=key)
|
|
241
|
+
layers.append(fields)
|
|
230
242
|
super().__init__(geometry, layers, scales=scales, name=name)
|
|
231
243
|
|
|
232
244
|
@classmethod
|
|
@@ -146,10 +146,12 @@ class Deck:
|
|
|
146
146
|
|
|
147
147
|
@property
|
|
148
148
|
def names(self) -> tuple[str, ...]:
|
|
149
|
+
"""The column names, in the deck's order."""
|
|
149
150
|
return tuple(self.columns)
|
|
150
151
|
|
|
151
152
|
@property
|
|
152
153
|
def nknots(self) -> int:
|
|
154
|
+
"""The number of knots, repeated radii counted twice."""
|
|
153
155
|
return self.radius.size
|
|
154
156
|
|
|
155
157
|
def __getitem__(self, name: str) -> np.ndarray:
|
|
@@ -189,6 +191,7 @@ class Deck:
|
|
|
189
191
|
|
|
190
192
|
@property
|
|
191
193
|
def nlayers(self) -> int:
|
|
194
|
+
"""The number of layers the repeated radii cut the deck into."""
|
|
192
195
|
return len(self.layers())
|
|
193
196
|
|
|
194
197
|
def __repr__(self) -> str:
|
|
@@ -63,7 +63,9 @@ class RadialDisplacement(Protocol):
|
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
65
|
def __call__(self, r: ArrayLike, theta: ArrayLike, phi: ArrayLike
|
|
66
|
-
) -> np.ndarray:
|
|
66
|
+
) -> np.ndarray:
|
|
67
|
+
"""The displacement at the broadcast coordinates."""
|
|
68
|
+
...
|
|
67
69
|
|
|
68
70
|
|
|
69
71
|
def _broadcast(r: ArrayLike, theta: ArrayLike, phi: ArrayLike
|
|
@@ -80,19 +82,23 @@ class ZeroDisplacement:
|
|
|
80
82
|
knots: tuple[float, ...] = ()
|
|
81
83
|
|
|
82
84
|
def __call__(self, r: ArrayLike, theta: ArrayLike, phi: ArrayLike) -> np.ndarray:
|
|
85
|
+
"""Zeros of the broadcast shape."""
|
|
83
86
|
r, _, _ = _broadcast(r, theta, phi)
|
|
84
87
|
return np.zeros(r.shape)
|
|
85
88
|
|
|
86
89
|
def radial_derivative(self, r: ArrayLike, theta: ArrayLike,
|
|
87
90
|
phi: ArrayLike) -> np.ndarray:
|
|
91
|
+
"""dh/dr: zeros of the broadcast shape."""
|
|
88
92
|
return self(r, theta, phi)
|
|
89
93
|
|
|
90
94
|
def angular_gradient(self, r: ArrayLike, theta: ArrayLike, phi: ArrayLike
|
|
91
95
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
96
|
+
"""(dh/dtheta, dh/dphi): zeros of the broadcast shape."""
|
|
92
97
|
z = self(r, theta, phi)
|
|
93
98
|
return z, z.copy()
|
|
94
99
|
|
|
95
100
|
def bounds(self) -> tuple[float, float]:
|
|
101
|
+
"""(min h, max h): both zero."""
|
|
96
102
|
return 0.0, 0.0
|
|
97
103
|
|
|
98
104
|
def __repr__(self) -> str:
|
|
@@ -125,6 +131,7 @@ class CallableDisplacement:
|
|
|
125
131
|
self.name = name
|
|
126
132
|
|
|
127
133
|
def __call__(self, r: ArrayLike, theta: ArrayLike, phi: ArrayLike) -> np.ndarray:
|
|
134
|
+
"""The wrapped callable at the broadcast coordinates."""
|
|
128
135
|
r, theta, phi = _broadcast(r, theta, phi)
|
|
129
136
|
return np.asarray(self._fn(r, theta, phi), dtype=float)
|
|
130
137
|
|
|
@@ -235,10 +242,12 @@ class LayerLinear:
|
|
|
235
242
|
|
|
236
243
|
@property
|
|
237
244
|
def boundaries(self) -> np.ndarray:
|
|
245
|
+
"""The skeleton boundaries the reliefs sit on, innermost first."""
|
|
238
246
|
return self._b
|
|
239
247
|
|
|
240
248
|
@property
|
|
241
249
|
def reliefs(self) -> tuple[Relief | None, ...]:
|
|
250
|
+
"""One relief per boundary, None where a boundary carries none."""
|
|
242
251
|
return tuple(self._reliefs)
|
|
243
252
|
|
|
244
253
|
def _relief(self, j: int, theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
|
|
@@ -264,6 +273,7 @@ class LayerLinear:
|
|
|
264
273
|
return r, theta, phi, lo, hi, below, above
|
|
265
274
|
|
|
266
275
|
def __call__(self, r: ArrayLike, theta: ArrayLike, phi: ArrayLike) -> np.ndarray:
|
|
276
|
+
"""The reliefs of the point's layer, interpolated linearly in r."""
|
|
267
277
|
r, theta, phi, lo, hi, below, above = self._pieces(r, theta, phi)
|
|
268
278
|
t = (r - lo) / (hi - lo)
|
|
269
279
|
return (1.0 - t) * below + t * above
|