Move MS:1000516 ChargeArray onto MzSpectrum where the peaks live (supersedes #1062)#1063
Move MS:1000516 ChargeArray onto MzSpectrum where the peaks live (supersedes #1062)#1063pcarvalho75 wants to merge 5 commits into
Conversation
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>
ae8d0b2 to
d4abb38
Compare
|
Pre-empted the codecov patch-coverage flag (it fired on #1062 against the same code patterns). Pushed
15/15 charge tests pass; 115/115 in the broader regression sweep ( Nothing else outstanding from our side - over to you. |
nbollis
left a comment
There was a problem hiding this comment.
Please take a look at the failing test. An issue seems to be persisting with indexing.
…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 Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
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>
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
ChargeArraylives onMsDataScanwhile the peaks it indexes live onMsDataScan.MassSpectrum(anMzSpectrum), anyMzSpectrummutator silently invalidates the alignment invariant. This PR movesChargeArrayontoMzSpectrumso 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 finalArray.Sort) reordered m/z and intensity but leftchargeArrayin source order. #1062 patches all six fix sites across the static and dynamic reader paths.But the same bug class exists at the
MzSpectrumlayer, where the readers cannot reach:MzSpectrum.XCorrPrePreprocessing(lines 509-605 of MzSpectrum.cs)After this method runs on a scan whose
MsDataScan.ChargeArrayis non-null, the parent scan's chargeArray:MzSpectrumdoes not know its parentMsDataScanexists - it cannot reach back to update chargeArray. The bug is invisible at every call site.MsDataScan.TransformMzs/MzSpectrum.ReplaceXbyApplyingFunctionCalibration. 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/YArraybut lives onMsDataScan, and the type system cannot help.What this PR does
Move
ChargeArrayontoMzSpectrumThe 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.XCorrPrePreprocessingnow drops ChargeArray explicitlyThe 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.ChargeArraybecomes a delegating propertyThe legacy
chargeArrayctor parameter is preserved for source compatibility:MzSpectrumalready carries charges, the ctor requires them to beSequenceEqualand throwsArgumentExceptionon mismatch (programming error - surface immediately rather than silently picking one).MzSpectruminto a new charge-carrying one.Pinned with
MsDataScanCtorChargeArrayConflictThrowsandMsDataScanCtorWrapsChargesIntoBareMassSpectrumForBackwardCompat.Reader paths use the new construction (and incorporate #1062's fixes)
Both
Mzml.csreader paths now:SortInPlaceByKeyhelper for 3-array sorts;WindowModeHelper.Rungets a charge-aware overload).MzSpectrumwithchargeArrayattached via the new 4-arg ctor - instead of passing it separately to theMsDataScanctor.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.Runcharge-aware overloadWhen
chargeArrayis non-null and length-matchesmArray, 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 withchargeArray=null- every existing caller (8 sites acrossMzSpectrum,TofSpectraMerger,ThermoRawFileReader,Mgf,Bruker, plus tests) is untouched.Tests (11 total, all passing)
ChargeArrayDefaultsToNullWhenAbsentChargeArrayRoundTripChargeArrayRoundTripWithNoiseDataChargeArrayUnsortedMzInputAlignsAfterReaderSortArray.Sortpermutes charges in lockstepChargeArraySurvivesZeroIntensityFilterChargeArraySurvivesFilteringParamsPruningWindowModeHelpercharge-aware pathMzSpectrumChargeArrayLengthMismatchThrowsMsDataScanChargeArrayDelegatesToMassSpectrumMsDataScanCtorChargeArrayConflictThrowsMsDataScanCtorWrapsChargesIntoBareMassSpectrumForBackwardCompatMzSpectrumXCorrPreprocessingDropsChargeArrayBroader 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 withchargeArray=null).MzSpectrum.ChargeArrayis new (no existing caller could be using it).MsDataScan'schargeArrayctor parameter still works; the wrapping path keeps every existing call site compiling.MsDataScan.ChargeArraygetter still works (now delegates).WindowModeHelper.Run's 5-parameter signature preserved.The only behavior change for existing consumers:
MzSpectrum.XCorrPrePreprocessingnow dropsChargeArray(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:
FilteringParams.MinimumAllowedIntensityRatioToBasePeakM- public property has a stray trailingM. The constructor parameter isminimumAllowedIntensityRatioToBasePeak(noM). Smells like an incomplete refactor and produces a mildly confusing developer experience (caused aCS1739while 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.MsDataScanhas noIEquatable<MsDataScan>today (verified - unrelated to Param equality #1055 which only added it to*Paramstypes). Not a bug, just a heads-up: if anyone adds equality later,ChargeArraybelongs in the comparison since two scans with the same peaks but different charges are not semantically equal.Files changed
mzLib/MassSpectrometry/MzSpectrum.csChargeArrayproperty, new 4-arg ctor with length validation, XCorr clears chargesmzLib/MassSpectrometry/MsDataScan.csChargeArray, ctor wrap/conflict logicmzLib/MassSpectrometry/WindowModeHelper.csRunoverloadmzLib/Readers/MzML/Mzml.csSortInPlaceByKeyhelpermzLib/Test/FileReadingTests/SpectraFileReading/TestMzML.cs