Engine¶
The refinement engine: compiled per-orientation geometry plans, the forward simulation spine, the objective, the hard molecular constraints and soft penalties composed into it, and the (deliberately quarantined) imperative optimization loop.
Scientific composition is done with typed Python values, not config: build a problem with
build_refinement_problem and add hard constraints (e.g. with_hydrogen_riding) or soft penalties.
Refinement-invariant geometry plans: the shared scattering grid and per-orientation bundles.
These are the static (refinement-invariant) inputs the
RefinementEngine composes. The grid is owned once by
StructureFactorGrid and reused by both structure_factors and every BeamPlan, so
the two sides cannot silently disagree on the Fgb support (the difference-support constraint is
validated when the beam plans are built).
- class diffBloch.engine.plan.OrientationPlan(orientation: Tensor, tilts: Tensor, energy: float, u0: float, thickness: Tensor, beam_hkl: Tensor, beam_plans: tuple[BeamPlan, ...], pattern: PatternBatch, alignment: AlignmentPlan, tilt_reduction: PlainSum | MosaicSmoothed = PlainSum())[source][source]¶
Bases:
objectThe refinement-invariant plans for a single rotation/orientation.
Self-describing: it carries both its source / rebuild inputs (
orientation,energy,u0,thickness– whatpreprocesssteps likeselect_beams/optimize_orientation/optimize_thicknessconsume to rebuild) and the built geometry (beam_plan,alignment– whatengine.simulateconsumes). Source and built geometry are only ever set together bybuild(), so they cannot desync.orientationis the source of truth; the lab-frame basis is derived from it, never stored.tilts(N, 3, 3)is the rocking-curve integration tilt set (source): N goniometer sub-orientations, each built into the matching entry ofbeam_plans(N = len(beam_plans)). The default is a single identity tilt(1, 3, 3)– one static solve; a longer set is baked byintegrate_rocking_curveand summed as|psi|^2over the tilts by the engine.thickness(T,)is the specimen’s thickness for this rotation (its beam path length at this tilt), held fixed during refinement. It is seeded from the sample thickness and later replaced by the best-fitting valueoptimize_thicknessfinds. The forward model uses it for this orientation unless the caller is refining thickness directly (see_thickness_for()).- pattern: PatternBatch¶
- alignment: AlignmentPlan¶
- tilt_reduction: PlainSum | MosaicSmoothed = PlainSum()¶
- classmethod build(grid: StructureFactorGrid, beam_hkl: NDArray[int64], pattern: PatternBatch, *, energy: float, thickness: Tensor | NDArray[float64] | Sequence[float], u0: float = 0.0, orientation: Tensor | NDArray[float64] | None = None, tilts: NDArray[float64] | None = None, tilt_reduction: PlainSum | MosaicSmoothed = PLAIN_SUM, gather: StructureFactorGather | None = None, validate: bool = True, build_alignment: bool = True) OrientationPlan[source][source]¶
Assemble an orientation’s plans against the shared grid (enforces grid coupling).
orientation(3, 3)is the crystal orientation matrix for this rotation; the lab-frame reciprocal cell is derived from it and the grid’s real-spacecellviaorientation_basis(grid.cell, orientation) = reciprocal_cell(cell @ orientation.T)and drivesg->Sg/Miionly. WhenNonethe orientation is the identity and the sharedgrid.reciprocal_basisis used directly (the untilted / single-orientation case), making that path identical to the unoriented build. The rotation convention is derived upstream inpreprocess(seepreprocess.orientation); theFgbgather is keyed ongrid.structure_factor_hkland is unaffected.orientationaccepts either a NumPy array or aTensor(e.g. a prior plan’s storedorientation), so a laterPlan -> Planrebuild can passold_plan.orientationdirectly without ad-hoc conversion.thickness(T,)is required: this rotation’s frozen per-rotation conditioning, coerced to a 1-D float64 tensor. A rebuild threadsold_plan.thicknessthrough unchanged;optimize_thicknessbakes the single gridsearch winner(1,).tilts(N, 3, 3)is the optional rocking-curve integration set: N goniometer rotations, each left-multiplyingorientation(R_tilt @ orientation) into its own builtbeam_plan, sharing this orientation’s one beam set.None(the default) is a single identity tilt, sobeam_planshas length 1 and the untilted path is the plain static solve;integrate_rocking_curvepasses the tilt matrices fromrocking_curve_tilts().tilt_reductionselects how the engine reduces the tilt sub-solutions over the rocking curve:PlainSum(the default) sums them;MosaicSmoothedapplies mosaicity broadening first. It is a rebuild-preserved attribute (geometry-independent), resolved during dataset setup.gathermay be a precomputedStructureFactorGatherfor this beam set against the shared grid. The F-gather is basis- and orientation-free, so all N tilts here share one, and a caller rebuilding this plan over a fixed beam set (rocking integration, orientation-search trials) passes the seed plan’s gather (op.beam_plans[0].gather) to skip re-deriving it on every rebuild – the dominant preprocess cost. WhenNoneit is built once here and shared across the tilts.validate(defaultTrue) is forwarded tobuild_structure_factor_gather()when it builds the gather here – passFalseonly on a hot rebuild loop whose grid coverage an upstreamg_maxguard already guarantees. Ignored when a precomputedgatheris supplied.
- with_orientation(grid: StructureFactorGrid, orientation: Tensor | NDArray[float64]) OrientationPlan[source][source]¶
Rebuild this plan at a new
orientation, reusing everything else (F-gather included).The pure rebuild verb an orientation search needs: same beam set, tilts, thickness, reduction,
pattern/alignment– onlyorientationchanges, so only the orientation-dependent beam bases are recomputed while the orientation-freeStructureFactorGather(shared across the tilts) is reused viagather=.gridis required because the plan does not own the shared support the bases derive from; the caller threads itsPlan.structure_factor_grid. This makes a hexagonal-search trial one call and keeps the plan (not the fit) the source of truth for the beam set.
- class diffBloch.engine.plan.StructureFactorGrid(structure_factor_hkl: Tensor, cell: Tensor, reciprocal_basis: Tensor, gpts: tuple[int, int, int], cell_volume: float, g_max: float)[source][source]¶
Bases:
objectThe shared
Fgbsupport grid, owned once and reused by structure factors and beam plans.Three reciprocal-space radii are easy to conflate; they are distinct concerns:
solve cutoff – the beams that couple in one Bloch solve (
|g| <= solve_g_max; the coupled fit’sUnionCoupling.g_max).structure-factor support – the
Fgbgrid this class holds. It must cover every beam differenceg_j - g_i, which reaches2 * solve_g_max, so the support radius is~2xthe solve cutoff.from_cell_for_beam_cutoff()derives it from the solve cutoff.scored cutoff –
ScoredHklSelection.g_max, the reflections compared in the objective. It selects what enters the loss, not what solves. The default app recipe reuses the solve cutoff for it (see_trial_coupling); a custom composition may set a separate radius.
structure_factor_hkl(G, 3)are the Miller indicesFgbis tabulated on (|g| <= g_max);cell(3, 3)is the real-space basis andreciprocal_basis(3, 3)/cell_volumethe metric it derives (kept together; onlyfrom_cell()constructs them, so they cannot desync).gptsis the ravel box.g_maxis the structure-factor support radius and must span the beam difference support or beam-plan construction raises.- classmethod from_cell(cell: NDArray[float64], g_max: float) StructureFactorGrid[source][source]¶
Build the grid from a real-space cell
(3, 3)and a structure-factorg_max.g_maxhere is the structure-factor support radius – the grid must already span the beam difference support. Callers who know their solve cutoff rather than the support radius should usefrom_cell_for_beam_cutoff(), which derives the support for them.
- classmethod from_cell_for_beam_cutoff(cell: NDArray[float64], solve_g_max: float) StructureFactorGrid[source][source]¶
Build the grid from the solve cutoff – the radius of the beams in one Bloch solve.
A beam set bounded by
|g| <= solve_g_maxproduces dynamical-matrix termsF(g_j - g_i)whose differences reach|g_j - g_i| <= 2 * solve_g_max(triangle inequality), so the structure-factor support must cover2 * solve_g_max.The
2xis fundamental, not an implementation artifact: any coupled Bloch solve that gathersF(g - h)from a tabulated grid needs the table out to twice the beam cutoff, so the factor cannot be designed away. The API is arranged so the caller declares the physical solve cutoff and the2xsupport is derived here, rather than declaring the doubled radius and halving it internally (self.g_max = sf_g_max / 2) – an error-prone double-entry.solve_g_maxis the beam/coupling cutoff, distinct from the scoring-resolution cutoff (ScoredHklSelection.g_max), which selects reflections for the objective, not the solve.The support radius is
2 * solve_g_max + _SUPPORT_MARGIN. The half-Angstrom headroom reconciles the coupling filter’s orientation metric (u_matrixis not guaranteed exactly orthonormal – seepreprocess.orientation) with thisreciprocal_cellmetric of the same authoritative cell, so a coupled beam difference near2 * solve_g_maxin the former still lands inside the grid in the latter. The shell only enlarges the (unused-at-the-margin) SF table; it changes neither the coupled beam set nor the scored set.
- class diffBloch.engine.plan.SegmentPlan(plan: OrientationPlan, cover: Tensor, union_beam_index: Tensor)[source][source]¶
Bases:
objectOne coupled tilt-chunk of a
CoupledOrientationPlan: a sub-plan + reassembly map.planis an ordinaryOrientationPlanover the segment’s own (smaller) beam set, solved at just the tilts this chunk covers (plan.tiltsare the covered tilt matrices, solen(plan.beam_plans) == len(cover)).cover(C,)are the segment’s global rocking-curve tilt indices (contiguous; disjoint across a rotation’s segments; tiling every tilt once).union_beam_index(n_seg,)maps each of the segment’s beams to its column in the parent plan’s union beam set, so the segment’s per-tilt intensities scatter onto the shared rocking curve before the tilt reduction runs on the whole curve.- plan: OrientationPlan¶
- class diffBloch.engine.plan.CoupledOrientationPlan(orientation: Tensor, tilts: Tensor, energy: float, u0: float, thickness: Tensor, beam_hkl: Tensor, segments: tuple[SegmentPlan, ...], pattern: PatternBatch, alignment: AlignmentPlan, tilt_reduction: PlainSum | MosaicSmoothed = PlainSum())[source][source]¶
Bases:
objectA rotation whose rocking curve couples a different beam set per tilt chunk.
The tilt-dependent generalization of
OrientationPlan: instead of one beam set shared across all tilts, the curve is partitioned intoSegmentPlanchunks, each solving its own boundary-union beam set over its covered tilts (seediffBloch.preprocess.coupling.build_coupling_segments()). The engine solves each segment and reassembles every reflection’s per-tilt intensity onto the shared union beam axis before reducing over tilts (diffBloch.engine.forward.RefinementEngine._solve()), returning an ordinaryBlochSolutionover that union – soalign/ scoring stay identical to the tilt-independent path. Reassembling before the reduction is required: the mosaicity sample span can cover more tilts than any single chunk holds.beam_hkl(N_union, 3)is the union of every segment’s beams (deduplicated, sorted, and always including 000);pattern/alignmentbridge that union to the observed reflections;tilts(N, 3, 3)is the full rocking-curve set (Nthe total tilt count);tilt_reductionis carried over unchanged from the orientation this was coupled from (so a mosaicity broadening set upstream still applies).orientation/energy/u0/thicknessmirrorOrientationPlanas the rotation’s frozen conditioning.- segments: tuple[SegmentPlan, ...]¶
- pattern: PatternBatch¶
- alignment: AlignmentPlan¶
- tilt_reduction: PlainSum | MosaicSmoothed = PlainSum()¶
- classmethod build(grid: StructureFactorGrid, segments: Sequence[tuple[NDArray[int64], Sequence[int]]], pattern: PatternBatch, *, energy: float, thickness: Tensor | NDArray[float64] | Sequence[float], u0: float, orientation: Tensor | NDArray[float64], tilts: NDArray[float64], tilt_reduction: PlainSum | MosaicSmoothed = PLAIN_SUM, scored_hkl: NDArray[int64] | None = None, gathers: Sequence[StructureFactorGather] | None = None, validate: bool = True) CoupledOrientationPlan[source][source]¶
Assemble a segmented plan from
(beam_hkl, cover)chunks against the shared grid.Each
segmentsentry is one chunk’s beam set(n_seg, 3)and the global tilt indices it covers;tilts(N, 3, 3)is the full rocking-curve set the covers index into. The union beam set is the sorted, deduplicated concatenation of every chunk’s beams (000 is present because each chunk’s coupling always includes it); each chunk is built into anOrientationPlanover its beam set and covered tilts (sharing the rotation’sorientation/energy/u0/thickness), and itsunion_indexrecords where its beams sit in the union.scored_hkl(S, 3)pins the scored reflection set (viabuild_alignment_plan’srestrict_to): the union is the enlarged solve set, but scoring stays on this set intersected with the union – theselect_beamsselectioncouple_beamshands in, so expanding the solve does not drag scoring onto the union’s weak beams.Nonescores the wholepattern ∩ union(the tilt-independent behaviour).gathersoptionally supplies one precomputedStructureFactorGatherper segment (same order assegments), threaded into each chunk’sOrientationPlan.build()to skip re-deriving the orientation-free F-gather – the dominant cost. A rebuild at a new orientation over the same segments (with_orientation()) passes the seed plan’s per-segment gathers;Nonebuilds each fresh here.validate(defaultTrue) is forwarded to each segment’sOrientationPlan.build()when it builds a gather (i.e. wheregathersisNone) – passFalseonly on a hot per-trial re-couple whose grid coverage an upstreamg_maxguard already guarantees.
- with_orientation(grid: StructureFactorGrid, orientation: Tensor | NDArray[float64]) CoupledOrientationPlan[source][source]¶
Rebuild at a new
orientation, reusing the segments’ beams, covers, and F-gathers.The segmented counterpart of
OrientationPlan.with_orientation(): the segment partition (each chunk’s beam set + covered tilts), the union, the pinned scored set (alignment.hkl, idempotent under the intersection since it is already a subset of the union), and every chunk’sStructureFactorGatherare carried over; only the orientation-dependent bases recompute. This lets a fit tilt an already-coupled plan trial-by-trial at ~eigensolve cost (no re-gather, no re-coupling): the frozen-union fit.gridis threaded from the caller’sPlan.structure_factor_grid.
- diffBloch.engine.plan.mean_plan_thickness(plan: Sequence[OrientationPlan | CoupledOrientationPlan]) Tensor[source][source]¶
Return the mean physical thickness across a settled orientation plan.
Forward composition spine: raw structure parameters -> diffraction -> scalar objective.
A RefinementEngine holds the refinement-invariant plans (constraint spec, ASU-expansion
plan, the shared scattering grid, and one OrientationPlan per
rotation) and maps RefinableParams to a differentiable objective:
- constrain -> expand ASU -> structure_factors (Fgb on the shared grid)
-> per orientation: build_bloch_system -> propagate -> intensities -> align -> loss
objective_value / simulate are pure and differentiable;
run_refinement_model() delegates to the quarantined imperative loop in
diffBloch.engine.refine. Engines are assembled from explicit per-orientation beam sets that
preprocessing has already selected.
- class diffBloch.engine.forward.ForwardContext(thickness: Tensor | None = None)[source][source]¶
Bases:
objectForward-model values supplied by refinement model components.
Deliberately narrow: apparent thickness is the only value admitted, since it is the only value a component currently supplies. Scale/background/damage fields should be added only when consumed.
- type diffBloch.engine.forward.LossFn = Callable[[AlignedIntensities], Tensor]¶
- class diffBloch.engine.forward.ModelComponent(*args, **kwargs)[source][source]¶
Bases:
ProtocolA trainable/differentiable model component that can feed the forward simulation.
- initial_params(*, dtype: dtype, device: device) Mapping[str, Tensor][source][source]¶
Return initial parameter tensors for this component.
- forward_context(params: Mapping[str, Tensor], *, rotation_index: int, orientation: OrientationPlan | CoupledOrientationPlan) ForwardContext[source][source]¶
Return this component’s values for one orientation.
- class diffBloch.engine.forward.RotationMetrics(rotation_index: int, wr2: float, r_obs: float, n_matched: int)[source][source]¶
Bases:
objectOne rotation’s scaling-optimised wR2/R_obs for a settled model snapshot.
Report/plot use (
RefinementEngine.per_rotation_metrics()), not the objective: each metric independently re-optimises its own intensity scale (diffBloch.core.losses.optimal_scale()), exactly asrefinement_metrics/the training objective do, sowr2/r_obshere match what those report elsewhere.rotation_indexis the original zero-based PETS rotation index.
- type diffBloch.engine.forward.ScoresFn = Callable[[AlignedIntensities], Tensor]¶
- class diffBloch.engine.forward.StructureComponent(initial: RefinableParams, constraints: tuple[ConstraintTransform, ...] = ())[source][source]¶
Bases:
objectThe physical-structure component of a refinement model.
A thin wrapper around the structure refinement inputs:
initialis theRefinableParams, andconstraintsis the tuple of hard molecular transforms applied after the crystallographicconstrain. Structure-local hard parameterizations belong here.- initial: RefinableParams¶
- constraints: tuple[ConstraintTransform, ...] = ()¶
- class diffBloch.engine.forward.RefinementEngine(spec: ~diffBloch.params.ConstraintSpec, asu_plan: ~diffBloch.core.symmetry.AsuExpansionPlan, numbers: ~torch.Tensor, grid: ~diffBloch.engine.plan.StructureFactorGrid, orientations: tuple[~diffBloch.engine.plan.OrientationPlan | ~diffBloch.engine.plan.CoupledOrientationPlan, ...], loss: ~diffBloch.engine.forward.LossFn, scores: ~diffBloch.engine.forward.ScoresFn = <function wr2_scores>, method: ~diffBloch.core.solver.SolverMethod = 'matrix_exp', max_batch: int | None = None, absorption: ~diffBloch.specs.Absorption = Absorption(enabled=False), active_structure_factor_indices: ~torch.Tensor | None = None, profile: bool = False, checkpoint_activations: bool = True)[source][source]¶
Bases:
objectForward from raw structure parameters to a differentiable scalar objective.
Holds the refinement-invariant context: the constraint
spec, the ASU-expansionasu_plan, the ASU atomicnumbers, the sharedgrid, the per-rotationorientations(each carrying its own frozenthickness), the per-orientationloss, and the propagationmethod.- spec: ConstraintSpec¶
- asu_plan: AsuExpansionPlan¶
- grid: StructureFactorGrid¶
- orientations: tuple[OrientationPlan | CoupledOrientationPlan, ...]¶
- scores() Tensor[source]¶
Per-thickness scaling-optimised weighted-R2 (shape
(T,)).The calculated intensities come off the dynamical solve on an arbitrary structure-factor scale, while the observed are PETS intensities on their own scale. Compared raw (
w_rbragg_loss()), wR2 is denominator-dominated and parks near ~1 with a vanishing gradient, so a gradient refinement cannot descend it. Every call therefore re-fits the multiplicative intensity scale independently for every thickness throughoptimal_scale(). The selected grid branch remains differentiable in its calculated intensities (torch.minroutes the gradient through the winning candidate); only a boundary where the winning grid point changes is piecewise-smooth.
- method: SolverMethod = 'matrix_exp'¶
- absorption: Absorption = Absorption(enabled=False)¶
- simulate(params: RefinableParams) tuple[BlochSolution, ...][source][source]¶
Return the calculated
BlochSolutionfor every orientation (no loss).
- fgb(params: RefinableParams) Tensor[source][source]¶
The calculated structure factors
F_gbon the shared grid.The orientation-invariant part of the forward model: compute once and reuse across orientations (e.g. when scoring many trial orientations of one structure).
- structure_factor_values(params: RefinableParams, indices: Tensor) Tensor[source][source]¶
Calculate
Fgbonly for selected rows of the shared support grid.
- score_orientation(orientation: OrientationPlan | CoupledOrientationPlan, fgb: Tensor) Tensor[source][source]¶
This engine’s configured
scoresfor one orientation against its observed pattern.Runs the forward Bloch simulation for
orientationfrom a precomputedfgb(fgb()), aligns calculated vs observed intensities, and reduces byself.scores(default wR2, grid-searching the intensity scale viadiffBloch.core.losses.optimal_scale()). With multiple thicknesses the best-fitting thickness’s score is returned – thickness is a nuisance when scoring orientation. This is the objectiveoptimize_orientationminimises.
- score_orientation_per_thickness(orientation: OrientationPlan | CoupledOrientationPlan, fgb: Tensor) Tensor[source][source]¶
This engine’s configured
scoresfor each of the orientation’s thicknesses ((T,)).One forward Bloch simulation from a precomputed
fgb(fgb()) covers allTthicknesses at once: the expensive eigendecomposition depends only on the orientation andfgb, while thickness enters only the cheap propagation tail. The calculated and observed intensities are aligned once (alignment is thickness-independent), then reduced byself.scores(defaultwr2_scores()) – the same per-metric functionself.losssums for the gradient objective, so this search and the refinement stage share oneExperimentConfig.objective.optimize_thicknessgrid-searches this vector and bakes the lowest-scoring thickness;score_orientation()collapses it with.min()(thickness is a nuisance there).Runs under
torch.no_grad(): this is search scoring (optimize_orientation/optimize_thicknessgrid search + argmin), never backpropagated – every caller consumes a detached scalar. Without it theT-thickness solve builds an autograd graph whose retainedmatrix_expintermediates accumulate across every propagator block, defeating themax_batchmemory bound (propagate()) and OOMing a wide coupled segment on the GPU. Grad-off does not change the returned scores.
- objective_value(params: RefinableParams, penalties: tuple[PenaltyTerm, ...] = (), constraints: tuple[ConstraintTransform, ...] = ()) ObjectiveValue[source][source]¶
Return the objective as a scalar total plus named scalar components.
Differentiable in
params. The objective is composed in a fixed order that separates hard constraints (enforced transforms) from soft penalties (additive terms):raw RefinableParams -> crystallographic constraints (constrain / ConstraintSpec): site-symmetry position projector, ADP equalities, positivity/bounded transforms -> PhysicalState -> molecular hard constraints (the `constraints` ConstraintTransform layer) -> diffraction term + soft penalties -> scalar objectiveself.physical_state(params)applies the crystallographic constraints; eachConstraintTransforminconstraintsthen reparameterizes that state in tuple order (duplicate names rejected), so both the diffraction term and thepenaltiessee the transformed state. The"diffraction"component is always present;penaltiesadd weighted soft-penalty components (bond-length, etc.) without making the optimizer know their details. Hydrogen riding (HydrogenRiding) is one suchConstraintTransform; a soft penalty and a hard constraint are distinct – a constraint reparameterizes, it is not a cost term.
- objective_value_model(model: RefinementModel, *, penalties: tuple[PenaltyTerm, ...] = ()) ObjectiveValue[source][source]¶
Return the objective for a
RefinementModel.Structure-only models delegate to
objective_value()for exact behavior parity. Models with components use the same objective order, but let components supply forward-context values such as per-orientation thickness before falling back to the built-in thickness path.
- refinement_metrics(model: RefinementModel) tuple[float, int, int, int, int][source][source]¶
Return mean R_obs and reflection counts for one refinement-model snapshot.
Counts are over PETS rows in the selected rotations: matched rows enter the diffraction alignment, unmatched rows do not; strong/weak split matched rows at
I > 3 sigma.
- per_rotation_metrics(model: RefinementModel) tuple[RotationMetrics, ...][source][source]¶
Per-rotation wR2/R_obs for one refinement-model snapshot (report/plot use).
Same loop shape as
refinement_metrics()(component-aware thickness, one thickness per rotation picked by the wR2 that is actually minimised during training – not by R_obs, which is reported but never optimised), returning every rotation’s pair instead of an aggregate.
- physical_state(params: RefinableParams) PhysicalState[source][source]¶
Return bounded physical ASU quantities for
params.
- class diffBloch.engine.forward.RefinementModel(structure: StructureComponent, components: tuple[ModelComponent, ...] = (), component_params: Mapping[str, Mapping[str, Tensor]] = mappingproxy({}))[source][source]¶
Bases:
objectTrainable refinement model value, currently structure-only.
The model is the value optimized against a static
RefinementEngine. Non-structure components provide forward-context values such as apparent thickness.- structure: StructureComponent¶
- components: tuple[ModelComponent, ...] = ()¶
- class diffBloch.engine.forward.ModelRefinementResult(model: RefinementModel, losses: Tensor, best_model: RefinementModel, best_step: int, selection_losses: Tensor | None = None, history: tuple[~diffBloch.observability.RefinementStep, ...]=(), reflection_counts: Mapping[str, int]=<factory>, artifacts: Mapping[str, str]=<factory>, objective_manifest: ObjectiveManifest | None = None)[source][source]¶
Bases:
objectThe outcome of optimizing a refinement model.
Returns the optimized model value.
params/best_paramsproperties preserve the structure-only convenience used by the default app path and existing tests.- model: RefinementModel¶
- best_model: RefinementModel¶
- history: tuple[RefinementStep, ...] = ()¶
- objective_manifest: ObjectiveManifest | None = None¶
- property params: RefinableParams¶
Final refined structure parameters.
- property best_params: RefinableParams¶
Best recorded structure parameters.
- class diffBloch.engine.forward.RefinementProblem(penalties: tuple[PenaltyTerm, ...] = ())[source][source]¶
Bases:
objectObjective-side scientific composition for one refinement run.
The trainable model value is passed separately to
run_refinement_model. The problem records only additive objective terms such as bond, angle, and planarity penalties.- penalties: tuple[PenaltyTerm, ...] = ()¶
- diffBloch.engine.forward.build_refinement_model(*, initial: RefinableParams, constraints: tuple[ConstraintTransform, ...] = (), components: tuple[ModelComponent, ...] = (), component_params: Mapping[str, Mapping[str, Tensor]] = MappingProxyType({})) RefinementModel[source][source]¶
Construct a
RefinementModel.With no components supplied it is behavior-equivalent to structure-only refinement; supplied components contribute forward-context values (e.g. apparent thickness) to that structure core.
- diffBloch.engine.forward.build_refinement_problem(*, penalties: tuple[PenaltyTerm, ...] = ()) RefinementProblem[source][source]¶
Construct objective-side refinement composition data.
- diffBloch.engine.forward.run_refinement_model(engine: RefinementEngine, model: RefinementModel, problem: RefinementProblem, *, trainable: TrainableSpec, steps: int, optimizer: OptimizerName = 'adam', lr: float = 1e-3, logger: Logger = NULL_LOGGER, verbose: bool = False, profile: bool = False, selection_engine: RefinementEngine | None = None) ModelRefinementResult[source][source]¶
Optimize a refinement model against the supplied engine/static context and problem terms.
Before the first step this emits an
ObjectiveManifestnaming the penalties (with weights), constraints, and components the run actually composed, and returns the same value on the result. This is the one place that holds the problem and the model together, so it is the only place that can state the objective’s composition; the structure-onlyrun_refinement()receives a bare objective callable and therefore declares nothing.verbose(“verbose refinement”) additionally reports oneRefinementOrientationStepper rotation per step (wr2/r_obs/ diff_loss), for diagnosing which orientations drive the epoch mean reported by the ordinaryRefinementStep. Off by default: the per-rotation stream isn_orientations``x louder and is a diagnosis tool, not the default reporting shape. It is execution-only, like ``loggeritself – it changes what gets reported, never the objective or the optimizer trajectory, so it is a function argument / CLI flag, not anexperiment.yamlfield (a config field would enter the preprocess/refinement identity for no scientific reason).profilelogs per-phase wall time (structure factors, each rotation’s solve, backward, optimizer step) via stdlib diagnostics logging (logging.getLogger(__name__), level INFO, the"profile: "-prefixed lines) – seediffBloch.engine.forward._Timer. Execution-only and off by default: it forces a CUDA sync around every measured block, which is itself real overhead, so it is a diagnosis tool for one run, not something to leave on.engine.profilemust also be set (build_engine()threads it through) for the structure-factor/solve breakdown; this flag alone only times backward/optimizer-step.selection_engineis an optional held-out objective used only to choosebest_model/best_step. It does not contribute gradients or alter the optimizer trajectory. The app uses it forrefinement.split.train_testvalidation selection; without it, best selection remains the training objective. It costs one extra no-grad forward pass per step over the held-out rotations – roughlyval_fracof a training forward, paid every epoch with no opt-out – and it changes which objectiveRefinementCompletedreports (see that event’sselectionfield).
Hard molecular constraints on the physical state.
A ConstraintTransform is a hard reparameterization of the bounded
PhysicalState, distinct from a soft
BondLengthPenalty: a constraint rewrites the state so an
invariant holds exactly at every optimizer step (e.g. deriving hydrogen positions from their
parents), rather than adding a cost the optimizer may trade against. Constraints are applied in the
refinement objective after constrain and before the diffraction term, so the diffraction and
the penalties see the transformed state.
- class diffBloch.engine.constraints.ConstraintTransform(*args, **kwargs)[source][source]¶
Bases:
ProtocolA hard constraint applied to the physical state during refinement.
nameidentifies the transform for deterministic ordering and duplicate rejection;applyreturns a newPhysicalStatewith the constraint enforced (no in-place mutation), so gradients flow through the reparameterization to the free parameters it depends on.nameis a read-only member: constraints are immutable value-types (frozen dataclasses), so the protocol must not demand a settable attribute (a settable one excludes frozen implementations).- apply(state: PhysicalState) PhysicalState[source][source]¶
Return a new physical state with this constraint enforced.
- class diffBloch.engine.constraints.HydrogenRiding(name: str, h_index: Tensor, parent_index: Tensor, offset: Tensor, u_iso_scale: Tensor)[source][source]¶
Bases:
objectRide each hydrogen on its parent heavy atom – a hard constraint on the physical state.
Constant-offset riding: a hydrogen’s position is its parent’s position plus a fixed fractional
offsettaken from the input structure, and its displacement isu_iso_scaletimes the parent’suij_star. Both are reparameterizations – the H rows are overwritten so they carry no independent degree of freedom, and gradients flow through to the parent – so hydrogens stay in the forward scattering while tracking the refined heavy-atom frame. Pair withTrainableSpec(positions=exclude_elements("H"), adp=exclude_elements("H"))so the H rows are never optimizer leaves.Limitations (the model deliberately kept simple; upgrades slot into this same transform): the fixed offset does not re-point H when the parent’s local frame rotates (only translation is followed); the
uij_starscale is exact for an isotropic parent (all-Uiso) and approximate for an anisotropic one.- apply(state: PhysicalState) PhysicalState[source][source]¶
- diffBloch.engine.constraints.perceive_hydrogen_riding(structure: StructureRecord, *, cutoff_scale: float = 1.2, cutoff_margin_angstrom: float = 0.1, u_iso_scale: float = 1.2) HydrogenRiding | None[source][source]¶
Build a
HydrogenRidingfrom a structure: each H rides its nearest bonded heavy atom.For every hydrogen (atomic number 1) the parent is the nearest heavy atom within the covalent cutoff
cutoff_scale * (r_H + r_heavy) + cutoff_margin_angstrom(Cartesian distance via the cell). The storedoffsetis the fractional parent->H vector from the input structure; the molecule is assumed ASU-contiguous, so no minimum-image wrapping is applied (matching the penalties layer).Riding is for general-position hydrogens only: it overwrites the H coordinate after the crystallographic projector, so a special-position H would be pushed off its site-symmetry manifold. A hydrogen on a special position is therefore rejected. Returns
Nonewhen the structure has no hydrogens; raises when a hydrogen has no heavy neighbour within the cutoff.
- diffBloch.engine.constraints.with_hydrogen_riding(structure: StructureRecord, trainable: TrainableSpec) tuple[TrainableSpec, tuple[ConstraintTransform, ...]][source][source]¶
Compose hydrogen riding onto a base trainable selection (Python/API scientific composition).
Riding derives every hydrogen from its parent heavy atom each step – position (constant parent->H offset) and Uiso (scaled from the parent) – so the hydrogens must not also be optimizer leaves. This freezes them (excludes H from both
positionsandadp) and perceives theHydrogenRidingconstraint from the structure geometry, returning the(trainable, constraints)pair to place onStructureComponentviabuild_refinement_model(). This is expressed here in Python, not in config: it is scientific composition, not a stable default-path knob.When the structure has no hydrogens the constraint tuple is empty and the freeze is harmless (no H rows to exclude), so the call is safe to apply unconditionally.
Soft refinement penalties evaluated on the bounded physical ASU state.
- class diffBloch.engine.penalties.BondLengthPenalty(pairs: Tensor, target_angstrom: Tensor, sigma_angstrom: Tensor, frac_to_cart: Tensor, weight: float = 1.0, name: str = 'bond', criterion: Literal['mse', 'flat_bottom_l1'] = 'mse')[source][source]¶
Bases:
objectBond-length soft penalties in Cartesian Angstrom units.
The current ASU positions are fractional coordinates, so the penalty owns the invariant fractional-to-Cartesian cell matrix.
pairsindexes ASU atom rows;target_angstromandsigma_angstromcarry the penalty target and tolerance for each pair. The raw loss is the mean squared normalized bond-distance residual by default.flat_bottom_l1is a robust criterion: zero inside the sigma tolerance and linear outside.- value(state: PhysicalState) Tensor[source][source]¶
Return the raw mean squared normalized bond-distance residual.
- diffBloch.engine.penalties.perceive_bond_length_penalty(structure: StructureRecord, *, include_hydrogen: bool = False, sigma_angstrom: float = 0.02, cutoff_scale: float = 1.2, cutoff_margin_angstrom: float = 0.1, weight: float = 1.0, criterion: Literal['mse', 'flat_bottom_l1'] = 'mse') BondLengthPenalty[source][source]¶
Perceive ASU-contiguous bonds and tether them to the starting distances.
This is an explicit source/builder layer, separate from the pure penalty value. It assumes the bonded molecule is contiguous in the ASU and deliberately does not do minimum-image wrapping. The perceived target for each bond is the current Cartesian distance in the input structure; an external source of literature bond targets and sigmas could supply them instead.
Named LossFn/ScoresFn builders: the objective terms ExperimentConfig.objective picks.
Each adapts a pure diffBloch.core.losses intensity comparison (which reduces over the
reflection axis, yielding a (T,) per-thickness loss) into two shapes: a ScoresFn
(AlignedIntensities -> (T,) Tensor, one score per thickness) and the corresponding LossFn
(AlignedIntensities -> scalar, the ScoresFn summed over thickness). RefinementEngine
uses both off the same underlying metric – scores for the orientation/thickness
preprocessing search (score_orientation/score_orientation_per_thickness, which need a
per-thickness vector to argmin over) and loss for the differentiable gradient-refinement
objective – so picking one ExperimentConfig.objective genuinely drives the whole pipeline, not
just the gradient stage. Saves every caller from rewriting
lambda a: mse(a.calculated, a.observed).sum() and gives each objective a named, importable home
– e.g. RefinementEngine(loss=rbragg_loss, scores=robs_scores, ...).
- diffBloch.engine.losses.l1_loss(aligned: AlignedIntensities) Tensor[source][source]¶
Per-orientation L1 loss term, summed over thicknesses to a scalar.
- diffBloch.engine.losses.mse_loss(aligned: AlignedIntensities) Tensor[source][source]¶
Per-orientation MSE loss term, summed over thicknesses to a scalar.
- diffBloch.engine.losses.rbragg_loss(aligned: AlignedIntensities) Tensor[source][source]¶
Scaling-optimised Bragg R(obs) objective. Sums
robs_scores()over thickness.
- diffBloch.engine.losses.robs_scores(aligned: AlignedIntensities) Tensor[source][source]¶
Per-thickness scaling-optimised Bragg R(obs) (shape
(T,)).Refits the intensity scale per thickness exactly like
wr2_scores(), but against therbragg()metric instead of the defaultw_rbragg– the same R_obs the app reports elsewhere (refinement_metrics()).
- diffBloch.engine.losses.wr2_loss(aligned: AlignedIntensities) Tensor[source][source]¶
Scaling-optimised weighted-R2 – the default refinement and orientation-search objective.
Sums
wr2_scores()over the thickness axis to a scalar; see there for the scale-fit.
- diffBloch.engine.losses.wr2_scores(aligned: AlignedIntensities) Tensor[source][source]¶
Per-thickness scaling-optimised weighted-R2 (shape
(T,)).The calculated intensities come off the dynamical solve on an arbitrary structure-factor scale, while the observed are PETS intensities on their own scale. Compared raw (
w_rbragg_loss()), wR2 is denominator-dominated and parks near ~1 with a vanishing gradient, so a gradient refinement cannot descend it. Every call therefore re-fits the multiplicative intensity scale independently for every thickness throughoptimal_scale(). The selected grid branch remains differentiable in its calculated intensities (torch.minroutes the gradient through the winning candidate); only a boundary where the winning grid point changes is piecewise-smooth.
- diffBloch.engine.losses.w_rbragg_loss(aligned: AlignedIntensities) Tensor[source][source]¶
Per-orientation weighted-R2 term (default
mu), summed over thicknesses to a scalar.Raw: no calc<->obs scaling. Correct only where the caller has already put calculated on the observed scale; for the refinement objective use
wr2_loss(), which is thebuild_engine()default.
The imperative refinement loop – the one deliberately stateful corner of an otherwise pure core.
torch.optim optimizers mutate .grad and leaf tensors in place and carry internal state, so a
training loop cannot be a pure function. This module quarantines that imperativeness behind a
functional contract: run_refinement() takes the engine’s pure objective_value callable
and the caller’s parameters, clones the target fields into fresh requires_grad leaves (the
rest become detached constants), steps a chosen backend, and returns a new detached
RefinementResult.
The caller’s parameters are never touched. core/ stays free of torch.optim entirely.
- class diffBloch.engine.refine.AtomSelection(mode: Literal['all', 'none'], element_include: tuple[str, ...] = (), element_exclude: tuple[str, ...] = ())[source][source]¶
Bases:
objectA coarse atom/parameter selection for one trainable group.
The current modes distinguish whole-group
allvsnone. The value can be extended with element or index filters without reintroducing stringly-typed refinement targets.- classmethod all() AtomSelection[source][source]¶
Select every present parameter in the group.
- classmethod include_elements(*symbols: str) AtomSelection[source][source]¶
Select only atoms whose element symbol is listed.
- classmethod exclude_elements(*symbols: str) AtomSelection[source][source]¶
Select all atoms except those whose element symbol is listed.
- classmethod none() AtomSelection[source][source]¶
Select no parameters in the group.
- class diffBloch.engine.refine.ObjectiveComponent(raw: Tensor, weight: float = 1.0)[source][source]¶
Bases:
objectOne named refinement objective term.
rawis the scientifically meaningful scalar diagnostic (for example, a bond penalty before weighting).weightscales that diagnostic into the optimizer-facingcontribution.
- class diffBloch.engine.refine.ObjectiveValue(components: Mapping[str, ObjectiveComponent], diagnostics: Mapping[str, float] = MappingProxyType({}), per_rotation: Sequence[Mapping[str, float]] = ())[source][source]¶
Bases:
objectA scalar refinement objective plus named scalar components.
totalis computed from component contributions so reporting and optimization cannot silently drift.componentsis a read-only mapping whose values retain both raw diagnostics and weights for future penalty reporting.per_rotationis optional per-rotation diagnostics (rotation_index/wr2/r_obs/diff_loss) for verbose per-orientation reporting; empty for an objective with no rotation structure. It is plain data, computed unconditionally alongside the existing epoch-mean diagnostics (no extra forward cost) – whether it is actually reported is an execution-only choice made at the refinement loop, not here.- components: Mapping[str, ObjectiveComponent]¶
- class diffBloch.engine.refine.RefinementResult(params: RefinableParams, losses: Tensor, best_params: RefinableParams, best_step: int)[source][source]¶
Bases:
objectThe outcome of a refinement run (all tensors detached).
paramsare the final parameters after the last step;losses(steps,)is the per-step training curve (each entry is the objective before that step’s update);best_params/best_stepsnapshot the lowest recorded loss, for early-stopping callers.- params: RefinableParams¶
- best_params: RefinableParams¶
- class diffBloch.engine.refine.PenaltyTerm(*args, **kwargs)[source][source]¶
Bases:
ProtocolA soft refinement penalty evaluated on the physical ASU state.
Penalties are objective components, not hard constraints:
lossreturns the raw scientific diagnostic andweightscales it into the optimizer-facing contribution. Concrete terms own their invariant context (for example metric/cell, connectivity, targets, and sigmas) instead of bloatingPhysicalStatewith every possible penalty input.- value(state: PhysicalState) Tensor[source][source]¶
Return this penalty’s raw scalar loss for the current physical state.
- class diffBloch.engine.refine.TrainableSpec(positions: AtomSelection = <factory>, adp: AtomSelection = <factory>, occupancy: AtomSelection = <factory>)[source][source]¶
Bases:
objectExplicit selection of which parameter groups are trainable in a refinement problem.
- positions: AtomSelection¶
- adp: AtomSelection¶
- occupancy: AtomSelection¶
- classmethod positions_and_adp() TrainableSpec[source][source]¶
The default: refine positions and ADPs when present.
- diffBloch.engine.refine.run_refinement(objective_value: Callable[[RefinableParams], ObjectiveValue], params: RefinableParams, *, steps: int, trainable: TrainableSpec, optimizer: OptimizerName, lr: float, atomic_numbers: Tensor | None = None, logger: Logger = NULL_LOGGER) RefinementResult[source][source]¶
Optimize the
trainableparameter groups to minimiseobjective_value(params).total.Functional contract over an unavoidably imperative core: the caller’s
paramsare never mutated. Selected fields become freshrequires_gradleaves (non-selected fields detached constants); a backend steps them forstepsiterations via a closure (which unifies LBFGS’ re-evaluation with Adam/AdamW). Trainable selections map through_TRAINABLE_FIELDS; a selected group with no present parameter, or zerosteps, raises.loggerreceives aRefinementStepper iteration and oneRefinementCompletedat the end; the defaultNULL_LOGGERmakes emission a no-op, so the returned result is unchanged. Step events include the structuredObjectiveValuecomponents as numeric diagnostics, making diffraction/penalty tradeoffs inspectable.