Skip to content

fix: the energy axis refinement stops at an offset and gain bound and never reports converged there - #84

Merged
Paullo9 merged 2 commits into
mainfrom
issue-83-axis-runaway
Nov 1, 2026
Merged

Paullo9 merged 2 commits into
mainfrom
issue-83-axis-runaway

Conversation

@Paullo9

@Paullo9 Paullo9 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Fixes #83

Numbers move. This PR waits for Paul (auto-merge off). Ships as 0.13.2.

Cause

A spectrum with one strong line fixes two numbers: the line's channel and its width in channels. The refinement has three parameters (offset, gain, FWHM), so they can trade along a valley. On the reported synthetic spectrum (Al K-alpha strong, Mg and Si planted weak and fitted at 0, 4322 counts in all), I held Al K-alpha at channel 148.6 and 9 channels wide. Along that valley the residual changes by under 5 % between gain 0 and gain +444 % (test oneLineValleyIsFlat). Under the broadened step it keeps falling all the way to the 500 eV width bound. The edge step only decides where on the valley the simplex stops:

  • under the sharp step, the cut happened to make a shallow dip at gain +32.7 %, which is also wrong (the file axis is the truth);
  • under the broadened step there is no dip, so the simplex walked to the width bound and still reported converged.

The hypothesis that failed: "the edge step follows the axis and the FWHM, so there is a feedback". I tested it first. I held the continuum (edge positions and step width) on the file axis and file width during refinement. The broadened result came out bit-identical, so that feedback is not the cause. This would have refuted the valley diagnosis instead: a spectrum with two or more strong, well-separated lines running away. The 384 planted refinements of AxisGateTests E3 (lines spanning 1.25-1.84 keV) all stay converged, with gain at most 0.42 %.

Fix

  • AxisRefinementSettings gets two new bounds, maximumOffsetShiftEV (default 200) and maximumGainShift (default 0.05). A trial outside either is rejected, the same way the existing width bounds work. Inside the bounds the objective is unchanged, so any run that never crosses a bound takes exactly the same simplex path as before.
  • A refinement that ends within 1e-4 of a bound's span from any bound is reported not converged. It names each such parameter in AxisRefinementResult.parametersAtBound (public) and in a text clause.
  • When that happens, PooledQuantifier uses the file axis and the file width:
    • the warning reads "axis refinement stopped at its bound (gain +5.000 % (bound ±5 %)): the spectrum does not determine the energy axis; the file's axis is used";
    • the footer reads "energy axis: ... NOT used (stopped at its bound: gain +5.000 % (bound ±5 %))".
  • When a refinement hits its iteration cap and is still used, a warning now says it did not converge.
  • FitPolicy.problem now refuses an offset or gain bound that is not finite and positive.
  • ProposalAxisRefinement skips a refinement that stopped at a bound.
  • Migration: a stored policy without the two new keys reads the defaults, not "no bound". Re-running such a stored policy can give different numbers, but only where its old run walked against the new bounds.

Before / after (the reported synthetic spectrum, 200 kV, Al + Mg + Si, axis unlocked)

The reference is the file axis: offset 0, gain 0. The lines were planted on it. There is no clean truth for the net counts: the generator truncates each pixel's channel value to an integer before summing, which removes most of the planted counts (about 10,500 Al K-alpha counts were planted before truncation).

case before (0.13.1) after (this PR) reference
broadened (default): axis offset −6591.8 eV, gain +443.834 %, FWHM 500 eV, reported converged file axis used; refinement not converged, stopped at gain +5.000 % (bound ±5 %) offset 0, gain 0
broadened: Al net ± σ 4443.8 ± 99.5 3795.6 ± 91.3 (no clean truth)
broadened: Mg net ± σ 0 ± 6580.9 0 ± 23.3
broadened: Si net ± σ 0 ± 152.0 0 ± 6.6
sharp: axis offset −484.2 eV, gain +32.711 %, FWHM 157 eV, reported converged file axis used; stopped at gain +5.000 % (bound ±5 %) offset 0, gain 0
sharp: Al net ± σ 4023.3 ± 118.0 3644.3 ± 93.5
sharp: Mg net ± σ 0 ± 65.3 0 ± 24.9
sharp: Si net ± σ 0 ± 16.4 0 ± 5.3
every golden and parity case (demo-edx-tiny, eds-unlisted-sim, eds-weakline-sim, eds-realistic-l-sim, pins) — bit-identical; each unlocked refinement gains two empty lines for the new fields

After the fix, both steps give exactly the numbers of the locked-axis fit (the test asserts equality). An app that pins a golden of this spectrum will see these numbers move when it takes 0.13.2.

Tests (red first)

  • AxisRefinementRunawayTests:
    • this spectrum refuses the refined axis with the named reason under both steps;
    • its numbers equal the locked-axis fit;
    • the valley diagnosis;
    • the fixture is the reported 4322-count spectrum.
  • Before the fix, the broadened case reported converged at gain +443.8 % with the refined axis in use.
  • QuantificationPolicyTests.testAxisRefinementOptions adds four checks:
    • a refinement at the 140 eV width bound reads not converged, with parametersAtBound == ["FWHM"];
    • its pooled fit keeps the file axis and the footer says why;
    • a capped run carries the not-converged warning;
    • bad bounds are refused.
  • Fast gate (swift test -c release --parallel --skip SpectroKitParityTests): green; the 4 known issues were there before.
  • Parity target: green; the 2 known issues were there before.
  • I re-recorded the goldens in their own commit. The diff only adds the new field lines.
  • SwiftLint --strict and SwiftFormat --lint: clean.
  • I had a separate reviewer check the numerics. I applied its findings: validation of the bounds, the Migration note, the comment accuracy, and the cap warning.

🤖 Generated with Claude Code

Paullo9 and others added 2 commits November 1, 2026 20:21
…reports converged there

A spectrum with one strong line fixes two numbers (its channel and its width in channels) against three refined parameters
(offset, gain, FWHM). On a synthetic one-line spectrum (Al K-alpha, Mg and Si at 0, 4322 counts) the residual changes by under
5 % from gain 0 to +444 % along that valley, so the simplex walked: to gain +444 %, offset -6.59 keV and the 500 eV FWHM bound
under the broadened edge step, to gain +32.7 %, offset -484 eV under the sharp one, both reported converged. Holding the
continuum on the file axis left the broadened result bit-identical, so the edge step following the axis is not the cause.

AxisRefinementSettings gains maximumOffsetShiftEV (200) and maximumGainShift (0.05); a trial outside is rejected as the width
bounds are. A refinement that ends within 1e-4 of a bound span of any bound is not converged and names the parameter
(AxisRefinementResult.parametersAtBound); the pooled fit then uses the file axis and width, with a warning and the footer clause
NOT used (stopped at its bound: ...). A capped refinement that is used now says it did not converge. Stored policies without
the new keys read the defaults. Every golden and parity case is bit-identical (they gain two empty lines for the new fields).

Done: SpectroKit#83 fixed with red-first tests. Next: Paul reviews the before/after table; mac4DSTEM re-records its demo golden on 0.13.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AxisRefinementResult gained parametersAtBound and boundText, so every golden with an unlocked refinement gains two lines (an
empty list and an empty string). No existing line changed: every value is bit-identical. Re-recorded with
SPECTRO_GOLDEN_DIR and testRecordBaseline.

Done: goldens re-recorded for the new fields. Next: none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9 Paullo9 self-assigned this Nov 1, 2026
@Paullo9
Paullo9 merged commit 95bc318 into main Nov 1, 2026
7 checks passed
@Paullo9
Paullo9 deleted the issue-83-axis-runaway branch November 1, 2026 19:56
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.

Energy-axis refinement runs away under the broadened edge step and still reports converged

1 participant