Input and output¶
Two kinds of HDF5 files: analysis files, which hold what you asked to see (fields, derived
quantities, numbers) at a fixed cadence, and checkpoints, which hold everything needed to
continue a run exactly. Both are readable with h5py alone, and both carry the provenance record
of the run (code version, environment, every numerical choice: Reproducibility and provenance).
A run split over two jobs¶
On a cluster a long run is a chain of jobs, and every job runs the same script.
sim.resume_or_start(directory, u=...) continues from the latest checkpoint of directory when
there is one, and starts from the given initial state otherwise (first job; the directory is
created). An output without mode= then follows the same branch: its file is created on a fresh
start (an existing file is refused there: mode="w" replaces it explicitly, e.g. after a first
job killed before its first checkpoint) and continued after a restart (mode="resume": rows written after the checkpoint are
dropped, the restarted run writes them again). sim.explain() says which branch was taken and
which mode each file got:
import h5py
import numpy as np
import conatus as cn
def burgers():
m = cn.Model(cn.Box(x=cn.periodic(2 * np.pi)), name="viscous Burgers", nu=0.05)
m.scalar("u")
m.equations("dt(u) - nu*dx(dx(u)) = -u*dx(u)")
return m.simulate(N=64, scheme="SBDF3", dt=2e-3)
def job(steps):
"""The same script for every job of the chain."""
sim = burgers()
# the latest checkpoint of chk/ if there is one, else this initial state (t = 0)
sim.resume_or_start(work / "chk", u=lambda x: np.sin(x) + 0.3 * np.cos(2 * x))
sim.checkpoints(work / "chk", every=100, keep=2) # one every 100 steps, two kept
# no mode=: created on a fresh start, continued after a restart (said by explain())
sim.output(work / "snapshots.h5", tasks=["u", "dx(u)"], every=50)
sim.run(steps=steps)
return sim
sim = job(300) # first job, stopped at its time limit
sim = job(200) # next job: from the checkpoint of step 300
d = cn.io.read(work / "snapshots.h5")
print(f"{len(d.sim_time)} writes, t = {d.sim_time[0]:.1f} ... {d.sim_time[-1]:.1f}, "
f"u: {d.tasks['u'].shape}")
with h5py.File(work / "snapshots.h5", "r") as f: # without Conatus
print("tasks:", sorted(f["tasks"]), " format:", f.attrs["format"])
11 writes, t = 0.0 ... 1.0, u: (11, 64)
tasks: ['dx(u)', 'u'] format: conatus.analysis/1
two jobs equal to one uninterrupted run, bit for bit: True
The last line is the check made after the example: the two jobs give the same numbers as one uninterrupted run, bit for bit.
Analysis files¶
sim.output(path, tasks=..., every=k) writes every k steps, and the state at the start of the
next run first (initial=True, the default: a time series starts from the initial condition, as
in Dedalus; a restarted job does not write that row again, and a start between two multiples of
k writes none; initial=False starts at step k; the output’s explain() says which). A task is the name of an unknown or
of a component ("u", "u.z"), an expression written as in the equations ("dx(u)",
"0.5*u@u"), or a function of the simulation in a dictionary ({"KE": lambda s: ...}) for
anything else.
In the file |
Content |
|---|---|
|
one entry per write |
|
the grid points of each coordinate |
|
the coordinate names: the component axis of vector tasks |
|
one row per write: grid values, or the number returned by your function |
attributes |
problem, scheme, |
cn.io.read(path) returns the times, iterations and tasks as arrays, and explain() describes
the file.
Reading the files with other tools¶
The files describe themselves with plain HDF5 means, so other tools see named arrays on coordinates:
every task has a
dimsattribute (for example["t", "component", "x", "z"]) and each of its dimensions is labelled and attached to its coordinate dataset as an HDF5 dimension scale (tto/scales/sim_time,xto/scales/x,componentto/scales/component); vector tasks also carrycomponent_names;cn.io.read(path).to_xarray()gives anxarray.Dataset(dimensionst,componentand the coordinates;iterationalongt; parameters and version as attributes). xarray is optional: without it the call says how to install it;for a 2D or 3D box or channel,
sim.outputalso writes an XDMF sidecar<file>.xmfat the end of everysim.runcall: open it in ParaView (or VisIt) to see every real task on the grid over time, vector tasks as one array per component (u.x,u.z). The sidecar points into the HDF5 file (no copy). ParaView’s X axis is the last coordinate (HDF5 arrays are stored in coordinate order, row-major): for an(x, z)box, z is along ParaView’s X. For the file of a job that was killed,cn.io.write_xdmf(path)writes the sidecar for the rows it holds.Not covered by the sidecar: curvilinear geometries (disk, annulus, pipe, sphere, ball, shell: their arrays are self-describing in HDF5 and xarray, but no XDMF mesh of their points is written yet), mapped boxes with curved walls, 1D boxes, complex tasks (XDMF has no complex type), and tasks that are not on the grid (numbers returned by a function). The output’s
explain()says when no sidecar was written and why.
Checkpoints are labelled the same way: /fields/<name> (spectral coefficients, dimensions
<coordinate>_mode) and, with grids=True, /grids/<name> on /scales/<coordinate>;
cn.io.read(path).to_xarray() works on them too. Files written by earlier versions have none of
these labels: they still read and restart, and to_xarray() names only the dimensions whose
length identifies a coordinate (the rest are <name>_dim<k>). Native models declare no units, so
no unit attribute is written.
Conatus does not decide for you:
everyis a whole number of steps: the time step is never adjusted to land on an output time;an existing file is never replaced or extended without your saying so:
mode="w"(replace),mode="a"(append: time must go forward) ormode="resume". One file holds one computation: an append is refused, with the list of what differs, when the model, equations, parameters, given-field data, resolution, box, scheme,dt,everyor the source of any task differs from the computation the file records (a task function is compared by its code and the values it reads; one reading an object without a content hash is compared by its code only, said byexplain());mode="resume"is for a job that restarts from a checkpoint older than the last rows of the file (the previous job was killed between two checkpoints): the rows after the restart are dropped first, their number is printed byexplain(), and the restarted run writes them again;the file is closed after each write, so a job killed during a write leaves every completed row readable.
Checkpoints and exact restarts¶
sim.save(path) writes a checkpoint now; sim.checkpoints(directory, every=k, keep=n) writes one
every k steps and keeps the n most recent (keep=2 by default: the latest and the one before
it, in case a job is killed while writing). sim.restart(path) continues from a checkpoint and
cn.io.latest(directory) finds the most recent complete one, in either format:
checkpoint_<iteration>.h5 (written by sim.checkpoints) and checkpoint_<iteration>.npz
(sim.save(directory / f"checkpoint_{sim.iteration:09d}.npz")). The iteration is read from the file, a file still being written is ignored, two files of the
same iteration in the two formats are refused (keep one format per chain), an empty directory gives
None, and a directory that does not exist is refused: a misspelt path never restarts a chain from
t = 0. resume_or_start uses the same search.
A checkpoint holds the state, the history of a multistep scheme and the clock, so the restarted run
is the uninterrupted run bit for bit, for every scheme (Simulations and time steppers), on the CPU and on a
GPU: both formats are tested bit for bit on a device, including periodic .h5 checkpoints restarted
from cn.io.latest with a one-step and a multistep scheme and the CUDA graph replayed after a
restart. The restarting simulation must use the same numerics (scheme, dt, resolution, start-up,
options): a difference is refused with the option to change, never adapted. It must also be the same
problem: every checkpoint records the model name, a hash of the compiled equations, the parameter
values, the data of every given field (by content: a forcing f = 1 is not f = 2), the user
functions (m.function, by their code), the resolution and the bounds of every direction, and a
restart refuses any difference with the list of what differs (parameter kappa: 1 in the checkpoint, 5 now; given field f: its data differ ...; geometry along x: ...;
equations: hash ... (another equation, term or sign)), so a job chain whose script was edited
between two jobs cannot mix two problems in one output. To continue a state at another value
deliberately, name it: sim.restart(path, allow=("kappa",)) (a parameter, given-field or function
name, or allow="parameters", "data", "functions", "model", "equations", "geometry"; the
resolution can never be allowed). The allowed change is printed by
explain() and recorded in every later checkpoint. A checkpoint written before this record restarts
with a CheckpointWarning saying that only the problem name and the numerics could be compared (one
written by 0.1.0: that its given-field data were not compared). A checkpoint directory that holds
files of another run is refused as well: every checkpoint already in the directory is checked
against the one the run restarted from before anything is written or deleted, and a refused
write is refused again on every retry. cn.io.latest considers only complete Conatus checkpoints
(an HDF5 file of another format, or a .npz without the state, is ignored). With grids=True a checkpoint also stores the
grid values of each unknown, for reading with h5py. The suffix chooses the format: .h5 or .npz
(NumPy), which restart to the same numbers.
A run that starts at a time other than zero¶
sim.initial(u=..., t=t0) gives the state at the time t0 (default 0). Time-dependent forcing and
boundary data are then evaluated from t0, sim.t starts there, run(until=...) is read on that
clock, every output row and checkpoint carries the times t0 + k dt, and explain() states the
start time. Every checkpoint records the start time of its chain: resume_or_start(directory, u=..., t=t0) refuses to continue a chain that started at another time (the two scripts would not
describe the same run), and a checkpoint written before this record is continued only without t=
(native runs started at t = 0 until then). A non-finite or non-real t is refused. In a model
with an unknown named t there is no time t, and initial(t=...) gives that unknown’s data, as
before. Restarted from a checkpoint, explain() also says when the chain started.
import tempfile
from pathlib import Path
import numpy as np
import conatus as cn
work = Path(tempfile.mkdtemp()) # outputs and checkpoints of this example
m = cn.Model(cn.Box(y=cn.walls(0, 5)), name="moving walls")
m.scalar("u")
m.equations("dt(u) - dy(dy(u))/2 = 0") # exact: exp(-y) cos(t - y)
m.at("bottom", u="cos(t)")
m.at("top", "u = exp(-5)*cos(t - 5)")
sim = m.simulate(N=32, scheme="RK443", dt=0.01)
sim.initial(u=lambda y: np.exp(-y) * np.cos(0.7 - y), t=0.7) # the state at t = 0.7
sim.output(work / "run.h5", tasks=["u"], every=50)
sim.checkpoints(work / "chk", every=100, keep=1)
sim.run(until=1.7)
y = sim.coordinate("y")
err = np.abs(sim.grid("u") - np.exp(-y) * np.cos(sim.t - y)).max()
print(f"t = {sim.t:.2f}, error against the exact solution {err:.1e}")
print("output times:", cn.io.read(work / "run.h5").sim_time)
print([ln for ln in sim.explain().splitlines() if "Initial state" in ln][0])
t = 1.70, error against the exact solution 9.9e-09
output times: [0.7 1.2 1.7]
Initial state: u given at t = 0.7 (initial(t=...): forcing, boundary data and outputs on this clock, recorded by the checkpoints)
Long adjoint computations¶
The reverse sweep of an adjoint over a long time can also be split over several jobs, with its own checkpoints kept on the GPU, in host memory or on disk: see Adjoints and sensitivity.
Not yet¶
Expressions as tasks in curved geometries (disk, sphere, shell, …): write a function of the simulation instead.
One file shared by several MPI ranks (parallel HDF5).