Preprocess¶
The composable Plan → Plan preprocess pipeline: build the initial plan from an experiment, sharpen
it with swappable steps (beam selection, orientation/thickness determination, rocking-curve integration,
mosaicity, convergence/coverage sweeps), then hand the final plan to a terminal (run_inference or
engine.refine).
Spine¶
The Plan: the invariant geometry the differentiable refinement is conditioned on.
Plan is the spine of the preprocess pipeline – one immutable value bundling the shared
StructureFactorGrid and the per-rotation
OrientationPlans. Each Plan -> Plan step returns a sharpened
copy (via dataclasses.replace()); refine consumes the final Plan. The dependency
points preprocess -> engine (this module imports the engine’s geometry plans), never the
reverse – the engine stays unaware of Plan and remains a pure consumer of grid + orientations.
- class diffBloch.preprocess.plan.CandidatePlan(orientation: NDArray[float64], energy: float, u0: float, thickness: NDArray[float64], beam_hkl: NDArray[int64], pattern: PatternBatch)[source][source]¶
Bases:
objectThe pre-build candidate phase for one rotation: source only, no built geometry.
from_experimentlays down aCandidatePlanper rotation (the difference-safe candidatebeam_hkl+ orientation source),select_beamsprunesbeam_hkl(cheap, source-only), andbuild_orientation_plans()then builds it into anOrientationPlan– the one place the structure-factor gather is built, over the already-pruned beam set. ACandidatePlanhas nobeam_plans, so it is unsolvable by construction (the engine consumes only builtOrientationPlans); building the expensive gather over the full candidate pool is thereby avoided entirely.- orientation: NDArray[float64]¶
- thickness: NDArray[float64]¶
- beam_hkl: NDArray[int64]¶
- pattern: PatternBatch¶
- classmethod seed(beam_hkl: NDArray[int64], pattern: PatternBatch, *, energy: float, thickness: Tensor | NDArray[float64] | Sequence[float], u0: float = 0.0, orientation: Tensor | NDArray[float64] | None = None) CandidatePlan[source][source]¶
Assemble a candidate seed for one orientation (plain-numpy source; builds no gather).
- class diffBloch.preprocess.plan.Plan(structure_factor_grid: StructureFactorGrid, orientations: tuple[CandidatePlan | OrientationPlanLike, ...], provenance: tuple[StepRecord, ...] = <factory>)[source][source]¶
Bases:
objectShared
structure_factor_gridplus per-rotationorientations(the refinement spine).structure_factor_gridfixes theFgbsupport and metric;orientationsis oneOrientationPlanper rotation (each already coupled to the grid at build time). Immutable: preprocess steps returndataclasses.replace()copies rather than mutating in place.provenanceis the ordered tuple ofStepRecords that produced this plan –pipeline()appends one per step as it runs. A freshly built plan (fromfrom_experiment) has empty provenance; the recipe identity a checkpoint locks against is this tuple. Steps do not touch it (theirreplacepreserves it); the combinator owns it.- structure_factor_grid: StructureFactorGrid¶
- orientations: tuple[CandidatePlan | OrientationPlanLike, ...]¶
- provenance: tuple[StepRecord, ...]¶
- diffBloch.preprocess.plan.coupling_stats(op: CandidatePlan | OrientationPlan | CoupledOrientationPlan) dict[str, int][source][source]¶
One rotation’s solve-geometry shape: the
(unions, tilts-per-union, beams-per-union)cost.Phase-robust (a plan is summarised after every pipeline step, from the pre-build candidate on): a
CoupledOrientationPlanreports its real coupling (n_coupling_segmentsunions, per-unioncoverwidths andunion_beam_indexbeam counts); a builtOrientationPlanis one implicit union spanning all its tilts; a pre-buildCandidatePlanknows only its beam-pool size (no tilts/segments yet). These are exactly the(B, T, N)drivers of the segmented Bloch solve the refinement loop repeats.
- diffBloch.preprocess.plan.require_built_plans(plan: Plan) tuple[OrientationPlan | CoupledOrientationPlan, ...][source][source]¶
Parse the orientation phase to built (
OrientationPlan/CoupledOrientationPlan).Plan.orientationsis a phase-union (CandidatePlanbefore the build, built plans after) rather than a phase-indexedPlan[P], as the recipe is a runtimepipelinelist of homogeneousPlan -> Plansteps: the phase cannot ride in the type across that list (nor acrossread_plan, which reconstructs aPlanfrom.npzbytes). This is the parse that re-establishes the phase at that erased boundary – parse, don’t validate: it returns the narrowed type once, and the terminals (inference, the fits,couple_beams, checkpoint serialize) then hold built geometry without re-checking. The phase-indexed alternative (Plan[P]) does not survive contact with Python – there is no phase-changing composition over a homogeneous step list, anddisallow_any_genericsrejects the erased reconstruction.A
CandidatePlanhas nobeam_plansand is unsolvable, so this raises unlessbuild_orientation_planshas run (the default recipe runs it right afterselect_beams). Returns the plan’s ownorientationstuple (identity preserved) once narrowed.
- diffBloch.preprocess.plan.require_candidate_plans(plan: Plan) tuple[CandidatePlan, ...][source][source]¶
Parse the orientation phase to the pre-build candidate (
CandidatePlan).The candidate-phase counterpart of
require_built_plans()(which documents why the phase is parsed at a runtime boundary rather than carried in the type).select_beamsandbuild_orientation_plansoperate on the candidate phasefrom_experimentlays down; this raises with a clear error if the plan is already built (holdsOrientationPlans), sincebuild_orientation_plansis the single step that builds them and runs once, right afterselect_beams.
- diffBloch.preprocess.plan.require_orientation_plans(plan: Plan) tuple[OrientationPlan, ...][source][source]¶
Narrow a plan’s orientations to plain
OrientationPlans (reject segmented ones).The tilt-independent-only plan-shaping steps (
select_beams,integrate_rocking_curve) transform theOrientationPlan, which carries one shared beam set.couple_beamsreplaces each orientation with aCoupledOrientationPlan(a per-tilt-chunk beam set) that those steps cannot consume, so they must all precedecouple_beamsin a pipeline. This helper enforces that ordering with a clear error and narrows the element type for the caller.The fitting steps (
optimize_orientation,optimize_thickness) are deliberately not narrowed: they are plan-agnostic (they rebuild viaOrientationPlan.with_orientation()/replace(thickness=...), both defined on the segmented plan too), so they iterateplan.orientationsdirectly and run either before or aftercouple_beams.
- diffBloch.preprocess.plan.summarize_plan(plan: Plan) dict[str, float][source][source]¶
Plan-level shape as numeric measurements (the observability summary of a settled/mid Plan).
Emitted after every pipeline step (and once for the seed, as
PlanSeeded), so consecutive summaries are the per-stage survival counts: how many solve beams and scored reflections each filtering step left behind. The names are scoped because the sets are independent – SOLVE (n_solve_beams_*, the beams that couple dynamically) is not SCORED (n_matched_hkl, the reflections that enter the R-factor) is not the structure-factor support (n_grid_hkl).n_observed_hkl/n_matched_hklare deduplicated distinct(h, k, l)counts across every rotation (unique_hkl_count()), not a sum of each rotation’s own count – a reflection re-observed (or matched) in more than one rotation is counted once, not once per rotation.n_matched_hklis absent, not zero, beforebuild_orientation_plansruns: aCandidatePlanhas no alignment, and reporting0there would make “not built yet” indistinguishable from “matched nothing”.
- diffBloch.preprocess.plan.unique_hkl_count(hkl_batches: Iterable[Tensor]) int[source][source]¶
Count of distinct
(h, k, l)triples acrosshkl_batches(each(M, 3)).A reflection recorded in multiple rotations (rotation electron diffraction frames overlap in angle, so the same reciprocal-lattice point is routinely re-observed in several consecutive frames) is counted once here, not once per rotation it appears in – a plain
sum(len(batch) ...)double-counts exactly those overlaps.
from_experiment: the boundary constructor from parsed records + config to refinement inputs.
This is the initial total construction of the preprocess pipeline (it is not Plan -> Plan –
there is no Plan yet). It assembles two separable products from the same records/config:
a
Planpair (train/validation) – the invariant geometry the preprocess steps then sharpen andrefineconsumes;a
RefinementSetup– the structure-side static + refinable inputs theRefinementEngineneeds (ASU expansion, constraint spec, initial parameters, atomic numbers).
The structure side lives here so the structure/experimental split mirrors the two parsed records.
- class diffBloch.preprocess.experiment.DatasetSetup(plan: Plan, integration: IntegrationGeometry, mosaicity: MosaicSmoothed | None, energy: float, n_rotations: int, ignored_rotations: tuple[int, ...])[source][source]¶
Bases:
objectOne dataset’s seeded geometry: the candidate
planplus its own measurement context.The per-dataset product of
setup_datasets().planholds oneCandidatePlanper non-ignored rotation, withrotation_indexfile-local (the rotation’s original position within this dataset’s own PETS file, so ignoring a rotation leaves a gap rather than renumbering later frames);pool()maps these onto the pooled global index space.integrationis this file’s own rocking-curve semiangle – pooled datasets may differ (each file’s recipe runs with its own geometry).energyis the snapped beam energy (snap_to_standard_energy()over the PETS wavelength);poolguards that pooled datasets agree, since the engine solves one experiment at one energy.n_rotationsis the full pre-ignore rotation count (the pooled offset arithmetic and the train/val mask both run over original counts), andignored_rotationsthe sorted file-local ignore slice (part of this dataset’s checkpoint-lock identity).- integration: IntegrationGeometry¶
- mosaicity: MosaicSmoothed | None¶
- class diffBloch.preprocess.experiment.ExperimentSetup(plans: PlanSplit, refinement: RefinementSetup, integration: IntegrationGeometry, mosaicity: MosaicSmoothed | None)[source][source]¶
Bases:
objectThe full product of
from_experiment: the geometryplans+ structurerefinement.Two separable concerns from the same records/config:
plans(thePlan -> Plangeometry spine) andrefinement(the static structure context the engine is built from). Kept distinct so only thePlanpair flows through the preprocess pipeline.- refinement: RefinementSetup¶
- integration: IntegrationGeometry¶
- mosaicity: MosaicSmoothed | None¶
- class diffBloch.preprocess.experiment.PlanSplit(train: Plan, validation: Plan)[source][source]¶
Bases:
objectA
train/validationPlanpair sharing oneStructureFactorGrid.from_experimentsplits the rotations into the two plans (validation= every 10th rotation by default); both reference the same grid object, so the sharedFgbsupport cannot diverge.The split is currently dormant: nothing downstream distinguishes
trainfromvalidation(inference and the engine take a singlePlan), and whole-experiment work usescombined(all rotations). A whole-rotation holdout is a weak cross-validation guard for over-determined physics refinement anyway – the principled analog holds out reflections (R_free), not orientations. It is retained because it becomes informative for learned modes, where a learnedtheta -> thicknesscan overfit per rotation.
- class diffBloch.preprocess.experiment.RefinementSetup(asu_plan: AsuExpansionPlan, spec: ConstraintSpec, params: RefinableParams, numbers: Tensor, cell_parameters: NDArray[float64] | None = None)[source][source]¶
Bases:
objectThe structure-side inputs a
RefinementEngineis built from.Kept separate from the geometry
Plan(which carries the grid + orientations): thePlanflows through thePlan -> Planpreprocess steps, while this static structure context is handed to the engine at refinement time.paramsare the initial refinable parameters seeded from the CIF (positions at their CIF values, ADPs inverted from the CIF ADPs);specfreezes the constraint metadata (fixed positions, occupancies, ADP kinds, the reciprocal frame ADPs map through).- asu_plan: AsuExpansionPlan¶
- spec: ConstraintSpec¶
- params: RefinableParams¶
- classmethod from_structure(structure: StructureRecord, *, cell_parameters: NDArray[float64] | None = None, isotropic_displacements_only: bool = False) RefinementSetup[source][source]¶
Assemble the structure-side refinement inputs from a parsed
StructureRecord.Positions and symmetry are read directly off the CIF’s fractional coordinates and symmetry operators – both are metric-independent. ADPs are mapped to the reciprocal
U*frame bydiffBloch.params.constrain(), usingcell_parametersas the metric when given (PETS’s authoritative cell fromsetup_datasets()); otherwise the structure’s own CIF cell, for direct construction with no PETS record in scope. Per-rotation thickness lives elsewhere – on each per-rotation plan (seeded byfrom_experiment, fitted byoptimize_thickness) – because it varies per rotation, not per structure.Special-position degrees of freedom are constrained by the site-symmetry projector built natively from the structure’s symmetry operators (
symmetry_constraints()): an atom special position is held on its site under refinement, so it is neither over-parameterized nor free to drift off. A general-position atom gets the identity projector (unconstrained).isotropic_displacements_only(inputs.isotropic_displacements_only) forces every atom onto Uiso via a derived ADP record (_force_isotropic_adp()), never by mutatingstructureitself.
- diffBloch.preprocess.experiment.from_experiment(structure: StructureRecord, experimental_data: ExperimentalRecord, config: ExperimentConfig) ExperimentSetup[source][source]¶
Construct the geometry
Planpair + structureRefinementSetupfrom parsed inputs.The single-dataset public boundary, kept for API users and the inference/e2e paths: it is
setup_datasets()over one record, with the train/validation split applied on top. Rotations split intotrain/validationplans sharing the grid; split membership is defined on the original PETS order (ignoring a rotation must not renumber later frames and silently move them between train and validation). The app’s preprocess spine does not come through here – it runssetup_datasets()per dataset and applies the split afterpool().
- diffBloch.preprocess.experiment.resolve_dataset_mosaicity(enabled: bool, record: ExperimentalRecord, rocking: RockingCurve) MosaicSmoothed | None[source][source]¶
Resolve PETS apparent mosaicity to the applied tilt reduction, or
Nonewhen disabled.
- diffBloch.preprocess.experiment.resolve_dataset_orientations(record: ExperimentalRecord) NDArray[float64][source][source]¶
This dataset’s per-rotation orientations, in the frame the rest of the pipeline assumes.
Composes the two concerns kept separate in
preprocess.orientation: the as-collected derivation (orientation_matrices()) and the goniometer-axis correction (rotation_axis_correction()), which brings the rotation axis onto x so the left-multiplied rocking tilts andklar_beam_mask’s(g_y, g_z)lever arm are measured about the right axis.The azimuth is read from the PETS file and there is no override: a file that does not record it cannot be processed. A missing value is an error rather than an assumed zero, because absent, unparsed, and genuinely-zero are three different situations, and silently treating the first two as the third is how a wrong integration axis goes unnoticed.
- diffBloch.preprocess.experiment.seed_beam_hkl(grid: StructureFactorGrid, *, g_max: float) NDArray[int64][source][source]¶
Difference-safe seed beams: the grid reflections within
g_max(includes 000).The orientation-independent candidate pool
{hkl in grid : |g| <= g_max}thatfrom_experimentlays down. Selecting from the sharedgridkeeps every beam difference inside theFgbsupport as long as2 * g_max <= grid.g_max(the caller’s responsibility).
- diffBloch.preprocess.experiment.setup_datasets(structure: StructureRecord, records: Sequence[ExperimentalRecord], config: ExperimentConfig) tuple[RefinementSetup, tuple[DatasetSetup, ...]][source][source]¶
Seed one candidate
Planper dataset + the structure side.The initial total construction of the preprocess pipeline (not
Plan -> Plan– there is noPlanyet), generalized over one or more PETS files (inputs.multi_dataset). The structure-side products are dataset-independent and built once, shared by every dataset’s plan: the structure-factor grid is derived from the solve cutoff (from_cell_for_beam_cutoff()sizes it to2xthe cutoff so it spans every coupledg - hdifference – the same grid object rides on every per-dataset plan, so theirFgbsupport cannot diverge), as are the difference-safe seed beams and theRefinementSetup.Per dataset: the beam energy is derived from that file’s PETS wavelength and snapped onto the nearest standard TEM voltage when close (PETS records wavelength to only 4-5 significant figures, so the exact inverse lands a few hundred eV off 100/200/300 kV rather than on it), and the mean-inner-potential
u0follows the energy – computed once per distinct snapped energy, since it depends on nothing else that varies between datasets. OneCandidatePlanper rotation carries its crystal orientation matrix (native PETS derivation, no side-car file) and the observed pattern for that zone axis.blochwave.ignore_orientationsindexes the pooled rotation space (files concatenated inrecordsorder, original pre-ignore counts); it is validated against the pooled total and translated to each dataset’s file-local slice here. A dataset whose every rotation is ignored raises – its recipe would have nothing to fit.This is intentionally a module-level function rather than a classmethod: it is the single documented public boundary of the preprocess pipeline (records + config -> setups), and it returns a composite of products rather than constructing one domain object. The per-object constructors it delegates to follow the classmethod idiom (
StructureFactorGrid.from_cell_for_beam_cutoff,CandidatePlan.seed,RefinementSetup.from_structure).
- diffBloch.preprocess.experiment.validation_mask(n_rotations: int, split: DataSplitConfig) NDArray[bool][source][source]¶
Boolean per-rotation validation mask from the split policy.
train_test=Falseholds out nothing (every rotation trains). Otherwise everyround(1 / val_frac)-th rotation (1-based count -> 0-based indices) is held out for validation, e.g.val_frac=0.2holds out every 5th rotation.
The preprocess composition combinators: sequence and fixpoint over Plan -> Plan steps.
A step is a pure Plan -> Plan transformer (it fits something – numerics, orientation,
thickness – and returns a sharpened Plan). pipeline()
chains steps left to right; iterate_until() drives one step to a fixpoint (for convergence
testing or alternating fits). Both return a Plan -> Plan step, so they nest – a fixpoint of
steps is itself a step. refine is deliberately not expressible here: it is the terminal
Plan -> Result estimator, not a Plan -> Plan transform.
Provenance. A step is self-describing: it carries a StepRecord (its name + serialized
params). As pipeline() applies each step it stamps that record onto the resulting
Plan’s provenance tuple, so the final Plan records the
ordered recipe that produced it – each step appends its record as it runs.
This is what lets a checkpoint bind its identity to the recipe, not just the inputs. A step with no
record (a bare closure, a nested composite) stamps OPAQUE – a plan whose provenance
contains it can never be reused (safe: a miss, never a false hit).
- class diffBloch.preprocess.pipeline.Fork(predicate: Callable[[StructureFactorGrid], bool], when_true: tuple[PlanStep, ...], when_false: tuple[PlanStep, ...])[source][source]¶
Bases:
objectThe choice combinator: run one of two step lists, chosen by a predicate on the grid.
The one rule that makes it checkpointable: the predicate reads only the :class:`~diffBloch.engine.plan.StructureFactorGrid`, invariant across every preprocess step (steps
replaceorientations; nothing resizes the grid). So the branch is a deterministic function of the experiment’s fixed inputs – knowable before running – rather than of the mutatingPlan. That keeps the fork’s shape static, soresolve_recipe()can splice the chosen branch inline into a flat, fork-free recipe before the checkpoint lock ever looks at it. A predicate over thePlanwould make the shape depend on intermediate results and is deliberately unrepresentable here.Branches are step lists, not pre-composed
pipeline([...])closures, so each branch step’sStepRecordsurvives into the resolved recipe (a composed closure would collapse to oneOPAQUE).__call__()lets aForkalso run ad hoc inside a rawpipeline– it produces the rightPlanbut recordsOPAQUE(a non-Stepin the stamping loop), a safe miss; checkpointable identity comes only fromresolve_recipe().- predicate: Callable[[StructureFactorGrid], bool]¶
- class diffBloch.preprocess.pipeline.Step(record: StepRecord, run: PlanStep)[source][source]¶
Bases:
objectA self-describing
Plan -> Planstep: its provenancerecord+ theruntransform.Callable, so a
StepsatisfiesPlanStepstructurally and every existing caller (pipeline,run_inference(prepare=...)) treats it as before;pipeline()also readsrecordto stamp provenance.- record: StepRecord¶
- class diffBloch.preprocess.pipeline.StepRecord(name: str, params: dict[str, Any] | None = None)[source][source]¶
Bases:
objectA step’s provenance entry: its
nameand canonical serializedparams(orNone).Two records compare equal iff the step and its params are identical, so a recipe’s provenance is a stable, comparable identity.
paramsis thespec_to_params()form (JSON-able, with__type__tags), so the record round-trips through the lock and the.npz__meta__.
- diffBloch.preprocess.pipeline.as_step(name: str, spec: Any, run: PlanStep) Step[source][source]¶
Wrap a step’s
runclosure with itsStepRecord(name+ serialized spec).
- diffBloch.preprocess.pipeline.fork(predicate: Callable[[StructureFactorGrid], bool], *, when_true: Sequence[PlanStep], when_false: Sequence[PlanStep]) Fork[source][source]¶
Build a
Forkchoosing between two step lists by a predicate on the grid.predicatereceives the sharedStructureFactorGrid(e.g. a cell-volume / grid-size test routing a large cell to a coarse-precision branch);when_true/when_falseare the branch step lists (kept as lists so their records survive resolution).predicatemust be a pure function of the grid – no external or mutable state. The grid argument is only half the contract: the type stops it reading the mutatingPlan, but a predicate that closed over a global flag would desync the pre-run resolution (for the lock) from the runtime branch just as badly. Purity over an input that is itself pipeline-invariant is what makes the branch deterministic per experiment.
- diffBloch.preprocess.pipeline.iterate_until(step: PlanStep, *, until: ConvergenceCheck, max_iterations: int = 50) PlanStep[source][source]¶
Drive
stepto a fixpoint: re-apply it untiluntil(previous, current)holds.Returns a
Plan -> Planstep that appliessteprepeatedly, checkinguntilagainst the (previous, just-produced) Plan pair after each application, and returns the first Plan that satisfies it. RaisesRuntimeErrorifmax_iterationsis reached without convergence – silent non-convergence is never returned.max_iterationsmust be >= 1.Provenance: the fixpoint stamps a single
OPAQUErecord – the number of iterations is input-dependent, so a per-iteration log would not be a stable recipe identity. A plan produced throughiterate_untilis therefore not checkpoint-reusable (a safe miss).
- diffBloch.preprocess.pipeline.pipeline(steps: Sequence[PlanStep], *, logger: Logger = NULL_LOGGER) PlanStep[source][source]¶
Compose
stepsleft to right, stamping each step’s record onto the plan’sprovenance.After applying each step, appends its
StepRecord(orOPAQUEfor a bare closure) to the plan’sprovenance, so the composed result records the ordered recipe. An empty list yields the identity (provenance unchanged).logger(default the null sink) receives onePlanSeededfor the incoming plan and then aPlanStepCompletedafter each step – the step’s name as the event channel, its ordinal as the step, andsummarize_plan()of the resulting plan – so a fresh preprocess run streams the plan’s shape as it evolves. The baseline is what makes the stream survival counts rather than absolute ones: each stage’s beam and reflection totals are only legible as a filter’s effect against what entered it. Reusing a checkpoint bypasses this runner, so those fire only on a fresh run (the boundaryCouplingSummarycovers the reuse case). Emission is alongside the provenancetell; the null default keeps the pure composition path unchanged.
- diffBloch.preprocess.pipeline.resolve_recipe(steps: Sequence[PlanStep], grid: StructureFactorGrid) tuple[PlanStep, ...][source][source]¶
Compile every
Forkaway againstgrid-> a flat, fork-free step list.Splices each fork’s chosen branch inline (recursively, so nested forks flatten too). Because the grid is invariant across the pipeline, resolving against the base grid here yields exactly the recipe that will run – which is what lets the checkpoint lock key on a flat
step_recordslist with no knowledge of forks.
- diffBloch.preprocess.pipeline.spec_to_params(spec: Any) dict[str, Any] | None[source][source]¶
Serialize a frozen-dataclass value-type to a canonical, deterministic dict for provenance.
Recurses into nested dataclasses (the specs nest:
TrialCouplingholds a policy +ScoredHklSelectionholds aBeamSelectionholds anIntegrationGeometry), tagging each with__type__= its class name so a fieldless discriminated-union arm (e.g.TiltIndependent, whoseasdictis{}) is distinguishable from any other empty spec. Non-dataclass leaves (int/float/str/bool/None, Literals-as-str) pass through; tuples/lists recurse elementwise.NonereturnsNone(a paramless step).
- diffBloch.preprocess.pipeline.stateful_pipeline(steps: Sequence[StatefulPlanStep[State]]) StatefulPlanStep[State][source][source]¶
Compose state-threading plan phases left to right.
A
StatefulPlanStephas shape(Plan, State) -> (Plan, State): it can transform the plan while also carrying live driver state that should not become part of the publicPlan. This helper is the explicit, immutable-state counterpart topipeline(): it folds the phases left to right, threading(plan, state)through each one without mutating either in place.The returned value is still stateful. Use
stateful_plan_step()to adapt it back to the ordinaryPlan -> Planpreprocess boundary.
- diffBloch.preprocess.pipeline.stateful_plan_step(init_state: StateInitializer[State], step: StatefulPlanStep[State]) PlanStep[source][source]¶
Adapt a state-threading driver to the ordinary
Plan -> Planpipeline shape.init_statederives the driver’s initial state from the incoming plan;stepruns the stateful computation; the final state is intentionally discarded. This formalizes drivers such as numerical convergence, whose public product is a settledPlanbut whose internals must thread transient scalar choices between phases.Provenance note: this returns a bare closure. Wrap it with
as_step()if the driver has a stable, serializable recipe identity; otherwise it will stampOPAQUElike any custom closure, which is the safe checkpoint behavior for input-dependent loops.
- diffBloch.preprocess.pipeline.step_records(steps: Sequence[PlanStep]) tuple[StepRecord, ...][source][source]¶
The recipe
pipeline(steps)will stamp – one record per step, in order.The checkpoint driver reads this before running to compare against a lock (does the intended recipe match / extend the snapshot’s?). A bare step contributes
OPAQUE, so a recipe containing one can be detected and refused (never checkpointed) up front.
Native crystal-orientation derivation for the preprocess pipeline.
Reconstructs per-rotation crystal orientation matrices from the experiment’s goniometer geometry –
the UB matrix and per-rotation tilt angles recorded in the PETS data – with no side-car
orientation file. The orientations are first-class inputs to the Plan; optimize_orientation
refines them in-Plan – it must not re-orthonormalise them: U carries PETS’s own small
UB-vs-cell-parameters fit residual, so a polar/SVD projection would silently drop it.
Convention:
orientation = R_z(omega) . R_x(alpha) . R_y(beta) @ U, U = UB @ B^-1
This is the as-collected convention – what PETS recorded. The goniometer axis is additionally
brought onto x by R_z(-rotation_axis_position) (rotation_axis_correction()), composed at
the dataset boundary in resolve_dataset_orientations() rather
than here, so this module’s derivation stays a pure function of the PETS-recorded geometry.
where B is the Busing-Levy reciprocal matrix built from this dataset’s own PETS cell
parameters (the same cell UB was fit against, so U is close to a pure rotation – see
diffBloch.preprocess.experiment._resolve_authoritative_cell for how a combined experiment’s
shared cell is chosen and cross-checked) and the goniometer rotations are active, in degrees.
Geometry then uses orientation_basis() = reciprocal_cell(cell @ orientation.T) (NOT
reciprocal_basis @ orientation.T), because orientation is not guaranteed exactly orthonormal
even so.
Reference: W. R. Busing & H. A. Levy, Acta Cryst. 22, 457 (1967) (the UB-matrix formalism).
- diffBloch.preprocess.orientation.busing_levy_matrix(cell_parameters: FloatArray) FloatArray[source][source]¶
Busing-Levy reciprocal B matrix from
(a, b, c, alpha, beta, gamma), angles in degrees.Rows follow the standard
a*-along-x setting. The cell volume is computed exactly from the parameters; we deliberately do not consume a rounded_cell_volumefield if the source file carries one (for the quartz anchor that rounding shifts orientations by ~1e-6, negligible).
- diffBloch.preprocess.orientation.goniometer_rotation(alpha: float, beta: float, omega: float) FloatArray[source][source]¶
Active goniometer rotation
R_z(omega) . R_x(alpha) . R_y(beta), angles in degrees.
- diffBloch.preprocess.orientation.hexagonal_tilt(azimuth: float, polar: float) FloatArray[source][source]¶
Palatinus hexagonal-search tilt
R_z(azimuth) . R_x(polar) . R_z(-azimuth), in degrees.A tilt of magnitude
polarabout the in-plane axis atazimuth– the delta rotationoptimize_orientationright-multiplies onto an orientation (orientation @ tilt). Being a true true rotation (det = 1) it preserves whatever small fit residualUalready carries exactly, so the re-orthonormalisation trap is dodged by construction.Reference: L. Palatinus et al., Acta Cryst. A69, 171-188 (2013), the hexagonal modified-simplex search.
- diffBloch.preprocess.orientation.orientation_basis(cell: FloatArray, orientation: FloatArray) FloatArray[source][source]¶
Lab-frame reciprocal basis for one orientation:
reciprocal_cell(cell @ orientation.T).The single home for the orientation convention.
orientationis not guaranteed exactly orthonormal – it carries PETS’s own UB-vs-cell-parameters fit residual (seepreprocess.orientation) – so this is NOTreciprocal_basis @ orientation.T.cellrows are the real-space basis; returns rowsa*, b*, c*in inverse Angstrom.orientation = Ireproducesreciprocal_cell(cell).
- diffBloch.preprocess.orientation.orientation_matrices(ub_matrix: FloatArray, cell_parameters: FloatArray, alphas: FloatArray, betas: FloatArray, omegas: FloatArray) FloatArray[source][source]¶
Per-rotation as-collected orientation matrices
R_goni @ U, shape(R, 3, 3).alphas/betas/omegasare the per-rotation goniometer angles (degrees), one entry per PETS zone axis, in the same order as the record’szone_axis_ids.As-collected means exactly what PETS recorded: this deliberately applies no goniometer-axis correction, so the result is a pure function of
UB, the cell, and the angles. Bringing the rotation axis onto x is a separate concern with a separate input, and lives inresolve_dataset_orientations().
- diffBloch.preprocess.orientation.rocking_curve_tilts(semiangle: float, sampling: int, *, geometry: str = 'continuous_rotation') FloatArray[source][source]¶
Rocking-curve integration tilts as
(N, 3, 3)rotation matrices,N = sampling.For
continuous_rotation,samplingtilts spanlinspace(-semiangle, +semiangle, sampling)degrees about x, the goniometer axis in the PETS coordinate frame.sampling = 1is the identity so that unit-sample rocking integration composes off. Forprecession, samples lie at the fixed cone semi-angle and uniformly spaced azimuths over[0, 360)usingR_z(phi) R_x(semiangle) R_z(-phi). This is the convention used by the legacy preprocessing path.In both modes these matrices left-multiply the already-PETS-rotated nominal orientation (
R_tilt @ orientation). Callers unpack a validatedRockingCurveinto these raw arguments (the value-type owns the invariants), matchinghexagonal_tilt()’s raw-float style.
- diffBloch.preprocess.orientation.rotation_axis_correction(rotation_axis_position: float) FloatArray[source][source]¶
R_z(-rotation_axis_position), degrees: brings the true goniometer axis onto x.Two independent parts of the pipeline assume the goniometer/rotation axis lies along x in PETS’s own coordinate frame:
rocking_curve_tilts(), whose tilts are left-multiplied onto an orientation (R_tilt @ orientation), andklar_beam_mask(), whose lever arm is the lab-frame(g_y, g_z)– the distance from the x rock axis. Both hold only when PETS’srotation axis positionis zero.A nonzero value means the real axis sits at that azimuth instead, so every per-rotation orientation must be pre-rotated back by its negative before any x-axis tilt is composed onto it, or the whole rocking-curve integration runs about the wrong axis. This is the pure matrix; the composition and the record-vs-config resolution live in
resolve_dataset_orientations().
- diffBloch.preprocess.orientation.u_matrix(ub_matrix: FloatArray, cell_parameters: FloatArray) FloatArray[source][source]¶
Crystal
UmatrixU = UB @ B^-1, from this dataset’s own PETS UB and cell parameters.Close to a pure rotation (
Bis built from the same cell PETS fitUBagainst), but not guaranteed to be exactly orthonormal – PETS’s own UB-vs-cell-parameters fit carries a small residual. Not re-orthonormalised (see the module docstring).
Assemble a RefinementEngine and score orientations against data (computes wR2).
Bridges the two products diffBloch.preprocess.from_experiment() returns – the geometry
Plan and the structure-side
RefinementSetup – into a runnable
RefinementEngine, then exposes the per-orientation scaling-optimised wR2
the orientation refinement (optimize_orientation) minimises.
build_engine is the general Plan + RefinementSetup -> engine assembly (the same engine
refine will consume); score_orientations is the thin convenience that computes the
orientation-invariant F_gb once and scores every orientation of a Plan. The forward
simulation inside is deterministic and depends only on its inputs (same inputs always give the same
result), so it does not change any shared state – it is ordinary computation reading captured
read-only context, not a side effect.
- diffBloch.preprocess.scoring.active_structure_factor_indices(orientations: tuple[OrientationPlan | CoupledOrientationPlan, ...], gpts: tuple[int, int, int]) Tensor[source][source]¶
Return grid rows referenced by the settled plans’ structure-factor gathers.
- diffBloch.preprocess.scoring.build_engine(plan: Plan, refinement: RefinementSetup, *, loss: LossFn = wr2_loss, scores: ScoresFn = wr2_scores, method: SolverMethod = 'matrix_exp', max_batch: int | None = None, absorption: Absorption = NO_ABSORPTION, compact_structure_factors: bool = True, profile: bool = False, checkpoint_activations: bool = True) RefinementEngine[source][source]¶
Wire a geometry
planand a structurerefinementinto a runnable engine (no compute).Pure assembly, not a forward pass.
PlanandRefinementSetupare kept deliberately separate – thePlan(shared grid + per-rotation orientations) flows through thePlan -> Planpreprocess steps, whilerefinement(constraint spec, ASU-expansion plan, ASU atomic numbers) is static structure context.build_engineis the single place that rejoins them when a simulation is actually needed; bothscore_orientationshere andrefinelater go through it.loss/scoresare the matching scalar/per-thickness forms of one objective (seediffBloch.engine.losses):lossis the per-orientation termrefineminimises,scoresis whatRefinementEngine.score_orientation()/score_orientation_per_thickness()search over (optimize_orientation/optimize_thickness). Both default to wR2 (wr2_loss()/wr2_scores()) after matching calculated total intensity to observed – calc and obs are on different scales, so the raw metric would be flat/gradient-free. Callers should pass both from the sameExperimentConfig.loss_metrics(to_loss()/to_scores()) so the search and the gradient objective agree.max_batch(defaultNone) caps thematrix_exppropagator block;Nonelets each solve pick a memory-safe block from its beam count, bounding peak memory while matching the unbounded solve to machine precision (a pin is only needed for a specific device budget). Execution-only, likemethod.compact_structure_factorscomputes only support-grid rows referenced by the settled solve gathers and scatters them into the unchanged grid-shaped interface. It changes neither the solve nor its gradients; callers whose beam sets change dynamically may disable it or extend the support lazily.profilelogs per-phase wall time (structure factors, each rotation’s solve) on the built engine; seeRefinementEngine. Execution-only and off by default – it forces a CUDA sync around every measured block.checkpoint_activations(defaultTrue) trades peak memory for one extra forward recompute per per-orientation/per-segment solve on the refinement backward pass; disabling it removes that recompute at the cost of retaining every solve’s intermediates until backward. Execution-only – gradients are identical either way. SeeRefinementEngine.
- diffBloch.preprocess.scoring.score_orientations(plan: Plan, refinement: RefinementSetup, *, method: SolverMethod = 'matrix_exp') tuple[Tensor, ...][source][source]¶
Scaling-optimised wR2 for every orientation in
planat the seededrefinement.params.Computes the orientation-invariant
F_gbonce and reuses it across orientations. This is the objective surfaceoptimize_orientationsearches over per rotation; here it evaluates the current (seed) orientations, returning one scalar score per rotation.
Tilt-segment-union beam coupling: per-tilt-chunk beam sets across a rocking curve.
Rocking-curve integration solves the crystal at many slightly-tilted sub-orientations. A single beam set for the whole curve either over-couples (every beam any tilt needs, slow) or drops beams a tilt needs (a reflection drifts through the Ewald sphere as the crystal rocks). This coupling policy instead partitions the tilts into contiguous chunks and couples, within each chunk, the union of the excited-beam sets at the chunk’s two boundary tilts.
This module is the pure geometry of that partition: given a
UnionCoupling
policy and the rotation’s tilt geometry, it returns the ordered Segment list (each a beam
set + the disjoint tilt indices it covers). It computes the excitation mask
(|Sg| < sg_max and |g| < g_max) from which the union sets are built,
reusing excitation_errors() and
orientation_basis(). It does not build or solve anything – an engine
step turns the segments into per-chunk plans and reassembles their curves before reduction.
- class diffBloch.preprocess.coupling.Segment(union_hkl: NDArray[int64], covered_tilt_indices: tuple[int, ...])[source][source]¶
Bases:
objectOne tilt chunk’s coupling: the beam set it solves and the tilt indices it covers.
union_hkl(n_beams, 3)is the union of the excited beams at the chunk’s boundary tilts (includes(0, 0, 0), always excited).covered_tilt_indicesare the global rocking-curve tilt indices this segment is responsible for – contiguous and, across a rotation’s segments, disjoint and covering every tilt exactly once. The segment solves its beam set at each covered tilt; the per-tilt intensities are later scattered back onto each reflection’s full rocking curve.- union_hkl: NDArray[int64]¶
- diffBloch.preprocess.coupling.build_coupling_segments(policy: UnionCoupling | PerTiltCoupling, candidate_beam_hkl: NDArray[int64], *, cell: NDArray[float64], orientation: NDArray[float64], tilts: NDArray[float64], energy: float, u0: float) tuple[Segment, ...][source][source]¶
Partition the rocking curve into boundary-union coupled segments (pure geometry).
candidate_beam_hkl(G, 3)is the beam candidate pool to select from (the sharedStructureFactorGridstructure_factor_hkl– radius2 * g_max, so the|g| < g_maxmask filters it to each tilt’s excited set).cell(3, 3)is the real-space basis,orientation(3, 3)the rotation’s crystal orientation,tilts(B, 3, 3)the rocking-curve tilt matrices (each left-multiplyingorientation).energy(eV) andu0(mean-inner-potential correction) set the Ewald geometry.Each boundary tilt’s excited mask is
|Sg| < sg_maxand|g| < g_maxwithg = candidate_beam_hkl @ orientation_basis(cell, tilt @ orientation). Segmenticouples the union of the masks at boundary tiltsiandi + 1and covers the half-open tilt range between them; the final segment includes the end, so the covers tile0 .. B - 1exactly.
Steps¶
select_beams: prune each orientation’s beams to its active set (Klar et al. 2023 filter).
A Plan -> Plan step that re-picks every OrientationPlan’s active
beam_hkl using the relative-/minimum-excitation-error criterion of the SI of Klar et al. (2023),
then rebuilds its BeamPlan + AlignmentPlan against the shared grid (pattern unchanged).
This is the per-orientation selection that replaces the orientation-independent
g_max seed laid down by from_experiment.
sg_max is the excitation-error span a reflection sweeps during the actual integration, so its
transverse lever arm is set by the tilt geometry (BeamSelection.geometry), which must match the
integrator’s (RockingCurve). For continuous_rotation the crystal rocks
about the goniometer axis (x in the PETS frame; rocking_curve_tilts builds R_x), so the
swept excitation error has amplitude |(g_y, g_z)| – the distance from the rock axis – and a
reflection on that axis (g_y = g_z = 0) never sweeps and is correctly dropped. For
precession (an isotropic cone about the -z beam) the lever arm is instead |(g_x, g_y)|,
the distance from the beam. The frame convention has the beam along -z and the rock axis along
x.
- diffBloch.preprocess.steps.beams.build_orientation_plans(rocking: RockingCurve | None = None, mosaicity: MosaicSmoothed | None = None, *, coupling: UnionCoupling | PerTiltCoupling | None = None, scoring_selection: BeamSelection | None = None, workers: int = 1) PlanStep[source][source]¶
Build each candidate’s final tilted Bloch geometry and intensity reduction.
The single build boundary of the preprocess pipeline: it materialises each orientation’s structure-factor gather (the dominant cost) over its beam set via
OrientationPlan.build, and the rebuiltAlignmentPlanre-bridges the simulator output to the observedpattern. A custom pipeline may compose it afterselect_beams(); the default coupled path instead derives the SOLVE beams directly fromg_max/sg_maxand the explicit sub-tilts. The engine consumes only these built plans; aCandidatePlanhas nobeam_plansand is unsolvable by construction.When
rockingis supplied, the builder directly creates its complete sub-tilt geometry instead of first building a temporary central-orientation plan and rebuilding it later.mosaicityselects the reduction applied to those sub-tilt intensities and therefore requiresrocking. Whencouplingis supplied, each segment’s beam set is selected from the full support grid by|g| < g_maxand|Sg| < sg_maxat its boundary tilts, then the ordinary alignment intersects the resulting simulator HKLs with the PETS experimental data.scoring_selectionoptionally applies the former Klarrsg/dsg/semiangle filter to the candidate scoring pool before that intersection; it does not alter the coupled SOLVE beams.workersfans independent rotation builds over threads while preserving input order. It is execution-only and therefore intentionally absent from the step’s provenance record. Omittingcouplingpreserves the simple builder used by focused APIs/tests.
- diffBloch.preprocess.steps.beams.klar_beam_mask(g: NDArray[float64], *, energy: float, u0: float = 0.0, rsg: float, dsg: float, semiangle: float, geometry: Literal['continuous_rotation', 'precession'] = 'continuous_rotation') NDArray[bool][source][source]¶
Boolean keep-mask for reflections
g(N, 3)under the Klar (2023) rsg/dsg filter.Each reflection’s excitation error
|Sg|(Spence & Zuo, viaexcitation_errors(); beam along-z) is compared againstsg_max, the excitation-error span it sweeps during integration:sg_max = |g_lever| * deg2rad(semiangle). The lever arm depends ongeometry– forcontinuous_rotationthe rock is about the goniometerxaxis, sog_lever = (g_y, g_z)(distance from the rock axis); forprecession(cone about the beam) it isg_lever = (g_x, g_y)(distance from the-zbeam). A reflection is kept when both|Sg| / sg_max < rsg(relative excitation error small) andsg_max - |Sg| > dsg(a minimum absolute margin). Reflections withsg_max = 0(on the rock axis, resp. optic axis) fail the relative test and are dropped – they never sweep through the Ewald sphere; the 000-beam retention required by the Bloch system is handled by the caller.
- diffBloch.preprocess.steps.beams.select_beams(selection: BeamSelection) PlanStep[source][source]¶
Return a
Plan -> Planstep that prunes each candidate to its Klar active beam set.A source-level prune on the
CandidatePlanphase: for every orientation the candidatebeam_hklis re-selected byklar_beam_mask()against that orientation’s lab-frameg(derived from its storedorientationand the grid cell), keeping only the active set. No geometry is built here – the structure-factor gather is built later, over the pruned set, bybuild_orientation_plans().selectionis a pre-validatedBeamSelection(rsgrelative excitation-error cutoff,dsgminimum margin,integration_semianglein degrees); invalid cutoffs are unrepresentable, so this step never re-validates. The observedpatternis untouched.The 000 transmitted beam is retained whenever present (the
from_experimentseed always includes it):BeamPlananchorspsi0onhkl == 000, and 000 hasg = 0so itssg_max = 0would otherwise reject it. Beams stay within the seed radius (blochwave.g_max), so theFgbdifference support remains valid oncebuild_orientation_plansruns.
optimize_orientation: per-rotation crystal-orientation refinement.
A Plan -> Plan step that sharpens each orientation by minimising the scaling-optimised wR2 of
the full dynamical simulation against the observed pattern – the objective exposed by
score_orientation(). A local
scipy.optimize.minimize(method="Nelder-Mead") simplex search over the three
goniometer-correction angles directly, seeded from a fixed initial simplex of edge length
search.step_size around (alpha, beta, omega) = (0, 0, 0) (see _refine_one()).
The captured refinement is read-only context the step never mutates; the simulation inside is
deterministic and depends only on its inputs, so it is ordinary computation, not a side effect.
Without coupling=, the SCORED reflection set is held fixed at each orientation’s seed
selection across the search (with_orientation() only
recomputes the orientation-dependent bases). The rocking-curve tilt set carried by the Plan is
threaded through every trial unchanged, so each candidate is scored under the same integration as
the seed – the optimize/eval consistency invariant. Ordering integrate_rocking_curve before this
step therefore couples the search to the integrated model; with rocking off the tilt set is a single
identity, identical to a static search.
Coupling (opt-in, ``coupling=TrialCoupling(…)``) re-derives the excitation-selected SOLVE
beams and re-selects the SCORED set at every trial’s own orientation (coupling.scored’s Klar
rsg/dsg window + resolution cap, reapplied to that trial’s fresh lab-frame geometry) – mirroring
the reference implementation’s per-trial filter_hkls, rather than pinning scoring to the seed’s
selection. A trial’s matched-reflection count can therefore differ from the seed’s and from other
trials’; the default wR2 formula itself has no reflection-count term
(diffBloch.core.losses.w_rbragg(): plain sum/sum), so the search can in principle prefer
a trial that improves partly by matching a different, easier subset –
search.penalize_fewer_reflections (NelderMeadSearch, off by default)
guards against exactly this by dividing the comparison score by the matched count
(_comparable_score()); the reported score stays the plain metric regardless. The seed is
rebuilt through the same builder, and the last accepted trial is already the
coupled-at-optimized-orientation plan – no separate couple_beams step is needed. Atomic F_gb
values are cached lazily by support-grid row: each trial computes only previously unseen beam
differences, while every segment’s structure matrix remains a cheap gather-index into that cache.
With coupling=None (the default) each trial is current.with_orientation(...), defined on
both the tilt-independent OrientationPlan and the
CoupledOrientationPlan, so an already-segmented plan is optimized
under its frozen union.
- diffBloch.preprocess.steps.optimize_orientation.optimize_orientation(refinement: RefinementSetup, search: NelderMeadSearch, *, method: SolverMethod = 'matrix_exp', coupling: TrialCoupling | None = None, validate: bool = True, workers: int = 1, device: Device | None = None, max_batch: int | None = None, logger: Logger = NULL_LOGGER, absorption: Absorption = NO_ABSORPTION, scores: ScoresFn = wr2_scores, residual: str = 'wr2') PlanStep[source][source]¶
Return a
Plan -> Planstep refining each orientation by orientation search.residual(default"wr2") is the display name forscores– passcfg.loss_metrics.residualalongsidescores=cfg.loss_metrics.to_scores()soOrientationOptimizedreports the score under its real name.scores(defaultwr2_scores()) is the per-thickness metricscore_orientation()searches – passcfg.loss_metrics.to_scores()to search the same residual the gradient refinement stage minimises (to_scores()). Execution-only likemethod: it changes what the search optimizes for, not the recipe’s own identity (the resolvedExperimentConfig.loss_metricsalready rides indataset_config_digest()).refinement(constraint spec, ASU expansion, atomic numbers, seeded params) is captured read-only and rejoined to the geometryPlanviabuild_engine(); the orientation-invariantF_gbis computed once and reused across every orientation and trial.searchis a pre-validatedNelderMeadSearch(invalid bounds are unrepresentable, so this function never re-validates).methodconfigures the engine’s solver (score_orientationscores withscores, a scaling-optimised wR2 by default).coupling(defaultNone) opts the optimization into per-trial re-coupling: aTrialCouplingre-derives the solve union and re-selects the scored set at every trial orientation (see the module docstring for the non-stationary-objective nuance).Nonekeeps the tilt-independent search (one fixed beam set across the search).validate(defaultTrue) forwards to the per-trial coupled gather rebuild (build_structure_factor_gather()).Falseskips its O(N^2) integrity checks – the dominant per-trial cost over a large coupled union – for the large-cell fast path. It is sound only because the coupled coverage guard (above) proves the grid spans the beam-difference support; without that guarantee a skipped check would let a gather silently read zeros. Inert unlesscouplingis set (the tilt-independent path rebuilds no gather in the search), and, like the coverage guard, it does not enter the recipe identity: the checks are pure, soFalseyields identical gather indices when coverage holds.device(defaultNone= CPU) places the search’s forward solve on the given accelerator: the seed params are moved there andengine.fgbis computed on-device, so every per-trialscore_orientationco-locates onto the param-derivedfgb.deviceat the use site (the CPU trial rebuilds are cheap numpy; only their tensors reach the device). Kept out of the recipe identity likeworkers/logger– but unlike those it is not bit-exact: the solve shifts ~1e-11 cross-device, and because the greedy search accepts on a threshold, that shift can flip a near-tie into a full-radius orientation difference (a well-conditioned optimization stays; a knife-edge one legitimately diverges). Safe regardless: reproducibility is anchored at the checkpoint boundary, so a committed CPU checkpoint is reused (not recomputed) on GPU, cannot restale – only a fresh GPU-computed checkpoint would differ from a CPU one.workers(default 1, sequential) fans the per-rotation searches over a thread pool. Rotations are independent, the engine andF_gbare read-only shared context, results keep plan order, and each rotation’s gather cache is thread-local – so the results are identical to a sequential run. Threads (not processes) suffice because torch’s CPU linalg releases the GIL.max_batch(defaultNone) caps thematrix_exppropagator block;Nonelets each solve derive a memory-safe block from its beam count. Execution-only and matches the unbounded solve to machine precision (memory only), likedevice– raise it to fill a larger GPU. Seebuild_engine().loggerreceives anOrientationOptimizedper rotation as its search completes (the optimization is the run’s long phase, so this is the progress stream); the defaultNULL_LOGGERdiscards them. Withworkers > 1events arrive in completion order.The greedy search restarts at the same radius on every accepting (improving) pass, so the radius schedule alone does not bound the pass count. Mirroring
iterate_until(),search.max_iterationscaps the total passes per orientation and aRuntimeErroris raised if it is reached – silent non-convergence is never returned.The cap is a runaway guard: the search terminates by construction for a non-degenerate objective (monotone wR2 descent + the radius floor), so the cap only guards pathological ridge-walking on (near-)degenerate landscapes. Its default of
2000is calibrated on the quartz anchor under the integrated recipe (slowest legitimate search: 1288 passes across 99 rotations, so 2000 has headroom); raise it via config if a dataset with shallower minima trips it.
optimize_thickness: per-rotation specimen-thickness calibration by grid search.
A Plan -> Plan step that replaces each rotation’s thickness with the value that best matches its
observed pattern. The specimen’s 3D shape is irregular, so each orientation presents a different
beam path length; this optimizes that length per rotation rather than assuming one shared thickness.
For each orientation it evaluates n_steps candidate thicknesses spaced evenly from
min_thickness to max_thickness and keeps the candidate with the lowest scaling-optimised
weighted R-factor (wR2). All candidates are simulated in a single forward Bloch pass: the expensive
eigendecomposition depends only on the orientation and the structure factors, while thickness enters
only the cheap propagation tail, so scoring 100 thicknesses costs barely more than scoring one
(score_orientation_per_thickness()).
The captured refinement is read-only context the step never mutates; the simulation inside is
deterministic and depends only on its inputs, so it is ordinary computation, not a side effect.
The search is an evenly-spaced (np.linspace) grid of
candidate thicknesses, per-candidate wR2 via the scaling factor, then the per-rotation minimum.
Plan-agnostic: replace(op, thickness=...) swaps the thickness on either an
OrientationPlan or a CoupledOrientationPlan (whose
_solve_segmented reads the top-level thickness, ignoring the stale sub-plan copies), so a
coupled plan is optimized unchanged.
- diffBloch.preprocess.steps.optimize_thickness.optimize_thickness(refinement: RefinementSetup, grid: ThicknessGrid, *, method: SolverMethod = 'matrix_exp', device: Device | None = None, max_batch: int | None = None, logger: Logger = NULL_LOGGER, absorption: Absorption = NO_ABSORPTION, scores: ScoresFn = wr2_scores, residual: str = 'wr2') PlanStep[source][source]¶
Return a
Plan -> Planstep optimizing each rotation’s thickness by grid search.scores(defaultwr2_scores()) is the per-thickness metric the grid search argmins over – passcfg.loss_metrics.to_scores()to search the same residual the gradient refinement stage minimises (to_scores()). Execution-only likemethod: the resolvedExperimentConfig.loss_metricsalready rides indataset_config_digest().residual(default"wr2") is the display name forscores– passcfg.loss_metrics.residualalongside it soThicknessOptimizedreports the score under its real name.refinement(constraint spec, ASU expansion, atomic numbers, seeded params) is captured read-only and rejoined to the geometryPlanviabuild_engine(); the orientation-invariantF_gbis computed once and reused across every orientation. Each rotation is then assigned the lowest-wR2 ofgrid.n_stepscandidate thicknesses spaced evenly fromgrid.min_thicknesstogrid.max_thickness(inclusive, Angstroms).gridis a pre-validatedThicknessGrid(invalid bounds are unrepresentable, so this function never re-validates);methodconfigures the engine’s solver.device(defaultNone= CPU) places the grid search’s forward solve on the given accelerator by moving the seed params there; the engine co-locates every invariant onto the param device at the use site. Execution-only (kept out of the recipe identity), exactly as inoptimize_orientation().max_batch(defaultNone) caps thematrix_exppropagator block.Nonelets each solve derive a memory-safe block from its beam count – it matters most here because the grid search evaluatesgrid.n_stepsthicknesses at once, so a wide coupled segment’s(C, T, N, N)propagator can be tens of GiB if left unbounded. Raise it to fill a larger GPU. The bound matches the unbounded solve to machine precision (memory only) and is execution-only, likedevice.logger(default the null sink) receives aThicknessOptimizedper rotation as its grid search completes – the progress stream for this phase (mirroringoptimize_orientation); the memory-heavy thickness search is otherwise silent under a console logger.
integrate_rocking_curve: bake each rotation’s rocking-curve tilt set into the geometry.
A Plan -> Plan step that replaces each rotation’s single static geometry with N tilted
sub-orientations spanning the integration semi-angle – the forward model then sums |psi|^2 over
the tilts (an incoherent rotation-frame integration; see
diffBloch.core.products.BlochSolution.integrate()). The rocking curve is the scientific
enabling structure of the rotation-electron-diffraction forward model, so it is a composable,
toggleable step rather than baked into from_experiment: composing it in with
rocking.sampling == 1 (a single angle-0 tilt) is the identity, so leaving it out leaves the
Plan unchanged.
It is pure geometry – no engine, no structure factors, no refinement: the tilts depend only on the
fixed RockingCurve and each settled nominal orientation, so they are prebuilt into the
Plan exactly like the per-orientation beam plans. Ordered last in the pipeline (after
select_beams / optimize_orientation / optimize_thickness, which score on the fast single-solve):
the fits settle the nominal orientation and the one shared beam set, then this bakes the integration
geometry those results are held fixed at, reusing that beam set across every tilt.
Building the tilt matrices and integrating their intensities is exposed here as one composable unit, rather than being wired in unconditionally.
- diffBloch.preprocess.steps.rocking_curve.integrate_rocking_curve(rocking: RockingCurve) PlanStep[source][source]¶
Return a
Plan -> Planstep baking each rotation’s rocking-curve tilt geometry.rockingis a pre-validatedRockingCurve(invalid bounds are unrepresentable, so this never re-validates):rocking.samplingtilts about the goniometer axis spanning+/- rocking.integration.semiangledegrees (rocking.integration.geometryselects the sweep). The tilt matrices are orientation-independent, so they are generated once and left-multiplied onto every rotation’s nominal orientation (R_tilt @ orientation); each rotation is rebuilt with itsNsub-orientations sharing its one existing beam set.
Convergence testing: grow a simulation-accuracy knob until the diffraction pattern stops moving.
A convergence sweep is self-referential – unlike optimize_orientation / optimize_thickness (which
match the simulation to observed data), it asks whether two consecutive simulations still
differ,
so it is a numerical resolution study (has the calculation stopped depending on the knob?), run
before and orthogonally to the accuracy fit. This module has three layers:
simulation_rfactor()– the measurement:(previous, current) -> float, the mean per-orientation R-factor between two Plans’ simulations (0 when they are identical).converge_scalar()– the parameter-agnostic driver: given abuild(value) -> objectand ameasureit clicks a scalar knob upward until two consecutive builds stop changing (the R-factor drops below threshold), or a hard cap raises. It knows nothing about beams (or even Plans); adapters instantiate it.converge_beams()– the beam-window adapter: aPlan -> Planstep that widensintegration_semiangleuntil the pattern stabilises, re-runningselect_beamsfrom the seed.converge_sampling()– the forward-model lever: refines the rocking-curve tilt count (rocking_curve_sampling) until the integrated pattern stabilises. Independent of the beam levers (the tilt count does not touch theFgbsupport), so it composes as an ordinary lever.
simulation_converged() wraps simulation_rfactor() with a threshold to give the boolean
ConvergenceCheck that
iterate_until() drives to a fixpoint.
The convergence sweep grows each numeric knob (beam-pool radius, excitation window, tilt count) and
stops the first time the per-orientation Bragg R-factor between consecutive simulations drops below
r_factor_threshold – a deliberately simple stopping rule (no patience, no skip-null).
- diffBloch.preprocess.steps.convergence.converge_beams(selection: BeamSelection, refinement: RefinementSetup, tolerance: ConvergenceTolerance, *, step: float, method: SolverMethod = 'matrix_exp') PlanStep[source][source]¶
Return a
Plan -> Planstep: widenintegration_semiangleuntil the pattern stabilises.The window lever of beam-set convergence (the physically primary “how many near-Ewald beams”). Each candidate re-runs
select_beams()from the incoming seed Plan at a widerintegration_semiangle– selecting from the fixed seed each time, not from the previous (already-pruned) candidate, so widening can admit beams a narrower window dropped. The sweep starts atselection.integration.semiangleand clicks up bystep(degrees) untilconverge_scalar()settles the pattern (first sub-threshold step wins);rsg/dsgare held fixed.stepmust be positive.
- diffBloch.preprocess.steps.convergence.converge_sampling(rocking: RockingCurve, refinement: RefinementSetup, tolerance: ConvergenceTolerance, *, step: float, method: SolverMethod = 'matrix_exp') PlanStep[source][source]¶
Return a
Plan -> Planstep: refine the rocking-curve tilt count until the pattern stops.The forward-model convergence lever (independent of the two beam levers): each candidate bakes the rocking-curve integration geometry at a finer
rocking_curve_sampling(the tilt count) viaintegrate_rocking_curve(), so the summed|psi|^2over tilts approaches the continuous rotation-frame integral. The sweep starts atrocking.samplingand clicks up bystep(rounded to a whole tilt count) untilconverge_scalar()settles the pattern (first sub-threshold step wins): it settles when a finer tilt grid stops moving the integrated intensities. Onlysamplingis swept – the tilt span (rocking.integration.semiangle) androcking.integration.geometryare held fixed. Re-integrating from the incoming seed each step (integrate_rocking_curverebuilds tilts from each nominal orientation, discarding any prior tilts) makes the sweep independent of the seed’s tilt state.stepmust be positive. Unlike the beam levers this needs no grid guard (the tilt count does not touch theFgbsupport) and does not couple to them, so it composes as an ordinary extra lever.
- diffBloch.preprocess.steps.convergence.converge_scalar(build: Callable[[float], T], measure: Callable[[T, T], float], tolerance: ConvergenceTolerance, *, start: float, step: float, accept_converged_candidate: bool = True) T[source][source]¶
Grow a scalar knob until two consecutive builds stop changing; return the converged object.
The parameter-agnostic convergence driver – it knows nothing about beams or Plans.
build(value)rebuilds the object at a knob value;measure(previous, candidate)is the consecutive-output R-factor (0 when identical). Starting fromstartand clicking bystepeach iteration, it stops at the first candidate whose R-factor against the previous build is belowtolerance.r_factor_threshold. By default it returns that candidate;accept_converged_candidate=Falseretains the previous value instead. The stopping rule is deliberately simple – the first dip stops the sweep, and an unchanged build (R = 0) counts as converged – with no patience and no null-step handling. RaisesRuntimeErroriftolerance.max_iterationssteps pass without a dip below threshold (silent non-convergence is never returned, matchingiterate_until()).
- diffBloch.preprocess.steps.convergence.simulation_converged(refinement: RefinementSetup, tolerance: ConvergenceTolerance, *, method: SolverMethod = 'matrix_exp') ConvergenceCheck[source][source]¶
Return a
(previous, current) -> boolcheck: have consecutive simulations stabilised?Thin threshold wrapper over
simulation_rfactor(): the mean per-orientation R-factor is compared againsttolerance.r_factor_threshold. This is the booleanConvergenceCheckthatiterate_until()drives to a fixpoint (the cross-lever composition);converge_scalar()uses the underlying float measure directly, applying the same threshold inline.
- diffBloch.preprocess.steps.convergence.simulation_rfactor(refinement: RefinementSetup, *, method: SolverMethod = 'matrix_exp', comparison_hkl: tuple[Tensor, ...] | None = None) SimulationRfactor[source][source]¶
Return
(previous, current) -> float: the mean consecutive-simulation R-factor.refinement(the read-only structure context) is captured and rejoined to each Plan viabuild_engine();methodconfigures the solver. The returned measure simulates both Plans, computes the scale-optimisedrbraggR-factor between them on each orientation’s shared reflections, and averages over orientations. The comparison is a control-flow decision, not a gradient path, so the simulated intensities are detached. It is 0 exactly when the two Plans produce identical simulations (the null-step signal the sweep skips).The two Plans must describe the same orientations in the same order (a convergence step rebuilds each orientation, changing only its beam set), and each pair must share at least one reflection (the retained 000 guarantees this in practice).
Coverage sweep: grow a beam knob to the minimum that recovers the most matched reflections.
The second convergence operation (the first is self-stability in convergence.py). Where a
converge_* sweep asks whether two consecutive simulations agree, a coverage sweep asks a
cheaper, purely geometric question: how many of each orientation’s observed reflections does the
current beam set actually include? Growing a beam knob admits more beams; a candidate is accepted
only while it increases that matched count, and the sweep stops at the first knob step that buys
no new match – the minimal beam set that still covers the data.
plan_coverage()– the objective:sum over orientations |beam_hkl & observed hkl|. It is a pure function of the Plan (no engine, no structure factors): a “match” is set membership (an experimental hkl present in the filtered simulated beam set, with no intensity gate).maximize_scalar()– the parameter-agnostic driver: click a scalar knob upward, keep the build while the objective strictly increases, return the last build at the first non-increase, or raise at a hard cap. The match-count analogue ofconverge_scalar().cover_beams()– thePlan -> Planadapter for the Klar window (integration_semiangle) lever.
The two convergence operations are two kinds of objective: coverage maximises observed matches
(sequential per-knob sweeps, accepting a candidate only while the match count increases, capped),
while self-stability (convergence.py) settles simulations.
- diffBloch.preprocess.steps.coverage.cover_beams(selection: BeamSelection, *, step: float, max_iterations: int = 100) PlanStep[source][source]¶
Return a
Plan -> Planstep: widen the Klar window to the minimum that maximises coverage.The window (
integration_semiangle) lever of the coverage sweep: each candidate re-runsselect_beams()from the incoming seed at a wider window, andmaximize_scalar()keeps widening whileplan_coverage()strictly increases, stopping at the first window that admits no new matched reflection.stepmust be positive.
- diffBloch.preprocess.steps.coverage.maximize_scalar(build: Callable[[float], T], objective: Callable[[T], float], *, start: float, step: float, max_iterations: int = 100) T[source][source]¶
Grow a scalar knob while
objectivestrictly increases; return the last accepted build.The parameter-agnostic coverage driver – it knows nothing about beams or Plans.
build(value)rebuilds the object at a knob value;objective(obj)is the score to maximise (for coverage,plan_coverage()). Starting fromstartand clicking bystep, it keeps a candidate while its score is strictly greater than the best so far and returns the best at the first step that does not improve it (accept whilecandidate > best, else stop). RaisesRuntimeErrorifmax_iterationssteps pass while the score is still increasing (the score never plateaus).max_iterationsmust be >= 1.
- diffBloch.preprocess.steps.coverage.plan_coverage(plan: Plan) int[source][source]¶
Count matched reflections:
sum over orientations |beam_hkl intersect observed hkl|.A pure, engine-free measure of how much of the observed data the current beam set covers. A “match” is an observed reflection whose hkl is present in that orientation’s active beam set (set membership, no intensity threshold).
couple_beams: choose the rocking curve’s beam-coupling policy across its tilts.
A Plan -> Plan step selecting how each rotation couples beams over its rocking-curve tilts, a
discriminated union rather than a boolean toggle:
TiltIndependent– the default: one beam set (theselect_beamsactive set) shared across every tilt. A no-op here (the shared set is already on the plan).UnionCoupling– a policy that partitions the tilts into contiguous chunks and gives each its own boundary-union beam set (build_coupling_segments()), replacing eachOrientationPlanwith aCoupledOrientationPlanthe engine reassembles + reduces.
It is the tilt-dependent generalization of select_beams. The default app recipe does not use
this step – it couples per trial inside optimize_orientation (coupling=...); couple_beams
is the explicit composable step when a caller wants to settle a coupled Plan directly. It is
only meaningful once select_beams has established the Klar-selected scored set (before it, an
orientation’s
alignment is the seed-pool intersection, too wide). It accepts either a plain
OrientationPlan (the first application) or an already-coupled
CoupledOrientationPlan (the re-couple), re-deriving each
rotation’s segments from its current source fields (orientation / tilts / energy /
u0). The
candidate pool is the shared grid (plan.structure_factor_grid.structure_factor_hkl, which spans
the coupling cap), and the
tilt set is the one integrate_rocking_curve already baked, so this raises if a rotation has
fewer than two tilts. The upstream tilt_reduction (e.g. a mosaicity broadening) is carried
through unchanged.
Crucially it decouples the two reflection sets the plan otherwise conflates: the solve set
expands to the excitation coupling union, while the scored set stays pinned to
the pre-couple select_beams selection (op.alignment.hkl), intersected with the union.
- diffBloch.preprocess.steps.coupling.couple_beams(policy: TiltIndependent | UnionCoupling | PerTiltCoupling) PlanStep[source][source]¶
Return a
Plan -> Planstep applying thepolicybeam-coupling to every rotation.policyis aCouplingPolicyselected by construction:TiltIndependentyields the identity (the shared beam set is kept), andUnionCouplingreplaces each rotation with its per-chunkCoupledOrientationPlan. Pre-validated, so this never re-validates its bounds.
Orchestration and terminals¶
Simulation-convergence testing over g_max, sg_max, and tilt steps.
- class diffBloch.preprocess.driver.ConvergenceState(g_max: float, sg_max: float, tilt_steps: int)[source][source]¶
Bases:
objectThe three numerical controls varied by a convergence test.
- diffBloch.preprocess.driver.converge_numerics(test: ConvergenceTest, rocking: RockingCurve, simulation: UnionCoupling | PerTiltCoupling, refinement: RefinementSetup, tolerance: ConvergenceTolerance, *, method: SolverMethod = 'matrix_exp', logger: Logger = NULL_LOGGER) PlanStep[source][source]¶
Return a step that converges
g_max,sg_max, and rocking-curve tilt steps.
- diffBloch.preprocess.driver.run_convergence(plan: Plan, state: ConvergenceState, test: ConvergenceTest, rocking: RockingCurve, simulation: UnionCoupling | PerTiltCoupling, refinement: RefinementSetup, tolerance: ConvergenceTolerance, *, method: SolverMethod = 'matrix_exp', logger: Logger = NULL_LOGGER) tuple[Plan, ConvergenceState][source][source]¶
Run coordinate sweeps until all three simulation controls are self-stable.
The inference terminal: run the forward model over every rotation and score it, no refinement.
run_inference is the eval-only member of the preprocess pipeline’s terminal family (the other is
engine.refine, which optimizes structure): it runs one forward Bloch pass per orientation under
no_grad and reports a per-rotation RotationInference (the Bragg R-factor R_obs and
two diagnostics).
Built entirely from the public forward spine – engine.simulate + core.products.align() +
core.losses.rbragg()/core.losses.optimal_scale() – so callers never reach into engine
internals. Preprocess is composed in optionally via the preprocess
PlanStep; the solver is swappable via method.
- class diffBloch.preprocess.inference.InferenceResult(per_rotation: tuple[RotationInference, ...])[source][source]¶
Bases:
objectPer-rotation forward-inference metrics for a whole
Plan.- per_rotation: tuple[RotationInference, ...]¶
- property n_evaluated: int¶
Rotations with a finite
r_obs(i.e. at least oneI > 3*sigmareflection).
- property mean_wr2: float¶
Mean weighted-R2 over rotations with a finite value;
nanwhen none has one.The companion to
mean_r_obs, filtered independently: a rotation can produce a finite score under one metric and not the other, so the two means need not share a denominator.
- class diffBloch.preprocess.inference.RotationInference(r_obs: float, wr2: float, n_observed: int, n_beams: int)[source][source]¶
Bases:
objectOne rotation’s forward-inference metrics.
r_obsis the scaling-optimised Bragg R-factor of calculated vs observed intensities over the reflections withI > 3*sigma(core.losses.rbragg); it isnanwhen no reflection passes that cut.n_observedcounts those reflections andn_beamsthe active beam set – both diagnostics for why anr_obsis what it is.
- diffBloch.preprocess.inference.run_inference(plan: Plan, refinement: RefinementSetup, *, prepare: PlanStep = identity, method: SolverMethod = 'matrix_exp', device: Device | None = None, max_batch: int | None = None, absorption: Absorption = NO_ABSORPTION, logger: Logger = NULL_LOGGER) InferenceResult[source][source]¶
Run the forward model once per orientation and score each against its observed pattern.
First applies
preparetoplan– one composedPlan -> Planpipeline (compose the run’s steps withpipeline(), e.g.build_orientation_plans->optimize_orientation->optimize_thickness); it defaults to the identity (evaluate the plan as given). Then builds aRefinementEngine, simulates every orientation underno_gradwith the swappablemethodsolver, and returns per-rotationRotationInference.Emits a
RotationScoredper rotation and oneInferenceCompletedaggregate tologger(theNULL_LOGGERdefault discards them, so the returned value is unchanged whether or not a sink is attached). Attach a console/wandb logger at the boundary to watch per-rotationR_obslive – e.g. while chasing a residual.device(defaultNone= CPU, unchanged) runs the forward solve on the given accelerator: the seed params are moved there, and the engine co-locates every invariant onto the param device at the use site (RefinableParams.to()), so the whole eigensolve runs on-device. The scoring tail (align/optimal_scale) is device-safe (observed data is co-located there), so the returnedR_obsis identical (to solver tolerance) across devices.max_batch(defaultNone) caps thematrix_exppropagator block on the terminal solve;Nonelets the engine pick a memory-safe block per beam count. Execution-only (memory), likedevice. Seebuild_engine().
Checkpoint / resume¶
Serialize a settled Plan to a portable .npz and read it back (source persisted, compiled
geometry rebuilt on load). The infer CLI checkpoints/resumes against this plus the
plan.lock provenance in diffBloch.config.manifest.
Serialize a preprocessed Plan to a portable .npz checkpoint and read it back.
The persistence primitive behind the run program’s checkpoint/resume: serialize the whole
``Plan``, never per-facet state. It
exploits the plan types’ own design – both OrientationPlan and
CoupledOrientationPlan separate their source / rebuild inputs
(orientation / tilts / thickness / beam set(s) / observed pattern / energy / u0 /
tilt_reduction, plus the segmented plan’s per-chunk (union_hkl, covered_tilt_indices) and
pinned scored set) from their built geometry (beam_plans incl. the heavy
StructureFactorGather, alignment, the union + union_beam_index), and
StructureFactorGrid.from_cell + .build rebuild
the built parts from the source. So we persist only the source and rebuild the derived geometry
on read – a stored gather is a pure function of the beam set + grid and could only desync.
Format: one .npz (numpy is already a core dependency; portable, non-pickle hence auditable, and
it holds the ragged per-rotation/per-segment arrays natively as index-keyed entries). Scalars,
structure, the per-orientation kind discriminant + tilt_reduction discriminant, and the
plan’s provenance (the recipe that produced it) ride in a JSON string stored as the reserved
__meta__ array, so the file is self-describing. Read uses allow_pickle=False: a checkpoint
is data, never code.
This lives in preprocess (where Plan lives), not io – io is the input-record layer
below core / engine / preprocess, so a Plan serializer there would invert the
dependency. The plan.lock provenance that binds a checkpoint to the inputs + config + software
version that produced it is a separate, data-free concern in config.manifest.