Skip to content

Add pinned polymer diffraction peak inference - #267

Draft
NJMarchese wants to merge 36 commits into
devfrom
paper/polymers
Draft

Add pinned polymer diffraction peak inference#267
NJMarchese wants to merge 36 commits into
devfrom
paper/polymers

Conversation

@NJMarchese

Copy link
Copy Markdown
Collaborator

Summary

  • add BraggPeaksPolymer, peak detection, angular-origin/polar transforms, and calibrated flowline/figure helpers
  • retain the checkpoint-exact paper architecture separately from the newer core CNN
  • add checksum-verified pinned/latest model resolution with atomic downloads, offline cache reuse, and private local-directory overrides
  • document the full dev/polymer overlap reconciliation

The default paper record remains intentionally unavailable for network download until its immutable public Zenodo record exists. This PR should remain draft until that URL is pinned.

Verification

  • pytest -p no:cacheprovider -q tests/diffraction tests/datastructures/test_dataset4dstem.py (32 passed)
  • interactive GPU job 56237850: strict paper checkpoint load and finite (1, 2, 256, 256) inference passed
  • downloader tests cover pinned/latest, cache hit, interruption, incompatibility, and checksum failure

Scope exclusions

Grain clustering, generator/training launchers, research notebooks, generated results, and the private Zenodo archive are not included.

NJ March and others added 26 commits July 22, 2026 17:58
Wire the existing quantem calibration tools into preprocess() (previously a
stub): image-center finding, ellipticity fitting, descan (CoM plane fit), and
detector-rotation estimation. Follows the class's lazy design -- parameters are
measured and cached (image_centers, ellipse_params, descan_origin,
detector_rotation_deg, sampling_inv_A), then consumed downstream by the polar
transforms rather than re-warping the raw 4D data.

- Ellipticity via fit_probe_ellipse on the mean DP -> metadata["ellipticity"]
- Descan + rotation via CenterOfMassOriginModel (calculate_origin,
  fit_origin_background, estimate_detector_rotation)
- image_centers via find_central_beams_4d ("descent"/"grid"/"peaks") or the
  CoM/descan field, selectable with center_source
- Register the new calibration attributes in __init__

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render a boustrophedon cursor walk over the scan to an animated GIF: each frame
pairs the real-space intensity map (cursor crosshair) with that position's
diffraction pattern and detected Bragg-peak overlay, reusing the save_peak_figures
rendering primitives. PIL-based writer.
…spacing axis

- estimate_peak_windows(mode='intensity'|'count', log_scale=False): detect peak
  windows on the peak-count radial profile as well as intensity, and optionally on
  log1p(profile) so small peaks are not dominated by large ones. peak_info now
  carries 'mode', 'log_scale', and 'profile'.
- peak_radial_intensity_plot / peak_radial_count_plot: add log_scale (log y-axis
  with a positive fill baseline) and show_d_spacing (top axis in real-space
  d-spacing (Å) = 1/q) when plotting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- estimate_peak_windows: printed per-peak summary now reports d-spacing (Å) = 1/q
  alongside the q values (center and window).
- plot_peak_count_map: panel titles now include the d-spacing range (Å) under the
  q range (1/Å).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reorder BraggPeaksPolymer.preprocess() so calibration runs
descan/rotation -> centering -> ellipticity (was ellipse first).
Fitting the ellipse on the raw mean DP smeared the diffraction ring
by the descan drift and biased the fit toward the central beam.

The ellipse is now fit on a centered mean DP built by the new
_centered_dp_mean() helper: when a fitted CoM model is available it
reuses shift_origin_to()'s sub-pixel grid-sampler to align every
pattern to the detector center on-device, otherwise it falls back to
translating the plain mean DP by the average center offset. Centering
(find_central_beams_4d) now runs without ellipse_params since the
ellipse is not yet known.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace fit_probe_ellipse (which Otsu-thresholds and fits the brightest
blob = the central beam, so it measured probe shape and latched onto a
smeared/off-center beam) with a diffraction-RING fit using Karen
Ehrhardt's angular-uniformity criterion.

New _fit_ellipse_from_ring() holds the found center fixed and searches
(b/a, theta) to minimise the azimuthal variance of a ring annulus in the
polar transform (quantem.diffraction.polar_transform). It samples out at
the ring radius and never touches the central beam, so a doubled /
off-center / drift-smeared central beam no longer biases the ellipse.

Details:
- ring band auto-detected from the circular radial profile (skip central
  beam to first trough, take strongest ring beyond), or set explicitly
  via new ellipse_radial_min / ellipse_radial_max preprocess params.
- coarse (b/a, theta) grid + local refine; output canonicalised to
  a/b >= 1.
- centered mean DP now cached on self.dp_mean_centered (was ephemeral);
  preprocess caches self.ellipse_ring_band; show=True plots the DP plus
  before/after polar so the ring flattening is visible.
- ellipse_threshold kept but unused (back-compat).

Validated against polar_transform on synthetic elliptical rings
(a/b and theta recovered to <0.5%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_centered_dp_mean used integer torch.round rolls. The per-pattern
rounding residual (up to 0.5 px) does NOT average out when the origin
spread is narrow -- every pattern rounds to the same integer, so the
fractional part becomes a constant bias and the mean beam lands off the
detector center by a fraction of a pixel (visible as the central beam
sitting up/right of the geometric-center crosshair, and it also throws
off the fixed-center ring ellipse fit).

Replace integer rolls with bilinear splatting: each pattern's continuous
shift (center - fitted origin) is split into floor + fraction, and its
intensity is distributed over the 4 integer-shift corners with bilinear
weights, grouped by floor shift for efficiency. Same low memory (one
(Qy,Qx) accumulator + one batch), now centers to <0.01 px.

Also fix the no-CoM fallback to average image_centers over valid
(non-zero) positions only -- it is 0 outside the scan mask, which
otherwise biases the fallback center toward the origin.

Validated on synthetic beams: bilinear hits the detector center exactly
at every origin spread; integer rolls drift up to ~0.4 px at narrow spread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The centered mean DP still showed the central beam a few px off the
detector center. Root cause: _centered_dp_mean shifted each pattern by
the descan CoM origin (origin_fitted). The CoM is the centroid of the
WHOLE pattern, so ring/background asymmetry (e.g. a bright corner) pulls
it several px off the actual beam -- centering by it puts the CoM at the
center and leaves the beam off.

Center instead by image_centers, the angular-uniformity BEAM center
(found by ring symmetry, immune to intensity asymmetry, and the center
everything downstream already uses). Only ROI (in-mask, non-zero)
patterns are averaged. Still sub-pixel bilinear, same low memory.

Validated on a synthetic pattern with an asymmetric background pulling
the CoM to (130,132): centering by CoM leaves the beam at (114.5,114.3)
(~7 px off); centering by image_centers lands it at (107.9,107.9) on the
(107.5,107.5) detector center.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The descan residual (origin_com_measured - descan_origin) showed a
uniform ~1-2 px offset inside the ROI. Cause: CenterOfMassOriginModel
fits fit_origin_background over the WHOLE scan, so out-of-ROI patterns
(vacuum/substrate, meaningless CoM) drag the global plane and leave a
constant offset inside the mask; hot/dead pixels contaminate it too.

CenterOfMassOriginModel has no mask support, so preprocess now fits the
plane itself over ROI patterns via new _fit_origin_roi (ordinary LS
z = a + b*row + c*col per component, with sigma-clip passes to reject
outlier CoM), then assigns com_model.origin_fitted and zeroes the
residual outside the ROI (measured := fitted there) so
estimate_detector_rotation's curl isn't contaminated by junk patterns
either. Falls back to the full scan when scan_mask is None.

Other centering steps already respect the ROI: find_central_beams_4d
takes scan_mask, and the ellipse fit runs on the image_centers-centered
(ROI-only) mean DP.

Validated: with out-of-ROI junk + hot-pixel outliers, the global fit
leaves a -13 px uniform ROI residual; the ROI fit brings it to -0.005 px
and recovers the true plane exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
save_cartesian_peaks / save_polar_peaks / save_polar_data /
save_peak_intensities did np.save(vector). A canon Vector is array-like,
so numpy flattened it into an (Ry,Rx) object array of cells, losing the
Vector's fields/units. On load, .item() only unwraps a size-1 array, so
you got back a size-(Ry*Rx) object array instead of a Vector, and
polar_transform_peaks raised "can only convert an array of size 1 to a
Python scalar".

Add _save_object() which wraps the object in a 0-d object array so
np.save pickles the whole Vector; the existing size-1/.item() unwrap in
the load_* methods then restores it. Route all four save_* through it.

Note: files saved by the old code are already flattened and can't be
reconstructed (metadata lost) -- re-run find_peaks_model and re-save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
calculate_orientation_correlation sized its angular-mode batches from
torch.cuda.mem_get_info(), which reports memory free on the *device* and
is blind to torch.cuda.set_per_process_memory_fraction. Under a cap it
budgeted headroom the process was not allowed to allocate and then died
in fft2 with the card still mostly free: a 23.74 GiB cap against 60.65
GiB device-free raised OutOfMemoryError on a 100x100 scan at
orientation_upsample=8.

Headroom is now min(device_free, cap - memory_allocated). It is measured
against *allocated*, not *reserved*: cached blocks no tensor is using are
reusable, and after a failed run they can occupy most of the cap, which
would otherwise report a zero budget and refuse to run.

Also reserve room for theta_spectrum, which is allocated after the
estimate and was previously unaccounted, and retry once after
empty_cache() before giving up, so a failed run no longer poisons the
next attempt. The MemoryError now reports the cap, live usage, spectrum
reservation and device-free rather than a bare "0.00 GiB".

Tests: tests/diffraction/test_orientation_correlation.py, 10 passed.
Numerics unchanged, max relative difference 8.96e-08. Both failure modes
reproduced on GPU and confirmed fixed: a cap saturated with stale cache
now completes at 2.74 GiB under a 7.60 GiB cap, and a 1.14 GiB cap
degrades to 182 small batches instead of failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the central-beam/probe-blob ellipse fit with a ridge fit on the
diffuse ring. It measures the ring radius around every azimuth, jointly
refines the residual center and the ellipse, and accepts the correction
only when held-out angular sectors improve over a plain circle. Because
it never touches the central beam, descan-drift smearing and doubling of
the beam no longer bias the ellipse.

Adds _fit_ellipse_from_ridge with polar_at / extract_ridge helpers and
ellipse and circle residual models, wires it into preprocess() via
ellipse_fit_method, and records the outcome in ellipse_fit_diagnostics
(method, accepted, selected) alongside ellipse_ring_band.

Tests: tests/diffraction/test_ellipse_ring_fit.py plus
tests/diffraction/test_origin_finding.py, 21 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The correlation-equals-one boundary saturates: on real data (pg3T2
07-03-2024 scan 61) ring pair (2,2) climbs from 33.8 degrees at zero
separation to 71.6 by mid-lobe, then flattens to 78.9. Fitting an
unweighted straight line over the whole lobe let that flat tail dominate,
which displaced the intercept off the measured boundary and biased the
slope low. The fitted intercept landed up to 6.99 degrees away, visibly
inside the blue region rather than on the gray baseline.

Weight the fit exponentially towards short distances so the reported
slope is the near-origin tangent, which is the physically meaningful
quantity. Intercept error against the measured boundary at zero
separation, across all six ring pairs:

  before  +0.66  +3.14  -6.99  +4.70  -3.66  +6.89   (max 6.99)
  after   -1.11  +1.40  -1.69  -1.09  +1.31  -1.39   (max 1.69)

The 1/e decay defaults to SLOPE_WEIGHT_FRACTION (0.10) times the fitted
distance span, chosen by sweeping the fraction against this dataset.
slope_weight_scale=numpy.inf restores the previous unweighted fit
exactly. R-squared is now weighted with the same weights and is not
comparable to the old value. Adds slope_fit_intercept_degrees,
slope_fit_effective_point_count (Kish) and slope_weight_scale to the
metrics so the fit can be checked numerically. The fit line is drawn
only out to three decay lengths, past which the boundary has saturated
away from the tangent.

Note this changes reported slopes, which were biased low: pair (2,2)
0.231 -> 0.371 deg/px (+61%), (1,1) +49%, (1,2) +46%, (0,2) -20%.
Previously exported slope values need regenerating.

Tests: tests/diffraction/test_orientation_correlation.py, 10 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The polymer ice flagger only tested "bright and in the q band, on one
six-fold lattice". Three additions, each off by default:

Sharpness gate. Ice reflections are small and sharp; polymer signal is a
larger dot or a broad diffuse region. Peaks carry no width, so measure it
from the polar volume: radial/annular FWHM above a local baseline, at each
candidate's (r, theta). max_width_r_invA / max_width_theta_deg gate the q
band before the lattice search, so broad peaks neither get flagged nor drag
the phi estimate. sharpness_mode picks intersection ("both", compact dots)
or union ("either", which also keeps thin streaks). sharpness_mask is public
so a tuning preview applies the same rule instead of reimplementing it, and
collect_peak_widths / measure_ice_peak_widths report the widths to tune from.

Multiple crystallites. A pattern can hold several crystallites at unrelated
orientations; one pass only ever saw the strongest. The matcher now peels:
claim the best-supported lattice, remove its peaks, look again, up to
max_crystallites. min_phi_separation_deg stops a single lattice being
re-found as a near-duplicate. IceFlaggerDebug.phi_deg lists what was found.

Folded theta. process_polar(two_fold_symmetry=True) folds theta to [0,180),
collapsing every Friedel pair onto one angle -- so a pair scored one bin and
was rejected by min_matches=2, and arms 3-5 were unreachable, making
min_matches>3 silently impossible. The matcher is now period-aware
(theta_period_deg, read off the BraggPeaksPolymer), and detect_ice raises on
an unsatisfiable min_matches rather than matching nothing. min_peaks_per_arm
recovers the distinction folding destroys: 2 demands a Friedel pair on an
arm and rejects a lone peak.

Also: plot_q_intensity_density shades the candidate region as a rectangle
instead of drawing bare window edges.

Tests: 77 pass in tests/diffraction (28 new in test_polymer_ice.py).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folding theta to [0,180) for two-fold symmetry discarded which half of the
circle a peak came from, so a Friedel pair became indistinguishable from two
peaks that merely sit close together. Peaks are small, so keep the full angle:
polar_transform_peaks now emits a "theta_unfolded" column alongside the folded
"theta". Nothing else reads it by position -- lookups go through
fields.index() -- so the extra column is transparent.

IceFlaggerParams.require_friedel_pair uses it: an arm counts only if it holds
two peaks whose unfolded angles differ by 180 +/- dtheta_deg. That is the test
min_peaks_per_arm can only approximate, since a count of two is also satisfied
by two neighbours within dtheta_deg. The field is optional -- detect_ice raises
a clear "re-run polar_transform_peaks" error only when the strict test is
requested on a vector that predates it.

Tests: 80 pass in tests/diffraction. test_bragg_peak_polar_transform_inverts_
ellipse_mapping updated for the new column, and now pins the field order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the sharpness gate, from working it against a real polymer scan.

Annular width is the discriminator; drop sharpness_mode. Ice is annularly
sharp both as compact dots and as radial streaks (narrow in theta, extended in
q), while polymer at the same q is an annularly broad arc. The radial axis does
not separate ice from polymer -- dots and arcs measure alike there -- it only
separates streaks from dots, so a radial ceiling costs the streaks and buys
nothing. That makes the "both"/"either" combining rule pointless: with one
ceiling the modes are identical, and "either" silently defeated a tight annular
ceiling for any radially sharp peak. Ceilings are now simply ANDed.

Width measurement is noise-robust. The half-maximum walk stopped at the first
sample below half, so one downward fluctuation on a weak diffuse arc ended it
early: a true 40 degree arc measured 10-30 degrees at realistic SNR, i.e. as
narrow as ice. The profile is now Gaussian-smoothed (sigma 1 bin) and the
crossing must persist for 3 samples. The kernel is removed in quadrature --
exact for a Gaussian convolved with a Gaussian, which is why it is a Gaussian
and not a boxcar; a boxcar leaves a residual bias on sharp peaks. Median over 8
seeds, true 40 degrees at SNR 2: 10.5 -> 43.6, while a true 5 degree peak stays
at 6.0. Also widened the annular window to 90 degrees and dropped the baseline
quantile to 0.05, since a window the feature fills puts the baseline partway up
it and saturates the measurement near 39 degrees.

Parameters validate on construction. Every confusion this cost came back as
"nothing was flagged" rather than an error.

Module docstring now states the four criteria and the folding constraints.

Tests: 89 pass in tests/diffraction. The synthetic polar fixture sampled q at
0.025 1/A per bin, too coarse for the default radial window; now 0.005.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce flagger

Choosing IceFlaggerParams against a real dataset needs plots, not just the
algorithm: a selection box over the sharpness ceilings, ice-band orientation
histograms for all/kept/removed peaks, a widget view of either side of the
split, and a per-peak FWHM probe that shows the cuts each width is measured
from. These started as notebook cells, which meant every scan directory
carried its own drifting copy; in the package any notebook can import them.

Free functions taking (bp, params) explicitly rather than methods on
BraggPeaksPolymer, which is already 79 methods. The algorithms stay in
polymer_ice; this is only the interactive layer. Nothing mutates bp --
IcePeakView delegates to it so the widget can show a peak subset without the
caller's object being altered. The quantem.widget import is deferred into the
one function that needs it, so the module imports without that package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of this was polymer-specific. Measured inside exact AST method boundaries,
the calibration cluster referenced `self` only to reach a Dataset4dstem, a peak
Vector, a device string, and a slot to deposit diagnostics into -- all general
core types. The two ellipse fitters take a plain 2D array and a centre, so they
apply to any diffuse or crystalline ring from any instrument; `_fit_origin_roi`
did not reference `self` at all.

New `ellipse_fitting.py` holds `fit_ellipse_from_ring` (angular variance) and
`fit_ellipse_from_ridge` (joint centre + ellipse), returning an `EllipseFit`
dataclass. Returning the fit instead of assigning `self.ellipse_fit_diagnostics`
was what actually completed the extraction: that assignment was the only thing
tying 837 lines of arithmetic to the class. `EllipseFit.diagnostics` rebuilds
the flat mapping the class has always published, so `bp.ellipse_fit_diagnostics`
keeps its exact key set -- pinned by new tests for both methods.

Origin/descan work moves to `polar_transform.py`, which already owned three
origin finders, rather than to a third location: `find_central_beams_4d`,
`find_central_beams_from_peaks_4d`, `fit_origin_roi`, `centered_dp_mean`. The
dataset, peaks and device are now parameters. `find_central_beams_4d` stays on
the class as a forwarding wrapper since it is public API; every internal caller
already used keyword arguments.

BraggPeaksPolymer: 79 -> 74 methods, 6798 -> 5691 lines. bragg_peaks.py drops
below 8k. test_ellipse_ring_fit.py no longer constructs the class via
object.__new__ to reach the fitters; the one remaining use there legitimately
exercises preprocess argument validation.

Tests: 488 passed, 10 skipped across the full suite (102 in tests/diffraction).
Verified separately that both fitters still recover a synthetic ellipse
(true ratio 1.111 -> 1.105 ring / 1.117 ridge; true theta 35 deg -> 36.0 / 35.3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second extraction, same pattern as the calibration one. New
`peak_visualization.py` holds the seven plotting functions whose only tie to the
class was reading one or two attributes: the radial intensity and count
profiles, the per-position count and histogram maps, the interactive pattern
browser, figure export, and the pattern grid. They take the arrays and peak
vectors they draw, so they work for any 4D-STEM dataset.

All seven are public API and three appear in 51 notebooks, so each keeps a
forwarding method on the class. The wrappers take *args/**kwargs and prepend the
data, which preserves positional calls -- verified that the notebooks'
`bp.plot_peak_count_map(peak_windows, return_values=True)` still lands
`peak_windows` on `q_ranges`.

Five shared module-level helpers moved too. The three display helpers
(`_normalized_dp`, `_resolve_intensity_map`, `_intensity_display_limits`, plus
`_mean_intensity_map`) live with the plots and are imported back for the three
peak-overlay methods that stay. The two ragged-Vector field accessors went to a
new `vector_fields.py` instead: they are needed by `estimate_peak_windows` and
`make_orientation_histogram`, which are not visualization, and importing them
from a plotting module would have been the wrong direction -- or a cycle.

Found and fixed a bug the extraction introduced: threading
`self.peak_coordinates_cartesian` in as `peaks` turned
`peaks = self.peak_coordinates_cartesian[i, j].array` into `peaks = peaks[i, j]
.array`, shadowing the parameter and failing on the second scan position. Only
two of the seven functions had any test coverage -- including none of the three
used by 51 notebooks -- so this was invisible. Added
`test_peak_visualization.py` covering the five untested ones through both entry
points, which is what caught it, and AST-scanned the rest for the same
shadowing pattern (no others).

Also repaired `test_bragg_plotting_and_save_smoke`, which monkeypatched
`interactive_output` on `bragg_peaks` only; `plot_interactive_image_map` now
resolves it from `peak_visualization`, so the patch had silently stopped
applying. It now patches both modules.

BraggPeaksPolymer: 5691 -> 4812 lines (79 -> 74 methods across both steps, since
the public plotting entry points remain as wrappers). bragg_peaks.py 7103 ->
6162, down from 8206 at the start.

Tests: 495 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written before the orientation extraction so it pins current behaviour rather
than post-extraction behaviour. make_flowline_map and make_flowline_rainbow_image
appear in 51 notebooks each and had no tests; make_flowline_combined_image,
make_flowline_rainbow_legend and the two intensity helpers had none either -- 678
lines with no coverage, which is what let a shadowing bug through in the previous
extraction.

Covers the pipeline end to end on a small synthetic orientation histogram with a
coherent stripe (a uniform histogram seeds no flowlines), plus a set/get
round-trip for the interpolating intensity helpers.

Note the flowline map is asserted to be *concentrated* in the seeded band rather
than exactly zero outside it: the tracer steps along directions and set_intensity
spreads weight across neighbouring bins, so a little legitimately bleeds out.

Tests: 112 passed in tests/diffraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third extraction. The cleanest cluster yet: of the 1564 lines, 678 referenced
`self` not once -- the three flowline renderers and both intensity helpers were
methods purely by accident. Nothing in it is polymer-specific; orientation
histograms and flowlines apply to any material with anisotropic scattering.

New `orientation.py` holds the pipeline -- make_orientation_histogram (threaded
polar_peaks + peak_intensities), make_flowline_map, and the rainbow/combined/
legend renderers -- plus `_get_intensity`/`_set_intensity`, now private module
helpers rather than public methods, which removes make_flowline_map's only tie to
the class. `plot_orientation_correlation` went to `orientation_correlation.py`
beside `calculate_orientation_correlation`, taking SLOPE_WEIGHT_FRACTION with it.

All eight public names keep forwarding methods.
`plot_orientation_correlation`'s wrapper preserves its self-fallback, so
`bp.plot_orientation_correlation()` with no arguments still plots the volume the
object last computed. `calculate_orientation_correlation` stays put: it writes
orient_corr/orient_corr_pairs onto the object, and already delegated its maths.

Verified per the protocol in dp_peak_detection's HANDOFF:
- Baseline tests written FIRST (e80ffa7) for the 678 previously uncovered lines.
- AST scan for rebinding of injected parameter names: none.
- refactor_diff_check.py: 23/23 bit-identical against baseline 1265551, the seven
  new orientation checks included.

One bug caught by the baseline tests: the generated wrapper prefixed `_` onto the
already-underscored `_set_intensity`, producing `__set_intensity`, which Python
name-mangles inside a class body. Fixed. Exactly the class of failure the
tests-first ordering is meant to catch, and it would have been invisible before
e80ffa7 since these had no coverage.

BraggPeaksPolymer: 4812 -> 3296 lines. bragg_peaks.py 6162 -> 4648, from 8206 at
the start of the refactor.

Tests: 500 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written before extracting them. Neither resize_images nor _postprocess_single had
any tests, and between them they bracket the whole ML detection path: one prepares
every pattern for the network, the other turns its two output channels into peak
coordinates and intensities.

test_resize_images_shape_and_intensity_convention pins the existing intensity
convention rather than the one I expected. Bilinear interpolation to half the
linear size already divides the sum by 4; the code then multiplies by
scale_factor = (16*16)/(32*32) = 1/4 as well, so the total comes out 16x smaller.
Count conservation would need the reciprocal (x4). Presumably this matches the
convention the network was trained under -- pinned as-is, deliberately not
changed, and called out in the docstring so the next reader does not assume the
sum is preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth extraction, and a deliberately small one -- the honest finding here is how
little of this cluster should move.

Of the 617-line ML cluster, only 115 lines are cleanly extractable:
resize_images_for_model (prepares patterns for the network; read only
self._final_shape) and peaks_from_model_output (turns the two output channels into
coordinates and intensities; referenced self not at all). Both go to
peak_detection.py beside the blob detection and refinement they feed into, so the
module now covers the whole detection path rather than just its middle.

The other nine stay, and should. find_peaks_model, infer_peaks_single,
adapt_batchnorm, ensure_normalization_params, set_model_weights,
_infer_train_batch_output, _invalidate_inference_caches, prepare_inference and
resize_data read AND write instance state -- the model, the resolved
normalization parameters, the BatchNorm-adaptation flag, the live chunk cache.
That is a model lifecycle, which is what an object is for; free functions would
mean threading a mutable context through all of them, which is worse than the
class. save_peak_animation also stays: it calls infer_peaks_single and shares
seven peak-overlay drawing helpers with plot_interactive_peak_map and
save_peak_figures, so it belongs with that group's render-context decision.

Verified per protocol: baseline tests first (b199e4d), AST scan for
injected-parameter shadowing (none), refactor_diff_check.py 26/26 bit-identical.

BraggPeaksPolymer: 3296 -> 3193 lines. bragg_peaks.py 4648 -> 4550.

Tests: 504 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A survey of what was left in bragg_peaks.py turned up something the earlier
cluster analysis had missed entirely: ScanMaskEditor is a *separate* 885-line
class with 46 methods, sharing the file with BraggPeaksPolymer for no reason. Its
only mention of BraggPeaksPolymer is a button tooltip string, and it uses none of
the module's helpers. So this is a file split rather than a refactor -- no
signature changes, no parameter threading, no shadowing risk.

bragg_peaks.py now contains exactly one class. 3663 lines, from 8206 when the
refactor started.

All three import paths still resolve to the same object, verified:
quantem.diffraction.ScanMaskEditor (what the tests use),
quantem.diffraction.bragg_peaks.ScanMaskEditor (preserved by the import that
edit_scan_mask needs anyway), and the new
quantem.diffraction.scan_mask_editor.ScanMaskEditor.

edit_scan_mask and create_interactive_circular_mask stay on the class: the editor
is constructed as ScanMaskEditor(self), so those are factories bound to the
analysis whose mask they edit, which is a legitimate reason to be a method.

Tests: 504 passed, 10 skipped. ScanMaskEditor already had coverage
(test_scan_mask_editor.py), so no new baseline tests were needed for this step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 16x shrink has no effect on results, and not merely by train/inference
consistency -- the factor is annihilated. Resize precedes normalization
everywhere, and every normalization strategy here starts with a per-image
min-max, which is invariant under positive uniform scaling. Both reported
intensity fields are post-normalization, so no pre-normalization absolute
intensity reaches any output.

Replaces the speculative note with the proof, and records the one genuinely
latent concern: scale_factor depends on input detector size, so a future
normalization with a fixed absolute divisor would make it matter, differently
per dataset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NJ March and others added 7 commits July 27, 2026 13:02
Written before extracting it. estimate_peak_windows appears in 51 notebooks and
had no tests, and it decides the q-windows that every count map, radial profile
and flowline family downstream is computed over -- a silent change would shift
every result without failing anything.

Covers recovery of three injected radial families, window bracketing and clipping
plus the min_width floor, intensity/count mode agreement under uniform
intensities, log compression, the empty-result shapes, and mode validation. Also
pins the peak_info key set including the profile/intensity_profile alias, which is
the same array under two names and easy to drop by accident when moving code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Appended to peak_detection.py, which now covers the detection path end to end:
model input prep, blob detection and refinement, output post-processing, and the
radial families the peaks cluster into. Two attribute reads threaded in as leading
parameters, both documented in the existing numpydoc block; the class keeps a
forwarding method since 51 notebooks call it.

Verified per protocol: baseline tests first (90cf2cb) -- it had none, despite
deciding the q-windows every count map, radial profile and flowline family
downstream is computed over. Then AST scan for injected-parameter shadowing
(none), then refactor_diff_check.py: 33/33 bit-identical, with seven new checks
covering the returned centres, windows, and five peak_info arrays separately.

BraggPeaksPolymer: 3193 -> 3050 lines. bragg_peaks.py 3663 -> 3521.

Tests: 511 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion QA grid

The two interactive_probe_selector methods (275 lines) existed to build a list of
(ry, rx) coordinates. That now lives in the viewer people actually use
(quantem.widget 3f364b7), so they are deleted rather than moved. Verified
unreferenced first: no callers in quantem, in any notebook, or in the widget repo.
The two hits in dp_peak_detection are older vendored copies of the whole class,
which carry their own definitions and are unaffected.

visualize_peak_detection is extracted to peak_visualization.py -- but it had to be
repaired first, because it did not run at all. It called len() on a Vector scan
cell and indexed one with [:, 0]; both raise against the current Vector API, which
needs .array. Any call failed on the first pattern. That is presumably why it was
unused, and why nothing noticed: it had no tests. Repaired minimally (.array),
which cannot regress behaviour since there was none, and it now has baseline
coverage. Also dropped a per-pattern debug print and several commented-out
alternate renderings that referenced instance attributes -- meaningless in a free
function and non-executable either way.

Its six attribute reads are threaded as leading parameters and documented in the
numpydoc block; the class keeps a forwarding method. Draw-only, so nothing to
compare numerically -- recorded in refactor_diff_check's DRAW_ONLY with a note that
no pre-move behaviour existed. AST scan clean; the wider check stays 33/33.

BraggPeaksPolymer: 3050 -> 2649 lines, 74 -> 72 methods. bragg_peaks.py 3521 ->
3121, from 8206 when the refactor started.

Tests: 513 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The selection could only be axis-aligned, so a feature lying diagonally across the
scan had to be bracketed by an oversized upright box. Adds an Angle control
(degrees, 0-180 since 180 is the same selection as 0) plus an initial_angle
argument threaded through BraggPeaksPolymer.edit_scan_mask.

The mask rotates the sampling grid rather than the shape, so the axis-aligned
inside-tests are untouched. The overlay passes the same angle to the Ellipse and
Rectangle patches, with rotation_point="center" so the rectangle turns about the
selection centre rather than the corner its xy names. A circle is rotation
invariant, so the control is hidden for it and .angle reports 0.

Angle is persisted: schema bumped to 3, and schema 1/2 files still load with
angle 0, since they were axis-aligned by construction.

The invariant worth testing is that the drawn outline agrees with what will
actually be selected -- a rotation applied to the mask but not the overlay, or with
the opposite sign, would silently mislead. Parametrised over three geometries and
four angles, comparing the patch's own inside-test against the mask on every scan
pixel: ellipse agrees ~100%, rectangle and square 97-98%, the residual being the
mask's inclusive edges versus the patch drawn a half pixel outside.

One of my own tests was wrong first: I asserted the 45 degree long axis lay on the
anti-diagonal, and it lies on the main diagonal. Which screen diagonal that is
depends on the display origin, so the assertion now records the actual behaviour
and defers the real question to the outline-agreement test.

Also updated test_controls_follow_probe_directions_and_apply_is_explicit, which
pinned the image row by index; the angle row now sits above it, and the assertions
name each shape-control row rather than just counting.

Tests: 531 passed, 10 skipped (18 new in test_scan_mask_editor.py).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the lobe

The 50% radial distance came back NaN for orthogonal ring pairs. The metric
was pinned to a relative orientation of zero, but those pairs correlate at
the other end of the axis, where their zero cut is a shallow anticorrelated
plateau that need never reach the half level.

Investigating that turned up a larger error. Orientations are pi-periodic,
so num_theta bins cover 180 degrees and the retained num_theta // 2 + 1 lags
are spaced 180 / num_theta apart: the relative-angle axis always ends at 90
degrees. It was labelled 0-180, so every annular distance and every slope
was a factor of two too large. Radial distances were unaffected. Verified to
2e-16 at num_theta = 180 and 360 against a synthetic with a known angular
offset, where the cross-correlation is provably the autocorrelation shifted
by that offset. The lamellar/backbone lobe is at 90 degrees, perpendicular,
as expected physically rather than the "180 degrees" previously displayed.

Three changes:

- MAX_RELATIVE_ANGLE_DEGREES = 90 drives the angle axis, the imshow extent
  and the slope-fit clipping.
- Intercepts reference the pair's lobe. Candidates are the two ends only,
  parallel or perpendicular. An earlier revision took a free argmax; it
  selected interior noise on 56 of 181 real pairs and swung decay lengths
  0.09x-6.2x, so interior maxima are deliberately not eligible.
- An intercept outside the measured window is censored rather than silently
  NaN: *_censored flags and *_lower_bound values let a correlation that
  outlasts the scan be told apart from one that could not be measured.

Across 18 cached volumes (181 pairs): 108 lobes parallel, 73 perpendicular,
0 interior, 0 silent NaNs, 25 censored and bounded.

Tests: four added covering axis span against ground truth, lobe reference,
interior-noise rejection, and censoring. Three existing tests are updated —
their fixtures built synthetic boundaries on a 0-180 axis, so the corrected
labelling halved their expected slopes; their local angle axes now use 0-90.
Full suite 536 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RNGMixin moved from core/utils/utils.py to core/utils/rng.py in c1a8abc
(2025-10-07); augment_dp was never updated, so importing DPAugmentor has
been broken since. Path-only change: RNGMixin is byte-identical across
the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant