Skip to content

fix: MLLS composition counts a negative amount as not detected and keeps the other elements - #81

Merged
Paullo9 merged 1 commit into
mainfrom
issue-69-mlls-negative-amount
Nov 1, 2026
Merged

Paullo9 merged 1 commit into
mainfrom
issue-69-mlls-negative-amount

Conversation

@Paullo9

@Paullo9 Paullo9 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Found in an app EELS room on a real STEM spectrum image: with C K, N K and O K picked and N absent, the MLLS composition was empty at every pixel, for C and O as well as N.

Cause

EELSQuantification.composition(_: EELSMLLSResult, symbols:) refused the whole pixel (noComposition) when any amount was below zero. MLLS is unconstrained, so an absent element's amount scatters around zero and is negative at about half the pixels, or at all of them when another edge's tail is not fully modelled.

Refuted if: the pixels without composition were not exactly those with a negative amount. Checked: in the synthetic test the odd pixels (N below zero) were noComposition, the even ones (N above zero) were not.

What the references do

  • exSpy 0.3.2 has no EELS composition (EELSModel.quantify prints the intensities, negative ones included). HyperSpy has none either.
  • exSpy EDS Cliff-Lorimer uses a 0.1 floor only to pick its reference lines; once two lines exceed it, a negative line passes through as a negative fraction. That is not a usable rule for an absent element.
  • The kit's own exact model fit is non-negative (NNLS) and gives an absent element 0, keeping the others.

Fix (chosen rule)

An amount at or below zero is not detected: it counts as 0 at% and 0 wt%, the new per-element EELSCompositionPixel.notDetected flags it, and the others are normalised among themselves. Why: it matches what the NNLS model fit gives on the same picks, leaves every all-positive pixel bit-identical, and needs no detection-limit choice. A 1-sigma or 3-sigma detection limit would also move small positive amounts; that stays an app or later kit decision.

  • noComposition now means no amount is above zero.
  • notDetected == false only means the amount is above zero, not above a detection limit (documented). Clamping per pixel biases an average of pixel compositions upward for an absent element; the doc comment says to quantify a summed region instead.
  • The model-fit composition also fills notDetected; its numbers do not change.
  • API: one new public stored property on EELSCompositionPixel, which has no public initialiser, so consumers stay source-compatible.

Numbers move

Synthetic: the planted C, N and O image, 3 x 2 pixels, with N moved by linearity to +1.5 % of C on even pixels and below zero on odd pixels (per-pixel planted exponents, whole axis).

case before after reference
pixel 1, amounts C 1063461, N -14400, O 967474 noComposition: no at%, no wt% for C, N or O at% C 52.363, N 0, O 47.637; wt% C 45.211, N 0, O 54.789; N not detected NNLS model fit: N clamped to 0, C and O kept
pixels 3 and 5 (N below zero) noComposition at% C 52.412 / 52.446, N 0, O 47.588 / 47.554 as above
pixel 0, amounts C 880532, N 12000, O 790033 (all positive) at% C 52.333, N 0.713, O 46.954 the same, bit-identical
every pixel with all amounts positive (all existing fixtures) unchanged unchanged MLLS parity pins green
every amount at or below zero noComposition noComposition

Tests

  • Red first: testCompositionWithNegativeAbsentElementKeepsTheOthers failed before the fix (pixel 1 noComposition).
  • Fast gate: swift test -c release --parallel --skip SpectroKitParityTests green (132 + 322 + 66 + 1 tests, the known issues unchanged).
  • Parity target once: 83 tests green, MLLSPinsTests included.
  • SwiftLint strict and SwiftFormat lint clean on the changed files.
  • Numerics review done: code correct. It found that the first draft described exSpy Cliff-Lorimer wrongly; the docs now call the rule a kit decision. It also asked for the model-fit and masked-pixel notDetected checks and a stale test comment fix, all done.

This PR changes numbers a user sees: it waits for Paul.

🤖 Generated with Claude Code

…eps the other elements

Found in an app EELS room: with C K, N K and O K picked and N absent, the unconstrained MLLS amount of N came out below zero, and composition(_: EELSMLLSResult, symbols:) refused the whole pixel, so C and O lost their at% and wt% too.

An amount at or below zero now counts as 0 at% and 0 wt%, is flagged in the new EELSCompositionPixel.notDetected, and the others are normalised among themselves. This is a kit decision consistent with the non-negative model fit, which gives an absent element 0. exSpy has no EELS composition; its EDS Cliff-Lorimer passes a negative line through as a negative fraction. noComposition now means no amount is above zero. Pixels with every amount positive are bit-identical. The model-fit composition fills notDetected; its numbers do not change.

Done: per-element not-detected rule for MLLS composition, red-first test. Next: the app shows not-detected elements in the footer and the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9 Paullo9 self-assigned this Nov 1, 2026
@Paullo9

Paullo9 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Checked on 3 real EELS spectrum images (C K, N K, O K picked; N absent) through the macTEM EELS room, with this change on top of 0.12.0. Before: every MLLS at% and wt% pixel was NaN and the table had no at% or wt%. After: 0 NaN pixels, N is 0 at% and flagged not detected at every pixel and in the table, and the MLLS intensities are unchanged. The median |MLLS − NNLS| per image is 1.4 to 4.1 at% for C and 1.6 to 4.1 at% for O (MLLS puts more in O), and 0 to 0.2 at% for N. NNLS and the other stages are bit-identical.

@Paullo9
Paullo9 merged commit b20397d into main Nov 1, 2026
7 checks passed
@Paullo9
Paullo9 deleted the issue-69-mlls-negative-amount branch November 1, 2026 17:57
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