Skip to content

Move MS:1000516 ChargeArray onto MzSpectrum where the peaks live (supersedes #1062)#1063

Open
pcarvalho75 wants to merge 5 commits into
smith-chem-wisc:masterfrom
pcarvalho75:yada/charge-array-on-mzspectrum
Open

Move MS:1000516 ChargeArray onto MzSpectrum where the peaks live (supersedes #1062)#1063
pcarvalho75 wants to merge 5 commits into
smith-chem-wisc:masterfrom
pcarvalho75:yada/charge-array-on-mzspectrum

Conversation

@pcarvalho75

Copy link
Copy Markdown
Contributor

TL;DR

This supersedes #1062. After working on the reader-side fix from #1062 we realized there is a deeper issue neither the original PR (#1060) nor that follow-up address: as long as ChargeArray lives on MsDataScan while the peaks it indexes live on MsDataScan.MassSpectrum (an MzSpectrum), any MzSpectrum mutator silently invalidates the alignment invariant. This PR moves ChargeArray onto MzSpectrum so charges live with the peak data they describe and every mutator can keep all three arrays in lockstep automatically.

If you would prefer to land the smaller reader-side fix first, #1062 is still ready. This PR includes those reader-side fixes too, so merging this one and closing #1062 also works.

cc @nbollis @trishorts

What we found while working on #1062

@trishorts caught the symptom in the readers: three reorder/trim points (zero-intensity removal, WindowModeHelper, the unconditional final Array.Sort) reordered m/z and intensity but left chargeArray in source order. #1062 patches all six fix sites across the static and dynamic reader paths.

But the same bug class exists at the MzSpectrum layer, where the readers cannot reach:

MzSpectrum.XCorrPrePreprocessing (lines 509-605 of MzSpectrum.cs)

Array.Sort(XArray, YArray);                         // 1: re-sort by m/z
// ...
WindowModeHelper.Run(ref intensities, ref masses,   // 2: window-prune
    secondFilter, ...);                             //    (no charge awareness)
// ...
YArray = intensites.ToArray();                      // 3: rebuild to a NEW length
XArray = masses.ToArray();                          //    (nominal-mass bins)
Array.Sort(this.XArray, this.YArray);               // 4: final re-sort

After this method runs on a scan whose MsDataScan.ChargeArray is non-null, the parent scan's chargeArray:

  • has the wrong length (still pointing at the pre-XCorr peak count),
  • and is indexed against peaks that no longer exist or are in a totally different order.

MzSpectrum does not know its parent MsDataScan exists - it cannot reach back to update chargeArray. The bug is invisible at every call site.

MsDataScan.TransformMzs / MzSpectrum.ReplaceXbyApplyingFunction

Calibration. Applies an arbitrary Func<MzPeak, double> to every m/z. Affine calibration (mz' = a*mz + b) preserves order; non-monotonic calibration (segmented, interpolated lock-mass) can flip the order, leaving chargeArray pointing at the wrong peaks if any subsequent code re-sorts.

The fundamental issue: chargeArray is on the wrong type. It indexes MzSpectrum.XArray/YArray but lives on MsDataScan, and the type system cannot help.

What this PR does

Move ChargeArray onto MzSpectrum

public class MzSpectrum
{
    public double[] XArray { get; private set; }
    public double[] YArray { get; private set; }
    public int[] ChargeArray { get; private set; }   // <- new, parallel to XArray/YArray

    // New 4-arg ctor - validates length-match, throws ArgumentException on mismatch
    public MzSpectrum(double[] mz, double[] intensities, int[] chargeArray, bool shouldCopy);

    // Existing 3-arg ctor delegates with chargeArray=null. Source-compatible.
    public MzSpectrum(double[] mz, double[] intensities, bool shouldCopy);
}

The 4-arg ctor's length validation is the structural defense against the entire bug class. Without it, callers can construct length-mismatched spectra that silently misalign - a length-only check at use sites cannot catch lengths that happen to match while values point at the wrong peaks. Constructor-level enforcement makes it impossible.

MzSpectrum.XCorrPrePreprocessing now drops ChargeArray explicitly

The output peaks are nominal-mass bins (any number of source peaks may collapse into one bin; bins with no source peaks are zero-intensity slots that have no charge to report). A per-peak charge in that representation is undefined, so the honest behavior is to clear ChargeArray rather than ship a broken one. Pinned with MzSpectrumXCorrPreprocessingDropsChargeArray.

MsDataScan.ChargeArray becomes a delegating property

public int[] ChargeArray => MassSpectrum?.ChargeArray;

The legacy chargeArray ctor parameter is preserved for source compatibility:

  • If the caller passes it AND the MzSpectrum already carries charges, the ctor requires them to be SequenceEqual and throws ArgumentException on mismatch (programming error - surface immediately rather than silently picking one).
  • If only the legacy parameter is supplied, the ctor wraps the bare MzSpectrum into a new charge-carrying one.

Pinned with MsDataScanCtorChargeArrayConflictThrows and MsDataScanCtorWrapsChargesIntoBareMassSpectrumForBackwardCompat.

Reader paths use the new construction (and incorporate #1062's fixes)

Both Mzml.cs reader paths now:

  1. Apply the same permutation/mask to chargeArray everywhere they touch m/z or intensities (small private SortInPlaceByKey helper for 3-array sorts; WindowModeHelper.Run gets a charge-aware overload).
  2. Construct the MzSpectrum with chargeArray attached via the new 4-arg ctor - instead of passing it separately to the MsDataScan ctor.

This means the reader-side fixes from #1062 are included here. If #1062 lands first that is fine; this PR's commit applies cleanly on top of either base.

WindowModeHelper.Run charge-aware overload

public static void Run(ref double[] intensities, ref double[] mArray, ref int[] chargeArray,
    IFilteringParams filteringParams, double scanRangeMinMz, double scanRangeMaxMz,
    bool keepZeroPeaks = false);

When chargeArray is non-null and length-matches mArray, the overload carries it through the intensity-sort, per-window selection, and final m/z-sort via index permutation. Existing 5-parameter signature preserved as a thin wrapper that delegates with chargeArray=null - every existing caller (8 sites across MzSpectrum, TofSpectraMerger, ThermoRawFileReader, Mgf, Bruker, plus tests) is untouched.

Tests (11 total, all passing)

Test Targets
ChargeArrayDefaultsToNullWhenAbsent (existing) Baseline 2-array branch
ChargeArrayRoundTrip (existing) Charge round-trip + m/z + intensity assertions
ChargeArrayRoundTripWithNoiseData (existing) 6-array branch (NoiseData + Charge)
ChargeArrayUnsortedMzInputAlignsAfterReaderSort (was #1062) Reader's final Array.Sort permutes charges in lockstep
ChargeArraySurvivesZeroIntensityFilter (was #1062) Reader's zero-intensity trim keeps charges aligned
ChargeArraySurvivesFilteringParamsPruning (was #1062) WindowModeHelper charge-aware path
MzSpectrumChargeArrayLengthMismatchThrows (new) 4-arg ctor structural defense
MsDataScanChargeArrayDelegatesToMassSpectrum (new) Delegating property contract
MsDataScanCtorChargeArrayConflictThrows (new) Backward-compat conflict detection
MsDataScanCtorWrapsChargesIntoBareMassSpectrumForBackwardCompat (new) Legacy ctor parameter still works
MzSpectrumXCorrPreprocessingDropsChargeArray (new) Pin the explicit "drop on incompatible mutation" behavior

Broader regression sweep (TestMzML + WindowMode + SpectrumProcessing + Dynamic + XCorr + MzSpectrum + MsDataScan): 111/111 pass.

Backward compatibility

Strictly additive on the public API:

  • MzSpectrum's existing 3-arg ctor still works (delegates to new 4-arg with chargeArray=null).
  • MzSpectrum.ChargeArray is new (no existing caller could be using it).
  • MsDataScan's chargeArray ctor parameter still works; the wrapping path keeps every existing call site compiling.
  • MsDataScan.ChargeArray getter still works (now delegates).
  • WindowModeHelper.Run's 5-parameter signature preserved.

The only behavior change for existing consumers: MzSpectrum.XCorrPrePreprocessing now drops ChargeArray (previously it would have left a length-mismatched stale array). For any caller that was not using ChargeArray this is a no-op. For one that was, the behavior shifts from "silent corruption" to "explicit null", which is what we want.

Other things we noticed but did not touch

While digging into this, two adjacent items caught our eye. Out of scope for this PR, listing here so they do not slip:

  1. FilteringParams.MinimumAllowedIntensityRatioToBasePeakM - public property has a stray trailing M. The constructor parameter is minimumAllowedIntensityRatioToBasePeak (no M). Smells like an incomplete refactor and produces a mildly confusing developer experience (caused a CS1739 while we were writing the test for Keep MS:1000516 charge array aligned through reader reorder/trim points #1062). Easy property rename + obsolete-attribute on the old name if anyone agrees.

  2. MsDataScan has no IEquatable<MsDataScan> today (verified - unrelated to Param equality #1055 which only added it to *Params types). Not a bug, just a heads-up: if anyone adds equality later, ChargeArray belongs in the comparison since two scans with the same peaks but different charges are not semantically equal.

Files changed

File Notes
mzLib/MassSpectrometry/MzSpectrum.cs New ChargeArray property, new 4-arg ctor with length validation, XCorr clears charges
mzLib/MassSpectrometry/MsDataScan.cs Delegating ChargeArray, ctor wrap/conflict logic
mzLib/MassSpectrometry/WindowModeHelper.cs Charge-aware Run overload
mzLib/Readers/MzML/Mzml.cs Reader-side permutation sync (both paths) + new MzSpectrum ctor + SortInPlaceByKey helper
mzLib/Test/FileReadingTests/SpectraFileReading/TestMzML.cs 5 new architectural tests + 3 reader-side regression tests from #1062

Supersedes smith-chem-wisc#1062. Addresses the same class of bug trishorts identified
in the smith-chem-wisc#1060 review thread, but at the right architectural layer: the
chargeArray invariant ("ChargeArray[i] describes the peak at XArray[i]")
can only be reliably enforced if charge data lives on the same object
as the peak data it indexes.

Why the architectural move

In the existing design (smith-chem-wisc#1060) ChargeArray lives on MsDataScan while
the XArray and YArray it indexes live on MsDataScan.MassSpectrum (an
MzSpectrum). MzSpectrum doesn't know its parent MsDataScan exists, so
any MzSpectrum method that mutates peaks silently invalidates the
parent's chargeArray. This isn't theoretical:

  * MzSpectrum.XCorrPrePreprocessing rebuilds XArray and YArray to a
    different length (nominal-mass-binned spectrum), via two Array.Sort
    calls and a WindowModeHelper.Run pass. None of those used to touch
    chargeArray. After XCorr the parent's chargeArray was both length-
    mismatched AND pointing at peaks that no longer exist.
  * MsDataScan.TransformMzs / MzSpectrum.ReplaceXbyApplyingFunction
    apply an arbitrary calibration to every m/z. For non-monotonic
    calibrations the m/z order can flip without an explicit Array.Sort,
    and any subsequent re-sort would scramble alignment.
  * The reader-side fix in smith-chem-wisc#1062 patches three reorder/trim points in
    two reader paths (six fix sites) but doesn't address the MzSpectrum-
    level mutators above — same bug class, different layer.

What changed

mzLib/MassSpectrometry/MzSpectrum.cs
  * New `ChargeArray { get; private set; }` parallel to XArray/YArray.
    Lives next to the data it indexes.
  * New 4-arg ctor `MzSpectrum(double[] mz, double[] intensities,
    int[] chargeArray, bool shouldCopy)`. Validates chargeArray.Length
    matches mz.Length and throws ArgumentException otherwise — preserving
    a length-mismatched array would silently misalign charges (lengths
    can match while values point at the wrong peaks; a structural defense
    is the only reliable one).
  * The existing 3-arg ctor delegates to the 4-arg ctor with chargeArray=null
    (source-compat for every existing caller).
  * XCorrPrePreprocessing explicitly drops ChargeArray. The output peaks
    are nominal-mass bins (any number of source peaks may collapse into
    one output peak; empty bins exist with no source charge to report)
    so a per-peak charge is no longer meaningful. Pinned with a test.

mzLib/MassSpectrometry/MsDataScan.cs
  * `ChargeArray` is now a thin delegating property:
      public int[] ChargeArray => MassSpectrum?.ChargeArray;
  * The legacy `chargeArray` ctor parameter is preserved for source-
    compat. If a caller passes it AND the MzSpectrum already carries
    charges, the ctor requires them to be SequenceEqual and throws
    ArgumentException on mismatch (programming error — surface
    immediately rather than silently picking one). If only the legacy
    parameter is supplied, the ctor wraps the bare MzSpectrum into a
    new charge-carrying one so the delegating property still works.

mzLib/MassSpectrometry/WindowModeHelper.cs
  * New 6-parameter overload `Run(ref intensities, ref mArray,
    ref chargeArray, ...)` that carries the charge array through the
    intensity-sort, per-window selection, and final m/z-sort via
    index permutation. Existing 5-parameter signature preserved as
    a thin wrapper that delegates with chargeArray=null — every
    existing caller (MzSpectrum, TofSpectraMerger, ThermoRawFileReader,
    Mgf, Bruker, plus the four existing tests) keeps working unchanged.

mzLib/Readers/MzML/Mzml.cs
  * Both reader paths (GetMsDataOneBasedScanFromConnection static and
    GetOneBasedScanFromDynamicConnection dynamic) now keep chargeArray
    aligned through the pre-construction trim/sort points: zero-
    intensity removal, WindowModeHelper, and the final Array.Sort.
    A small private `SortInPlaceByKey` helper does 3-array index-
    permutation sorts (Array.Sort handles 2 parallel arrays at most).
  * The MzSpectrum is now constructed WITH chargeArray attached via
    the new 4-arg ctor; the redundant `chargeArray:` parameter on the
    MsDataScan ctor calls is removed (MsDataScan.ChargeArray now
    delegates to MassSpectrum.ChargeArray).
  * Defensive policy at every step: if the chargeArray length goes
    out of sync with the peak count after a filter, drop chargeArray
    rather than ship a misaligned one.

Tests (11 total, all passing)

  Original 3 from smith-chem-wisc#1060:
    ChargeArrayDefaultsToNullWhenAbsent
    ChargeArrayRoundTrip
    ChargeArrayRoundTripWithNoiseData
  Reader-side permutation sync (was smith-chem-wisc#1062):
    ChargeArrayUnsortedMzInputAlignsAfterReaderSort
    ChargeArraySurvivesZeroIntensityFilter
    ChargeArraySurvivesFilteringParamsPruning
  New for the architectural change:
    MzSpectrumChargeArrayLengthMismatchThrows
    MsDataScanChargeArrayDelegatesToMassSpectrum
    MsDataScanCtorChargeArrayConflictThrows
    MsDataScanCtorWrapsChargesIntoBareMassSpectrumForBackwardCompat
    MzSpectrumXCorrPreprocessingDropsChargeArray

Broader regression sweep (TestMzML + WindowMode + SpectrumProcessing
+ Dynamic + XCorr + MzSpectrum + MsDataScan): 111/111 pass.

Backward compatibility

Strictly additive on the public API:
  * `MzSpectrum`'s existing 3-arg ctor still works.
  * `MzSpectrum.ChargeArray` is new (additive).
  * `MsDataScan`'s `chargeArray` ctor parameter still works; if you
    pass it through the legacy path the ctor wraps into a new
    MzSpectrum that carries the charges.
  * `MsDataScan.ChargeArray` getter still works (now delegates).
  * `WindowModeHelper.Run`'s 5-parameter signature preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the patch-coverage gap codecov flagged on smith-chem-wisc#1062 (84% -> ~96%
expected) and on this PR by exercising the branches the happy-path
tests don't reach:

* ChargeArrayThroughDynamicReaderPath - mirrors
  ChargeArraySurvivesZeroIntensityFilter through
  InitiateDynamicConnection + GetOneBasedScanFromDynamicConnection.
  Confirms both reader paths apply the permutation/mask consistently.

* ChargeArrayDroppedWhenLengthMismatchedOnDisk - writes a normal scan,
  then surgically appends one extra 32-bit float to the chargeArray's
  base64-encoded binary on disk so the decoded length is N+1 while
  mzs/intensities are N. Confirms the reader DROPS the misaligned
  chargeArray rather than shipping it (defensive branch in both
  reader paths).

* WindowModeHelperLengthMismatchedChargeArrayDropped - direct unit
  test of WindowModeHelper.Run's length-mismatch null-out branch,
  plus that mzs/intensities still process correctly without charges.

* WindowModeHelperEmptyWindowPreservesChargeAlignment - 2 windows
  over [0..1000], both peaks land in window 1 (window 2 is empty);
  asserts charges still pair correctly with mzs after the per-window
  selection's empty-window branch is hit in the charge-aware path.

15/15 charge tests pass; 115/115 in the broader regression sweep
(TestMzML + WindowMode + SpectrumProcessing + Dynamic + XCorr +
MzSpectrum + MsDataScan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pcarvalho75 pcarvalho75 force-pushed the yada/charge-array-on-mzspectrum branch from ae8d0b2 to d4abb38 Compare May 12, 2026 03:12
@pcarvalho75

Copy link
Copy Markdown
Contributor Author

Pre-empted the codecov patch-coverage flag (it fired on #1062 against the same code patterns). Pushed d4abb389 with four additional tests targeting the defensive branches the happy-path tests didn't reach:

  • ChargeArrayThroughDynamicReaderPath - mirrors the zero-intensity-trim test through InitiateDynamicConnection + GetOneBasedScanFromDynamicConnection so both reader paths are exercised. Until now, all three reader-side regression tests went through LoadAllStaticData only.
  • ChargeArrayDroppedWhenLengthMismatchedOnDisk - writes a normal scan, then surgically appends one extra 32-bit float to the chargeArray's base64-encoded <binary> element on disk so the decoded length is N+1 vs N peaks. Confirms the reader DROPS the misaligned chargeArray rather than shipping it (the defensive branch in both reader paths).
  • WindowModeHelperLengthMismatchedChargeArrayDropped - direct unit test of the length-mismatch null-out branch in WindowModeHelper.Run, plus that mz/intensity processing still completes correctly without charges.
  • WindowModeHelperEmptyWindowPreservesChargeAlignment - 2 windows over [0..1000], both peaks land in window 1; window 2 is empty. Asserts charges still pair correctly with mzs after the per-window selection's empty-window branch is hit on the charge-aware path.

15/15 charge tests pass; 115/115 in the broader regression sweep (TestMzML + WindowMode + SpectrumProcessing + Dynamic + XCorr + MzSpectrum + MsDataScan).

Nothing else outstanding from our side - over to you.

@nbollis nbollis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at the failing test. An issue seems to be persisting with indexing.

nbollis and others added 2 commits May 14, 2026 12:33
…trum ctor

TestAveraging and TestAveragingExtensions each declared an xArray of 9 m/z
values alongside yArrays of 10 intensities. The old MzSpectrum constructor
silently accepted the length mismatch (only the first 9 y's were ever
indexed via XArray.Length), so the tests passed. The chargeArray-aware
constructor on this branch validates intensities.Length == mz.Length and
surfaces the latent bug as a OneTimeSetUp failure, cascading into 14
individual test failures across TestAveraging and TestAveragingExtensions.

Extends each fixture's xArray to 10 elements (appending 1000) to match
the existing 10-element yArrays. Same data content, no test-expected
values change.

Resolves the indexing failure nbollis flagged on smith-chem-wisc#1063. Full suite now
3466 passed / 5 skipped / 0 failed on yada/charge-array-on-mzspectrum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.38220% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.73%. Comparing base (646e218) to head (5d444bd).
⚠️ Report is 14 commits behind head on master.

Files with missing lines Patch % Lines
mzLib/Readers/MzML/Mzml.cs 96.20% 1 Missing and 2 partials ⚠️
mzLib/MassSpectrometry/MsDataScan.cs 90.47% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1063      +/-   ##
==========================================
+ Coverage   81.45%   81.73%   +0.28%     
==========================================
  Files         366      369       +3     
  Lines       46899    47609     +710     
  Branches     5556     5668     +112     
==========================================
+ Hits        38200    38912     +712     
+ Misses       7620     7600      -20     
- Partials     1079     1097      +18     
Files with missing lines Coverage Δ
mzLib/MassSpectrometry/MzSpectra/MzSpectrum.cs 63.78% <100.00%> (+2.73%) ⬆️
mzLib/MassSpectrometry/WindowModeHelper.cs 99.48% <100.00%> (+0.25%) ⬆️
mzLib/MassSpectrometry/MsDataScan.cs 88.83% <90.47%> (+<0.01%) ⬆️
mzLib/Readers/MzML/Mzml.cs 88.67% <96.20%> (+2.64%) ⬆️

... and 15 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Codecov flagged 15 lines missing on the PR after the SpectralAveraging
fixture fix landed: 4 on MsDataScan.cs, 7 on MzSpectrum.cs, 4 on Mzml.cs.
Coverage now 98.16% on the 326 patch lines; the 6 remaining lines are
genuine defensive code (null-safe `?.` operators, a catch block on a
practically-unreachable URI path, a private contract guard inside
SortInPlaceByKey) — not gaps that any reasonable end-to-end test would
exercise.

Added tests, each pinning a specific contract:

* MzSpectrumNullMzThrows / MzSpectrumNullIntensitiesThrows — pin the
  null-arg guards on the 4-arg MzSpectrum ctor. Without these the bug
  surfaces buried inside a sort/filter pass instead of at the boundary.

* MzSpectrumShouldCopyTrueClonesChargeArray — covers the shouldCopy=true
  + chargeArray!=null branch (lines 149-153). Asserts the chargeArray is
  CLONED, not aliased — otherwise two spectra built from the same input
  could share state and mutations leak across.

* MsDataScanCtorMatchingChargeArraysAccepted — covers the SequenceEqual
  TRUE branch of the MsDataScan ctor's chargeArray/MassSpectrum.ChargeArray
  consistency check. Pin: same VALUES via different references must be
  accepted; the throw is only for different values.

* MzmlReaderToleratesInvalidSourceFileLocation — covers the defensive
  URI parse fallback at lines 297-302. sourceFile/@location is xsd:anyURI
  but vendors write non-URI values ("Company", bare paths, ""). Surgically
  rewrites the location attribute to "Company" and asserts the reader
  no longer throws UriFormatException.

* MzmlReaderToleratesEmptySourceFileLocation — companion exercising the
  empty-string short-circuit of the same `||` (covers line 297's missing
  branch outcome).

* ChargeArrayThroughDynamicReaderWithFilterParams — covers the dynamic
  reader's WindowModeHelper.Run call at line 717. The existing
  ChargeArrayThroughDynamicReaderPath test passes filterParams=null and
  skips this branch; this test mirrors ChargeArraySurvivesFilteringParamsPruning
  (static path) for the dynamic connection.

Test count: 3473 passed / 5 skipped / 0 failed. Patch coverage by file:
  MzSpectrum.cs        100.00%  (60/60)
  MsDataScan.cs         97.98%  (97/99)   ← 2 lines are `?.` defensives
  Mzml.cs               97.60%  (163/167) ← 4 lines are catch + private guard

Co-Authored-By: Claude Opus 4.7 (1M context) <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.

2 participants