Skip to content

Commit bb0b0d3

Browse files
committed
feat(snapshot v1.1, phase 2): mesh + meshvar bulk via #146 + bit-exact roundtrip
Builds on phase 1's metadata wrapper to actually carry mesh + mesh- variable state to disk and read it back. Delegates the heavy lifting to #146's `Mesh.write_checkpoint` / `MeshVariable.read_checkpoint` PETSc-DMPlex primitives — phase 2's job is layout, dispatch, and tying the wrapper to the bulk data via a simple convention. Layout (final v1.1 shape): /path/to/run.snap.h5 wrapper (h5py-inspectable) /path/to/run.snap.bulk/ companion directory (one per snap) {mesh_safe}.mesh.00000.h5 {mesh_safe}.{var_clean}.00000.h5 Wrapper carries /meshes/{mesh_safe}/ with @name, @mesh_file, and /meshes/{mesh_safe}/variables/{var_safe}/ with @name, @components, @degree, @continuous, @external_file. The bulk-dir path is derived from the wrapper path by convention (`.h5` → `.bulk`), so no external_file attr is needed for the standard placement. Move them together; a clear FileNotFoundError fires if bulk is missing on read. Phase 1 layout refactor folded in: - /mesh (singular) → /meshes (plural) — supports multi-mesh natively. - /variables removed from the top level — now nests under each mesh as /meshes/{name}/variables/{var}, matching the in-memory snapshot's mesh→vars structure. New API: - `write_snapshot(model, path)` — writes wrapper + bulk; covers every registered mesh and every allocated meshvar on each mesh. Lazy-allocated vars (_gvec is None) are skipped — same rule as the in-memory path. - `read_snapshot(model, path)` — loads var DOFs back into already- registered meshes by name. Mesh / variable mismatch raises a clear ValueError (mesh-rebuild on read is v1.2 scope). - `write_snapshot_skeleton` / `read_snapshot_metadata` / `inspect_snapshot` stay as phase-1 metadata-only entry points. Branch hygiene: merged origin/development (which now has #146) into this branch so the new code can actually call read_checkpoint. The merge was clean — #146 and the snapshot toolkit only overlap at different methods in `discretisation_mesh.py`, as the earlier analysis predicted. PR target will be development once #195/#196 land; the diff stays clean because the merged dev commits are already there. Tests (12 total, 5 new in phase 2, tier_a level_1): - write produces wrapper + bulk-dir with the expected file pattern - wrapper populated with the per-mesh + per-var metadata that makes inspectability self-sufficient - bit-exact write→scribble→read roundtrip on a 2D mesh with one scalar + one vector variable (np.array_equal, zero tolerance) - missing bulk-dir → clear FileNotFoundError - mismatched mesh on read → clear ValueError (not an obscure h5py trace) Regression: 64 tests pass (24 snapshot + 9 tracker + 12 disk-format + 19 core/regression). Phase 3 next: swarms (with the @external_file freedom kept open for bulky swarms) + /python_state for DDt + ModelTracker via dataclass- to-HDF5-attrs serialisation. Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
1 parent 66a2d74 commit bb0b0d3

3 files changed

Lines changed: 347 additions & 8 deletions

File tree

src/underworld3/checkpoint/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
from .disk_snapshot import (
3131
DISK_SNAPSHOT_SCHEMA_VERSION,
3232
inspect_snapshot,
33+
read_snapshot,
3334
read_snapshot_metadata,
35+
write_snapshot,
3436
write_snapshot_skeleton,
3537
)
3638

@@ -48,6 +50,8 @@
4850
"TrackerState",
4951
"DISK_SNAPSHOT_SCHEMA_VERSION",
5052
"inspect_snapshot",
53+
"read_snapshot",
5154
"read_snapshot_metadata",
55+
"write_snapshot",
5256
"write_snapshot_skeleton",
5357
]

src/underworld3/checkpoint/disk_snapshot.py

Lines changed: 189 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import datetime
3838
import json
39+
import os
3940
from typing import Any, Optional
4041

4142
import numpy as np
@@ -46,16 +47,17 @@
4647
DISK_SNAPSHOT_SCHEMA_VERSION = 1
4748

4849
# Top-level group names — fixed; renaming would be a schema-version bump.
50+
# Variables are NOT a top-level group; they nest under each mesh:
51+
# /meshes/{name}/variables/{var}. Swarms similarly carry their own
52+
# variables when phase 3 lands.
4953
_GROUP_METADATA = "metadata"
50-
_GROUP_MESH = "mesh"
51-
_GROUP_VARIABLES = "variables"
54+
_GROUP_MESHES = "meshes"
5255
_GROUP_SWARMS = "swarms"
5356
_GROUP_PYTHON_STATE = "python_state"
5457

5558
_TOP_LEVEL_GROUPS = (
5659
_GROUP_METADATA,
57-
_GROUP_MESH,
58-
_GROUP_VARIABLES,
60+
_GROUP_MESHES,
5961
_GROUP_SWARMS,
6062
_GROUP_PYTHON_STATE,
6163
)
@@ -180,8 +182,7 @@ def write_snapshot_skeleton(model, path: str) -> str:
180182
# see the file's intended shape from day one — phases 2/3
181183
# populate them.
182184
for name in (
183-
_GROUP_MESH,
184-
_GROUP_VARIABLES,
185+
_GROUP_MESHES,
185186
_GROUP_SWARMS,
186187
_GROUP_PYTHON_STATE,
187188
):
@@ -237,6 +238,188 @@ def read_snapshot_metadata(path: str) -> dict:
237238
return md
238239

239240

241+
# ----- Phase 2: mesh + meshvar bulk via #146's PETSc primitives -----
242+
#
243+
# Layout convention:
244+
#
245+
# /path/to/run.snap.h5 wrapper (metadata, h5py-readable)
246+
# /path/to/run.snap.bulk/ companion directory (one per snapshot)
247+
# {mesh_safe}.mesh.00000.h5 mesh DM dump (PETSc HDF5)
248+
# {mesh_safe}.{var_clean}.00000.h5 per-variable section + vec (PETSc HDF5)
249+
# ... one set per (mesh, var) ...
250+
#
251+
# The bulk-dir path is derived from the wrapper path by convention, so a
252+
# user opening just the wrapper file can find the bulk. They are a unit
253+
# for portability — move them together.
254+
255+
256+
def _bulk_dir_for(wrapper_path: str) -> str:
257+
"""Convention: wrapper at `run.snap.h5` ⇒ bulk at `run.snap.bulk/`."""
258+
base = wrapper_path[:-3] if wrapper_path.endswith(".h5") else wrapper_path
259+
return base + ".bulk"
260+
261+
262+
def _sanitise(name: str) -> str:
263+
"""Sanitise a mesh / variable name for use as a filename component.
264+
265+
Replaces anything that isn't alphanumeric or in ``._-`` with ``_``.
266+
Falls back to ``unnamed`` if the result is empty. The original name
267+
is preserved in HDF5 group attrs as the ``@name`` field.
268+
"""
269+
safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
270+
return safe or "unnamed"
271+
272+
273+
def write_snapshot(model, path: str) -> str:
274+
"""Write a complete on-disk snapshot of the model's mesh + mesh-variable
275+
state (phase 2 scope; swarms and python_state land in phase 3).
276+
277+
Produces two artifacts:
278+
279+
- ``path`` — the wrapper HDF5 file with rich metadata and the group
280+
structure inspectable via ``h5ls``.
281+
- ``_bulk_dir_for(path)`` — companion directory containing the
282+
PETSc HDF5 files (mesh DM + per-variable section/vec) produced
283+
by #146's :meth:`Mesh.write_checkpoint`.
284+
285+
The two are a unit; move them together. Returns the wrapper path.
286+
"""
287+
import h5py
288+
289+
# Phase-1 layer: metadata + skeleton groups.
290+
write_snapshot_skeleton(model, path)
291+
bulk_dir = _bulk_dir_for(path)
292+
293+
# rank-0 creates the bulk directory; collective ops below need it
294+
# to exist on the rank doing the PETSc-HDF5 write (which is rank 0
295+
# in this single-file write — actually PETSc's HDF5 viewer is
296+
# collective, so all ranks participate).
297+
with uw.selective_ranks(0) as rank0:
298+
if rank0:
299+
os.makedirs(bulk_dir, exist_ok=True)
300+
uw.mpi.barrier()
301+
302+
# For each registered mesh, drive #146's write_checkpoint into the
303+
# bulk directory. write_checkpoint is collective (PETSc HDF5
304+
# viewer), so all ranks must participate.
305+
mesh_records: list[dict] = []
306+
for mesh in list(model._meshes.values()):
307+
mesh_safe = _sanitise(mesh.name)
308+
mesh_vars = list(mesh.vars.values())
309+
# Filter to allocated variables — same skip rule as the in-memory
310+
# path: lazy-allocated vars with _gvec == None have no data.
311+
mesh_vars = [v for v in mesh_vars if v._gvec is not None]
312+
313+
mesh.write_checkpoint(
314+
mesh_safe,
315+
outputPath=bulk_dir,
316+
meshVars=mesh_vars,
317+
index=0,
318+
)
319+
320+
mesh_records.append({
321+
"name": mesh.name,
322+
"safe_name": mesh_safe,
323+
"mesh_file": f"{mesh_safe}.mesh.00000.h5",
324+
"vars": [
325+
{
326+
"name": v.clean_name,
327+
"components": int(v.num_components),
328+
"degree": int(v.degree),
329+
"continuous": bool(v.continuous),
330+
# Per-variable file produced by Mesh.write_checkpoint
331+
# at outputPath: "{base}.{var.clean_name}.{index:05}.h5".
332+
"external_file": (
333+
f"{mesh_safe}.{v.clean_name}.00000.h5"
334+
),
335+
}
336+
for v in mesh_vars
337+
],
338+
})
339+
340+
# Reopen the wrapper to populate /meshes with the per-mesh records
341+
# and to mark the groups filled.
342+
with uw.selective_ranks(0) as rank0:
343+
if rank0:
344+
with h5py.File(path, "a") as f:
345+
meshes_group = f[_GROUP_MESHES]
346+
meshes_group.attrs["filled_by"] = "phase2"
347+
meshes_group.attrs["bulk_dir"] = os.path.basename(bulk_dir)
348+
349+
for rec in mesh_records:
350+
g = meshes_group.create_group(rec["safe_name"])
351+
g.attrs["name"] = rec["name"]
352+
g.attrs["mesh_file"] = rec["mesh_file"]
353+
354+
vars_g = g.create_group("variables")
355+
for var_rec in rec["vars"]:
356+
v = vars_g.create_group(_sanitise(var_rec["name"]))
357+
v.attrs["name"] = var_rec["name"]
358+
v.attrs["components"] = var_rec["components"]
359+
v.attrs["degree"] = var_rec["degree"]
360+
v.attrs["continuous"] = var_rec["continuous"]
361+
v.attrs["external_file"] = var_rec["external_file"]
362+
363+
uw.mpi.barrier()
364+
return path
365+
366+
367+
def read_snapshot(model, path: str) -> None:
368+
"""Load mesh-variable DOFs from an on-disk snapshot into the model.
369+
370+
The model must already have the same meshes (by name) and the
371+
same variables (by ``clean_name``) registered — this is the
372+
same-rank-count restart path that mirrors :func:`restore` for the
373+
in-memory snapshot. Cross-run / rebuild-on-load is v1.2 scope.
374+
375+
Bulk data is read via #146's :meth:`MeshVariable.read_checkpoint`;
376+
no KDTree remapping (that's phase 4's compatibility layer in
377+
``read_timestep``).
378+
"""
379+
import h5py
380+
381+
md = read_snapshot_metadata(path)
382+
bulk_dir = _bulk_dir_for(path)
383+
if not os.path.isdir(bulk_dir):
384+
raise FileNotFoundError(
385+
f"snapshot bulk directory missing: {bulk_dir} (expected next "
386+
f"to wrapper {path})"
387+
)
388+
389+
# Build {original_name -> registered Mesh} for lookup
390+
meshes_by_name = {m.name: m for m in model._meshes.values()}
391+
392+
with h5py.File(path, "r") as f:
393+
meshes_group = f[_GROUP_MESHES]
394+
for mesh_safe in meshes_group.keys():
395+
g = meshes_group[mesh_safe]
396+
mesh_name = str(g.attrs.get("name", mesh_safe))
397+
mesh = meshes_by_name.get(mesh_name)
398+
if mesh is None:
399+
raise ValueError(
400+
f"snapshot at {path} contains mesh {mesh_name!r} which "
401+
f"is not registered on this model "
402+
f"(registered: {sorted(meshes_by_name.keys())})"
403+
)
404+
405+
current_vars = {v.clean_name: v for v in mesh.vars.values()}
406+
vars_g = g["variables"]
407+
for var_safe in vars_g.keys():
408+
v_attrs = vars_g[var_safe].attrs
409+
var_name = str(v_attrs["name"])
410+
external_file = str(v_attrs["external_file"])
411+
var = current_vars.get(var_name)
412+
if var is None:
413+
raise ValueError(
414+
f"snapshot variable {var_name!r} not registered on "
415+
f"mesh {mesh_name!r}"
416+
)
417+
var.read_checkpoint(
418+
os.path.join(bulk_dir, external_file),
419+
data_name=var_name,
420+
)
421+
422+
240423
def inspect_snapshot(path: str) -> str:
241424
"""Human-readable one-shot summary of a snapshot file's metadata.
242425

0 commit comments

Comments
 (0)