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.
Files changed (50) hide show
  1. planetmodel-1.2.4/PKG-INFO +166 -0
  2. planetmodel-1.2.4/README.md +130 -0
  3. {planetmodel-1.2.1 → planetmodel-1.2.4}/pyproject.toml +1 -1
  4. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/__init__.py +3 -2
  5. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/behaviours.py +101 -6
  6. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/catalogue.py +24 -12
  7. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/deck.py +3 -0
  8. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/displacement.py +11 -1
  9. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/fields.py +34 -5
  10. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/geometry.py +7 -0
  11. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/layerfunction.py +24 -5
  12. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mapping.py +26 -3
  13. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/materials.py +2 -0
  14. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/__init__.py +8 -1
  15. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/__init__.py +9 -6
  16. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_geometry.py +1 -0
  17. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_sizing.py +50 -14
  18. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/export.py +104 -9
  19. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/layered.py +19 -8
  20. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/manifest.py +146 -13
  21. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/offset.py +13 -3
  22. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/spec.py +304 -15
  23. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/model.py +204 -6
  24. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/pushforward.py +1 -0
  25. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/__init__.py +2 -2
  26. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/sampling.py +2 -0
  27. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/testing.py +42 -1
  28. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/units.py +2 -0
  29. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/vocabulary.py +4 -0
  30. planetmodel-1.2.1/PKG-INFO +0 -161
  31. planetmodel-1.2.1/README.md +0 -125
  32. {planetmodel-1.2.1 → planetmodel-1.2.4}/LICENSE +0 -0
  33. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/character.py +0 -0
  34. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/frames.py +0 -0
  35. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/harmonics.py +0 -0
  36. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/gll.py +0 -0
  37. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/gravity.py +0 -0
  38. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh1d/mesh.py +0 -0
  39. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_orient.py +0 -0
  40. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_session.py +0 -0
  41. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_tagging.py +0 -0
  42. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_validate.py +0 -0
  43. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/mesh3d/_writer.py +0 -0
  44. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/plotting.py +0 -0
  45. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/basis.py +0 -0
  46. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/fields.py +0 -0
  47. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/mesh.py +0 -0
  48. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/randomfield/operator.py +0 -0
  49. {planetmodel-1.2.1 → planetmodel-1.2.4}/src/planetmodel/rheology.py +0 -0
  50. {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.1"
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.1"
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
- A density that depends on direction is refused, and the geometry's
251
- mapping does not enter (see `mesh1d.gravity`)."""
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 constant exact
25
- layers from a few numbers, a fluid layer being one with vs = 0; its
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 constant layers between `boundaries`.
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
- value per layer, and a layer with vs = 0 is fluid. `name` is the
207
- model's.
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], *, rho: Sequence[float],
211
- vp: Sequence[float], vs: Sequence[float],
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
- layers.append({
226
- "rho": constant_field(iv, rho[i], character=DENSITY, name="rho"),
227
- "vp": constant_field(iv, vp[i], name="vp"),
228
- "vs": constant_field(iv, vs[i], name="vs"),
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