Skip to content

feat!: the pooled fit models sum peaks when the strongest line's self pair is significant - #59

Merged
Paullo9 merged 4 commits into
mainfrom
issue-8-pooled-sum-peaks
Oct 31, 2026
Merged

Paullo9 merged 4 commits into
mainfrom
issue-8-pooled-sum-peaks

Conversation

@Paullo9

@Paullo9 Paullo9 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Part of #8
Part of #15

Stacked on issue-15-pileup-explained (the sum and escape peaks as explained counts for the proposer). This PR's base is that branch; retarget to main once it merges. It is stacked because PooledQuantification.swift is already in two open PRs.

What changes

The pooled quantification fitted one Gaussian per line and no sum (pile-up) peaks, exSpy's model. FitPolicy.sumPeaks (SumPeakRule, default .whenSignificant) now tests the strongest line's sum peak with itself alone (Al K-alpha + Al K-alpha in an Al alloy). If its pile-up coefficient is 3 sigma above 0, the fit keeps the sum peaks of the four strongest lines (EDSSumEscapeFit). Sigma is scaled by sqrt(max(chi2_red, 1)) of that fit. If the test fails, the result is the old fit, bit for bit.

  • Why the self pair is the test. On a synthetic spectrum with no pile-up and Cu, Ga and Ge left off the list, the four-parent model kept c at 48 sigma, because Ni K-alpha + O K-alpha sits on Cu K-alpha. The Si self pair read 0.
  • The footer names the decision and its number. When the sum peaks are kept, the footer and the warnings also name the alpha lines within one FWHM of the self pair: Ar K-alpha and Ag L-alpha under Al+Al. A real line there is read as pile-up.
  • The exSpy-equivalent path stays as a switch: .off (FitPolicy.exspyEquivalent). A caller that passes sumEscape keeps full control. The golden scenarios pin the explicit off model; their numbers are byte-identical, and only the two new nil fields are added.
  • Refits. The unlisted-line and fit-range refits repeat the reported decision. The proposer's axis refinement runs without sum peaks.
  • The Al K-alpha tail is not part of the default; it is a detector calibration. Fitted together with the sum peaks on the demo's Poisson pools (256 to 2 897 px), it read 0 to a third of the planted fraction and left Mg K-alpha up to 25 % low. Measured once with TailEstimate on a high-count spectrum and typed into FitPolicy.lineTail, it takes Mg K-alpha to within 5 %.

Numbers against planted truth on synthetic data

The truth is the simulation recipe of sim_edx.py v2.0 (mac4DSTEM demo-edx, public), not a measurement, so every result stays validation: "none". The pools come from tools/edx-sim/demo_pools.py and are stored in the new fixture edx-demo-pools.json; the kit's eds-absorption-truth.json is the sixth pool. Setup: true k typed, truth geometry, absorption on, axis locked.

Si planted counts include the Si internal-fluorescence peak, the kit's convention. The "before" columns are the .off fit, which equals the old default bit for bit.

Si K-alpha, net against planted, noise-free

pool before after
L5 (recipe A, highest rate) -57.2 % -7.9 %
L6 (precipitate) -8.4 % -1.2 %
L7 (pure Mg5Si6) -0.2 % -0.0 %
M_A (recipe A map, 1217 px) -14.9 % -3.6 %
M_matrix (2897 px) -16.6 % -3.9 %
absorption-truth fixture -18.2 % -4.0 %

Cu K-alpha, read against planted, noise-free

pool before after
L5 0.42 0.97
L6 0.00 0.92
L7 0.40 0.93
M_A 1.00 0.99
M_matrix 0.98 0.99

On the L6 Poisson counts Cu reads 0.90 of planted, against 0 before.

Mg K-alpha, net against planted, noise-free

pool before sum peaks sum peaks + calibrated tail (f 0.0095, 0.073 keV)
L5 -24 % -22 % +3.2 %
L6 +0.5 % +0.1 % +1.1 %
L7 +0.1 % +0.1 % +0.8 %
M_A -32 % -32 % +2.6 %
M_matrix -45.5 % -45 % -14.7 % (not explained; seven pooled recipes and thicknesses)

Reduced chi2 on the Poisson counts: L5 6.36 to 0.70, L6 31.3 to 1.08, L7 3.30 to 0.99, M_A 2.11 to 0.83, M_matrix 4.95 to 0.98. The self pair passes on every pool's Poisson counts at z = 5.1 (L6) to 35.5 (M_matrix).

At% on the Poisson counts (Si target includes the internal fluorescence):

  • L6: Mg 23.55 (truth 23.03), Si 27.98.
  • L7: Mg 45.27 / Si 54.73 (truth 45.45 / 54.55).

Absorption-truth pin: the Si bias moves from -0.203 +- 0.040 to -0.067 +- 0.051. The old pin stays, on the .off fit.

Unlisted-line check (#9): with the default, Ar and Ag are not named on L5, M_A or M_matrix. The Al+Al sum is a modelled peak and is explained to the proposer.

Bounds are measured regression bounds on fixed inputs, not derived limits: Si 5 % (10 % on L5), Cu 0.85 at high rate, chi2_red 1.2.

Migration

PooledQuantifier.run now fits sum peaks by default when the data support them, so nets, at% and wt% change on spectra with pile-up. To keep the old numbers, pass either:

  • a policy with fit.sumPeaks = .off (or FitPolicy.exspyEquivalent), or
  • sumEscape: SumEscapeModel().

A stored QuantificationPolicy without the new field decodes as .off, and its footer names the non-default policy.

Test evidence

  • swift test -c release --parallel --num-workers 1 -j 1 --skip SpectroKitParityTests: green, 284 tests (2 known issues, unchanged).
  • SpectroKitParityTests run once, because a default changed: green, 83 tests.
  • Red-first: the new tests check the default against the .off fit, which is the old default bit for bit (asserted). Before, Si read -8 to -57 % and Cu 0 to 0.42 at high rate; now they pass the bounds.
  • Two independent numerics reviews before the commit. Fixed:
    • sigma_c scaled by the misfit;
    • the coincident-line caveat (Ar/Ag under Al+Al);
    • the self pair passing on the Poisson counts, now asserted;
    • the axis refinement cost (no sum-peak fits there);
    • several wording corrections.

What remains

  • The docs/parity.md and docs/open-items.md rows, and the tools/edx-sim/README.md line for demo_pools.py, wait until the open PRs on those files land (each file is already in two open PRs).
  • Not covered by a test:
    • the default with the axis refinement on (every new test locks the axis);
    • a pile-up spectrum with an incomplete element list, where the cross pairs could still pull on c.
  • A real line under the self pair is flagged, not separated.
  • Accuracy against published values on real data (a public Al-alloy EDS dataset or standard with a stated composition): next step, not in this PR.
  • Auto ID (Auto ID leftovers after 0.11.0 #9): the proposeOnRefinedAxis path still lists Ar and Ag as possible candidates and does not give them the sum-peak question. That needs proposer files in other open PRs.

🤖 Generated with Claude Code

Paullo9 and others added 3 commits October 31, 2026 14:42
…aks as explained

With the sum/escape model on, the unlisted-line check proposed on the bare spectrum and named the modelled pile-up and escape peaks as elements. SumEscapeResult.componentCounts (the fitted c S(A) + f E(A)) is now carried by the quantification and handed to ElementProposer.propose(explained:), which adds it to every fit as a fixed offset. nil (the default, and the model off) runs the proposer as before.

Planted pools (tools/edx-sim proposer_sim, model on): 100 counts/px no longer names Ar, Ag, Pd; 300 counts/px no longer names Rh, Ar, In, Sn, Pd, Ag, Cd, Ru, Bi, K; a planted Fe escape with the free escape columns off no longer names La, Ce, Cr, Cs. A planted K K-alpha between two sum peaks is still named first. Model off: unchanged.

Done: sum/escape peaks are explained counts for the proposer and the check. Next: docs rows for parity.md and open-items.md item 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pair is significant

The pooled quantification fitted one Gaussian per line and no sum (pile-up) peaks, exSpy's model. On the public
synthetic AlMgSi demo (planted truth: the simulation recipe of sim_edx.py v2.0, not a measurement) that fit read
noise-free Si K-alpha 15-18 % low on the Al-rich map and fixture pools and 57 % low on the highest-rate pool, held
a planted Cu K-alpha at 0 at high rate, and had a reduced chi2 of 2.1-31 on the Poisson counts. The Al+Al sum peak
(2.97 keV) and its neighbours pull the continuum segment above the Al K edge, and Si K-alpha with it.

FitPolicy.sumPeaks (SumPeakRule, default .whenSignificant) now tests the strongest line's self pair alone with
EDSSumEscapeFit; when its pile-up coefficient is 3 sigma above 0 (sigma scaled by sqrt(max(chi2_red, 1)) of that
fit) the sum peaks of the four strongest lines are fitted and kept, otherwise the result is the old fit bit for
bit. The self pair is the test because the cross pairs absorbed an unlisted Cu K-alpha (Ni K-alpha + O K-alpha) on a
synthetic spectrum with no pile-up. The footer names the decision with its number, and names the alpha lines within
one FWHM of the self pair (Ar K-alpha, Ag L-alpha under Al+Al): a real line there is read as pile-up.
.off (FitPolicy.exspyEquivalent) is the old fit; a caller passing sumEscape keeps full control. The unlisted-line
and fit-range refits repeat the reported decision; the proposer axis refinement runs without sum peaks.

Noise-free Si K-alpha against planted: -0.0 to -4.0 % on five pools, -7.9 % at the highest rate; Cu K-alpha 92-97 %
of planted at high rate (0-42 % before); reduced chi2 on the Poisson counts 0.70-1.08. The absorption-truth Si pin
moves from -0.203 to -0.067 (+- 0.051), and the old pin stays on the .off fit. The Al K-alpha tail stays a detector
calibration: measured once with TailEstimate on a high-count spectrum and typed, it brings Mg K-alpha from -22 to
-32 % to within 5 % (matrix map -15 %). New fixture edx-demo-pools.json from tools/edx-sim/demo_pools.py. The
golden scenarios pin the explicit off model; their numbers are unchanged, only the two new nil fields are added.
Results stay validation none. Reviewed (numerics, two independent reviews): sigma scaling, the coincident-line
caveat, the axis refinement cost and the wording fixed.

BREAKING CHANGE: PooledQuantifier.run fits sum peaks by default when the data support them, so nets, at% and wt%
change on spectra with pile-up. Migration: pass a policy with fit.sumPeaks = .off (or FitPolicy.exspyEquivalent),
or sumEscape: SumEscapeModel(), to keep the old numbers. A stored QuantificationPolicy without the new field
decodes as .off and its footer names the non-default policy.

Done: sum peaks on by default in the pooled fit, with planted-truth tests on the demo pools. Next: the parity and
open-items rows once the open PRs on those files land, a check of the default on real data with published values,
and the sum-peak question for the auto ID possible candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-peak branch

Conflicts resolved keeping both sides:
- FitPolicy.init takes sumPeaks, sumPeakSignificance and continuumDeadLayer.
- FitPolicy CodingKeys and init(from:) now carry continuumDeadLayer. The branch had a hand-written decoder, so without this the dead-layer term was dropped from the encoded policy, its footer line and canonicalJSON (caught by testPooledQuantifierRunsTheTermTypedAndFittedAndNamesIt).
- PooledQuantification keeps appliedSumEscape and continuumDeadLayerMicrons; the result sets both.
- Spectro goldens: both new nil lines kept. Re-recorded from the merged code: byte-identical to the resolved files, no number moves.

Done: the sum-peak branch is on current main, fast gate and SpectroGoldenTests green.
Next: stack the continuum-edge branch on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9
Paullo9 merged commit e91a75a into main Oct 31, 2026
9 checks passed
@Paullo9
Paullo9 deleted the issue-8-pooled-sum-peaks branch October 31, 2026 22:26
Paullo9 added a commit that referenced this pull request Oct 31, 2026
Main gained #59, which carries this branch's feature in a wider form: the pooled
result's sumEscapeCounts is filled from every kept sum-peak fit (explicit model,
always, and whenSignificant), and SumEscapeExplainedTests is a superset of this
branch's copy. Conflicts in PooledQuantification.swift and
SumEscapeExplainedTests.swift take main's side; the golden re-record and its
header note are already in main. After this merge the tree equals origin/main.

Done: branch up to date; it adds nothing over main.
Next: the PR can close as superseded by #59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paullo9 added a commit that referenced this pull request Oct 31, 2026
Main gained #59 (squashed, the base this branch was stacked on) and the
continuum dead-layer term. Conflicts:
- PooledQuantification.swift, QuantificationPolicy.swift: this branch does not
  change them; main's side kept (it is the branch side plus continuumDeadLayer).
- the four golden baselines: main's files taken, then re-recorded with
  SPECTRO_GOLDEN_DIR. Against main the recording adds only the
  explainedBySumPeak = nil lines of this branch (35 in eds-realistic-l-sim,
  60 in eds-unlisted-sim); demo-edx-tiny-dm4, eds-weakline-sim and
  eds-proposer-sim match exactly. No number moves.

After the merge the diff against main is this branch's two commits only.

Done: branch up to date with main, builds, gate green.
Next: review and merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paullo9 added a commit that referenced this pull request Nov 1, 2026
Keeps one copy of the #59 sum-peak migration note (it appeared five times) and adds #65's breaking change: the broadened continuum step is the new default, and FitPolicy.continuumEdgeStep = .sharp keeps the old numbers. Collapses the duplicate #46, #47 and CI lines. Notes under #68 that refits on the opt-in fitted dead layer repeat the reported thickness, so their numbers can change. The version is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paullo9 added a commit that referenced this pull request Nov 1, 2026
* chore(main): release 0.13.0

* docs: tidy the 0.13.0 CHANGELOG entry

Keeps one copy of the #59 sum-peak migration note (it appeared five times) and adds #65's breaking change: the broadened continuum step is the new default, and FitPolicy.continuumEdgeStep = .sharp keeps the old numbers. Collapses the duplicate #46, #47 and CI lines. Notes under #68 that refits on the opt-in fitted dead layer repeat the reported thickness, so their numbers can change. The version is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: macem-release[bot] <349909138+macem-release[bot]@users.noreply.github.com>
Co-authored-by: Paullo9 <paullobpreis@icloud.com>
Co-authored-by: Claude Opus 5.5 <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