Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
584d84e
feat: the proposer and the unlisted-line check take the sum/escape pe…
Paullo9 Oct 31, 2026
97d0892
Merge remote-tracking branch 'origin/main' into issue-15-pileup-expla…
Paullo9 Oct 31, 2026
bce80a3
feat!: the pooled fit models sum peaks when the strongest line's self…
Paullo9 Oct 31, 2026
014db3a
feat: opt-in detector-broadened continuum step at absorption edges
Paullo9 Oct 31, 2026
c7a751e
Merge the sum-peak branch: stack the edge step on it
Paullo9 Oct 31, 2026
32fa994
test: the synthetic EDX generators blur the continuum with the detect…
Paullo9 Oct 31, 2026
433eafa
feat!: the continuum step at absorption edges is broadened by the det…
Paullo9 Oct 31, 2026
6aa9f1d
test: re-record the spectro goldens for the broadened continuum step
Paullo9 Oct 31, 2026
8d1daed
Merge origin/main (the dead-layer term, the sigma terms) into the bro…
Paullo9 Oct 31, 2026
44195f8
Merge origin/main (the dead-layer term, the sigma terms) into the sum…
Paullo9 Oct 31, 2026
2aa20b5
Merge the updated sum-peak branch (now on main: the dead-layer term, …
Paullo9 Oct 31, 2026
e0f8c0b
test: re-record the spectro goldens for the continuumEdgeStep field, …
Paullo9 Oct 31, 2026
ad844a0
Merge the updated edge-step branch into the broadened-default branch
Paullo9 Oct 31, 2026
31a4460
Merge origin/main (the squashed sum-peak PR) into the edge-step branch
Paullo9 Oct 31, 2026
60c7a3c
Merge branch 'issue-8-continuum-edge' into issue-8-broadened-default
Paullo9 Oct 31, 2026
dff8615
style: drop the extra blank line at the end of the edge-broadening tests
Paullo9 Nov 1, 2026
df7f5d7
Merge branch 'issue-8-continuum-edge' into issue-8-broadened-default
Paullo9 Nov 1, 2026
d3c86e0
Merge origin/main (self-pair bound and overlap check, demo auto ID pi…
Paullo9 Nov 1, 2026
ecc05b1
test: re-record the spectro goldens after merging the self-pair half …
Paullo9 Nov 1, 2026
3ce279f
Merge remote-tracking branch 'origin/main' into issue-8-continuum-edge
Paullo9 Nov 1, 2026
8942e65
Merge branch 'issue-8-continuum-edge' into issue-8-broadened-default
Paullo9 Nov 1, 2026
376da59
Merge origin/main (opt-in broadened continuum edge, #64) into issue-8…
Paullo9 Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions Sources/SpectroEDX/Fit/Continuum.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
// the model has no nonlinear parameter: n_0 + 1 and n_1 + 1 coefficients, 10 + 6 = 16 by default, all
// linear and non-negative.
//
// THE STEP SEEN THROUGH THE DETECTOR (opt-in `ContinuumEdgeStep.broadened`): a detector records the incoming spectrum
// THE STEP SEEN THROUGH THE DETECTOR (`ContinuumEdgeStep.broadened`, the default since the generators were blurred): a detector records the incoming spectrum
// convolved with its resolution, so a measured edge is a Gaussian-blurred step (about 75 eV FWHM at 1.56 keV for 128 eV at
// Mn K-alpha), never a jump between two channels. The broadened form weights segment s by
// w_s(E) = Phi((E - a_s) / sigma(a_s)) - Phi((E - b_s) / sigma(b_s)),
Expand All @@ -42,8 +42,11 @@
// Measured: on a pure Al wedge (Titan 200 kV, Super-X; Snelson, Collins, MacArthur, University of Leeds, doi 10.5518/1826,
// CC BY 4.0) the sharp step made the model fall 351 counts more than the data between the channels at 1.559 and 1.564 keV,
// left the data 1677 counts above the model in 1.53-1.65 keV and the model 39 % above the data in 1.65-2.2 keV; broadened:
// 42 counts, 907 counts and 19 %. `.sharp` (the default) is the earlier form, value for value: the kit's registered
// synthetic generators plant an unbroadened step, so making `.broadened` the default re-measures those results first.
// 42 counts, 907 counts and 19 %. `.sharp` (opt-in) is the earlier form, value for value: the cut at the edge channel. The kit's
// synthetic generators blur their continuum with the detector resolution (tools/edx-sim/detector_blur.py), as a detector does, and
// the registered synthetic results were re-measured under `.broadened` when it became the default.
// The remaining data excess on that wedge at 1.56-1.65 keV is the Al K-beta line, not the continuum: eXSpy's table ties it at
// 0.0132 of K-alpha; fitted free it reads 0.028 there (NIST EPQ 0.0315). The quantification fit keeps eXSpy's weight (parity).
//
// The Al K edge energy 1.5596 keV is the standard tabulated value (X-ray Data Booklet, Table 1-1).
// The Si K edge (1.839 keV, a detector dead-layer step) is NOT in the default: add it to `edges` to test it, or model the step
Expand Down Expand Up @@ -91,14 +94,14 @@ public nonisolated struct ContinuumForm: Sendable, Equatable {
/// roll-off is modelled and P_s only carries what epsilon does not. nil: the round-1 form. The model is generic
/// (`DetectorEfficiency`), not a measured Super-X calibration.
package var efficiency: (any DetectorEfficiency)?
/// How the step at each edge is drawn: sharp at the edge channel (default) or broadened by the detector resolution.
/// How the step at each edge is drawn: broadened by the detector resolution (default) or sharp at the edge channel.
package var edgeStep: ContinuumEdgeStep

package static let alKEdge = FitPolicy.alKEdgeKeV

package init(beamEnergy: Double, edges: [Double] = [ContinuumForm.alKEdge], orders: [Int] = [9, 5],
nonNegative: Bool = true, efficiency: (any DetectorEfficiency)? = nil,
edgeStep: ContinuumEdgeStep = .sharp) {
edgeStep: ContinuumEdgeStep = .broadened) {
precondition(orders.count == edges.count + 1, "ContinuumForm needs one order per segment")
precondition(zip(edges, edges.dropFirst()).allSatisfy { $0 < $1 }, "edges ascend")
self.beamEnergy = beamEnergy; self.edges = edges; self.orders = orders; self.nonNegative = nonNegative
Expand Down Expand Up @@ -216,15 +219,15 @@ public nonisolated struct ContinuumForm: Sendable, Equatable {
public static let weakLineBiasNote =
"a weak line next to Al K\u{03B1} reads low by \u{2248} 300 counts per pooled spectrum on synthetic data "
+ "(cause: the Al K\u{03B1} incomplete-charge tail, unmodelled until a tail is measured on an Al matrix, `TailEstimate`); "
+ "unmeasured on real data. Weak-line bias measured on synthetic data: \u{0394} = \u{2212}295 \u{00B1} 31 counts at 900 planted Mg K\u{03B1} "
+ "(generator A, no tail: continuum\u{2013}line projection) and \u{2212}310 \u{00B1} 23 at 882 (generator S); "
+ "\u{2212}402 \u{00B1} 33 at 3000 (A) and \u{2212}310 \u{00B1} 23 at 2899 (S)"
+ "unmeasured on real data. Weak-line bias measured on synthetic data: \u{0394} = \u{2212}270 \u{00B1} 31 counts at 900 planted Mg K\u{03B1} "
+ "(generator A, no tail: continuum\u{2013}line projection) and \u{2212}272 \u{00B1} 23 at 882 (generator S); "
+ "\u{2212}325 \u{00B1} 38 at 3000 (A) and \u{2212}226 \u{00B1} 22 at 2899 (S)"
}

/// How the continuum's step at an absorption edge is drawn (`FitPolicy.continuumEdgeStep`).
public nonisolated enum ContinuumEdgeStep: String, Sendable, Codable, Equatable {
/// Seen through the detector: each segment weighted by the detector's Gaussian step at its edges.
/// Seen through the detector: each segment weighted by the detector's Gaussian step at its edges (the default).
case broadened
/// A jump between the two channels either side of the edge (the default).
/// A jump between the two channels either side of the edge: the form before `.broadened` was the default, value for value.
case sharp
}
19 changes: 10 additions & 9 deletions Sources/SpectroEDX/Quant/QuantificationPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
public var continuumEdges: [Double]
/// Bernstein basis with non-negative coefficients (the continuum cannot go negative). False: free-sign monomials.
public var continuumNonNegative: Bool
/// How the continuum steps at each edge. Default `.sharp`, the form the registered synthetic results were measured with.
/// `.broadened` blurs the step by the detector resolution, as a measured edge is: on a pure Al spectrum (Titan, Super-X) the
/// sharp step left the model 39 % above the data in 1.65-2.2 keV, the broadened one 19 %; on a synthetic Al film with a
/// broadened edge a weak Si K-alpha read -38.7 % sharp and -25.4 % broadened.
/// How the continuum steps at each edge. Default `.broadened`: the step blurred by the detector resolution, as a measured edge
/// is. On a pure Al spectrum (Titan, Super-X) the sharp step left the model 39 % above the data in 1.65-2.2 keV, the broadened
/// one 19 %; on a synthetic Al film with a broadened edge a weak Si K-alpha read -38.7 % sharp and -25.4 % broadened.
/// `.sharp` is the form before `.broadened` became the default, value for value; a stored policy without the field reads it.
public var continuumEdgeStep: ContinuumEdgeStep
/// A detector efficiency multiplying the whole continuum form (its low-energy roll-off modelled instead of absorbed in the
/// polynomial). Default nil: the Kramers-only form.
Expand Down Expand Up @@ -116,7 +116,7 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
irls: IRLSSettings = IRLSSettings(), escapeRatioWarning: Double = 0.05,
axisRefinement: AxisRefinementSettings = AxisRefinementSettings(),
lineTail: LineTail? = nil, sumPeaks: SumPeakRule = .whenSignificant, sumPeakSignificance: Double = 3,
continuumEdgeStep: ContinuumEdgeStep = .sharp, continuumDeadLayer: ContinuumDeadLayer? = nil) {
continuumEdgeStep: ContinuumEdgeStep = .broadened, continuumDeadLayer: ContinuumDeadLayer? = nil) {
self.continuumDeadLayer = continuumDeadLayer
self.fitFromKeV = fitFromKeV; self.defaultUpperLimitKeV = defaultUpperLimitKeV
self.continuumOrders = continuumOrders; self.continuumEdges = continuumEdges
Expand All @@ -127,7 +127,8 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
self.continuumEdgeStep = continuumEdgeStep
}

/// The fit before sum peaks were the default (one Gaussian per line, no sum peaks): exSpy's line model.
/// The fit before sum peaks were the default (one Gaussian per line, no sum peaks): exSpy's line model. Its continuum is the
/// default one (exSpy's polynomial has no edge at all), broadened step included.
public static let exspyEquivalent = FitPolicy(sumPeaks: .off)

private enum CodingKeys: String, CodingKey {
Expand All @@ -136,8 +137,8 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
continuumDeadLayer
}

/// The synthesized encoding, except that `continuumEdgeStep` is written only when it is not the default, so the default
/// policy's canonical JSON stays byte for byte what it was before the field existed.
/// The synthesized encoding in a fixed key order. Every field with a value is written, `continuumEdgeStep` and `sumPeaks`
/// included; a stored policy without them reads the form it was run with (see `init(from:)`).
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(fitFromKeV, forKey: .fitFromKeV)
Expand All @@ -154,7 +155,7 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
try c.encode(axisRefinement, forKey: .axisRefinement)
try c.encode(sumPeaks, forKey: .sumPeaks)
try c.encode(sumPeakSignificance, forKey: .sumPeakSignificance)
if continuumEdgeStep != .sharp { try c.encode(continuumEdgeStep, forKey: .continuumEdgeStep) }
try c.encode(continuumEdgeStep, forKey: .continuumEdgeStep)
try c.encodeIfPresent(continuumDeadLayer, forKey: .continuumDeadLayer)
}

Expand Down
Binary file not shown.
Loading
Loading