Resuming a calculation and checking compatibility#

Continue a saved calculation only when the saved state and the requested problem are compatible. Solprop checks a resume identity and completion state before proceeding. A deliberately changed physical model should instead start a new segment from a saved field.

What a resume compares#

A resume continues a file exactly when the file’s configuration hash equals the hash of the run about to start (ADR-066). Otherwise it is refused, by naming the fields that differ. There is no override: to change the physics or the numerics, launch a new segment from a saved field or write a new file.

So a resume is for carrying on the same run, with the same solprop and the same libraries, after a stop, a crash or a requeue. These are all refused:

  • a changed tolerance or integrator, in either direction;

  • a changed launch, pulse or noise seed;

  • an optmat release, even one that moved no number the run uses, since the optmat version is in the key;

  • a solprop release or commit that adds a recorded field or moves a recorded number;

  • another kind of machine, whose mathematics library, BLAS or FFT moves the last bits of a recorded number.

Hashing is exact, and a record has no tolerance: a difference in a number’s last bit refuses, and the refusal names that number.

Stay on one kind of machine. A record holds numbers computed on the machine that built it: the launch, the absorber’s rates, quadrature weights, effective areas. A rebuilt run has the same hash in a fresh process and at another thread count. In one measurement, 31 example recipes in one environment had the same hash on AMD EPYC 7763 and EPYC 7513 nodes, but every one of them differed between those and an Intel Xeon Gold 6240, which also had another glibc. So a job requeued onto a node of the same CPU family should continue. One requeued from an Intel node to an AMD node, or moved to another platform, should be expected to be refused. Restrict a long job to one CPU family, for example with a scheduler constraint.

check_cache() is the gate; whether any work is left is the driver’s to decide:

from solprop.output import check_cache, resume

if check_cache("run.h5", metadata):        # False when there is no file
    state = resume("run.h5")               # metadata, last plane, step history
    start_from(state.plane)                # already back in the FFT convention

A refusal raises OutputError naming every part of the record that changed, with an array as one entry and its count, and says what to do instead:

OutputError: run.h5 cannot be resumed; these fields changed: stepper.controller.rtol.
A resume needs the same configuration hash: to change the physics or the numerics, launch
a new segment from a saved plane, or write a new file.

A source= simulation given to field_plane() is held to the same rule, since it has to be the run that wrote the file.

The verdict#

With equal hashes, what a resume does depends on whether the file has work left: a save plane ahead of it, or a terminal checkpoint still owed its plane. That is decided by position, not status, so a file killed after its last plane has none.

The file

resume=True

resume="interpolated"

has work left

continues

continues

has work left, from a plane interpolated inside a step

refused

continues afresh from that field

is finished

returned, nothing computed

returned, nothing computed

Any other value of resume is a ValueError. A scan has no resume, and rules on its points by the same hash (scans).

The resume key#

The hash a resume compares is configuration_hash(), the digest of the hashed part of the run record, hashed(). It is stamped in every file; the stamp tells whether the file is intact, and a suspended writer, a snapshot and a scan row compare it too.

The record holds the schema version, the grid, the modes, the configuration, the noise, the stepper settings, the material manifest’s identities and policies, the stepping method when the Adams integrator (SEP 13, the Adams integrator) stepped the run instead of the tableau, named with its formulation, and the region the error norm scores. A run whose field plasma integrates by the band rule (SEP 18) records the rule in its configuration as plasma_integration. The record holds the policy, K, \(\theta_b\), the taps at full precision, the certified error, and the boundary and exposure policies. A resume that would integrate the plasma differently is therefore refused. The file’s root attributes repeat it as plasma_integration, plasma_integration_half_width, plasma_integration_theta_b, plasma_integration_taps and plasma_integration_certified_error. A field-plasma run also records its held-current source equation under configuration.plasma_source, whose certificate limit and taper counts are part of the identity; the measured certificate is execution evidence under backend.settings.plasma_source, outside the hash. A resolved modal run records its transverse rule in quadrature and in the key – the family (radial_rule), the requested and realised counts, the nodes and the physical weights (SEP 19) – so a resume on another rule is refused. A run on the plain trapezoid records none of these, and a response’s integration is left out of the key when it is "trapezoid". The key also includes the absolute launch position, the scientific TDSE descriptor, the interval-cap and start-up rules, interpolation of save planes (interpolate_planes) and saving of accepted-step endpoints (step_planes) (SEP 14). It is not Python’s hash, which is salted per interpreter, so the same run digests to the same string in every process — a test runs it in a subprocess with a different PYTHONHASHSEED and compares.

For a TDSE response the identity includes the resolved atomic window, the source gate and its breakpoints, so a run never resumes under another gate or window (the edge-free gated source).

Four things are deliberately outside it: the wall-clock start time, the package versions, the git provenance and the backend record. An unrelated commit or a change of backend does not refuse to resume a run that is otherwise the same run, and nor does a library upgrade, as long as it moves no number the record holds. So is the boundary check’s budget and guard width: a run stopped at the time window’s edge may be resumed under a relaxed check, and its monitor’s record continues across the resume (watching the window’s edges). Only the quiet-collar opt-in, which changes what the stages compute, is part of the key.

A file of another output schema is not read at all (the schema version).

Noise drawn without a seed#

A call without seed= draws fresh operating-system entropy each time it is built (reproducibility). So that re-running the same call with resume=True continues its own file, a realisation the constructor drew without a seed is drawn again from the seed the file recorded before the hashes are compared, and the run logs that it did. This covers the default noise of prop_gnlse, prop_capillary, prop_waveguide and prop_radial and an explicit noise="input" or "modified" given without a seed. A seed the call gives, or a realisation it passes in, is never replaced: a resume that differs in it is refused, and the refusal names the seed the file was drawn from (pass seed=17 to draw it again).

To carry on in the same file by hand, build the handler with append=True: it opens the file read-write instead of replacing it, refuses a record offered with another hash, keeps the file’s own record and creation time as its own, and numbers the next plane and the next attempt where the file left off.

Resuming from the file alone#

A run built from a recipe records it in its output, and the file then resumes without the script that made it:

solprop resume run.h5 [--interpolated]

or solprop.recipe.resume_file(path, mode=True) from Python. The simulation is rebuilt from the file’s resolved recipe and continued with resume=mode, so the hash decides, as for any resume. A file written from a script records no recipe, and its script resumes it.

Resuming a file runs the setup it holds. A file from someone else is a program to run: its recipe passes the same checks as a recipe file (registered types only, no keys that choose paths), its embedded data files are read into memory and checked against their hashes, and it does not choose its own limit: its node_budget is ignored, and yours applies. Opening an HDF5 file at all exposes the HDF5 library to it.

What a file allows#

solprop.output.resumable.resumability() answers three questions about a file without resuming it:

from solprop.output import resumability

answer = resumability("run.h5")
# Can it continue, from where, and with which resume=?
print(answer.resumable, answer.z, answer.mode)
# Why not: complete, no state, another schema, a live writer.
print(answer.reason)
# "stored", "none" (a file from a script) or "newer".
print(answer.recipe)
# What a resume would refuse over, at the cost of a rebuild: empty if nothing.
print(resumability("run.h5", gate=True).differences)

mode is "interpolated" when its last plane lies inside a step. gate=True rebuilds the run from its recipe and compares the hashes, so a new optmat release shows before a resume is tried.

Stopping a run#

Simulation.run(..., stop=flag) takes any object with an is_set() method, such as a threading.Event. The run polls it before its first step and before every trial step, so it answers within one step. When it is set, the run writes its state as SIGTERM would, tells the reporter through interrupted, and returns the handler: a requested stop is not an error, and the outcome’s message says the caller’s flag stopped it. resume=True continues the file. A front end sets the flag from any thread, on any platform, with no signals; each run polls its own. solprop run and solprop resume set it on Ctrl-C, SIGTERM and, on Windows, Ctrl-Break, and exit with the usual status; a second Ctrl-C interrupts at once.

Completion status and terminal checkpoints#

Both standard handlers implement the optional CheckpointOutput capability. Their outcome property is a RunOutcome: running, complete, budget_exhausted or failed, together with the actual position, a stop message, cumulative logical and atomic call ledgers, and the latest execution facts. The previous_executions records retain earlier runs of the same resumed file. These records are outside the scientific configuration hash. A budget stop is unfinished; it never becomes a successful result by changing the requested end position.

An ordinary exception or a call-budget stop also writes a TerminalCheckpoint at the last available finite field. Normally this is an accepted step endpoint. A failure while processing a dense observation can instead retain that interpolated field. It is separate from the regular observations: n_saved, handler["z"], handler["field"] and the per-plane statistics keep their original meanings. Checkpoint creation uses available observations and histories and makes no new material-statistics request. An interpolated checkpoint is labelled as such: resume(path) reads it, while restarting requires the explicit Simulation.run(..., resume="interpolated") policy. Ordinary resume=True refuses it. TDSE uses landed save planes.

from solprop.output import open_file, resume

with open_file("run.h5") as stored:
    status = stored.outcome.status if stored.outcome is not None else "unrecorded"
    checkpoint = stored.terminal_checkpoint
    regular_planes = stored.n_saved

state = resume("run.h5")
restart_z = state.plane.z

Resume prefers the terminal checkpoint and computes the next scheduled plane from its absolute position. Modal amplitudes are divided by \(\sqrt{\operatorname{Re}n_{\rm eff}(\omega,z)}\) at that same position. The Adams march continues from the snapshot kept beside the checkpoint (below), and starts afresh at order one where there is none; its logical call ledger and the atomic attempt ledger remain cumulative either way, so restarting cannot replenish a budget. A checkpoint exactly on a scheduled plane whose statistics were interrupted is saved there before stepping on. Pending step history is attached once to the next regular save, or to the next terminal checkpoint if execution stops again.

Scientific changes still require a new run. Device choice, batch width and execution memory limits may change on resume and are recorded in outcome.execution; a new atomic call limit cannot be smaller than the calls already attempted. The driver validates the checkpoint and ledgers before preparing atomic resources. Resuming an already completed file returns it without preparing another backend or atomic runtime. A hard process kill cannot run exception recovery, so the file retains its last flushed state and running status rather than a manufactured completion record.

A run that keeps no field at its planes (fields="none" or "planes", output) continues only from a plane that carries a field (SEP 22). The terminal checkpoint always carries the field, whatever the run keeps at its planes: it costs one observation when the run stops, and a run stopped by an error, a signal or a call budget resumes from it. A file whose last regular plane kept no field and which holds no checkpoint — what a run killed outright leaves — is refused by name:

the run's last saved plane, at z = 1.000000e-04 m, kept no field (it was saved with
fields='none' or 'planes') and the file holds no terminal checkpoint, which always
carries the field, so there is no state to continue from: a run stopped by an error or
a signal writes one, a run killed outright does not. Rerun it, or keep the field on its
last plane

A finished field-free file resumes as finished, running nothing. A run continuing a file keeps its fields by the file’s rule, and is refused if it asks for another.

Custom handlers implementing only Output remain usable for ordinary propagation, keeping every field; a field-free run needs a handler that can hold field-free planes (retain), as both standard handlers do. TDSE requires the checkpoint capability and refuses an incompatible handler before native preparation.

handler = HDF5Output("run.h5", append=True)
handler.open(metadata)                     # refused, by field, if it is another run
handler.n_saved                            # the planes already there

To read a finished file without writing to it, open_file() opens it read-only through the same protocol: the accessors, stat_names, n_saved and metadata answer from the file, open and save are refused, and a with block closes it. That is the way solprop.processing reads a run after the driver has closed its handler.

from solprop.output import open_file

with open_file("run.h5") as stored:
    energy = stored["stats/energy"]

What a general modal run’s key holds#

A general modal guide is propagated in a basis a file must describe before a reader can say what its mode axis means, so that identity is inside the key. Two runs on guides that differ in a core radius, a declared chi3, a node rule, a table extent or the order of their channels have different digests, and a resume is refused by the field that moved — modal.guide.provider, modal.guide.regions, modal.tables, modal.channels — not by a bare hash mismatch.

A guide that declares a change of channel basis — circular partners of a degenerate pair, see general modal propagation — is in the key four times over: modal.metric.kind moves from identity to dense, modal.metric.gauge_id and modal.tables.gauge_id both gain the composed +circular(i,j), modal.channels carries handedness instead of parity, and modal.guide.sampling.basis carries the declaration itself, matrix and all. A file written in one basis therefore cannot resume in another, and the refusal names the field. The sampling’s entry is present only where a basis is declared, so a guide in its provider’s own coordinates keeps the key it would have without the feature; the two gauges are always compared, so the other direction is refused too.

The key also holds what the basis costs. modal.metric.occupied_departure is how far the interpolated basis is from orthonormal over the bins the run occupies — measured at the run’s own coarse bins, between the table’s knots, and gated against the same budget as the knot defect — and modal.metric.active_departure is the same quantity over every bin the active band admits, weighted by nothing, which is warned on rather than gated (see general modal propagation).

modal.accessors is in the record and deliberately not in the key. It is the list of statistic names the basis admits, which is a statement about what the package version that wrote the file could compute — not about what the run computed, which is what every other entry of modal says. Were it in the key, every accessor solprop gains would invalidate every general modal file already written, for a capability those files gain rather than lose. check_cache() compares the two lists as a subset instead: a file whose list lacks a name this version admits resumes, and a file naming an accessor this version does not admit is refused by that name.

Because the record holds the metric’s identity and not its arrays, solprop.processing.modal.physical_energy() refuses a file whose metric is not the identity by name: the number it could form from the stored state alone is the unweighted one, which is the answer for a different basis. The run’s own recorded physical_energy statistic carries the weighted value, and solprop.processing.modal.reconstructed_flux() reconstructs the physical flux from the guide’s reference and needs no metric at all.

Reading E and H back#

A general modal file stores channel amplitudes, not fields. solprop.processing.modal is the way back: given the run’s own reference tables, rebuilt from the guide the record names, it synthesises E(x, y, t) in V/m and H(x, y, t) in A/m at the guide’s quadrature nodes and forms what a detector would see.

from solprop.processing.modal import (
    channel_labels,
    electric_field,
    plane_nodes,
    reconstructed_flux,
)
from solprop.translate.general import general_modal_route

with open_file("run.h5") as stored:
    print([one.display_label for one in channel_labels(stored)])
    tables = general_modal_route(guide, grid, z_max=length, omega_reference=w0).tables
    field = electric_field(stored, tables, plane=-1, guide=guide)  # (n_nodes, 3, n_t)
    power = reconstructed_flux(stored, tables, plane=-1, guide=guide)  # (n_t,), W
    xy, weights = plane_nodes(stored, tables, plane=-1, guide=guide)  # m, m^2

plane_nodes() says where those values are: the nodes’ positions (x, y) in the cross-section, in the order of the node axis every field accessor returns, and the area weights the fluxes are integrated with, so that weights @ reconstructed_flux(..., density=True) is reconstructed_flux(...). On a guide whose geometry moves along z the plane is reconstructed on its own modes: the accessors solve the guide again at the plane’s stored position, on the rule that has moved there, so the nodes the fields are returned at, and the ones plane_nodes returns, are that position’s, and the flux is integrated with that position’s area weights (a guide whose geometry moves).

The tables are not stored in the file: they are tens of megabytes per run and are rebuilt from the guide in tens of milliseconds for a one-channel guide of 288 nodes (under a second if the guide itself has still to be solved, which is most of that time), and the record carries their identity instead. Every accessor checks the tables it is given against that identity, path by path, and refuses the first entry that disagrees, so a reconstruction is never made from the wrong guide.

The guide is required. A table identity records the run’s own extents and the node count and says nothing about the frequencies the provider was solved at or what each region declared, so two guides sampled at different interior knots, or declaring different chi3, build tables whose identity agrees entry for entry. guide= is checked against metadata.modal["guide"] — the sampling with its knots, and the regions — and the refusal names the path: modal.guide.sampling.omega.4, or modal.guide.regions.0.kerr.chi3. The keyword has no default on any of the six accessors that reconstruct from the tables (electric_field, magnetic_field, reconstructed_flux, instantaneous_flux, coordinate_intensity and plane_nodes): every caller has the guide, because that is what the tables were rebuilt from, and a check that closes the hole only when it is asked for is not a gate. A call that omits it raises TypeError. A record whose identity block is a subset of the rebuilt one is refused as incomplete rather than passing on the entries it happens to carry: what was not compared was not checked.

A file written in a declared channel basis needs the guide rebuilt with that same declaration, ModalGuide.from_provider(..., basis=circular_pair(...)): the sampling’s basis entry is part of the identity, and a guide in the provider’s own coordinates is refused at modal.guide.sampling.basis.

electric_field() and magnetic_field() take convention= like every other processing accessor and with the same "optics" default; the real field is convention-free, and analytic=True gives the complex signal in the convention asked for.

Beside the two fluxes, instantaneous_flux() keeps the sum-frequency term of the real fields that the cycle-averaged reconstructed_flux() drops, and coordinate_intensity() gives the flux density each channel would carry alone. That last one is not additive: the sum over channels is the physical density only where the channels do not interfere, and nothing is clipped to hide the difference. channel_amplitudes() is the inverse map, through the coefficient dual; reprojecting a field that was not synthesised from this basis creates a new initial-value problem and is not a resume path.

An ordinary modal or spatial run is refused every one of these names: it carries neither the certified metric nor the E/H reference they promise. Which names a basis admits is solprop.output.capability’s table, and it is checked at four doors: here, when the run’s statistics are admitted, in each backend’s stats, and in energy() and peak_power() (statistics).

Restoring a live-run snapshot#

Periodic snapshots preserve a running accepted endpoint while the original process continues. Use read_snapshot to inspect a complete generation and restore_snapshot(generation, new_directory) to verify and copy it into a new writable directory. Restoration checks every file’s size and SHA-256, configuration and provider identities, checkpoint position, retained history, archive UUID/session and recorded counters. Missing manifests, incomplete staging, mismatched pairs and altered files are refused before working files are published.

The returned SnapshotFiles.output and optional SnapshotFiles.tdse_archive are the arguments for the existing resume=True workflow. Reconstruct the same simulation; the normal configuration and provider compatibility rules still apply. The original archive identity, saved data, Adams history and cumulative accounting continue from the checkpoint. A fresh process adds an ordinary resume session to the restored archive. Taking a snapshot in the original process adds no session. Cumulative atomic call limits remain cumulative: restoring a snapshot does not grant a new budget.

Snapshots never claim that propagation finished. Even one taken at the requested end is a running endpoint; the normal resume workflow establishes completion. Independent resumed computations meet the repository’s accuracy-envelope policy; stored copies, history arrays and structural identifiers remain exact.

Resuming a run from its file and the recipe it records were proposed and carried out by SEP 41.