planetmodel 1.3.0__tar.gz → 1.3.2__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 (84) hide show
  1. {planetmodel-1.3.0 → planetmodel-1.3.2}/PKG-INFO +1 -1
  2. {planetmodel-1.3.0 → planetmodel-1.3.2}/pyproject.toml +1 -1
  3. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/__init__.py +3 -1
  4. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/modelfile.py +76 -49
  5. planetmodel-1.3.2/src/planetmodel/surfacefile.py +631 -0
  6. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/testing.py +110 -3
  7. {planetmodel-1.3.0 → planetmodel-1.3.2}/LICENSE +0 -0
  8. {planetmodel-1.3.0 → planetmodel-1.3.2}/README.md +0 -0
  9. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/behaviours.py +0 -0
  10. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/catalogue.py +0 -0
  11. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/character.py +0 -0
  12. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/__init__.py +0 -0
  13. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/building.py +0 -0
  14. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/laws.py +0 -0
  15. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/lithosphere.py +0 -0
  16. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/parameters.py +0 -0
  17. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/profiles.py +0 -0
  18. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/prony.py +0 -0
  19. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/conversion/tables.py +0 -0
  20. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/__init__.py +0 -0
  21. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/building.py +0 -0
  22. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/coefficients.py +0 -0
  23. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/decks.py +0 -0
  24. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/entries.py +0 -0
  25. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/fetch.py +0 -0
  26. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/grids.py +0 -0
  27. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/models.py +0 -0
  28. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/processing.py +0 -0
  29. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/projections.py +0 -0
  30. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/registry.py +0 -0
  31. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/sph.py +0 -0
  32. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/strata.py +0 -0
  33. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/datasets/surfaces.py +0 -0
  34. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/deck.py +0 -0
  35. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/displacement.py +0 -0
  36. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/expansions.py +0 -0
  37. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/fields.py +0 -0
  38. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/frames.py +0 -0
  39. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/geometry.py +0 -0
  40. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/gridded.py +0 -0
  41. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/harmonics.py +0 -0
  42. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/lateral.py +0 -0
  43. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/layerfunction.py +0 -0
  44. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mapping.py +0 -0
  45. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/materials.py +0 -0
  46. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/means.py +0 -0
  47. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh1d/__init__.py +0 -0
  48. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh1d/gll.py +0 -0
  49. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh1d/gravity.py +0 -0
  50. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh1d/mesh.py +0 -0
  51. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/__init__.py +0 -0
  52. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_geometry.py +0 -0
  53. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_orient.py +0 -0
  54. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_session.py +0 -0
  55. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_sizing.py +0 -0
  56. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_tagging.py +0 -0
  57. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_validate.py +0 -0
  58. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/_writer.py +0 -0
  59. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/export.py +0 -0
  60. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/layered.py +0 -0
  61. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/manifest.py +0 -0
  62. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/offset.py +0 -0
  63. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/mesh3d/spec.py +0 -0
  64. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/model.py +0 -0
  65. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/perturbation.py +0 -0
  66. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/__init__.py +0 -0
  67. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/maps.py +0 -0
  68. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/profiles.py +0 -0
  69. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/sections.py +0 -0
  70. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/spectra.py +0 -0
  71. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/style.py +0 -0
  72. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/plotting/vtk.py +0 -0
  73. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/pushforward.py +0 -0
  74. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/randomfield/__init__.py +0 -0
  75. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/randomfield/basis.py +0 -0
  76. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/randomfield/fields.py +0 -0
  77. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/randomfield/mesh.py +0 -0
  78. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/randomfield/operator.py +0 -0
  79. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/rheology.py +0 -0
  80. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/sampling.py +0 -0
  81. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/skeleton.py +0 -0
  82. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/slices.py +0 -0
  83. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/units.py +0 -0
  84. {planetmodel-1.3.0 → planetmodel-1.3.2}/src/planetmodel/vocabulary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planetmodel
3
- Version: 1.3.0
3
+ Version: 1.3.2
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.3.0"
3
+ version = "1.3.2"
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 = [
@@ -22,6 +22,8 @@ Modules reached by name, one level down:
22
22
  sampling a model on a radial mesh times an angular grid
23
23
  lateral values between the nodes of an angular grid: the lateral rules
24
24
  modelfile the model file: a model in one netCDF file (netCDF4, optional)
25
+ surfacefile the surface file: fields on one spherical surface, static or
26
+ in time (netCDF4, optional)
25
27
  harmonics real spherical harmonics, through ducc0
26
28
  slices a field on a sphere, a section or a profile; spectra and
27
29
  correlations by degree
@@ -127,7 +129,7 @@ from .units import EARTH_MEAN_DENSITY, G_SI, Dimensions, Scales
127
129
  from .vocabulary import CONSTANTS, VOCABULARY, Constant, FieldSpec
128
130
  from . import testing # noqa: F401
129
131
 
130
- __version__ = "1.3.0"
132
+ __version__ = "1.3.2"
131
133
 
132
134
  __all__ = [
133
135
  "Skeleton",
@@ -8,7 +8,9 @@ definition of the format and the reference the C++ readers are held to;
8
8
  `docs/source/formats/model_file.rst` describes it for consumers.
9
9
 
10
10
  **Global attributes**: `schema` (`SCHEMA`, checked by every reader),
11
- `Conventions`, the model's `name`, `scale_length`, `scale_mass`,
11
+ `Conventions`, `source` (the writing code and version) and `created`
12
+ (when, ISO 8601) — both stamped by the writers, and empty on reading a
13
+ file from before they were — the model's `name`, `scale_length`, `scale_mass`,
12
14
  `scale_time` and `scale_temperature` (what one stored unit is in SI),
13
15
  `rtol` (the geometry's tolerance, the default where a file has none),
14
16
  `constant_<name>` for every constant in the model's units, `citation`
@@ -130,7 +132,9 @@ __all__ = ["SCHEMA", "CONVENTIONS", "DEFAULT_INTERPOLATION", "Grids",
130
132
  "Interpolation", "Surfaces", "FileVariable",
131
133
  "FilePart", "FileGeometry", "FileSurfaces", "ModelFile", "write_model",
132
134
  "read_model_file", "file_layers", "file_geometry", "file_surfaces",
133
- "read_model", "read_surfaces", "validate_structure"]
135
+ "read_model", "read_surfaces", "validate_structure",
136
+ "netcdf_module", "put_variable", "variable_attributes",
137
+ "variable_values", "grid_degrees", "grid_of_group"]
134
138
 
135
139
  #: The schema string every reader checks; bumped only for an incompatible change.
136
140
  SCHEMA = "planetmodel.model/1"
@@ -171,7 +175,7 @@ type Grids = AngularGrid | Mapping[int | str, AngularGrid]
171
175
  type Surfaces = Mapping[str, Mapping[int | str, Relief]]
172
176
 
173
177
 
174
- def _netcdf() -> ModuleType:
178
+ def netcdf_module() -> ModuleType:
175
179
  """netCDF4, imported here and nowhere else in planetmodel."""
176
180
  try:
177
181
  import netCDF4
@@ -254,6 +258,12 @@ class ModelFile:
254
258
  surfaces: FileSurfaces | None = None
255
259
  #: The works the model cites, one a line of the attribute `citation`.
256
260
  citation: tuple[str, ...] = ()
261
+ #: The code that wrote the file (the attribute `source`); empty in
262
+ #: files written before the writers stamped it.
263
+ source: str = ""
264
+ #: When the file was written, ISO 8601 (the attribute `created`);
265
+ #: empty in files written before the writers stamped it.
266
+ created: str = ""
257
267
 
258
268
  def part_of(self, layer: int) -> FilePart:
259
269
  """The part holding `layer`."""
@@ -463,7 +473,7 @@ def _radial_layers(attributes: Mapping[str, Any]) -> set[int]:
463
473
  return {int(i) for i in np.atleast_1d(attributes.get("radial_layers", []))}
464
474
 
465
475
 
466
- def _put(group: Any, name: str, dims: tuple[str, ...], values: np.ndarray,
476
+ def put_variable(group: Any, name: str, dims: tuple[str, ...], values: np.ndarray,
467
477
  attributes: Mapping[str, Any]) -> None:
468
478
  """A float64 variable with NaN as its fill, and its attributes."""
469
479
  var = group.createVariable(name, "f8", dims, fill_value=np.nan, **_ZLIB)
@@ -484,7 +494,7 @@ def _axes(group: Any, character: Character) -> tuple[str, ...]:
484
494
  return _AXES[shape]
485
495
 
486
496
 
487
- def _degrees(grid: AngularGrid) -> tuple[np.ndarray, np.ndarray]:
497
+ def grid_degrees(grid: AngularGrid) -> tuple[np.ndarray, np.ndarray]:
488
498
  """The grid's latitudes and longitudes in degrees, each on its own axis."""
489
499
  lat, _ = geographic(grid.colatitudes, np.zeros_like(grid.colatitudes))
490
500
  _, lon = geographic(np.full_like(grid.longitudes, 0.5 * np.pi), grid.longitudes)
@@ -508,16 +518,16 @@ def _write_part(group: Any, model: Model, index: int, layers: list[int],
508
518
  length = scaled_unit(Dimensions(length=1), model.scales)
509
519
  r = mesh.r.ravel()
510
520
  node_layer = np.repeat(mesh.layer, mesh.ngll)
511
- _put(group, "radius", ("node",), r,
521
+ put_variable(group, "radius", ("node",), r,
512
522
  {"long_name": "radius of the node; an element boundary is two nodes",
513
523
  "units": length})
514
524
  var = group.createVariable("element_layer", "i4", ("element",))
515
525
  var[...] = mesh.layer
516
526
  var.long_name = "skeleton layer of the element"
517
- _put(group, "element_bounds", ("element", "bound"),
527
+ put_variable(group, "element_bounds", ("element", "bound"),
518
528
  np.stack([mesh.left, mesh.right], axis=-1),
519
529
  {"long_name": "radii of the element's ends", "units": length})
520
- lat, lon = _degrees(grid)
530
+ lat, lon = grid_degrees(grid)
521
531
  var = group.createVariable("latitude", "f8", ("latitude",))
522
532
  var[...] = lat
523
533
  var.units, var.standard_name = "degrees_north", "latitude"
@@ -527,7 +537,7 @@ def _write_part(group: Any, model: Model, index: int, layers: list[int],
527
537
  var.units, var.standard_name = "degrees_east", "longitude"
528
538
  var.long_name = "longitude"
529
539
  if grid.weights is not None:
530
- _put(group, "colatitude_weights", ("latitude",), grid.weights,
540
+ put_variable(group, "colatitude_weights", ("latitude",), grid.weights,
531
541
  {"long_name": "colatitude quadrature weights against sin(theta) dtheta",
532
542
  "units": "1"})
533
543
  lacks_pole = not all(grid.pole_nodes)
@@ -568,9 +578,9 @@ def _write_part(group: Any, model: Model, index: int, layers: list[int],
568
578
  attrs["coordinates"] = "radius"
569
579
  if not radial:
570
580
  _mark_radial_layers(attrs, fields)
571
- _put(group, name, dims + axes, values, attrs)
581
+ put_variable(group, name, dims + axes, values, attrs)
572
582
  if poles is not None:
573
- _put(group, f"{name}_poles", ("node", "pole") + axes, poles,
583
+ put_variable(group, f"{name}_poles", ("node", "pole") + axes, poles,
574
584
  {"long_name": f"{name} at the north and south poles, Cartesian "
575
585
  "components", "units": attrs["units"]})
576
586
  if form == "field":
@@ -588,12 +598,12 @@ def _write_part(group: Any, model: Model, index: int, layers: list[int],
588
598
  for b in BASE], dtype=np.int32),
589
599
  "radial_interpolation": rules[0],
590
600
  "lateral_interpolation": rules[1], "coordinates": "radius"}
591
- _put(group, "displacement", ("node", "latitude", "longitude") + axes,
601
+ put_variable(group, "displacement", ("node", "latitude", "longitude") + axes,
592
602
  values, attrs)
593
603
  if lacks_pole:
594
604
  from .frames import cartesian_points
595
605
  P = cartesian_points(r[:, None], np.array([0.0, np.pi]), np.zeros(2))
596
- _put(group, "displacement_poles", ("node", "pole") + axes,
606
+ put_variable(group, "displacement_poles", ("node", "pole") + axes,
597
607
  np.asarray(mapping(P), dtype=float) - P,
598
608
  {"long_name": "displacement at the north and south poles, "
599
609
  "Cartesian components", "units": length})
@@ -636,7 +646,7 @@ def _write_perturbed(group: Any, model: Model, name: str,
636
646
  attrs = _attributes(name, model, char, holders, ref_rules, lateral=False)
637
647
  attrs.update({"perturbation": f"{name}_perturbation", "perturbation_kind": kind,
638
648
  "coordinates": "radius"})
639
- _put(group, name, ("node",), ref, attrs)
649
+ put_variable(group, name, ("node",), ref, attrs)
640
650
  perturbed = [i for i, f in fields.items()
641
651
  if getattr(f, "reference", None) is not None]
642
652
  pattrs = _attributes(name, model, pert_char, perturbed, rules,
@@ -650,9 +660,9 @@ def _write_perturbed(group: Any, model: Model, name: str,
650
660
  _mark_radial_layers(pattrs, {i: f.perturbation for i, f in fields.items()
651
661
  if getattr(f, "reference", None) is not None})
652
662
  dims = ("node",) if pert_radial else ("node", "latitude", "longitude")
653
- _put(group, f"{name}_perturbation", dims, dp, pattrs)
663
+ put_variable(group, f"{name}_perturbation", dims, dp, pattrs)
654
664
  if poles is not None:
655
- _put(group, f"{name}_perturbation_poles", ("node", "pole"), poles,
665
+ put_variable(group, f"{name}_perturbation_poles", ("node", "pole"), poles,
656
666
  {"long_name": f"{kind} perturbation of {name} at the poles",
657
667
  "units": pattrs["units"]})
658
668
 
@@ -685,10 +695,10 @@ def _write_stack(root: Any, model: Model, parts: list[tuple[list[int], AngularGr
685
695
  if grid.lmax is not None:
686
696
  g.setncattr("grid_lmax", np.int32(grid.lmax))
687
697
  length = scaled_unit(Dimensions(length=1), model.scales)
688
- _put(g, "boundary_radius", ("boundary",), radii,
698
+ put_variable(g, "boundary_radius", ("boundary",), radii,
689
699
  {"long_name": "radius of the surface the displacement belongs to",
690
700
  "units": length})
691
- lat, lon = _degrees(grid)
701
+ lat, lon = grid_degrees(grid)
692
702
  var = g.createVariable("latitude", "f8", ("latitude",))
693
703
  var[...] = lat
694
704
  var.units, var.standard_name = "degrees_north", "latitude"
@@ -696,7 +706,8 @@ def _write_stack(root: Any, model: Model, parts: list[tuple[list[int], AngularGr
696
706
  var[...] = lon
697
707
  var.units, var.standard_name = "degrees_east", "longitude"
698
708
  if grid.weights is not None:
699
- _put(g, "colatitude_weights", ("latitude",), grid.weights, {"units": "1"})
709
+ put_variable(g, "colatitude_weights", ("latitude",), grid.weights,
710
+ {"units": "1"})
700
711
  values = np.zeros((radii.size, grid.ntheta, grid.nphi))
701
712
  poles = np.zeros((radii.size, 2))
702
713
  for k, rel in enumerate(reliefs):
@@ -706,12 +717,13 @@ def _write_stack(root: Any, model: Model, parts: list[tuple[list[int], AngularGr
706
717
  np.asarray(rel(grid.colatitudes[:, None], grid.longitudes[None, :]),
707
718
  dtype=float), (grid.ntheta, grid.nphi))
708
719
  poles[k] = np.asarray(rel(np.array([0.0, np.pi]), np.zeros(2)), dtype=float)
709
- _put(g, "boundary_displacement", ("boundary", "latitude", "longitude"), values,
710
- {"long_name": "radial displacement of the surface", "units": length,
711
- "lateral_interpolation": lateral, "between": "linear"})
720
+ put_variable(g, "boundary_displacement",
721
+ ("boundary", "latitude", "longitude"), values,
722
+ {"long_name": "radial displacement of the surface", "units": length,
723
+ "lateral_interpolation": lateral, "between": "linear"})
712
724
  if not all(grid.pole_nodes):
713
725
  g.createDimension("pole", 2)
714
- _put(g, "boundary_displacement_poles", ("boundary", "pole"), poles,
726
+ put_variable(g, "boundary_displacement_poles", ("boundary", "pole"), poles,
715
727
  {"long_name": "radial displacement at the north and south poles",
716
728
  "units": length})
717
729
 
@@ -788,7 +800,7 @@ def _write_surfaces(root: Any, model: Model, held: Mapping[str, Mapping[int, Rel
788
800
  var = g.createVariable("surface_interface", "i4", ("surface",))
789
801
  var[...] = np.asarray(faces, dtype=np.int32)
790
802
  var.long_name = "the interface the surface is, by its index at the root"
791
- lat, lon = _degrees(grid)
803
+ lat, lon = grid_degrees(grid)
792
804
  var = g.createVariable("latitude", "f8", ("latitude",))
793
805
  var[...] = lat
794
806
  var.units, var.standard_name = "degrees_north", "latitude"
@@ -798,7 +810,7 @@ def _write_surfaces(root: Any, model: Model, held: Mapping[str, Mapping[int, Rel
798
810
  var.units, var.standard_name = "degrees_east", "longitude"
799
811
  var.long_name = "longitude"
800
812
  if grid.weights is not None:
801
- _put(g, "colatitude_weights", ("latitude",), grid.weights,
813
+ put_variable(g, "colatitude_weights", ("latitude",), grid.weights,
802
814
  {"long_name": "colatitude quadrature weights against sin(theta) dtheta",
803
815
  "units": "1"})
804
816
  lacks_pole = not all(grid.pole_nodes)
@@ -828,9 +840,9 @@ def _write_surfaces(root: Any, model: Model, held: Mapping[str, Mapping[int, Rel
828
840
  if dims is not None:
829
841
  attrs["dimensions"] = np.asarray([getattr(dims, b) for b in BASE],
830
842
  dtype=np.int32)
831
- _put(g, name, ("surface", "latitude", "longitude"), values, attrs)
843
+ put_variable(g, name, ("surface", "latitude", "longitude"), values, attrs)
832
844
  if lacks_pole:
833
- _put(g, f"{name}_poles", ("surface", "pole"), poles,
845
+ put_variable(g, f"{name}_poles", ("surface", "pole"), poles,
834
846
  {"long_name": f"{name} at the north and south poles",
835
847
  "units": attrs["units"]})
836
848
 
@@ -841,7 +853,8 @@ def write_model(path: str | os.PathLike[str], model: Model, *, grid: Grids,
841
853
  interpolation: Interpolation | None = None,
842
854
  meta: Mapping[str, Any] | None = None,
843
855
  surfaces: Surfaces | None = None,
844
- surface_grid: AngularGrid | None = None) -> Path:
856
+ surface_grid: AngularGrid | None = None,
857
+ source: str | None = None) -> Path:
845
858
  """Write `model` to a model file at `path`, which is returned.
846
859
 
847
860
  `grid` is one `AngularGrid` for every layer, or a mapping from layers
@@ -858,13 +871,15 @@ def write_model(path: str | os.PathLike[str], model: Model, *, grid: Grids,
858
871
  `surfaces` on `surface_grid`: by default the grid the functions are
859
872
  held on where they share one, else the outermost part's. A surface
860
873
  field's dimensions and meaning are the model's spec of its name, and
861
- `interpolation` names its lateral rule as for a field. The model is
874
+ `interpolation` names its lateral rule as for a field. `source`
875
+ names the writing code and version in the attribute of that name,
876
+ this library and its version unless given. The model is
862
877
  written in its own units.
863
878
  """
864
879
  from .model import Model
865
880
  if not isinstance(model, Model):
866
881
  raise TypeError(f"expected a Model, got {type(model).__name__}")
867
- nc = _netcdf()
882
+ nc = netcdf_module()
868
883
  interpolation = dict(interpolation or {})
869
884
  names = model.field_names() if fields is None else tuple(fields)
870
885
  if isinstance(names, str):
@@ -901,7 +916,7 @@ def write_model(path: str | os.PathLike[str], model: Model, *, grid: Grids,
901
916
  os.close(fd)
902
917
  try:
903
918
  _write_file(nc, Path(partial), model, parts, form, names, radial,
904
- interpolation, meta, held, on)
919
+ interpolation, meta, held, on, source)
905
920
  os.replace(partial, path)
906
921
  finally:
907
922
  if os.path.exists(partial):
@@ -914,12 +929,19 @@ def _write_file(nc: ModuleType, path: Path, model: Model,
914
929
  names: Sequence[str], radial: RadialMesh | None,
915
930
  interpolation: Interpolation, meta: Mapping[str, Any],
916
931
  held: Mapping[str, Mapping[int, Relief]],
917
- surface_grid: AngularGrid | None) -> None:
932
+ surface_grid: AngularGrid | None,
933
+ source: str | None) -> None:
918
934
  """The file of `write_model`, at a path of its own until it is whole."""
935
+ from datetime import datetime, timezone
936
+
937
+ from . import __version__
919
938
  with nc.Dataset(path, "w", format="NETCDF4") as root:
920
939
  root.setncattr("schema", SCHEMA)
921
940
  root.setncattr("Conventions", CONVENTIONS)
922
941
  root.setncattr("title", f"planetmodel model {model.name or ''}".strip())
942
+ root.setncattr("source", source or f"planetmodel {__version__}")
943
+ root.setncattr("created",
944
+ datetime.now(timezone.utc).isoformat(timespec="seconds"))
923
945
  if model.name:
924
946
  root.setncattr("name", model.name)
925
947
  for base in ("length", "mass", "time", "temperature"):
@@ -941,14 +963,15 @@ def _write_file(nc: ModuleType, path: Path, model: Model,
941
963
  root.createDimension("bound", 2)
942
964
  b = model.skeleton.boundaries
943
965
  length = scaled_unit(Dimensions(length=1), model.scales)
944
- _put(root, "layer_bounds", ("layer", "bound"), np.stack([b[:-1], b[1:]], -1),
945
- {"long_name": "radii of the layer's inner and outer boundaries",
946
- "units": length})
966
+ put_variable(root, "layer_bounds", ("layer", "bound"),
967
+ np.stack([b[:-1], b[1:]], -1),
968
+ {"long_name": "radii of the layer's inner and outer boundaries",
969
+ "units": length})
947
970
  var = root.createVariable("layer_name", str, ("layer",))
948
971
  for i, layer in enumerate(model.geometry.layers):
949
972
  var[i] = layer.name or ""
950
973
  faces = model.geometry.interfaces
951
- _put(root, "interface_radius", ("interface",),
974
+ put_variable(root, "interface_radius", ("interface",),
952
975
  np.array([f.radius for f in faces], dtype=float),
953
976
  {"long_name": "radius of the interface", "units": length})
954
977
  var = root.createVariable("interface_name", str, ("interface",))
@@ -973,7 +996,7 @@ def _write_file(nc: ModuleType, path: Path, model: Model,
973
996
 
974
997
  # -- reading --------------------------------------------------------------------
975
998
 
976
- def _attrs(var: Any) -> dict[str, Any]:
999
+ def variable_attributes(var: Any) -> dict[str, Any]:
977
1000
  """A netCDF variable's attributes, numpy scalars made plain."""
978
1001
  out = {}
979
1002
  for key in var.ncattrs():
@@ -986,14 +1009,14 @@ def _attrs(var: Any) -> dict[str, Any]:
986
1009
  return out
987
1010
 
988
1011
 
989
- def _values(var: Any) -> np.ndarray:
1012
+ def variable_values(var: Any) -> np.ndarray:
990
1013
  """A variable's numbers as float64, NaN where it holds its fill."""
991
1014
  v = var[:]
992
1015
  return (v.filled(np.nan) if np.ma.isMaskedArray(v)
993
1016
  else np.asarray(v, dtype=float)).astype(float)
994
1017
 
995
1018
 
996
- def _grid_of(group: Any) -> AngularGrid:
1019
+ def grid_of_group(group: Any) -> AngularGrid:
997
1020
  """The angular grid a group's coordinates and attributes hold."""
998
1021
  from .sampling import AngularGrid
999
1022
  lat = np.asarray(group.variables["latitude"][:], dtype=float)
@@ -1017,7 +1040,7 @@ def validate_structure(source: str | os.PathLike[str] | Any) -> None:
1017
1040
  structure the module docstring defines, raising ValueError naming
1018
1041
  the first thing wrong."""
1019
1042
  if isinstance(source, (str, os.PathLike)):
1020
- nc = _netcdf()
1043
+ nc = netcdf_module()
1021
1044
  with nc.Dataset(source, "r") as ds:
1022
1045
  _validate(ds, source)
1023
1046
  else:
@@ -1134,7 +1157,7 @@ def _validate_part(g: Any, where: str, layers: np.ndarray, bounds: np.ndarray,
1134
1157
  want: tuple[int, ...] = (3,)
1135
1158
  else:
1136
1159
  try:
1137
- want = stored_shape(_character(_attrs(var)))
1160
+ want = stored_shape(_character(variable_attributes(var)))
1138
1161
  except (ValueError, TypeError) as error:
1139
1162
  _fail(path, f"{where}/{name} states no character: {error}")
1140
1163
  tail = var.shape[3 if lateral else 1:]
@@ -1145,7 +1168,7 @@ def _validate_part(g: Any, where: str, layers: np.ndarray, bounds: np.ndarray,
1145
1168
  "radial_interpolation") not in RADIAL_METHODS:
1146
1169
  _fail(path, f"{where}/{name} states an unknown radial rule")
1147
1170
  if "radial_layers" in a:
1148
- listed = _radial_layers(_attrs(var))
1171
+ listed = _radial_layers(variable_attributes(var))
1149
1172
  held = (set(int(i) for i in np.atleast_1d(var.getncattr("layers")))
1150
1173
  if "layers" in a else set(int(i) for i in layers))
1151
1174
  if not lateral or not listed <= held:
@@ -1235,7 +1258,7 @@ def _validate_surfaces(g: Any, path: object, ninterface: int, taken: set[str]) -
1235
1258
 
1236
1259
  def read_model_file(path: str | os.PathLike[str]) -> ModelFile:
1237
1260
  """A model file's numbers and metadata, its structure validated first."""
1238
- nc = _netcdf()
1261
+ nc = netcdf_module()
1239
1262
  with nc.Dataset(path, "r") as ds:
1240
1263
  _validate(ds, path)
1241
1264
  attrs = {k: ds.getncattr(k) for k in ds.ncattrs()}
@@ -1274,11 +1297,11 @@ def read_model_file(path: str | os.PathLike[str]) -> ModelFile:
1274
1297
  if not np.allclose(stored, mesh.r.ravel(), rtol=1e-12, atol=0):
1275
1298
  _fail(path, f"part_{k}: radius is not the GLL nodes of its elements")
1276
1299
  variables = {
1277
- name: FileVariable(_values(var), _attrs(var))
1300
+ name: FileVariable(variable_values(var), variable_attributes(var))
1278
1301
  for name, var in g.variables.items()
1279
1302
  if name not in ("radius", "element_layer", "element_bounds",
1280
1303
  "latitude", "longitude", "colatitude_weights")}
1281
- parts.append(FilePart(layers, mesh, _grid_of(g),
1304
+ parts.append(FilePart(layers, mesh, grid_of_group(g),
1282
1305
  MappingProxyType(variables)))
1283
1306
  geometry = None
1284
1307
  if "geometry" in ds.groups:
@@ -1289,7 +1312,7 @@ def read_model_file(path: str | os.PathLike[str]) -> ModelFile:
1289
1312
  if "boundary_displacement_poles" in g.variables else None)
1290
1313
  geometry = FileGeometry(
1291
1314
  np.asarray(g.variables["boundary_radius"][:], dtype=float),
1292
- _grid_of(g), np.asarray(var[:], dtype=float), poles,
1315
+ grid_of_group(g), np.asarray(var[:], dtype=float), poles,
1293
1316
  str(var.getncattr("lateral_interpolation")),
1294
1317
  str(var.getncattr("between")))
1295
1318
  surfaces = None
@@ -1297,10 +1320,12 @@ def read_model_file(path: str | os.PathLike[str]) -> ModelFile:
1297
1320
  g = ds.groups["surfaces"]
1298
1321
  faces = tuple(int(k) for k in
1299
1322
  np.asarray(g.variables["surface_interface"][:]))
1300
- variables = {name: FileVariable(_values(var), _attrs(var))
1323
+ variables = {name: FileVariable(variable_values(var),
1324
+ variable_attributes(var))
1301
1325
  for name, var in g.variables.items()
1302
1326
  if name not in _SURFACE_OWN}
1303
- surfaces = FileSurfaces(faces, _grid_of(g), MappingProxyType(variables))
1327
+ surfaces = FileSurfaces(faces, grid_of_group(g),
1328
+ MappingProxyType(variables))
1304
1329
  return ModelFile(schema=str(attrs["schema"]), name=attrs.get("name"),
1305
1330
  scales=scales, constants=MappingProxyType(constants),
1306
1331
  meta=MappingProxyType(meta), skeleton=skeleton,
@@ -1308,7 +1333,9 @@ def read_model_file(path: str | os.PathLike[str]) -> ModelFile:
1308
1333
  parts=tuple(parts), geometry=geometry,
1309
1334
  declared=MappingProxyType(declared),
1310
1335
  rtol=float(attrs.get("rtol", DEFAULT_RTOL)), surfaces=surfaces,
1311
- citation=tuple(str(attrs.get("citation", "")).splitlines()))
1336
+ citation=tuple(str(attrs.get("citation", "")).splitlines()),
1337
+ source=str(attrs.get("source", "")),
1338
+ created=str(attrs.get("created", "")))
1312
1339
 
1313
1340
 
1314
1341
  def _character(attrs: Mapping[str, Any]) -> Character: