Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 61 additions & 4 deletions Sources/SpectroEDX/Fit/EnergyAxisRefinement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ import Foundation
import SpectroCore

/// Which of the energy axis's offset, gain and the detector width the refinement adjusts, and how long it searches. Defaults are the
/// library's choice (all three, 600 simplex iterations, tolerance 1e-12, widths between 20 and 500 eV).
/// library's choice (all three, 600 simplex iterations, tolerance 1e-12, widths between 20 and 500 eV, offset within +-200 eV and
/// gain within +-5 % of the file's).
///
/// THE OFFSET AND GAIN BOUNDS (0.13.2). A spectrum with one strong line fixes two numbers (its channel and its width in channels)
/// against three parameters, so the residual has a flat valley along which offset, gain and width trade; the simplex then walks
/// until a bound stops it (a synthetic one-line spectrum went to gain +444 %, offset -6.6 keV). +-200 eV and +-5 % are a stated
/// choice, far outside a working detector calibration (the tests plant up to +-12 eV and 0.2 %), and they keep the fitted
/// energies near or above zero from the 0.2 keV start. A refinement that ends against any bound is not a minimum: it is reported not
/// converged, with the parameter named, and the pooled fit uses the file's axis.
/// A policy stored before 0.13.2 has no offset or gain key and reads these defaults, not "no bound": unlike the rule for other new
/// `FitPolicy` keys, a re-run can differ from its stored run, and only where that run walked against the new bounds.
public nonisolated struct AxisRefinementSettings: Sendable, Equatable, Codable {
/// Adjust the offset. Default true; false keeps the file's.
public var refineOffset = true
Expand All @@ -38,12 +48,36 @@ public nonisolated struct AxisRefinementSettings: Sendable, Equatable, Codable {
public var minimumResolutionEV = 20.0
/// A trial width at or above this, eV, is rejected. Default 500.
public var maximumResolutionEV = 500.0
/// A trial offset shift whose size is at or above this, eV, is rejected. Default 200.
public var maximumOffsetShiftEV = 200.0
/// A trial relative gain shift whose size is at or above this is rejected. Default 0.05 (5 %).
public var maximumGainShift = 0.05

public init(refineOffset: Bool = true, refineGain: Bool = true, refineResolution: Bool = true, maxIterations: Int = 600,
tolerance: Double = 1e-12, minimumResolutionEV: Double = 20, maximumResolutionEV: Double = 500) {
tolerance: Double = 1e-12, minimumResolutionEV: Double = 20, maximumResolutionEV: Double = 500,
maximumOffsetShiftEV: Double = 200, maximumGainShift: Double = 0.05) {
self.refineOffset = refineOffset; self.refineGain = refineGain; self.refineResolution = refineResolution
self.maxIterations = maxIterations; self.tolerance = tolerance
self.minimumResolutionEV = minimumResolutionEV; self.maximumResolutionEV = maximumResolutionEV
self.maximumOffsetShiftEV = maximumOffsetShiftEV; self.maximumGainShift = maximumGainShift
}

private enum CodingKeys: String, CodingKey {
case refineOffset, refineGain, refineResolution, maxIterations, tolerance, minimumResolutionEV, maximumResolutionEV,
maximumOffsetShiftEV, maximumGainShift
}

/// A policy stored before 0.13.2 has no offset or gain bound: it reads the defaults (see the type's note).
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let defaults = AxisRefinementSettings()
self.init(refineOffset: try c.decode(Bool.self, forKey: .refineOffset), refineGain: try c.decode(Bool.self, forKey: .refineGain),
refineResolution: try c.decode(Bool.self, forKey: .refineResolution),
maxIterations: try c.decode(Int.self, forKey: .maxIterations), tolerance: try c.decode(Double.self, forKey: .tolerance),
minimumResolutionEV: try c.decode(Double.self, forKey: .minimumResolutionEV),
maximumResolutionEV: try c.decode(Double.self, forKey: .maximumResolutionEV),
maximumOffsetShiftEV: try c.decodeIfPresent(Double.self, forKey: .maximumOffsetShiftEV) ?? defaults.maximumOffsetShiftEV,
maximumGainShift: try c.decodeIfPresent(Double.self, forKey: .maximumGainShift) ?? defaults.maximumGainShift)
}
}

Expand All @@ -58,7 +92,13 @@ public nonisolated struct AxisRefinementResult: Sendable, Equatable {
package let rssFile: Double
package let rssRefined: Double
package let iterations: Int
/// The simplex met its tolerance AND no refined parameter ended against its bound.
package let converged: Bool
/// The refined parameters that ended against their bound ("offset", "gain", "FWHM"), in that order; empty when none did.
/// Not empty: the result is not a minimum of the residual and the pooled fit does not use it.
public let parametersAtBound: [String]
/// "gain +5.000 % (bound +-5 %)", one clause per parameter at its bound; empty when none is.
package let boundText: String

public var refinedAxis: EnergyAxis { EnergyAxis(offset: offset, scale: scale, size: size) }
package let size: Int
Expand Down Expand Up @@ -90,7 +130,8 @@ package nonisolated enum EnergyAxisRefinement {
let d = (options.refineOffset ? u[0] : 0) * 1e-3
let g = (options.refineGain ? u[1] : 0) * 1e-4
let r = fileResolutionMnKaEV + (options.refineResolution ? u[2] : 0)
guard r > options.minimumResolutionEV, r < options.maximumResolutionEV else { return .infinity }
guard r > options.minimumResolutionEV, r < options.maximumResolutionEV,
abs(d * 1000) < options.maximumOffsetShiftEV, abs(g) < options.maximumGainShift else { return .infinity }
let axis = EnergyAxis(offset: fileAxis.offset + d, scale: fileAxis.scale * (1 + g), size: fileAxis.size)
let model = EDSLineModel.build(elements: settings.elements, axis: axis, beamEnergy: settings.beamEnergy,
resolutionMnKaEV: r, escapePeaks: settings.escapePeaks, lineTail: settings.lineTail,
Expand All @@ -117,12 +158,28 @@ package nonisolated enum EnergyAxisRefinement {
let d = (options.refineOffset ? best[0] : 0) * 1e-3
let g = (options.refineGain ? best[1] : 0) * 1e-4
let r = fileResolutionMnKaEV + (options.refineResolution ? best[2] : 0)
// A parameter within 1e-4 of its bound span of a bound ended against it (the simplex presses on a rejected region).
var atBound: [String] = [], clauses: [String] = []
func near(_ v: Double, _ lo: Double, _ hi: Double) -> Bool { min(v - lo, hi - v) <= 1e-4 * (hi - lo) }
if options.refineOffset, near(d * 1000, -options.maximumOffsetShiftEV, options.maximumOffsetShiftEV) {
atBound.append("offset")
clauses.append(String(format: "offset %+.1f eV (bound \u{00B1}%g eV)", d * 1000, options.maximumOffsetShiftEV))
}
if options.refineGain, near(g, -options.maximumGainShift, options.maximumGainShift) {
atBound.append("gain")
clauses.append(String(format: "gain %+.3f %% (bound \u{00B1}%g %%)", g * 100, options.maximumGainShift * 100))
}
if options.refineResolution, near(r, options.minimumResolutionEV, options.maximumResolutionEV) {
atBound.append("FWHM")
clauses.append(String(format: "FWHM %.0f eV (bounds %g-%g eV)", r, options.minimumResolutionEV, options.maximumResolutionEV))
}
try SpectroCancellation.check(cancelled)
progress?(1)
return AxisRefinementResult(
fileOffset: fileAxis.offset, fileScale: fileAxis.scale, fileResolutionMnKaEV: fileResolutionMnKaEV,
offset: fileAxis.offset + d, scale: fileAxis.scale * (1 + g), resolutionMnKaEV: r,
rssFile: rss([0, 0, 0]), rssRefined: rss(best), iterations: iterations, converged: converged,
rssFile: rss([0, 0, 0]), rssRefined: rss(best), iterations: iterations, converged: converged && atBound.isEmpty,
parametersAtBound: atBound, boundText: clauses.joined(separator: ", "),
size: fileAxis.size)
}

Expand Down
15 changes: 12 additions & 3 deletions Sources/SpectroEDX/Quant/PooledQuantification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
// WHAT IS NOT ESTABLISHED (read before trusting a number):
// * The axis refinement was REFUTED at low counts: on demo-edx-tiny (706 counts, 24 px) it found -3.0 eV / +0.58 %
// gain against the planted +10 eV / -0.2 %. The sigma terms carry NO axis term, so a wrong refined axis is not in
// the shown sigma. Its recovery against counts is unmeasured (open-items).
// the shown sigma. Its recovery against counts is unmeasured (open-items). A spectrum with one strong line does not
// determine offset, gain and width together (issue 83); the refinement then ends against a bound
// (`AxisRefinementSettings`), is reported not converged, and the file's axis is used, named in the warnings and footer.
// * The absorption test (`SpectroscopyQuantifyTests`) checks plumbing only (applied/refused, terms present, the
// composition moves); it does not check the correction against a truth geometry (open-items).
//
Expand Down Expand Up @@ -283,7 +285,13 @@ public nonisolated enum PooledQuantifier {
catch { warnings.append("axis refinement failed (\(error)); the file's axis is used") }
}
if let r = refinement {
if r.rssRefined < r.rssFile { axis = r.refinedAxis; resolution = r.resolutionMnKaEV }
// A refinement that ended against a bound is not a minimum (issue 83: a one-line spectrum walked to gain +444 %).
if !r.parametersAtBound.isEmpty {
warnings.append("axis refinement stopped at its bound (\(r.boundText)): the spectrum does not determine the energy axis; the file's axis is used")
} else if r.rssRefined < r.rssFile {
axis = r.refinedAxis; resolution = r.resolutionMnKaEV
if !r.converged { warnings.append("axis refinement reached its iteration cap (\(r.iterations)) without converging; the refined axis is used") }
}
else { warnings.append("axis refinement did not lower the residual; the file's axis is used") }
}
}
Expand Down Expand Up @@ -509,7 +517,8 @@ public nonisolated enum PooledQuantifier {
else if let r = refinement {
footer.append(String(format: "energy axis: file %.3f keV + %.4f keV/ch \u{2192} refined %+.1f eV, gain %+.3f %%, FWHM(Mn K\u{03B1}) %.0f eV%@",
r.fileOffset, r.fileScale, r.offsetShiftEV, r.gainShift * 100, r.resolutionMnKaEV,
axis == r.refinedAxis ? "" : "; NOT used (residual not lower)"))
!r.parametersAtBound.isEmpty ? "; NOT used (stopped at its bound: \(r.boundText))"
: axis == r.refinedAxis ? "" : "; NOT used (residual not lower)"))
}
if let b = BeamEnergy.provenance(typedKeV: input.method.beamEnergyKeV, metadata: input.metadata) { footer.append(b) }
footer.append("\(fitRange.footerText) \u{00B7} \(input.pixelCount) px pooled (\(input.regionName))")
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpectroEDX/Quant/ProposalAxisRefinement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ extension ElementProposer {
do { fit = try PooledQuantifier.run(input, tables: tables, cancelled: cancelled) }
catch let c as SpectroCancelled { throw c }
catch { break }
guard let r = fit.refinement, fit.usedAxis == r.refinedAxis else { break }
guard let r = fit.refinement, r.parametersAtBound.isEmpty, fit.usedAxis == r.refinedAxis else { break }
anchorsUsed = anchors
refinement = r
axis = r.refinedAxis
Expand Down
4 changes: 4 additions & 0 deletions Sources/SpectroEDX/Quant/QuantificationPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ public nonisolated struct FitPolicy: Sendable, Equatable, Codable {
|| !(axisRefinement.minimumResolutionEV < axisRefinement.maximumResolutionEV) {
return "The axis refinement needs a cap of 1 or more, a positive tolerance and a width range with its lower bound below its upper."
}
if !(axisRefinement.maximumOffsetShiftEV.isFinite && axisRefinement.maximumOffsetShiftEV > 0)
|| !(axisRefinement.maximumGainShift.isFinite && axisRefinement.maximumGainShift > 0) {
return "The axis refinement needs a finite, positive bound on the offset shift and on the gain shift."
}
return nil
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ whole.quantification.refinement.rssFile = 40954ed514f96f63 1363.708087823318
whole.quantification.refinement.rssRefined = 40707f466a600e82 263.95469129106652
whole.quantification.refinement.iterations = 104
whole.quantification.refinement.converged = true
whole.quantification.refinement.parametersAtBound: n 0 sum 0000000000000000 0 fnv cbf29ce484222325
whole.quantification.refinement.boundText = ""
whole.quantification.refinement.size = 512
whole.quantification.plotModel: n 512 sum 408642460c1ad601 712.28420277562702 fnv 724e54731dbd18d7
whole.quantification.plotBackground: n 512 sum 406198f0ded13ffe 140.77940312260756 fnv dfb53193382aa4b
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ seed0.four.quantification.refinement.rssFile = 4224077700731f16 43012292665.5607
seed0.four.quantification.refinement.rssRefined = 42240444225b2cd6 42985460013.58757
seed0.four.quantification.refinement.iterations = 71
seed0.four.quantification.refinement.converged = true
seed0.four.quantification.refinement.parametersAtBound: n 0 sum 0000000000000000 0 fnv cbf29ce484222325
seed0.four.quantification.refinement.boundText = ""
seed0.four.quantification.refinement.size = 4096
seed0.four.quantification.plotModel: n 4096 sum 4150277a001e5418 4234728.0018511042 fnv 35bd38a650d42ab9
seed0.four.quantification.plotBackground: n 4096 sum 41443c04d862488b 2652169.6904993705 fnv 733b41c1d521b3d4
Expand Down Expand Up @@ -1483,6 +1485,8 @@ seed0.seven.quantification.refinement.rssFile = 4175186260e1f62a 22119974.055166
seed0.seven.quantification.refinement.rssRefined = 414a4bdcb7b8a233 3446713.43532207
seed0.seven.quantification.refinement.iterations = 94
seed0.seven.quantification.refinement.converged = true
seed0.seven.quantification.refinement.parametersAtBound: n 0 sum 0000000000000000 0 fnv cbf29ce484222325
seed0.seven.quantification.refinement.boundText = ""
seed0.seven.quantification.refinement.size = 4096
seed0.seven.quantification.plotModel: n 4096 sum 414e5cb35a013312 3979622.7031616056 fnv 19bc67e1afeb6e15
seed0.seven.quantification.plotBackground: n 4096 sum 412848dff77ef632 795759.9833905159 fnv fc0ed317795d391c
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ level0.quantification.refinement.rssFile = 414be7422632f3e3 3657348.2984299525
level0.quantification.refinement.rssRefined = 414b8fa4aa1546b0 3612489.3287742957
level0.quantification.refinement.iterations = 98
level0.quantification.refinement.converged = true
level0.quantification.refinement.parametersAtBound: n 0 sum 0000000000000000 0 fnv cbf29ce484222325
level0.quantification.refinement.boundText = ""
level0.quantification.refinement.size = 4096
level0.quantification.plotModel: n 4096 sum 4117b46554736a93 388377.33247152827 fnv 9185698d1dd3d97b
level0.quantification.plotBackground: n 4096 sum 40f606b5aa5f6d78 90219.354094913113 fnv 6d86cfd7139ceb36
Expand Down Expand Up @@ -405,6 +407,8 @@ level4.quantification.refinement.rssFile = 413eb2e2eef45d2f 2011874.9334161987
level4.quantification.refinement.rssRefined = 413e42935bd6e312 1983123.3587476653
level4.quantification.refinement.iterations = 84
level4.quantification.refinement.converged = true
level4.quantification.refinement.parametersAtBound: n 0 sum 0000000000000000 0 fnv cbf29ce484222325
level4.quantification.refinement.boundText = ""
level4.quantification.refinement.size = 4096
level4.quantification.plotModel: n 4096 sum 4116312abc4c0005 363594.68388366728 fnv a01fd9b34b164cb9
level4.quantification.plotBackground: n 4096 sum 40f56682454b6097 87656.141917588524 fnv 5fd90c12681b255f
Expand Down
Loading
Loading