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.
Files changed (48) hide show
  1. {planetmodel-1.2.0 → planetmodel-1.2.3}/PKG-INFO +1 -1
  2. {planetmodel-1.2.0 → planetmodel-1.2.3}/pyproject.toml +1 -1
  3. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/__init__.py +3 -2
  4. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/behaviours.py +101 -6
  5. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/catalogue.py +24 -12
  6. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/fields.py +17 -0
  7. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/__init__.py +7 -6
  8. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_sizing.py +39 -13
  9. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/export.py +104 -9
  10. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/layered.py +10 -4
  11. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/manifest.py +68 -11
  12. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/offset.py +18 -7
  13. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/spec.py +99 -4
  14. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/model.py +186 -5
  15. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/vocabulary.py +4 -0
  16. {planetmodel-1.2.0 → planetmodel-1.2.3}/LICENSE +0 -0
  17. {planetmodel-1.2.0 → planetmodel-1.2.3}/README.md +0 -0
  18. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/character.py +0 -0
  19. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/deck.py +0 -0
  20. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/displacement.py +0 -0
  21. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/frames.py +0 -0
  22. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/geometry.py +0 -0
  23. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/harmonics.py +0 -0
  24. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/layerfunction.py +0 -0
  25. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mapping.py +0 -0
  26. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/materials.py +0 -0
  27. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/__init__.py +0 -0
  28. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/gll.py +0 -0
  29. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/gravity.py +0 -0
  30. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh1d/mesh.py +0 -0
  31. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_geometry.py +0 -0
  32. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_orient.py +0 -0
  33. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_session.py +0 -0
  34. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_tagging.py +0 -0
  35. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_validate.py +0 -0
  36. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/mesh3d/_writer.py +0 -0
  37. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/plotting.py +0 -0
  38. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/pushforward.py +0 -0
  39. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/__init__.py +0 -0
  40. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/basis.py +0 -0
  41. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/fields.py +0 -0
  42. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/mesh.py +0 -0
  43. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/randomfield/operator.py +0 -0
  44. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/rheology.py +0 -0
  45. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/sampling.py +0 -0
  46. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/skeleton.py +0 -0
  47. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/testing.py +0 -0
  48. {planetmodel-1.2.0 → planetmodel-1.2.3}/src/planetmodel/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planetmodel
3
- Version: 1.2.0
3
+ Version: 1.2.3
4
4
  Summary: Spherically layered planetary models: reference bodies, fields, mappings and meshes
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "planetmodel"
3
- version = "1.2.0"
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.0"
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
- 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
@@ -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, InterfaceSizing, MeshResult, MeshSpec,
33
- PerInterface, Shell, SizingRule, UniformInterfaces,
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", "PerInterface",
39
- "ValidationReport", "build_layered_mesh", "build_offset_mesh",
40
- "export_mfem_mesh", "export_mfem", "ExportResult", "manifest",
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 Distance field per interface, each wrapped in a Threshold that
4
- grows the element size from `size` at the boundary to `far_size` over
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 Distance field
7
- over every boundary could only express one size for all of them, which
8
- is what a layered domain does not want.
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]) -> int:
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. Returns the tag of the field set as the
30
- background.
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
- entity_key = "SurfacesList" if tagging.dimension == 3 else "CurvesList"
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("Distance")
43
- gmsh.model.mesh.field.setNumbers(dist, entity_key, [float(face)])
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, or coarsen the geometry so the thin "
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 and the
465
- layers on which the values mean anything, and `scales` and
466
- `constants` blocks from the model.
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, orientation, curving, validation,
11
- and only then anything is written. A mesh that fails its checks and
12
- exists anyway looks finished, so nothing reaches disk before
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)