Source code for diffBloch.preprocess.steps.coverage
"""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.
- :func:`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).
- :func:`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 of
:func:`~diffBloch.preprocess.steps.convergence.converge_scalar`.
- :func:`cover_beams` -- the ``Plan -> Plan`` adapter 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*.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
import numpy as np
from diffBloch.preprocess.pipeline import PlanStep
from diffBloch.preprocess.plan import Plan
from diffBloch.preprocess.steps.beams import (
build_orientation_plans,
select_beams,
)
from diffBloch.specs import BeamSelection
__all__ = [
"cover_beams",
"maximize_scalar",
"plan_coverage",
]
[docs]
def plan_coverage(plan: Plan) -> int:
"""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).
"""
total = 0
for op in plan.orientations:
beams = {tuple(int(x) for x in hkl) for hkl in np.asarray(op.beam_hkl, dtype=np.int64)}
obs_hkl = np.asarray(op.pattern.hkl, dtype=np.int64)
observed = {tuple(int(x) for x in hkl) for hkl in obs_hkl}
total += len(beams & observed)
return total
[docs]
def maximize_scalar[T](
build: Callable[[float], T],
objective: Callable[[T], float],
*,
start: float,
step: float,
max_iterations: int = 100,
) -> T:
"""Grow a scalar knob while ``objective`` strictly 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,
:func:`plan_coverage`). Starting from ``start`` and clicking by ``step``, 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 while ``candidate > best``, else stop). Raises
``RuntimeError`` if ``max_iterations`` steps pass while the score is still increasing (the score
never plateaus). ``max_iterations`` must be >= 1.
"""
if max_iterations < 1:
raise ValueError("max_iterations must be >= 1")
best = build(start)
best_score = objective(best)
value = start
for _ in range(max_iterations):
value += step
candidate = build(value)
score = objective(candidate)
if score > best_score:
best = candidate
best_score = score
else:
return best
raise RuntimeError(f"maximize_scalar did not plateau within {max_iterations} steps")
[docs]
def cover_beams(selection: BeamSelection, *, step: float, max_iterations: int = 100) -> PlanStep:
"""Return a ``Plan -> Plan`` step: widen the Klar window to the minimum that maximises coverage.
The window (``integration_semiangle``) lever of the coverage sweep: each candidate re-runs
:func:`~diffBloch.preprocess.steps.beams.select_beams` from the incoming seed at a wider window,
and
:func:`maximize_scalar` keeps widening while :func:`plan_coverage` strictly increases, stopping
at the first window that admits no new matched reflection. ``step`` must be positive.
"""
if step <= 0.0:
raise ValueError("step must be positive")
def run(seed: Plan) -> Plan:
def build(semiangle: float) -> Plan:
geometry = replace(selection.integration, semiangle=semiangle)
selected = select_beams(replace(selection, integration=geometry))(seed)
return build_orientation_plans()(selected)
return maximize_scalar(
build,
plan_coverage,
start=selection.integration.semiangle,
step=step,
max_iterations=max_iterations,
)
return run