Repository navigation
feat: opt-in detector-broadened continuum step at absorption edges - #64
Merged
Merged
Conversation
…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>
…-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>
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
enabled auto-merge (squash)
November 1, 2026 12:42
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Paul · project thread
Part of #8
Stacked on #59 (
issue-8-pooled-sum-peaks): both PRs add aFitPolicyfield with a tolerant decoder inQuantificationPolicy.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..sharpstays the default and is the earlier form, value for value..sharp.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.
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:
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.
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 cause is the same in all five: their generators plant an unbroadened step, so they measure a sharp truth. Making
.broadenedthe 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
ContinuumEdgeBroadeningTests..sharpgives the same columns as the unbroadened form.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.validation: "none": one standard, one detector, synthetic truth.🤖 Generated with Claude Code