planetmodel 1.2.0__tar.gz → 1.2.3__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.0 → planetmodel-1.2.3}/PKG-INFO +1 -1
- {planetmodel-1.2.0 → planetmodel-1.2.3}/pyproject.toml +1 -1
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/__init__.py +3 -2
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/behaviours.py +101 -6
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/catalogue.py +24 -12
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/fields.py +17 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/__init__.py +7 -6
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_sizing.py +39 -13
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/export.py +104 -9
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/layered.py +10 -4
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/manifest.py +68 -11
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/offset.py +18 -7
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/spec.py +99 -4
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/model.py +186 -5
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/vocabulary.py +4 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/LICENSE +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/README.md +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/character.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/deck.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/displacement.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/frames.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/geometry.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/harmonics.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/layerfunction.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mapping.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/materials.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/__init__.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/gll.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/gravity.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/mesh.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_geometry.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_orient.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_session.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_tagging.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_validate.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_writer.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/plotting.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/pushforward.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/__init__.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/basis.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/fields.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/mesh.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/operator.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/rheology.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/sampling.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/skeleton.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/testing.py +0 -0
- {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "planetmodel"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.3"
|
|
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.3"
|
|
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
|
|
@@ -365,6 +365,23 @@ class RadialField(FieldBase):
|
|
|
365
365
|
raise ValueError(f"{self!r} has {self._fs.size} components; use functions")
|
|
366
366
|
return self._fs[()]
|
|
367
367
|
|
|
368
|
+
@property
|
|
369
|
+
def radial_degree(self) -> int | None:
|
|
370
|
+
"""The least polynomial degree in r reproducing every component
|
|
371
|
+
throughout the interval, or None where a component is not a
|
|
372
|
+
single polynomial piece (interior breakpoints, or not a
|
|
373
|
+
polynomial at all)."""
|
|
374
|
+
worst = 0
|
|
375
|
+
for idx in np.ndindex(self._fs.shape):
|
|
376
|
+
p = getattr(self._fs[idx], "ppoly", None)
|
|
377
|
+
if p is None or p.x.size != 2:
|
|
378
|
+
return None
|
|
379
|
+
c = p.c[:, 0]
|
|
380
|
+
nonzero = np.flatnonzero(c != 0.0)
|
|
381
|
+
if nonzero.size:
|
|
382
|
+
worst = max(worst, int(c.size - 1 - nonzero[0]))
|
|
383
|
+
return worst
|
|
384
|
+
|
|
368
385
|
def _values(self, r: np.ndarray, theta: np.ndarray | None,
|
|
369
386
|
phi: np.ndarray | None) -> np.ndarray:
|
|
370
387
|
if not self._fs.shape:
|
|
@@ -29,13 +29,14 @@ from . import manifest
|
|
|
29
29
|
from .export import ExportResult, export_mfem, export_mfem_mesh
|
|
30
30
|
from .layered import build_layered_mesh
|
|
31
31
|
from .offset import build_offset_mesh
|
|
32
|
-
from .spec import (AngularResolution,
|
|
33
|
-
PerInterface, Shell, SizingRule,
|
|
34
|
-
ValidationReport)
|
|
32
|
+
from .spec import (AngularResolution, CappedInterfaces, InterfaceSizing,
|
|
33
|
+
MeshResult, MeshSpec, PerInterface, Shell, SizingRule,
|
|
34
|
+
UniformInterfaces, ValidationReport)
|
|
35
35
|
|
|
36
36
|
__all__ = [
|
|
37
37
|
"MeshSpec", "MeshResult", "Shell", "InterfaceSizing", "SizingRule",
|
|
38
|
-
"AngularResolution", "UniformInterfaces", "
|
|
39
|
-
"
|
|
40
|
-
"
|
|
38
|
+
"AngularResolution", "UniformInterfaces", "CappedInterfaces",
|
|
39
|
+
"PerInterface", "ValidationReport", "build_layered_mesh",
|
|
40
|
+
"build_offset_mesh", "export_mfem_mesh", "export_mfem", "ExportResult",
|
|
41
|
+
"manifest",
|
|
41
42
|
]
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"""Telling gmsh how finely to resolve each interface.
|
|
2
2
|
|
|
3
|
-
One
|
|
4
|
-
|
|
3
|
+
One distance per interface, each wrapped in a Threshold that grows the
|
|
4
|
+
element size from `size` at the boundary to `far_size` over
|
|
5
5
|
`decay_width`, and a single Min over all of them so every point takes
|
|
6
|
-
the finest requirement that applies to it. A single
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
the finest requirement that applies to it. A single distance to every
|
|
7
|
+
boundary could only express one size for all of them, which is what a
|
|
8
|
+
layered domain does not want.
|
|
9
|
+
|
|
10
|
+
Every interface is a full circle or sphere when it is meshed, of radius
|
|
11
|
+
R about a centre c, so the distance to it is | |x - c| - R | and is
|
|
12
|
+
given to gmsh in closed form. gmsh's own Distance field is not used:
|
|
13
|
+
it measures the distance to a sampling of the entity by points, which
|
|
14
|
+
on the interface itself is zero at the points and about half their
|
|
15
|
+
spacing between them, and the Threshold turns that into an element size
|
|
16
|
+
that ripples along the interface and is on average coarser than the one
|
|
17
|
+
asked for, the more so the shorter the decay width.
|
|
9
18
|
"""
|
|
10
19
|
from __future__ import annotations
|
|
11
20
|
|
|
@@ -15,6 +24,7 @@ import gmsh
|
|
|
15
24
|
import numpy as np
|
|
16
25
|
from numpy.typing import ArrayLike
|
|
17
26
|
|
|
27
|
+
from ._orient import Centres
|
|
18
28
|
from ._tagging import Tagging
|
|
19
29
|
from .spec import InterfaceSizing
|
|
20
30
|
|
|
@@ -22,25 +32,29 @@ __all__ = ["apply_size_fields", "apply_mesh_options",
|
|
|
22
32
|
"check_sizing_resolves_spans", "check_sizing_scale"]
|
|
23
33
|
|
|
24
34
|
|
|
25
|
-
def apply_size_fields(tagging: Tagging, sizes: Mapping[int, InterfaceSizing]
|
|
35
|
+
def apply_size_fields(tagging: Tagging, sizes: Mapping[int, InterfaceSizing], *,
|
|
36
|
+
centres: Centres | None = None) -> int:
|
|
26
37
|
"""Build the background size field from per-interface sizings.
|
|
27
38
|
|
|
28
39
|
`sizes` maps interface index to InterfaceSizing, in the lengths the
|
|
29
|
-
geometry is drawn in.
|
|
30
|
-
|
|
40
|
+
geometry is drawn in. `centres` maps a surface's entity tag to its
|
|
41
|
+
centre where that is not the origin. Returns the tag of the field
|
|
42
|
+
set as the background.
|
|
31
43
|
"""
|
|
32
44
|
if not sizes:
|
|
33
45
|
raise ValueError("no sizing given: every interface needs one")
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
centres = centres or {}
|
|
36
48
|
thresholds: list[float] = []
|
|
37
49
|
|
|
38
|
-
for i, face in enumerate(tagging.faces):
|
|
50
|
+
for i, (face, radius) in enumerate(zip(tagging.faces, tagging.radii)):
|
|
39
51
|
sizing = sizes.get(i)
|
|
40
52
|
if sizing is None:
|
|
41
53
|
raise ValueError(f"no sizing for interface {i}")
|
|
42
|
-
dist = gmsh.model.mesh.field.add("
|
|
43
|
-
gmsh.model.mesh.field.
|
|
54
|
+
dist = gmsh.model.mesh.field.add("MathEval")
|
|
55
|
+
gmsh.model.mesh.field.setString(
|
|
56
|
+
dist, "F", _distance_to_sphere(centres.get(face, (0.0, 0.0, 0.0)),
|
|
57
|
+
radius))
|
|
44
58
|
|
|
45
59
|
thr = gmsh.model.mesh.field.add("Threshold")
|
|
46
60
|
gmsh.model.mesh.field.setNumber(thr, "InField", dist)
|
|
@@ -56,6 +70,17 @@ def apply_size_fields(tagging: Tagging, sizes: Mapping[int, InterfaceSizing]) ->
|
|
|
56
70
|
return combined
|
|
57
71
|
|
|
58
72
|
|
|
73
|
+
def _distance_to_sphere(centre: ArrayLike, radius: float) -> str:
|
|
74
|
+
"""The distance | |x - c| - R | as an expression gmsh evaluates.
|
|
75
|
+
|
|
76
|
+
Each number is written with the digits that round-trip and in
|
|
77
|
+
brackets, so a sign or an exponent is read as part of it.
|
|
78
|
+
"""
|
|
79
|
+
cx, cy, cz = (float(c) for c in np.asarray(centre, dtype=float))
|
|
80
|
+
return (f"Fabs(Sqrt((x-({cx!r}))^2+(y-({cy!r}))^2+(z-({cz!r}))^2)"
|
|
81
|
+
f"-({float(radius)!r}))")
|
|
82
|
+
|
|
83
|
+
|
|
59
84
|
def apply_mesh_options(*, order: int, algorithm_2d: int, algorithm_3d: int,
|
|
60
85
|
size_min: float, size_max: float) -> None:
|
|
61
86
|
"""Set the meshing options that go with a background size field.
|
|
@@ -111,7 +136,8 @@ def check_sizing_resolves_spans(boundaries: ArrayLike,
|
|
|
111
136
|
"the sizing is too coarse for this domain's thinnest layers, and "
|
|
112
137
|
"gmsh would fail with an unrelated-looking PLC error:\n - "
|
|
113
138
|
+ "\n - ".join(problems)
|
|
114
|
-
+ "\nRefine the sizing,
|
|
139
|
+
+ "\nRefine the sizing, cap it against thin layers "
|
|
140
|
+
"(CappedInterfaces), or coarsen the geometry so the thin "
|
|
115
141
|
"layers are merged.")
|
|
116
142
|
return problems
|
|
117
143
|
|
|
@@ -42,7 +42,7 @@ manifest's `mesh.read_options` says so in the form a C++ reader passes on.
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
44
|
import dataclasses
|
|
45
|
-
from collections.abc import Iterable
|
|
45
|
+
from collections.abc import Iterable, Sequence
|
|
46
46
|
from collections.abc import Mapping as MappingOf
|
|
47
47
|
from dataclasses import KW_ONLY, dataclass, field
|
|
48
48
|
from pathlib import Path
|
|
@@ -55,12 +55,13 @@ from numpy.typing import ArrayLike
|
|
|
55
55
|
from ..character import VECTOR, Character
|
|
56
56
|
from ..fields import stored_shape
|
|
57
57
|
from ..mapping import IdentityMapping, Mapping
|
|
58
|
+
from ..materials import is_fluid
|
|
58
59
|
from ..units import LENGTH
|
|
59
60
|
from . import manifest
|
|
60
61
|
from .spec import DELIVERIES, MeshResult
|
|
61
62
|
|
|
62
63
|
if TYPE_CHECKING:
|
|
63
|
-
from ..model import Model
|
|
64
|
+
from ..model import Layer, Model
|
|
64
65
|
|
|
65
66
|
__all__ = ["ExportResult", "export_mfem_mesh", "export_mfem",
|
|
66
67
|
"jacobian_report", "MESH_READ_OPTIONS"]
|
|
@@ -248,8 +249,8 @@ def _displacement_at(mapping: Mapping, X: ArrayLike, *, scale: float) -> np.ndar
|
|
|
248
249
|
|
|
249
250
|
|
|
250
251
|
def _field_entry(name: str, path: str | Path, fes: Any, *, character: Character,
|
|
251
|
-
dimensions: Any, si: bool, layers: Iterable[int]
|
|
252
|
-
) -> manifest.FieldEntry:
|
|
252
|
+
dimensions: Any, si: bool, layers: Iterable[int],
|
|
253
|
+
radial_degree: int | None = None) -> manifest.FieldEntry:
|
|
253
254
|
"""One `fields[]` record: where the GridFunction is and which space it
|
|
254
255
|
lives in, and what its values are."""
|
|
255
256
|
mfem = _mfem()
|
|
@@ -258,7 +259,7 @@ def _field_entry(name: str, path: str | Path, fes: Any, *, character: Character,
|
|
|
258
259
|
return manifest.FieldEntry.from_field(
|
|
259
260
|
name, path, fe_space=fes.FEColl().Name(), vdim=int(fes.GetVDim()),
|
|
260
261
|
ordering=ordering, character=character, dimensions=dimensions, si=si,
|
|
261
|
-
layers=layers)
|
|
262
|
+
layers=layers, radial_degree=radial_degree)
|
|
262
263
|
|
|
263
264
|
|
|
264
265
|
def export_mfem_mesh(result: MeshResult, path_base: str | Path, *,
|
|
@@ -439,6 +440,93 @@ def _field_values(model: Model, name: str, X: np.ndarray,
|
|
|
439
440
|
return values
|
|
440
441
|
|
|
441
442
|
|
|
443
|
+
def _fluidity(layer: Layer) -> bool | None:
|
|
444
|
+
"""The layer's fluidity, or None where its fields do not say."""
|
|
445
|
+
try:
|
|
446
|
+
return bool(is_fluid(layer))
|
|
447
|
+
except KeyError:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _interface_kind(below: int, above: int, in_model: Sequence[bool],
|
|
452
|
+
fluid: Sequence[bool | None]) -> str | None:
|
|
453
|
+
"""One of `manifest.KINDS` for an interface between the 0-based
|
|
454
|
+
layers `below` and `above` (-1 outside), or None where the model
|
|
455
|
+
does not say."""
|
|
456
|
+
b = below != -1 and in_model[below]
|
|
457
|
+
a = above != -1 and in_model[above]
|
|
458
|
+
if b and a:
|
|
459
|
+
fb, fa = fluid[below], fluid[above]
|
|
460
|
+
if fb is None or fa is None:
|
|
461
|
+
return None
|
|
462
|
+
if fb and fa:
|
|
463
|
+
return "fluid-fluid"
|
|
464
|
+
return "fluid-solid" if fb or fa else "solid-solid"
|
|
465
|
+
if b or a:
|
|
466
|
+
return "free"
|
|
467
|
+
return "outer" if above == -1 else None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _side_value(model: Model, layer: int, in_model: Sequence[bool], name: str,
|
|
471
|
+
radius: float) -> float | None:
|
|
472
|
+
"""`name` just inside the 0-based `layer` at `radius`, where it is
|
|
473
|
+
a real scalar radial value there; None otherwise."""
|
|
474
|
+
if layer == -1 or not in_model[layer]:
|
|
475
|
+
return None
|
|
476
|
+
fields = model.layer(layer)
|
|
477
|
+
if name not in fields:
|
|
478
|
+
return None
|
|
479
|
+
field = fields[name]
|
|
480
|
+
if field.character.rank != 0 or not getattr(field, "is_radial", False):
|
|
481
|
+
return None
|
|
482
|
+
value = np.asarray(field(radius))
|
|
483
|
+
if np.iscomplexobj(value):
|
|
484
|
+
return None
|
|
485
|
+
return float(value)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _radial_degree(model: Model, name: str) -> int | None:
|
|
489
|
+
"""The degree in r reproducing `name` within every layer holding
|
|
490
|
+
it, or None for a field of higher rank or one without a single
|
|
491
|
+
degree on some layer."""
|
|
492
|
+
if _character_of(model, name).rank != 0:
|
|
493
|
+
return None
|
|
494
|
+
degrees = []
|
|
495
|
+
for i in model.layers_with(name):
|
|
496
|
+
d = getattr(model.layer(i)[name], "radial_degree", None)
|
|
497
|
+
if d is None:
|
|
498
|
+
return None
|
|
499
|
+
degrees.append(int(d))
|
|
500
|
+
return max(degrees)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _annotate_from_model(card: manifest.MeshManifest, model: Model,
|
|
504
|
+
names: Iterable[str]) -> None:
|
|
505
|
+
"""Write what the model says onto the manifest's skeleton records.
|
|
506
|
+
|
|
507
|
+
`fluid` on every layer (False for a shell, None where the fields do
|
|
508
|
+
not say), `kind` on every interface from those flags, and `values`
|
|
509
|
+
on every interface: the one-sided values of each of `names` that is
|
|
510
|
+
a real scalar radial field, absent sides null.
|
|
511
|
+
"""
|
|
512
|
+
in_model = [bool(e["in_geometry"]) for e in card.layers]
|
|
513
|
+
fluid = [_fluidity(model.layer(e["attribute"] - 1)) if in_model[i] else False
|
|
514
|
+
for i, e in enumerate(card.layers)]
|
|
515
|
+
for entry, f in zip(card.layers, fluid):
|
|
516
|
+
entry["fluid"] = f
|
|
517
|
+
names = list(names)
|
|
518
|
+
for entry in card.interfaces:
|
|
519
|
+
below, above = (int(k) for k in entry["between_layers"])
|
|
520
|
+
entry["kind"] = _interface_kind(below, above, in_model, fluid)
|
|
521
|
+
values = {}
|
|
522
|
+
for name in names:
|
|
523
|
+
pair = [_side_value(model, k, in_model, name, entry["radius"])
|
|
524
|
+
for k in (below, above)]
|
|
525
|
+
if any(v is not None for v in pair):
|
|
526
|
+
values[name] = pair
|
|
527
|
+
entry["values"] = values or None
|
|
528
|
+
|
|
529
|
+
|
|
442
530
|
def export_mfem(result: MeshResult, path_base: str | Path, *, model: Model,
|
|
443
531
|
fields: Iterable[str] | None = None, delivery: str = "physical",
|
|
444
532
|
order: int | None = None) -> ExportResult:
|
|
@@ -461,9 +549,15 @@ def export_mfem(result: MeshResult, path_base: str | Path, *, model: Model,
|
|
|
461
549
|
sit on the geometry the mesh was built from: the same skeleton to
|
|
462
550
|
the model's geometry's `rtol`; a mesh not built from a geometry is
|
|
463
551
|
refused. The manifest gains one `fields[]` record per name, saying
|
|
464
|
-
the file, the space to read it into, the character, the unit
|
|
465
|
-
layers on which the values mean anything
|
|
466
|
-
|
|
552
|
+
the file, the space to read it into, the character, the unit, the
|
|
553
|
+
layers on which the values mean anything and, for a scalar field
|
|
554
|
+
polynomial in r, the degree that reproduces it (`radial_degree`);
|
|
555
|
+
and `scales` and `constants` blocks from the model. What the model
|
|
556
|
+
says about the skeleton is written onto its records: `fluid` on
|
|
557
|
+
every layer (the vanishing of shear in the exported description;
|
|
558
|
+
False for a shell, null where the fields do not say), `kind` on
|
|
559
|
+
every interface, and `values`, the one-sided values there of every
|
|
560
|
+
exported real scalar radial field.
|
|
467
561
|
"""
|
|
468
562
|
mfem = _mfem()
|
|
469
563
|
_check_model_sits_on(result, model)
|
|
@@ -499,7 +593,7 @@ def export_mfem(result: MeshResult, path_base: str | Path, *, model: Model,
|
|
|
499
593
|
entries.append(_field_entry(
|
|
500
594
|
name, path, fes, character=_character_of(model, name),
|
|
501
595
|
dimensions=None if spec is None else spec.dimensions, si=si,
|
|
502
|
-
layers=holders[name]))
|
|
596
|
+
layers=holders[name], radial_degree=_radial_degree(model, name)))
|
|
503
597
|
|
|
504
598
|
card = manifest.read(export.manifest_path)
|
|
505
599
|
for entry in card.fields:
|
|
@@ -508,6 +602,7 @@ def export_mfem(result: MeshResult, path_base: str | Path, *, model: Model,
|
|
|
508
602
|
card.fields.extend(dataclasses.asdict(e) for e in entries)
|
|
509
603
|
card.scales = manifest.scales_block(model.scales)
|
|
510
604
|
card.constants = manifest.constants_block(model)
|
|
605
|
+
_annotate_from_model(card, model, written)
|
|
511
606
|
manifest.validate_structure(card)
|
|
512
607
|
manifest_path = manifest.write(path_base, card)
|
|
513
608
|
return dataclasses.replace(export, manifest_path=manifest_path,
|
|
@@ -7,10 +7,10 @@ reference one, concentric spheres with the geometry's names as
|
|
|
7
7
|
attributes: gmsh meshes the skeleton and nothing else, and the
|
|
8
8
|
geometry's mapping is applied later, at the MFEM export, where a
|
|
9
9
|
displacement is a vector in the mesh's own nodal space. Then CAD,
|
|
10
|
-
tagging, sizing, meshing at order 1,
|
|
11
|
-
and only then anything is written.
|
|
12
|
-
exists anyway looks finished, so
|
|
13
|
-
validation.
|
|
10
|
+
tagging, sizing, meshing at order 1, optimisation of the linear mesh,
|
|
11
|
+
orientation, curving, validation, and only then anything is written.
|
|
12
|
+
A mesh that fails its checks and exists anyway looks finished, so
|
|
13
|
+
nothing reaches disk before validation.
|
|
14
14
|
|
|
15
15
|
With shells and a non-identity mapping the mapping must be defined and
|
|
16
16
|
orientation-preserving out to the outer boundary of the computational
|
|
@@ -145,6 +145,12 @@ def build_layered_mesh(spec: MeshSpec, path: str | Path, *, verbose: bool = Fals
|
|
|
145
145
|
gmsh.model.mesh.generate(d)
|
|
146
146
|
timings["mesh"] = clock() - t0
|
|
147
147
|
|
|
148
|
+
if d == 3 and spec.optimise:
|
|
149
|
+
t0 = clock()
|
|
150
|
+
for method in spec.optimise:
|
|
151
|
+
gmsh.model.mesh.optimize(method)
|
|
152
|
+
timings["optimise"] = clock() - t0
|
|
153
|
+
|
|
148
154
|
t0 = clock()
|
|
149
155
|
orient_mesh(d)
|
|
150
156
|
raise_order(d, spec.order)
|