App¶
The imperative shell: the diffbloch CLI, the default experiment runner it delegates to, and the
logger backends (console/CSV always available; Weights & Biases and Comet behind optional extras —
vendor SDKs never reach the core).
Thin command-line entry point.
This is the orchestration / SLURM boundary: a workflow engine (e.g. Dagster or Prefect) shells out to it, or a SLURM job runs it. Kept deliberately thin — it delegates to the library and holds no science.
- diffBloch.app.cli.main(argv: list[str] | None = None) int[source][source]¶
Parse arguments and dispatch to a subcommand. Returns a process exit code.
The default experiment runner the run infer CLI exposes.
run_experiment() encodes the default recipe as one ordered Plan -> Plan pipeline:
plan-shaping (build_orientation_plans, selecting coupled SOLVE beams from g_max/sg_max
and building rocking geometry plus its reduction) followed by
the config-enabled parameter fitting stages (orientation, then thickness), then run_inference
evaluates it – so a caller with an experiment directory gets a full result in one call. It is a
convenience, not the only path: every step is ordinary public API, so a Python user who wants a
different composition composes their own pipeline([...]) with from_experiment +
run_inference directly. The CLI stays thin by delegating here and holds no science.
Checkpoint / resume. The preprocess (the coupled fit especially) is the run’s expensive phase,
so each dataset’s settled Plan is checkpointed to <experiment_dir>/reproducibility/
(plan.<stem>.npz + plan.<stem>.lock per inputs.exp_data entry, see
_reproducibility_dir() and dataset_checkpoint_stem()) and
reused when still valid. The lock binds the checkpoint to per-dataset axes – the structure and
that dataset’s input bytes, the experiment’s authoritative PETS cell, the dataset-scoped config
projection, its file-local ignored rotations, the software version, and the recipe (the Plan’s
own provenance) – so it is reused only when all match, and never restaled by changes to other
datasets in a pooled experiment unless the first dataset’s authoritative cell changes. The match is
a longest-prefix over the recipe: an identical recipe is a full reuse; a recipe that appends
steps resumes from the snapshot and runs only the suffix (the append-only increment). Any run
that computes – fresh, stale-recompute, refresh, or resume – regenerates the .npz +
.lock pair, so the lock always describes the on-disk checkpoint. With inputs.multi_dataset
the settled per-dataset plans are pooled in memory (pool()) just before
refinement; the pooled plan itself is never checkpointed. Diagnostics about load/resume go through
stdlib logging (not the domain-observation logger).
- diffBloch.app.program.converge_experiment(experiment_dir: str | Path, *, logger: Logger = NULL_LOGGER, device: Device = 'cuda', n_orientations: int = 1) ConvergenceState[source][source]¶
Run the standard numerical-convergence test for an experiment.
Starting
g_maxprefers each PETS file’s owndstarmax(its processing-resolution cutoff,dstar_max) when present; a PETS version that doesn’t record it (or a file where the tag is otherwise absent) falls back to the experiment’s configuredblochwave.g_max, the previous manual-only behaviour.sg_maxand rocking-curve tilt steps still start from the configured simulation settings. The sweep then follows the defaults owned byConvergenceTestandConvergenceTolerance, returning the smallest settled values found.With
inputs.multi_datasetthe sweep runs per dataset – each file with its own integration geometry, its owndstar_max-or-fallback starting point, and its firstn_orientationsrotations – and the returned state is the elementwise maximum over the per-dataset settled states: the tightest single setting adequate for every pooled file. The per-dataset settled values remain visible through the convergence logger events.
- diffBloch.app.program.preprocess_experiment(experiment_dir: str | Path, *, logger: Logger = NULL_LOGGER, checkpoint: bool = True, refresh: bool = False, device: Device | None = 'cuda', workers: int = 1, max_batch: int | None = None, plot_thickness: bool = False, plot_thickness_dir: str | Path | None = None) Plan[source][source]¶
Load and preprocess the experiment at
experiment_dir, returning the settledPlan.The preprocess half of
run_experiment()with the terminal scoring stripped off: it loadsexperiment.yaml(verifying the input lock), reads the structure + experimental data, builds the geometry viafrom_experiment, and runs the default integrated recipe – returning the settled coupledPlan(fitted orientations, tilt-segment couplings, pinned scored sets). This is the entry point for callers who want only the calibrated Plan (to checkpoint it, or to drive their own downstream refinement) without paying for the terminal inference pass.The recipe includes per-trial beam coupling (the fit re-derives the SOLVE union + SCORED set at every trial orientation). Its coupling policy, orientation-search bounds, thickness grid, and whether hydrogens are loaded all come from config (
blochwave/preprocess.orientation/preprocess.thickness/inputs.load_hydrogens). A caller wanting a different composition (e.g. the cheaper tilt-independent fit) composes their ownpipeline([...])with the public steps.checkpoint(defaultTrue) reuses/resumes a validplan.npzin the experiment dir and writes a fresh one after computing;refreshforces a full recompute (ignoring any snapshot) while still regenerating the checkpoint.checkpoint=Falseneither reads nor writes.device(default"cuda") runs the forward solve on the selected accelerator for the coupled preprocess fits. If CUDA is requested on a host without CUDA, the app falls back to CPU and emits a device-selection event. The preprocess geometry (beam selection, coupling unions) stays CPU-side numpy; only the eigensolve – the O(N^3) cost – moves to the device (the fits move the seed params, sofgband every per-trial score co-locate there). Device is execution-only: it does not enter the checkpoint lock, so a committed CPU checkpoint is still reused when a run moves to GPU.workers(default 1, sequential) fans the per-rotation orientation search over a thread pool (rotations are independent). On a GPU run the per-trial cost is host-bound around a small eigensolve, so overlapping rotations across cores is the main wall-clock lever (a small worker count is usually the sweet spot; gains flatten as the solves serialise on one GPU stream). Likedeviceit is execution-only – the results are identical to a sequential run and it does not enter the checkpoint lock. Cap host threads to 1 when using it –OMP_NUM_THREADS/MKL_NUM_THREADS/TORCH_NUM_THREADS(ortorch.set_num_threads(1)); in a pod torch/BLAS size their pools from the node core count, not the cgroup limit, so an uncapped run oversubscribes the cores the workers need (capping alone can dominate the speedup, before any parallelism).max_batch(defaultNone) caps thematrix_exppropagator block for the coupled fits.Nonelets each solve derive a memory-safe block from its beam count; raise it to fill a larger accelerator’s memory budget. Execution-only (memory, bit-for-bit to machine precision), out of the checkpoint lock likedevice/workers. Seebuild_engine().plot_thickness(defaultFalse) ORs withcfg.preprocess.thickness.plot– either turns on one wR2-vs-thickness PNG per rotation fromoptimize_thickness’s grid search, saved underplot_thickness_dir(default<inputs.structure's directory>/thickness_optim). Execution-only likedevice/workers, out of the checkpoint lock.
- diffBloch.app.program.refine_experiment(experiment_dir: str | Path, *, logger: Logger = NULL_LOGGER, checkpoint: bool = True, refresh: bool = False, device: Device | None = 'cuda', workers: int = 1, max_batch: int | None = None, verbose: bool = False, profile: bool = False, checkpoint_activations: bool = True, plot_thickness: bool = False, plot_thickness_dir: str | Path | None = None) ModelRefinementResult[source][source]¶
Settle the coupled
Planand gradient-refine the structure against the observed data.preprocess_experiment()for the geometry (checkpoint reuse for free – see it for the recipe andcheckpoint/refresh/device/workers/plot_thickness/plot_thickness_dirsemantics), then run the default single-stage refinement on that settledPlan. This is the boring config-knobs path: the residual (to_loss()), the trainable selection (to_spec()), and the optimizer/step budget all come fromexperiment.yaml. It composes no hard constraints or penalties – scientific composition (hydrogen riding, freeze-H, penalties, multi-stage) is a Python/API concern, built withbuild_refinement_model(),build_refinement_problem(), andwith_hydrogen_riding(), then run viarun_refinement_model. TheRefinementProblemhere is pure optimization-definition data; the imperative loop lives inrun_refinement_model. Returns theModelRefinementResult(per-step losses + best snapshot);_write_refinement_outputs()persists the best structure/parameters/summary toexperiment_diralongside arefinement.lockbinding them to the settledPlan(plan.lock), the refinement-determining config, and the code version that produced them – the refinement-stage counterpart to the preprocess checkpoint’s own lock.deviceplaces the refinement solve on the accelerator: the seed params move there and the forward co-locates onto them (as in the preprocess fits).verbose(“verbose refinement”) reports one per-rotation wR2/R_obs/diffraction-loss line per step in addition to the epoch mean; seerun_refinement_model(). Execution-only, likelogger.profilelogs per-phase wall time (structure factors, each rotation’s solve, backward, optimizer step) via stdlib diagnostics logging; seerun_refinement_model()andbuild_engine(). Execution-only and off by default.checkpoint_activations(defaultTrue) trades peak memory for one extra forward recompute per solve on backward; disabling it removes that recompute at the cost of retaining solve intermediates until backward. Execution-only – gradients are unaffected. SeeRefinementEngine.
- diffBloch.app.program.run_experiment(experiment_dir: str | Path, *, logger: Logger = NULL_LOGGER, checkpoint: bool = True, refresh: bool = False, device: Device | None = 'cuda', workers: int = 1, max_batch: int | None = None, plot_thickness: bool = False, plot_thickness_dir: str | Path | None = None) InferenceResult[source][source]¶
Load, preprocess, and score every rotation of the experiment at
experiment_dir.preprocess_experiment()followed by the terminal forward model: it settles the coupledPlan(see that function for the recipe,checkpoint/refresh,device/workers, andplot_thickness/plot_thickness_dirsemantics – all shared), then evaluates every rotation withrun_inference– emitting per-rotation observations tologger(the null default discards them). Returns theInferenceResult(per-rotationR_obs+ aggregate).devicealso runs the terminal eigensolve on the accelerator.
Logger backends – the app/ boundary where sinks and vendor SDKs live, never the core.
Each backend consumes the uniform Event surface (channel +
measurements), so none knows the concrete event types and new events need no backend change.
ConsoleLogger (here, no vendor dependency) routes events to stdlib logging and
CSVLogger (here too) appends them to a file; each third-party backend lives in its own
confined submodule that imports its SDK lazily, so importing this package never requires an optional
dependency:
WandbLogger(diffBloch.app.loggers.wandb)CometLogger(diffBloch.app.loggers.comet)ThicknessPlotLogger(diffBloch.app.loggers.plotting)
Writing your own backend is a single method: implement report(event) for the events you care
about.
- class diffBloch.app.loggers.CSVLogger(path: Path)[source][source]¶
Bases:
objectAppend each event’s measurements to a CSV file in long format (Lightning-style sink).
One row per measurement –
channel, step, metric, value– so a heterogeneous event stream (rotation, inference, refinement) shares a single flat table with no sparse columns, ready to filter bychannelor pivot bystep. The header is written once at construction (a fresh file per run); eachreport()appends and flushes, so the file is crash-safe and tailable.This is an observation log, not persistence: run state is checkpointed by serialising the whole
Plan, never reconstructed from these rows.
- class diffBloch.app.loggers.ConsoleLogger(level: int = 20, per_rotation: bool = True)[source][source]¶
Bases:
objectLog each event to stdlib
loggingatlevel(console/file handlers attached by app).The bridge from the domain-observation channel to the diagnostics channel: handy for a live scroll of per-rotation
R_obswhile chasing a residual. Attach a handler (or calllogging.basicConfig()) at the app boundary to see it.On a real terminal (
sys.stdout.isatty()), refinement epochs, orientation-search rotations, and thickness-search rotations render as an in-place progress bar (with a linearly-extrapolated ETA) instead of one scrolling line per event – their respective*Startedevent supplies the total up front. Off a terminal (piped to a file, CI logs) this falls back to the plain per-event line, since\r-based in-place updates are meaningless there and would just corrupt a log file with control characters.
- class diffBloch.app.loggers.EarlyAbortLogger(wr2_ceiling: float = 0.6, patience: int = 5, inner: Logger = <diffBloch.observability.NullLogger object>)[source][source]¶
Bases:
objectWatch the per-rotation fit stream and abort a run that is not tracking the data.
A fit-quality guard for a long, oracle-less from-scratch fit – one with no committed checkpoint and no reference
R_obsto pin against, so a mis-set-up run (wrong energy /g_max, bad data lineage) would otherwise burn its whole budget producing a bad answer.optimize_orientationemits oneOrientationOptimizedper rotation as it finishes, carrying the scaling-optimisedwr2at the fitted orientation. A healthy run reaches a lowwr2on essentially every rotation; a fundamentally broken one stays high on all of them. This guard gives the runpatiencerotations to show at least one orientation reachingwr2 <= wr2_ceiling; if none does, it raisesFitAbortedError, unwinding the fit before the remaining rotations run. Pickwr2_ceilinggenerously (well above a healthy fit, well below a garbage one) so a real run clears it within the first rotation or two and never false-aborts.Only the fit stream drives the decision; every event is forwarded verbatim to
inner(defaultNULL_LOGGER), so this composes with aConsoleLogger/CSVLoggerfor the live scroll –EarlyAbortLogger(inner=ConsoleLogger()). Raising fromreport()is the abort mechanism: the fit loop’s only per-rotation hook is the logger, and both the sequential andworkers > 1paths callreportfrom the driving thread, so the raise unwinds the run cleanly. Compute saved: all remaining rotations underworkers = 1(sequential – nothing further starts); underworkers > 1the queued rotations are cancelled but the<= workersalready running cannot be interrupted and finish first (optimize_orientationcancels the rest on abort).
- exception diffBloch.app.loggers.FitAbortedError[source][source]¶
Bases:
RuntimeErrorRaised by
EarlyAbortLoggerto unwind a fit judged unpromising and stop it early.Carries the diagnostic that triggered the abort (rotations seen, best
wr2, the ceiling), so the caller running the fit sees why it stopped, not just that it did.
- diffBloch.app.loggers.format_measurements(event: Event) str[source][source]¶
Render an event’s measurements as space-joined
name=valuepairs (shared by backends).
- diffBloch.app.loggers.namespaced_measurements(event: Event) dict[str, float][source][source]¶
Map an event to
{channel}/{metric}: value(the series convention shared by the wandb/comet backends).
Weights & Biases logger backend – the only module that touches the wandb SDK.
wandb is imported lazily inside WandbLogger.report(), so it is an optional dependency
(uv sync --extra wandb): importing diffBloch.app.loggers never requires it, and the pure
core never touches it. The app owns the run lifecycle (wandb.init / wandb.finish); this
logger only forwards each event’s measurements to the active run as {channel}/{metric} series.
- class diffBloch.app.loggers.wandb.WandbLogger[source][source]¶
Bases:
objectLog each event’s measurements to Weights & Biases as
{channel}/{metric}series.
Comet ML logger backend – confined home for the comet_ml integration.
Unlike Weights & Biases (a module-level wandb.log after wandb.init), Comet ML logs to an
Experiment object. So the app owns the run lifecycle – it creates the
comet_ml.Experiment (uv sync --extra comet) and passes it in – and this logger only
forwards each event’s measurements to that experiment as {channel}/{metric} metric series. The
logger itself imports no vendor SDK (it duck-types experiment.log_metrics), so importing
diffBloch.app.loggers
never requires comet_ml and the pure core never touches it.
- class diffBloch.app.loggers.comet.CometLogger(experiment: object)[source][source]¶
Bases:
objectLog each event’s measurements to a Comet ML experiment as
{channel}/{metric}series.experimentis acomet_ml.Experimentcreated and owned by the app (Experiment.end()at the end of the run); this logger only callsexperiment.log_metricsper event.