Skip to content

feat: opt-in detector-broadened continuum step at absorption edges - #64

Merged
Paullo9 merged 11 commits into
mainfrom
issue-8-continuum-edge
Nov 1, 2026
Merged

Paullo9 merged 11 commits into
mainfrom
issue-8-continuum-edge

Conversation

@Paullo9

@Paullo9 Paullo9 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Part of #8

Stacked on #59 (issue-8-pooled-sum-peaks): both PRs add a FitPolicy field with a tolerant decoder in QuantificationPolicy.swift, and #50 touches it too. Merge #59 first; this PR's base is that branch.

What

The default continuum cuts its Bernstein segments at the Al K edge (1.5596 keV) with a jump between two adjacent channels. A detector cannot record a jump: every channel is the incoming spectrum convolved with the resolution (about 75 eV FWHM at 1.56 keV for 128 eV at Mn K-alpha). On top of that, the Al K-beta line sits at the edge energy itself.

FitPolicy.continuumEdgeStep = .broadened (opt-in) weights each segment by the detector's Gaussian step at its edges, w_s = Phi((E - a)/sigma_a) - Phi((E - b)/sigma_b). Sigma comes from the line-width law at the edge, at the line model's resolution, so it follows the axis refinement. Each polynomial continues past its edge at its end value. The weights telescope to 1 and stay non-negative, so the continuum stays non-negative.

  • .sharp stays the default and is the earlier form, value for value.
  • The default policy's canonical JSON is unchanged byte for byte: the key is written only when it is not the default.
  • A stored policy without the key decodes to .sharp.
  • exSpy's background is a plain polynomial with no edge, so no parity path is involved.

Diagnosis

Cause: the sharp step at the edge channel, plus the order-5 segment above the edge, which cannot follow the absorption recovery just above the edge. Cheap experiment first: broaden the step and refit. The step part would be refuted if broadening left the misfit unchanged. It halves it.

Pure-Al wedge, public dataset: Snelson, Collins, MacArthur (2026), University of Leeds, doi 10.5518/1826, CC BY 4.0. Titan 200 kV, Super-X, 214 793 px, 3e5 Al K-alpha counts, same refined axis.

continuum model drop minus data drop across 1.559/1.564 keV data minus model, 1.53-1.65 keV model / data - 1, 1.65-2.2 keV chi2/n, 0.8-2.6 keV
sharp (default) 351 counts +1677 +39.1 % 6.63
broadened 42 +907 +19.1 % 3.10
broadened, Poisson ML +850 +8.2 % 2.52
no edge split +3097 +49.1 % 12.0

Raising the order of the segment above the edge (9, 9) does not help on this spectrum (+20 %). What remains in 1.53-1.65 keV is data above the model on the high side of the Al K lines; the tied Al K-beta weight (EPQ 0.0132) at the edge is the next candidate.

Synthetic, noise-free (generated in the test, no measured file in the repo). Al film with a resolution-broadened edge, 3e5 Al K-alpha, weak Si K-alpha of 2000 counts:

continuum Si net vs planted
sharp -38.7 %
broadened -25.4 %
broadened, orders (9, 9) (measured, not shipped) -8.0 %

The public AlMgSi demo (sim_edx.py v2.0, noise-free pools ×100, true axis) plants an unbroadened step. Broadening still helps there, because Al K-beta sits on the split.

pool Si sharp Si broadened Si sharp + sum peaks Si broadened + sum peaks Mg sharp Mg broadened
recipe A (1217 px) -17.2 % -16.2 % -6.2 % -3.5 % -32.0 % -29.8 %
all matrix (2897 px) -18.8 % -17.9 % -6.4 % -3.6 % -31.8 % -29.7 %
precipitate end-on -3.2 % -3.2 % -3.0 % -3.0 % -1.2 % -1.2 %

Si planted counts include Si K-beta and the internal-fluorescence peak.

Why opt-in, not the default

Switching the default moves five registered synthetic results:

  • testWeakLineRecoveryRegisteredSelection, whose numbers appear in every footer;
  • the FitRange P1 and P3 Ti pins;
  • the proposer null-variance pins;
  • one detection-rate draw;
  • the seeded flatness test.

The cause is the same in all five: their generators plant an unbroadened step, so they measure a sharp truth. Making .broadened the default means first moving those generators to a convolved continuum and re-measuring the results. That stays open on #8, together with the order of the segment above the edge.

Tests

  • New: ContinuumEdgeBroadeningTests.
    • Red on main: on the broadened-edge spectrum the default (sharp) fit reads Si -38.7 % and misses the edge channel by 5.5 %.
    • Broadened against sharp, on pure Al and with weak Si.
    • .sharp gives the same columns as the unbroadened form.
    • The weights sum to 1, with one edge and with two close edges.
    • The policy default, the footer line, round-trip decoding, and an old stored policy.
  • Gate: swift test -c release --parallel --num-workers 1 -j 1 --skip SpectroKitParityTests, 290 tests green (on the merge with feat!: the pooled fit models sum peaks when the strongest line's self pair is significant #59). Goldens do not move (the default is unchanged), so the parity target was not run.
  • Numerics review done (one reviewer). It found that the product form over-counts between two close edges; fixed with the telescoping difference and a two-edge test. It also asked for byte-stable default JSON; done.
  • validation: "none": one standard, one detector, synthetic truth.

🤖 Generated with Claude Code

Paullo9 and others added 5 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>
The default continuum cuts its Bernstein segments at the Al K edge with a
jump between two channels. A detector cannot record that: every channel is
the incoming spectrum convolved with the resolution (about 75 eV FWHM at
1.56 keV), and the Al K-beta line sits on the edge energy itself.

FitPolicy.continuumEdgeStep = .broadened weights each segment by the
detector Gaussian step at its edges (Phi_a - Phi_b, sigma from the
line-width law at the edge, the line model resolution) and continues each
polynomial past its edge at its end value. The weights telescope to 1 and
stay non-negative. .sharp stays the default and is the earlier form value
for value; the default policy JSON is byte for byte unchanged (the key is
written only when not the default; old stored policies decode to .sharp).

Measured on the public Leeds pure-Al wedge (Snelson, Collins, MacArthur,
doi 10.5518/1826, CC BY 4.0), same refined axis: the model fell 351 counts
more than the data across the edge channel pair (42 broadened), the data
sat 1677 counts above the model in 1.53-1.65 keV (907) and the model 39 %
above the data in 1.65-2.2 keV (19 %). On a generated Al film with a
broadened edge a weak Si K-alpha reads -38.7 % sharp and -25.4 % broadened.

Done: broadened edge step as an option, tests on a generated spectrum.
Next: re-measure the registered synthetic results with a convolved
generator and make .broadened the default; the order-5 segment above the
edge is the remaining Si lever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both add FitPolicy fields with a tolerant decoder; the resolved policy
keeps sumPeaks and sumPeakSignificance as that branch encodes them and
writes continuumEdgeStep only when it is not the default.

Done: stacked on the pooled sum-peak branch, gate green.
Next: open the PR against that branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9 Paullo9 self-assigned this Oct 31, 2026
@Paullo9
Paullo9 changed the base branch from issue-8-pooled-sum-peaks to main October 31, 2026 19:26
Paullo9 and others added 3 commits October 31, 2026 21:44
…-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>
…the sigma terms) into the edge-step branch

Conflict in QuantificationPolicy.swift resolved keeping both sides:
- FitPolicy.init takes continuumEdgeStep and continuumDeadLayer.
- CodingKeys, the hand-written encode(to:) and init(from:) carry both: continuumEdgeStep written only when not sharp, continuumDeadLayer only when set, so the default canonical JSON is unchanged.

Done: the edge-step branch is on the updated sum-peak branch and main, fast gate green.
Next: re-record the goldens for the edgeStep line this branch adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no number moves

The fit-settings dump in each golden scenario gains one line per quantification, background.continuum.edgeStep = .sharp, from the field this branch adds; the branch had not re-recorded for it. Recorded with SPECTRO_GOLDEN_DIR from the merged code: 9 added lines, no other line differs, the array companions are unchanged.

Done: SpectroGoldenTests green on the edge-step branch.
Next: stack the broadened-default branch on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paullo9 and others added 3 commits October 31, 2026 23:41
Main now holds the sum-peak work as one squash commit whose tree equals the
sum-peak branch this branch was built on. The one conflict, in
QuantificationPolicy.swift, keeps the sum-peak code as on main and adds this
branch's continuumEdgeStep field, its encoder and its decoder default.
No golden moves in the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SwiftLint trailing_newline and SwiftFormat consecutiveBlankLines flagged line 184. Lint (SwiftLint 0.65.1, SwiftFormat 0.64.4) is now clean on the whole tree.

Done: lint green on this branch. Next: merge it forward into the stacked branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9
Paullo9 enabled auto-merge (squash) November 1, 2026 12:42
@Paullo9
Paullo9 merged commit 93931fc into main Nov 1, 2026
7 checks passed
@Paullo9
Paullo9 deleted the issue-8-continuum-edge branch November 1, 2026 12:51
Paullo9 added a commit that referenced this pull request Nov 1, 2026
…-broadened-default

Main equals the old issue-8-continuum-edge tip tree for tree, and this branch
already carried that tip, so every conflict keeps the branch side:
Continuum.swift, QuantificationPolicy.swift, ContinuumEdgeBroadeningTests.swift,
docs/parity.md and the four conflicted spectro goldens. The goldens were
re-recorded from the merged code (testRecordBaseline, testRecordPortableArrays)
and are byte-identical to the branch side; no re-record commit is needed.

Done: branch is current with main after the #64 squash merge.
Next: CI on the PR, then the broadened default lands.

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