Skip to content

Fd#1042

Open
trishorts wants to merge 89 commits into
smith-chem-wisc:masterfrom
trishorts:FD
Open

Fd#1042
trishorts wants to merge 89 commits into
smith-chem-wisc:masterfrom
trishorts:FD

Conversation

@trishorts

@trishorts trishorts commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Revives this PR as MetaFlashDecon — a faithful C# reimplementation of OpenMS FLASHDeconv for top-down (and bottom-up) deconvolution in mzLib, plus a pluggable mass-feature tracer and an _ms1.feature writer that feeds MetaMorpheus FromFile search.

Stacking note: this branch is currently stacked on #1069 (it reuses #1069's MassFeature model and Ms1FeatureFile writer as the shared feature output). The diff will reduce to the MetaFlashDecon-only changes once #1069 merges and this is rebased onto the new master.

What's here

Faithful FLASHDeconv port (MetaFlashDecon*) — ported function-by-function from the OpenMS source and differential-tested against it:

  • Candidate generation from FLASHDeconvAlgorithm (setFilters_, updateMzBins_, updateCandidateMassBins_, filterMassBins_, getCandidatePeakGroups_) — continuous-charge support, the low-charge isotope-presence path, two-level harmonic rejection, and the tol_div_factor voting/dedup tolerances.
  • Scoring pipeline from PeakGroup/PeakGroupScoring — the per-candidate recruit→Qscore offset loop, pre-loop isotope-cosine offset seed, charge-fit / isotope-cosine / charge-SNR gates, removeChargeErrorPeakGroups_, and removeOverlappingPeakGroups_.
  • IsotopicEnvelope.Score carries the logistic-regression Qscore (FLASHDeconv convention), not the raw averagine cosine.

Pluggable feature tracing

Supporting utilities

  • MsAlignWriter (MS1/MS2 msAlign export); SpectralSimilarity.CosineOfAlignedVectors.

Validation — reproduces real OpenMS FLASHDeconv

Fidelity was established by differential testing against the real OpenMS library (MSVC extracts that link the built OpenMS.dll) and against the production _ms1.msalign ground truth on a Jurkat top-down fraction.

Densest real MS1 scan (scan 2301, 19,184 peaks) — our pipeline vs real OpenMS, identical parameters:

Stage Ours OpenMS
candidates 5840 5840
after scoring gates 32 32
after removeChargeError 29 29
final masses 17 17
ground-truth recovery 17/17 17/17

Whole-run per-spectrum fidelity — 157 MS1 scans spread evenly across the full run, compared to the real FLASHDeconv _ms1.msalign (RT-aligned, identical spectra):

  • 97.6% of real masses matched within 15 ppm (98.4% including ±1-isotope offsets)
  • count ratio 1.04× (real 6767 / ours 7009)
  • low-mass (<5 kDa) recovery complete (6243 vs 6181)

Reaching exact stage-level and per-spectrum agreement required matching OpenMS down to numeric detail — e.g. the intensity-weighted monoisotopic-mass summation is float-typed and logMz-sorted to match PeakGroup::updateMonoMassAndIsotopeIntensities, which is what recovers the last borderline isotope-peak assignments.

Tests

  • Full mzLib suite: 4041 passed / 0 failed / 5 skipped.
  • Per-spectrum fidelity and OpenMS differential tests are [Explicit] (they require local Jurkat data and the built-OpenMS difftest harness), so they don't run in CI.

@codecov

codecov Bot commented Apr 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.47059% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.54%. Comparing base (92be818) to head (b5aae03).

⚠️ Current head b5aae03 differs from pull request most recent head 35e6eea

Please upload reports for the commit 35e6eea to get more accurate results.

Files with missing lines Patch % Lines
...volution/Algorithms/FLASHDeconvolutionAlgorithm.cs 91.29% 18 Missing and 11 partials ⚠️
mzLib/Readers/MsAlign/MsAlignWriter.cs 94.73% 2 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1042      +/-   ##
==========================================
- Coverage   81.63%   81.54%   -0.10%     
==========================================
  Files         369      347      -22     
  Lines       47605    45981    -1624     
  Branches     5649     5396     -253     
==========================================
- Hits        38863    37495    -1368     
+ Misses       7643     7424     -219     
+ Partials     1099     1062      -37     
Files with missing lines Coverage Δ
...Lib/MassSpectrometry/Deconvolution/Deconvoluter.cs 100.00% <100.00%> (ø)
...olution/Parameters/FLASHDeconvolutionParameters.cs 100.00% <100.00%> (ø)
...b/MassSpectrometry/MzSpectra/SpectralSimilarity.cs 98.98% <100.00%> (-0.12%) ⬇️
mzLib/Readers/MsAlign/MsAlignWriter.cs 94.73% <94.73%> (ø)
...volution/Algorithms/FLASHDeconvolutionAlgorithm.cs 91.29% <91.29%> (ø)

... and 70 files with indirect coverage changes

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

trishorts and others added 23 commits April 8, 2026 17:40
Add subtilisin|p to the embedded proteases.tsv with full cleavage
specificity and proline-inhibition motifs (N[P]|, S[P]|, L[P]|,
K[P]|, I[P]|, D[P]|, Y[P]|, V[P]|, G[P]|, F[P]|, T[P]|, E[P]|,
Q[P]|, A[P]|, R[P]|).

Add TestSubtilisinP_DigestsCorrectlyAndRespectsProlineRestriction to
ProteinDigestionTests to verify:
- subtilisin|p is present in the embedded protease dictionary with
  CleavageSpecificity.Full
- All expected cleavage sites fire on a proline-free sequence (ANKTIDE)
- The K[P]| proline-inhibition rule is respected (AKPIDE keeps KP intact)
  PR 1064 ships an Ms1Feature reader that recognises only the older
  FlashDeconv / TopFD-v1.6.2 _ms1.feature schema (Sample_ID, ID,
  Time_begin, Time_end, Minimum_charge_state, Maximum_charge_state,
  Minimum_fraction_id, Maximum_fraction_id). Newer TopFD output keeps
  the same _ms1.feature extension but uses different column names
  (File_name, Fraction_ID, Feature_ID, Min_time, Max_time, Min_charge,
  Max_charge), plus extras like Envelope_num and EC_score. Format
  detection picks Ms1FeatureFile by extension; CsvHelper then throws
  because none of the expected [Name(...)] columns are present.

  Discovered while integrating PR 1064's FromFileDeconvolutionParameters
  into MetaMorpheus and pointing it at a real TopFD .ms1.feature from a
  top-down yeast run -- the file parsed by hand looks identical in shape
  to the old schema, just relabelled.

  Fix is column-name aliases on the existing record, plus [Optional] on
  fields that the newer schema omits entirely. Downstream
  GetSingleChargeFeatures() reads only Mass, ChargeStateMin/Max,
  RetentionTimeBegin/End, and IntensityApex -- all aliased to a column
  present in both schemas, so the join algorithm behaves identically
  regardless of which producer wrote the file.

  Per-field summary:
    Sample_ID         -> [Optional]            (newer TopFD has File_name
                                                instead; type-incompatible
                                                -- int vs string path --
                                                and not used downstream)
    ID                -> alias "Feature_ID"    + [Optional]
    Time_begin        -> alias "Min_time"
    Time_end          -> alias "Max_time"
    Minimum_charge_state -> alias "Min_charge"
    Maximum_charge_state -> alias "Max_charge"
    Minimum_fraction_id -> [Optional]          (newer TopFD has a single
                                                Fraction_ID column, not a
                                                min/max pair; not used
                                                downstream so alias would
                                                add no value)
    Maximum_fraction_id -> [Optional]

  No tests added in this commit -- a follow-up should drop a newer-TopFD
  _ms1.feature sample into Test/FileReadingTests/ExternalFileTypes/ and
  extend the existing Ms1FeatureFile read-roundtrip tests to cover both
  schemas. The current
    Ms1Feature_FlashDeconvOpenMs3.0.0_ms1.feature
    Ms1Feature_TopFDv1.6.2_ms1.feature
  fixtures keep passing because the existing [Name(...)] heads remain
  the first entry in every alias list.
   from real TopFD output that uses the File_name / Fraction_ID /
   Feature_ID / Min_time / Max_time / Min_charge / Max_charge schema, and
   wires it into the existing TestMsFeature parameterised tests:

     * TestFeaturesLoadAndCountIsCorrect gains a TestCase asserting the
       fixture loads four features end-to-end via FileReader.
     * TestTopFDLatestMs1FeatureFirstAndLastAreCorrect locks the per-
       field mapping for both the aliased columns (Time_begin/Min_time,
       Minimum_charge_state/Min_charge, etc.) and the [Optional] fields
       absent from the newer schema (SampleId, FractionIdMin/Max default
       to 0). Covers the single-charge edge case (Min_charge == Max_charge
       == 1) in the last record.
     * TestTopFDLatestMs1GetSingleChargeFeatureFunctions confirms charge-
       range expansion is identical across the two TopFD schemas: a 6-14
       range yields 9 envelopes; a 1-1 range yields exactly one;
       GetMs1Features() flattens to 9 + 12 + 10 + 1 = 32 across the four
       fixture features.
     * TestMs1FeatureReadWrite gains the new fixture as a TestCase. The
       writer emits the older-schema headers (newer columns aren't on
       the record), so the round-trip converts schema-newer -> schema-
       older + Optional defaults. Comment in the test explains why that
       is correct: every field downstream consumers actually read
       survives the round-trip; the columns that don't are exactly the
       ones marked [Optional] and unused.

   All 17 TestMsFeature tests pass, as do the 148 tests covering the
   related Ms1Feature / FromFile / SupportedFileExtensions / DinosaurTsv
   surface area.
Move the post-decon consensus pipeline (per-charge trace grouping,
median off-by-one correction, cross-charge feature stitching) from
Development.Deconvolution.ConsensusTracing into a new production
namespace MassSpectrometry.Deconvolution.Consensus. The types had
been parked in Development as the research arc explored their design
(see NOTES.md, phases 1-13); promotion was flagged as the next move
once the design stabilised.

New files under mzLib/MassSpectrometry/Deconvolution/Consensus/:

  * MassTrace -- per-scan envelope list at one charge, anchored to
    the first envelope's mass.
  * MassTraceBuilder -- greedy charge-locked grouper, gap-tolerant
    within a configurable mass tolerance.
  * CorrectedEnvelope -- one envelope inside a corrected trace,
    carrying both original and post-correction mass plus a
    WasCorrected flag.
  * CorrectedTrace -- a MassTrace after weighted-median off-by-one
    correction.
  * TraceCorrector -- the corrector itself, with uniform/intensity/
    scorer-weighted variants via an EnvelopeWeight delegate.
  * MassFeature -- a cross-charge feature: a group of CorrectedTrace
    entries whose consensus masses agree within a ppm tolerance and
    whose RT windows overlap.
  * MassFeatureBuilder -- the union-find sweep that produces them.

No behaviour change. The classes, methods, and signatures are
identical to the research-namespace versions; only their namespace
and file locations changed (each type now lives in its own file).
Doc comments updated to drop references to phase numbers and to the
"Development" location.

The research scaffolding in Development.Deconvolution.ConsensusTracing
(Phase*.cs fixtures, MakeConsensusSnips, LargeTestDataLocator) stays on
the consensus-tracing branch; it will be updated to consume the new
namespace when that branch is rebased on top of this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the consensus-tracing pipeline persist its cross-charge feature
list as a FLASHDeconv-style _ms1.feature file -- the existing wire
format any Ms1FeatureFile consumer (notably MetaMorpheus's
FromFileDeconvolutionParameters) already understands. The
consensus-to-search bridge then needs no new file format and no new
MetaMorpheus surface area.

Two pieces:

  * MassFeature.ToMs1Feature(sequentialId, sampleId = 0,
    fractionId = 0) extension method (under Readers, because the
    return type is the Readers-namespace Ms1Feature record). Maps:

      Mass                   <- MassFeature.ConsensusMass
      Intensity              <- MassFeature.SummedIntensity
      RetentionTimeBegin     <- MassFeature.RTStart
      RetentionTimeEnd       <- MassFeature.RTEnd
      RetentionTimeApex      <- RT of the highest-intensity envelope
                                on the highest-summed-intensity
                                constituent trace ("apex of the
                                dominant charge state at its most
                                intense scan")
      IntensityApex          <- intensity of that same envelope
      ChargeStateMin/Max     <- min/max of MassFeature.Charges
      SampleId / FractionId  <- callers' choice (defaults 0)

  * Static Ms1FeatureFile.FromMassFeatures(IEnumerable<MassFeature>,
    sampleId, fractionId, software) factory. Builds the records via
    ToMs1Feature, populates a fresh Ms1FeatureFile instance, and
    returns it. The caller invokes .WriteResults(path) on the
    returned file to actually emit bytes -- no new IO path, the
    existing reader's writer is reused. Software label defaults to
    FLASHDeconv because that's the canonical schema the writer
    emits; the [Name(...)] alias machinery from PR smith-chem-wisc#1064 +
    schema-aliases PR means newer-TopFD readers also see the
    output as valid.

No filter applied: every MassFeature becomes one row. The original
question surfaced four filtering options (all, multi-charge only,
score-thresholded, all + side artifact); per the design pin "all
features, no filter" is the right default. Downstream consumers
(MetaMorpheus's precursor HashSet + FDR machinery) handle the noise.

The writer is decoupled from the producer: callers pass a sequence
of finalised MassFeature objects; the factory doesn't care where
they came from. Future producers (mass-trace results from non-
Classic algorithms, etc.) can use the same path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five NUnit cases under
Test\FileReadingTests\ExternalFileReading\TestMs1FeatureFromMassFeature.cs:

  * ToMs1Feature_SingletonFeature_MapsAllFields -- one trace, one
    envelope. Locks the mapping: Mass, Intensity, RT begin/end/apex,
    apex intensity, charge bounds, fraction id all come from
    Finalise()-derived MassFeature fields.

  * ToMs1Feature_MultiChargeFeature_ApexIsDominantTraceMaxEnvelope --
    two-charge feature with five envelopes. Confirms apex is "highest-
    intensity envelope on the highest-summed-intensity constituent
    trace", and that cross-charge consensus mass is the intensity-
    weighted mean of per-trace consensus masses.

  * ToMs1Feature_SampleIdAndFractionIdHonoured -- callers can override
    Sample_ID and Fraction_ID for multi-file/multi-fraction outputs.

  * FromMassFeatures_WriteThenRead_AllFieldsSurvive -- three features
    built in-memory, written via WriteResults, re-read with
    FileReader.ReadFile<Ms1FeatureFile>, every downstream-consumed
    field compared to the original ToMs1Feature output. Catches any
    serialisation drift between writer and reader on the new path.

  * FromMassFeatures_AssignsSequentialIdsFromZero -- producer's
    internal MassFeature.Id is ignored; written rows get 0..N-1. Keeps
    the output file's IDs dense and stable even when the upstream
    pipeline has filtered features and left holes.

Fix discovered while writing the second-to-last test: the original
FromMassFeatures factory used `file.Results.Add(...)` in a loop, which
goes through ResultFile<T>.Results' lazy-load getter. The getter calls
LoadResults() whenever the backing list is empty; for an in-memory-
constructed Ms1FeatureFile with no FilePath, LoadResults throws
ArgumentException on the empty path. Reshaped the factory to build
the records up front, then assign Results once via the setter (which
bypasses the lazy-load). Inline comment explains why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demonstrates the consensus-to-search loop the rest of this PR was
built for: load an mzML, run Classic decon per MS1 scan with top-down
parameters (charge 1..60), build mass traces with Pass-B grouping
(loose 1.5 Da to cohort off-by-one twins), apply weighted-median
correction, stitch cross-charge features at 10 ppm, write the
resulting MassFeature list as a FLASHDeconv-style _ms1.feature via
the new Ms1FeatureFile.FromMassFeatures factory, and round-trip read
the written file as a sanity check.

Lives as an NUnit [Explicit] test because it consumes a 165 MB local
mzML at a hard-coded path that's not part of the repo or CI:

  E:\TestData\MetaMorpheus\05-26-17_B7A_yeast_td_fract7_rep1.mzML

Assume.That on File.Exists keeps it silently skipped on machines
without the data. Run with:

  dotnet test --filter FullyQualifiedName~TestConsensusToMs1FeatureEndToEnd

On the yeast top-down test file (4837 scans total, ~2700 MS1) the
driver writes a 24 MB _ms1.feature with 266,373 features. That file,
fed to MetaMorpheus's FromFile precursor path (#2650), yields a
Cal -> GPTMD -> Search pipeline that converges to a tighter precursor
mass tolerance (1.6 ppm) than the equivalent classic-only baseline
(2.4 ppm) and finds 907 PSMs at 1% FDR vs 785 -- a 15.5% increase in
identifications from consensus precursors alone, before any additive
combination with the classic source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets a single dotnet test invocation run the consensus pipeline over
multiple raw/mzML files in succession. Each case captures its own
source path, output path, and decon charge ceiling -- bottom-up uses
MaxCharge=12 (matching MetaMorpheus's standard BU default), top-down
would use 60.

Currently shipping two bottom-up HEK293 Velos raws as the parameterised
test cases:

  * 20100609_Velos1_TaGe_SA_293_3.raw (5163 MS1 scans, 76878 features
    after correction, 7756 multi-charge -- 8.9 s wall time)
  * 20100609_Velos1_TaGe_SA_293_4.raw (5111 MS1 scans, 74173 features,
    7318 multi-charge -- 5.7 s)

Each test case still gates on File.Exists via Assume.That; cases for
machines without the data skip silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FlashDeconv and TopFD both canonically emit _ms1.feature files with
RetentionTime columns in seconds (e.g. Time_begin = 2787 for a 46-min
LC run). MetaMorpheus and mzLib's MsDataScan.RetentionTime are always
in minutes (mzML / Thermo convention). FromFileDeconvolutionAlgorithm
compared the file's RT directly against the scan's RT, so on any real
FlashDeconv or TopFD output the seconds-vs-minutes mismatch produced
zero overlapping windows -- and zero PSMs at search time.

Caught while running a Cal -> GPTMD -> Search pipeline over a 20-mzML
Jurkat top-down dataset using its FlashDeconv companion _ms1.feature
files. Classic-only produced 23,636 PSMs at 1% FDR; FromFile-only
produced 0 with no error or warning -- silent failure mode.

Fix: in the file-path FromFileDeconvolutionParameters constructor,
detect seconds-as-loaded via a max-RetentionTimeEnd > 500 heuristic
(no realistic LC run exceeds 8 hours = 500 min) and divide all RT
fields by 60. Files already in minutes pass through unchanged. The
internal pre-loaded-features constructor (test seam) is unaffected
so callers supplying explicit synthetic units still get raw values.

Verification: re-running the same Jurkat pipeline after the fix
yields 23,976 PSMs in FromFile-only mode and 39,700 PSMs in additive
Both mode (vs Classic-only's 23,636) -- +68% PSMs / +68% proteoforms
over the Classic baseline on this dataset.

Tests:
  * Existing EndToEnd_FilePathCtor_ResolvesExpectedChargeState
    updated to use MS2 RT in minutes (39.85) instead of seconds
    (2390.0) -- the fixtures are FlashDeconv / TopFD with seconds-RT
    and the fix now normalises them at load, so the test had to
    match the new units.
  * New FromFileDeconvolutionParameters_FileWithRtInSeconds_NormalisesToMinutes
    writes a synthetic seconds-RT file, loads via the file-path
    ctor, asserts every per-charge feature's RT lands in the
    expected minutes range (~39.83 / ~40.17), and pairs against an
    in-minutes MS2 to confirm overlap is found.
  * New FromFileDeconvolutionParameters_FileWithRtInMinutes_LeavesUnchanged
    sister test: file already in minutes (max RT < 500) is NOT
    double-converted.

31/31 tests in TestFromFileDeconvolution + TestMs1FeatureFromMassFeature
pass after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chem-wisc#1069 conflicts)

Upstream independently added the same logical changes for two pieces of this PR:

- subtilisin entry in proteases.tsv. Upstream named the protease "subtilisin|P"
  (uppercase P, consistent with proteomics convention for the proline restriction
  notation in the cleavage column); the PR had "subtilisin|p". Adopting upstream's
  uppercase form, including the matching ProteinDigestionTests assertions
  (upstream also adds LoadProteaseDictionary_SubtilisinP_HasProlineRestrictedMotifs).
- Newer-TopFD Ms1Feature test data and tests. The PR named its data file
  Ms1Feature_TopFDvLatest_ms1.feature; upstream named the identical content
  Ms1Feature_TopFDv1.7.0_ms1.feature. Adopting upstream's version-specific
  naming and removing the redundant TopFDvLatest copy.

Conflict resolution: took upstream's version for proteases.tsv, Test.csproj,
Test/FileReadingTests/ExternalFileReading/TestMsFeature.cs, and
Test/ProteomicsTests/ProteolyticDigestion/ProteinDigestionTests.cs. Deleted
Test/FileReadingTests/ExternalFileTypes/Ms1Feature_TopFDvLatest_ms1.feature.

Build clean. The 80 tests in the affected fixtures
(TestMsFeature, TestFromFileDeconvolution, TestMs1FeatureFromMassFeature,
TestConsensusToMs1FeatureEndToEnd, ProteinDigestionTests) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TraceCorrector now derives its off-by-one window from per-trace mass
scatter (3*sigma via MAD, capped below half the isotope spacing) instead
of a fixed +/-0.05 Da. An envelope at a resolvable, non-isotope offset
(e.g. a deamidated form at +0.98402 Da, which the old fixed window snapped
to the unmodified mass) is split into its own CorrectedTrace and surfaced
as a distinct feature; when scatter is too large to resolve it from the
1.00335 Da isotope spacing it conservatively merges. Correct() now returns
List<CorrectedTrace>. Bottom-up search needs this -- deamidation detection
is a requirement there.

Also in this hardening pass:
- MassFeatureBuilder sorts components by (mass, charge, RT) before
  assigning IDs, so feature IDs and written _ms1.feature row order are
  deterministic instead of depending on Dictionary enumeration.
- Guard clauses with clear messages on Finalise (empty traces),
  ToMs1Feature (un-finalised feature), and BuildTraces (scan/envelope
  length mismatch).
- FromFileDeconvolutionParameters exposes RetentionTimeNormalizedFromSeconds
  and warns when the seconds->minutes heuristic fires, rather than silently
  mutating RT.
- Unit + performance tests for the consensus engines (0 -> 100% line
  coverage on the promoted types) covering the behaviours above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[Explicit], local-data drivers used to benchmark the consensus pipeline:
JurkatMs1FeatureGenerator and VelosMs1FeatureGenerator regenerate the
_ms1.feature files for the Jurkat top-down (charge 1..60) and HEK293 Velos
bottom-up (charge 1..12) datasets via the new deamidation-split pipeline.

ConsensusFeatureScoring computes the generic per-envelope deconvolution
score (DeconvolutionScorer.ScoreEnvelope: averagine cosine, ppm error, peak
completeness, intensity-ratio consistency) for every Classic envelope and
aggregates it per feature into a sidecar TSV aligned to the _ms1.feature
row order. This gives a uniform, search-independent feature-quality value
for a computed noise-filter cutoff -- scored at generation time because the
consensus pipeline keeps only mass+intensity per envelope and drops the
peaks the scorer needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hardening fixes to the consensus MassFeature -> _ms1.feature path surfaced by
code review. None were observed in normal use, but each is a real edge-case
correctness or crash issue:

- Write one row per contiguous charge run so a gapped set (e.g. {10,12,15},
  when intermediate charges fall below the score cutoff) no longer reads back
  as the fabricated full range 10..15. Adds ToMs1Features.
- FromMassFeatures: empty input writes a valid header-only file instead of
  crashing; also fixes a LoadResults infinite recursion when reading a
  zero-row file (the Results getter re-entered LoadResults on an empty set).
- Ignore non-finite RetentionTimeEnd when sniffing seconds-vs-minutes, so a
  single NaN row can't flip an in-minutes file into a /60 conversion.
- Default the produced file's Software to TopFD to match how it is re-detected
  on reload (the writer always emits Apex_intensity); fix the misleading
  "FLASHDeconv canonical" doc.
- Document that per-charge Intensity comes from Apex_intensity, not the summed
  Intensity column.

Adds NUnit coverage for each; tidies now-stale FLASHDeconv comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TraceCorrector defined its own IsotopeSpacingDa = 1.00335, a less-precise copy of the canonical Chemistry.Constants.C13MinusC12 (1.00335483810). Alias the shared constant so the C12/C13 spacing has one source of truth; the 4.8e-6 Da change is far inside the off-by-one detection window, so results are unchanged.

Addresses review feedback on smith-chem-wisc#1069.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResultFile<T>.Results lazy-loaded whenever _results was empty, which crashed for a factory-built file with an empty FilePath (e.g. a zero-feature Ms1FeatureFile.FromMassFeatures). Guarding the load with File.Exists(FilePath) lets an in-memory file return its set records directly, removing the need for Ms1FeatureFile's parallel _factoryRecords store so the records have a single source of truth.

Addresses review feedback on smith-chem-wisc#1069.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trishorts and others added 29 commits May 27, 2026 20:37
… 229 -> bug is in our code

DumpDensestScanPeaks writes the densest MS1 scan's 19,184 centroid peaks for the
new flashdeconv_snip OpenMS extract (difftest/avg_snip/flashdeconv_cpp.cpp, 2nd
CMake target linking the built OpenMS) which runs the real
FLASHDeconvAlgorithm::performSpectrumDeconvolution exactly as the TOPP tool does
for MS1.

With OUR exact params (min_charge=1, max_charge=60, tol=10, min_mass=50,
max_mass=100000, min_isotope_cosine=0.85) OpenMS produces 17 masses on scan 2301
-- the true masses (1549.2, 3741.3, 3894.2, 4389.6, 4698.7, 4788.8, 5330.9,
5574.5, 6585.4, 6970.7, 6971.7, 7907.9, 8040.4 ~= the GT msalign) -- while our
pipeline produces 229 and recovers only 3 of them. Same input, same params, same
(separately confirmed identical) averagine.

So the over-production is DEFINITIVELY a bug in our code, not averagine/params/GT.
OpenMS also emits single-charge groups (z3-3, z4-4, z8-8) so "single-charge" was
a red herring; we form WRONG monos (miss 3894.2@z4 cos 0.970 that OpenMS finds)
and pile up halos. fd_cpp_z1_60.txt is the 17-mass ground truth to debug against.
Prime suspect: scoreAndFilterPeakGroups_ pre-loop offset seeding our loop omits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… truths, scoring drops them

DenseScan_OursVsOpenMSTruth reads OpenMS's 17 ground-truth masses (fd_cpp_z1_60.txt)
and, for each, reports our nearest CANDIDATE and our nearest FINAL group.

Result: our candidate generation forms the true masses (3894.2@z4 EXACT, plus
1549.2, 4698.7, 4788.8, 3741.3, ...; a few off by ~1 Da / one charge), but our
FINAL output drops most of them (ourFinal NONE for 1549.2, 3894.2, 5330.9, 5435.8,
6970.7, 7907.9, 8040.4, 12112.3) while keeping 229 halos.

So the over-production bug is entirely in our SCORING pipeline (10-iter
recruit->updateQscore loop + gates + removeChargeError + removeOverlapping): it
discards correct candidates and retains wrong ones. Candidate gen is fine. Next:
instrument the real OpenMS scoreAndFilterPeakGroups_ to record per-candidate
intermediate state to emulate/debug against.

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

Instrumented real OpenMS (env-gated FD_TRACE/FD_CANDTRACE/FD_BINTRACE/FD_PEAKTRACE
in difftest/avg_snip/flashdeconv_snip via OpenMSConfig linkage) to dump per-stage
intermediate state for scan 2301. With OUR exact params (min_charge=1, max_charge=60)
OpenMS produces 17 masses; ours was producing 229. Stage counts pinpointed the
divergence to getCandidatePeakGroups_ (post-fix 12157 emit vs 5840 -- still off but
this commit closes the easily-identified transcription gaps). Each fix is confirmed
verbatim against the OpenMS source:

1. Candidate MinAbsCharge = 1 (was crLo+1) -- OpenMS line 678:
   PeakGroup pg(1, per_mass_abs_charge_ranges(1,bin)+1, is_positive_)
   hardcodes minAbsCharge to 1. Using crLo+1 collapsed candidates to narrow/single-
   charge (85% span0) where OpenMS produces mostly wide (63% span5+) -- flipping it
   the right way. Scoring then narrows via updateChargeRange_.

2. Forward-final harmonic add = RAW h_max (was Math.Min(max_peak, h_max)) --
   OpenMS line 842: total_harmonic_intensity[l] += h_max_isotope_intensity[l]

3. Backward-final harmonic add = Math.Min(max_peak, h_max) (was RAW) --
   OpenMS line 925: total_harmonic_intensity[l] += std::min(max_peak_intensity, h_max_isotope_intensity[l])
   (The forward/backward "final" adds were swapped vs OpenMS: forward uses RAW,
    backward uses MIN; the in-loop adds use the opposite. Our port had them flipped.)

4. Added signal-peak bin-validity check `sbin < massBins.Length` in both forward
   and backward signal branches (OpenMS lines 783, 864 bin < mass_bin_size).
   Currently a no-op on this scan but faithful.

Effect on scan 2301 (z1-60): final masses 229 -> 165 (target 17); candidate
charge-span flipped from 85% single-charge to span5+=7652; harmonic gate rejection
13350 -> 13686 (target 19682). Remaining gap localized to an m/z-bin-boundary
peak-collection difference (we collect an extra iso-0 peak 602.1932 at bin 601
charge1 that OpenMS's walk excludes) -- to be debugged in a follow-up.

Note: 2 CandidateGen differential tests (CandidateGen_MatchesOpenMS,
CandidateGen_DenseRealScan_MatchesOpenMS) now fail because they diff against
cand_cpp (a hand-transcribed snip that still has the pre-fix logic). The real
OpenMS link snip (flashdeconv_snip) supersedes it; either update cand_cpp to
match these fixes or retire it. Full unit suite otherwise green (the 42 KoinaProsit/
PFly failures are unrelated network-dependent tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Groups -> 12478 -> 5822 candidates (matches OpenMS 5840)

THE candidate-gen bug. OpenMS FLASHDeconvAlgorithm::updateMembers_ (cpp:170-175)
narrows the tolerance fed into the finder bins + getCandidatePeakGroups_:

    for (double& j : tolerance_) { j *= 1e-6; j /= tol_div_factor; }

So tolerance_[ms_level-1] = 10ppm / 2.5 = 4e-6 (NOT 1e-5). That tol drives the
mz_delta tolerance in the peak walk (cpp:759 mz_delta = min(0.16/abs_charge,
2*tol*mz)), so mz_delta = ~0.0048 for charge 1 (vs ~0.012 with un-narrowed tol).

Our FindCandidates was passing the UN-narrowed `p.DeconvolutionTolerancePpm * 1e-6`
(= 1e-5) to GetCandidatePeakGroups. That made our mz_delta 2.5x too wide, so
borderline peaks ~7 mDa off the isotope grid (e.g. 602.1932 at iso0 for the
candidate at 601) fell in our signal branch when OpenMS's tighter mz_delta puts
them in the harmonic branch. Result: our total_signal was inflated -> harmonic
gate too lenient -> 12478 candidates emitted vs OpenMS's 5840.

Fix: pass `p.DeconvolutionTolerancePpm * 1e-6 / p.TolDivFactor` (= 4e-6) to
GetCandidatePeakGroups, matching OpenMS exactly.

Verified on scan 2301 (z1-60):
  candidate count:  12478 -> 5822  (OpenMS 5840; within 0.3%)
  ours-within-10ppm-of-OpenMS: 4961
  Stage counts (ours): 5822 -> 158 (scoring gates) -> 145 (removeCE) -> 100 (overlap)
  vs OpenMS:           5840 -> 32 -> 29 -> 17.
Candidate generation now essentially matches OpenMS. Remaining ~5x overproduction
is in the SCORING pipeline (next target).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…> 16 final masses on scan 2301

Same tol-narrowing bug as the candidate-gen fix (parent commit), now in the
scoring loop. ScoreCandidatesViaPeakGroups was passing the un-narrowed
p.DeconvolutionTolerancePpm * 1e-6 (= 1e-5) as `tolFraction` to
RecruitAllPeaksInSpectrum, while OpenMS scoreAndFilterPeakGroups_ (cpp:1133)
uses `tolerance_[ms_level-1]` which updateMembers_ has already narrowed by
tol_div_factor (= 4e-6).

Effect: our recruit was gathering 2.5x too many borderline-isotope peaks per
candidate, building mis-scored envelopes that survived the 0.85 cosine / SNR /
Qscore gates.

Fix: tolFraction = p.DeconvolutionTolerancePpm * 1e-6 / p.TolDivFactor.

Effect on scan 2301 (z1-60), OURS vs OpenMS ground truth (from instrumented
flashdeconv_snip):
  Stage counts:
                              OURS              OpenMS
    candidates              5822              5840
    afterScoringGates         37                32
    afterRemoveChargeError    29                29  <- exact match
    afterRemoveOverlap        16                17  <- within 1

  Truth recovery (DenseScan_OursVsOpenMSTruth): 11/17 of OpenMS's truths now
  recovered with the EXACT charge ranges, vs 3/17 before this session:
    3741.3 z3, 3894.2 z4, 4389.6 z6, 4698.7 z4, 4788.8 z4, 5435.8 z8,
    6585.4 z8, 7576.6 z7-8, 7907.9 z8, 12112.3 z12-15, 27889.7 z23-36.
  Missed 6: 1549.2 z1, 5330.9 z5, 5574.5 z5, 6970.7 z6-8, 6971.7 z6-9, 8040.4 z7-10.

Per-spectrum over-production essentially solved (229 -> 16; OpenMS 17). Remaining
work: recover the 6 missed truths.

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

OpenMS scoreAndFilterPeakGroups_ (cpp:1126) only drops a peak group if
`peak_group.empty()`. There is NO `>= MinIsotopicPeakCount` gate at this stage.
Our prior `if (pg.SignalPeaks.Count < p.MinIsotopicPeakCount /*=3*/) continue;`
was an unfaithful strictness that, at the now-tightened tolerance, was dropping
real low-mass / few-isotope candidates (e.g. 1549.2 z1) that OpenMS retains.

Result on scan 2301 (z1-60):
                              OURS              OpenMS
  candidates              5822              5840
  afterScoringGates         39                32
  afterRemoveChargeError    31                29
  afterRemoveOverlap        17                17   <- EXACT MATCH

Truth recovery 12/17 (vs 11/17 before): now picks up 1549.2 z1-1 (cos 0.912).
The synthetic placeholder test MetaFlashDecon_ReturnsScoredEnvelopesOnRealSpectrum
(broken since tightening tol) is green again.

5 truths still missed (5330.9, 5574.5, 6970.7, 6971.7, 8040.4) — all have
candidates at the right mass region but with monos shifted 0.3-1.7 Da; suspect
the 10-iter recruit/updateQscore loop converging to a wrong isotope offset (no
pre-loop seed from getIsotopeCosineAndDetermineIsotopeIndex on the candidate's
per_isotope_intensities, FLASHDeconvAlgorithm.cpp:1091-1093).

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

OpenMS scoreAndFilterPeakGroups_ (cpp:1090-1101) does an initial cosine-vs-averagine
alignment BEFORE the 10-iter recruit->updateQscore loop, to pick a starting offset
that corrects ±isotope mis-anchors in the candidate's mono mass:

    float prev_cos = peak_group.getIsotopeCosine();
    float cos = getIsotopeCosineAndDetermineIsotopeIndex(monoMass, peak_group.getIsotopeIntensities(),
                                                         offset, avg, -minNegIsoIdx, -1, allowed_iso_error_, ...);
    prev_mono_mass = monoMass + offset * iso_da_distance_;
    peak_group.setIsotopeCosine(cos);
    if (cos < min(0.5, min_isotope_cosine) - 0.1) continue;

Our ScoreCandidatesViaPeakGroups skipped this step (started at offset=0), so the
loop could stabilise at the wrong isotope for candidates whose getCandidatePeakGroups_
mono came out off by +/-1 Da.

Implementation:
  - Added PerIsotopeIntensities to MetaFlashDeconAlgorithm.CandidateMass (shape =
    MetaFlashDeconPeakGroup.PerIsotopeInt: indexed by `anchored_iso - MinNegativeIsotopeIndex`).
  - MetaFlashDeconCandidateFinder.GetCandidatePeakGroups now computes & stores this
    vector right after the apex-anchored mono walk (uses the same peaks list +
    isoTol filter that builds the mono mass).
  - ScoreCandidatesViaPeakGroups adds the OpenMS pre-loop block: calls
    GetIsotopeCosineAndDetermineIsotopeIndex(perIso, b, apex, isoIntShift=1,
    windowWidth=-1, minIsoSize=2 [matches OpenMS const min_iso_size_=2],
    out int offset), applies the cos<min(0.5,minCos)-0.1 pre-filter, and sets
    prevMono = seedMono + offset*isoDa (matches cpp:1093).

Result on scan 2301 (z1-60):
                              OURS              OpenMS
  candidates              5822              5840
  afterScoringGates         40                32
  afterRemoveChargeError    34                29
  afterRemoveOverlap        22                17

  Truth recovery 14/17 (up from 12/17): now recovers 5574.5 z5 (cos 0.864) and
  8040.4 z7-10 (cos 0.877). Still missed (3): 5330.9 z5, 6970.7 z6-8, 6971.7 z6-9
  (candidates exist with monos shifted 0.3-1.5 Da; suspect convergence loop
  detail or removeChargeError/overlap subtlety).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…C13-C12 (1.0033548) -> exact 5840=5840, 32=32

OpenMS Constants::ISOTOPE_MASSDIFF_55K_U = 1.002371 (Constants.h:100) -- the
average isotope mass difference for a 55 kDa peptide, used as `iso_da_distance_`
throughout FLASHDeconv (PeakGroup.h:315; FLASHDeconvAlgorithm::performSpectrumDeconvolution
sets it from this constant at cpp:76-78). Ours was using mzLib's
Chemistry.Constants.C13MinusC12 = 1.0033548 (the pure 13C-12C diff).

The ~0.001 Da/isotope drift was small but flipped borderline isotope assignments
in dense regions and pushed mono masses off by a fraction of a Da at higher
masses (compounding through the 10-iter loop). Centralised the OpenMS value as
`MetaFlashDeconAlgorithm.IsoDaDistance55K` and switched all 3 files
(MetaFlashDeconAlgorithm, MetaFlashDeconCandidateFinder, MetaFlashDeconPeakGroup)
to use it instead of Constants.C13MinusC12.

Result on scan 2301 (z1-60) -- now matches OpenMS at the early stages exactly:
                              OURS              OpenMS
  candidates              5840              5840   <- exact match
  afterScoringGates         32                32   <- exact match
  afterRemoveChargeError    24                29
  afterRemoveOverlap        16                17

  Truth recovery 15/17 (up from 14/17): now recovers 5330.9 z5 (cos 0.854).
  Remaining 2 missed: 6970.7 z6-8 and 6971.7 z6-9 (a 1-Da-apart pair).

For DenseScan_OursVsOpenMSTruth, ourCand for 6585.4 now lands at 6585.4 z1-8
(was 6586.3 z1-9) and ourCand for 5330.9 at 5329.9 z1-5 (was 5332.6 z1-7) --
the more accurate iso_delta shifts candidate monos closer to truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r-group CE overlap-ratio trace

Added to MetaFlashDeconVsRealPerSpectrum (both DenseScan_OursVsOpenMSTruth and
DenseScan_ClassifyExtrasVsReal):
  - Dump of every entry in our `scored` (post-gates, n=32) and `afterCE` (n=24)
    next to ourFinal (16), for direct comparison against the OpenMS FD_TRACE
    AFTERCE dump (now also adds AFTERCE per-group lines in OpenMS).
  - PER-GROUP table in the CE-instrument block listing (mono, z-range, rep,
    overlap_intensity, GetIntensity, ratio, DROP/keep) so we can see exactly
    which groups exceed the 0.5 threshold and by how much.
  - TRACE block for one named target mass (currently 6970.7) listing every
    shared-peak contribution to its overlap_intensity (pmz, pint, j, mass_j,
    repz comparison, snr-comparison verdict).

Used these to localise the 2 missed truths on scan 2301 (6970.7 z6-8 and
6971.7 z6-9). Diagnosis: borderline overlap-ratio drops (0.524 / 0.532) from
accidental m/z collisions between mid-mass groups' early isotopes and the
27889.7 z23-36 protein's DEEP isotopes (iso-17 of 27889@z32 = m/z 872.84 = iso-2
of 6970@z8, etc.). 27889 has chargeSnr ~8.78 at z32 vs 6970's ~0.91 at z8 ->
6970 loses the snr-comparison and accumulates overlap that just barely exceeds
the 0.5 drop threshold. OpenMS does the same computation but keeps these groups
(snr borderline must fall the other way for it) -- gap is small (~5%) and the
remaining investigation requires instrumenting OpenMS's per-charge SNRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… extra peak in 27889@z32

Instrumented OpenMS scoreAndFilterPeakGroups_ to dump per-charge SNRs alongside
SCORED entries + the per-peak (mz, intensity, isotopeIndex) content of the 27889
z32 group. Mirrored on our side in DenseScan_ClassifyExtrasVsReal.

Side-by-side comparison (scan 2301, mass 27889.7 z32):
   Ours: 17 z32 signal peaks
 OpenMS: 16 z32 signal peaks
   diff: ONE EXTRA peak at iso 25, m/z 873.3480, int 3.52e4 (only in ours)

That single extra peak inflates our PerChargeInt[32] and PerChargeSumSignalSq[32]
just enough to push chargeSnr_27889(z32) from OpenMS's 7.33 to our 8.78 (20%
higher). Per-charge SNRs match at every other charge of 27889 (z23-z36) and the
averagine for mass 27889 matches OpenMS exactly (apex 17/17, left 10/10, right
12/12, b-cos 1.0000).

The extra peak triggers a cascade: the 27889@z32 SNR inflation slightly shifts
the snr-comparison budget in removeChargeError, just enough to flip the
overlap-intensity ratio for 6970.7's 4 dups + 6971.7 from ~0.49 to 0.52-0.58
(crossing the 0.5 drop threshold). Result: we drop the entire 6970-6971 cluster
where OpenMS keeps it.

Mechanism of the extra peak (signal-check threshold):
  pmz = 873.3480, cmz_z32 = 872.5604, iso_delta = 0.031323
  iso_index = round((873.348 - 872.5604) / 0.031323) = 25
  error = |0.7876 - 25*0.031323| = 0.00453
  threshold = pmz * tol = 873.348 * 4e-6 = 0.00349
  Nominal: 0.00453 > 0.00349 -> should NOT be signal (OpenMS correctly excludes).
Our recruit nevertheless adds it. Suspect: our pg.MonoisotopicMass after the
10-iter loop differs from OpenMS's by ~0.04 Da at this position -- enough to
shift cmz and bring 873.3480's iso-25 error just under threshold.

This commit only extends the test diagnostics; the fix requires either matching
the convergence behaviour of OpenMS's 10-iter loop more precisely (probably
updateMonoMassAndIsotopeIntensities or updatePerChargeInformation precision) or
accepting the borderline 1-mass delta on dense scans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-25 borderline -> 2 missed truths

Instrumented OpenMS PeakGroup::recruitAllPeaksInSpectrum (gated FD_RECRUITTRACE,
+include <fstream>/<cstdlib>) to dump the per-iteration signal-check computation
for peak m/z 873.348 at charge 32 of the 27889 group. Our C# test side dumps
the same. Side-by-side:

OpenMS 10-iter loop hits m/z 873.348 three times (3 different mono values):
  iter A: mono=27885.7258 -> iso=29, err=0.00341 < threshold 0.00349 -> SIGNAL
  iter B: mono=27890.7279 -> iso=24, err=0.00372 > threshold 0.00349 -> NOT signal
  iter C: mono=27889.7321 -> iso=25, err=0.003516 > threshold 0.003493 -> NOT signal (just over by 2e-5)

Our final iter: mono=27889.7368 -> iso=25, err=0.003345 < threshold 0.003493 -> SIGNAL

So OpenMS's final-iter mono (27889.7321) lands the iso-25 check ~2e-5 above
threshold; ours (27889.7368) lands it ~1e-4 below. The 0.005 Da convergence
drift between the two pipelines' 10-iter loops is the entire cause of the 2
missed truths cascade.

Root of the convergence drift is NOT:
  - ProtonMass: mzLib 1.007276466879 vs OpenMS PROTON_MASS_U 1.0072764667710
    (diff 1e-10, negligible).
  - iso_da_distance: both 1.002371 exactly (fixed earlier in 3badfcb).
  - averagine: apex/left/right/b cosine = 1.0000 at mass 27889 (matches OpenMS).
  - Pre-loop offset: matches OpenMS shape (cpp:1091-1093, min_iso_size_=2).

Suspect: subtle precision/order in updateMonoMassAndIsotopeIntensities (the
intensity-weighted mass formula or how it accumulates across iterations through
the recruit-then-updateQscore loop). Fixing it would close the last 2 missed
truths but requires per-iter trace dumps from BOTH pipelines (updateQscore
result + mono after each iter).

Best result remains: candidates 5840=5840, gates 32=32, removeCE 24 vs 29,
removeOL 16 vs 17, truth recovery 15/17. Investigation committed for the future.

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

Instrumented BOTH 10-iter scoring loops to dump per-iteration recruit-mono,
returned offset, and mono-after-updateQscore for a target mass window (gated by
FD_ITERTRACE / FD_ITERTRACE_CS, target 27880-27900):

  Cand A (start ~27885):
    OpenMS iter 0: recruit=27885.72584 offset=0 mono_after=27885.73788
    Ours  iter 0: recruit=27885.72160 offset=0 mono_after=27885.73111

  Cand B (start ~27890):
    OpenMS iter 0: recruit=27890.72791 offset=-1 mono_after=27890.73444
    Ours  iter 0: recruit=27890.73700 offset=-1 mono_after=27890.73747
    OpenMS iter 1: recruit=27889.73207 offset= 0 mono_after=27889.74032
    Ours  iter 1: recruit=27889.73509 offset= 0 mono_after=27889.73683

Both pipelines: (a) same number of iterations, (b) same offset sequence
(0 for A; -1, 0 for B), (c) loop converges in the same step.

But the iter 0 recruit_mono ALREADY differs by 0.004 (cand A) / 0.009 (cand B).
That's `candidate.Mass + offset_preloop * isoDa`. With offset_preloop=0 for cand A,
our candidate.Mass = 27885.722 vs OpenMS 27885.726 (CAND line). With
offset_preloop=1 for cand B, our candidate.Mass = 27889.735 vs OpenMS 27889.726.

So the entire 0.005 Da convergence drift is INHERITED from candidate generation
-- the candidate's intensity-weighted mono in `getCandidatePeakGroups` is what
differs. The 10-iter scoring loop just propagates it. Looking at the algorithm
(nominator += pi * (um - iso*isoDa); intensity += pi; mono = nominator/intensity)
the candidates have identical PEAK COUNT (verified 5840=5840) and same averagine,
threshold, and isoDa -- yet the per-candidate mono diverges 4-9 mDa. Suspect a
precision issue in either:
  (a) `um = (pk.mz - ProtonMass) * pk.charge` -- OpenMS uses (float)PROTON_MASS_U
      vs our double Constants.ProtonMass; per-peak diff ~1.8e-6 * z, but for
      z23-36 of a 150-peak group this could cumulate.
  (b) max-intensity peak (tMass) selection -- OpenMS Peak1D::intensity_ is float
      while our peaks list stores double intensity; ties could break differently
      and pick a different reference peak -> different anchored iso indices ->
      different mono.

Reverting the float-intensity casts in updateMonoMassAndIsotopeIntensities +
updatePerChargeInformation (tried; no effect). The drift is at candidate-gen
stage, not scoring stage. Next investigation point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sses -> fixes +2 mass-bin shift

Found the root cause of the 134-bin discrepancy and the entire 2-missed-truths
cascade: our `MetaFlashDeconAveragine.GetEntry` computed `avgDelta = (weighted
mean K) * iso_da_distance`, treating the +k peak as mono + k*1.002371. But the
ACTUAL C-13 isotope shift is ~1.00335 (and varies per element), so for low-mass
peptides where the +1 peak is dominated by C-13, our `avgDelta(50)` was 0.026 vs
OpenMS's 0.025 (0.0004 Da bias).

That 0.0004 Da bias propagates into `mass_bin_min_value` (cpp:1074 / our
FindCandidates line 77: `log(max(1, min_mass - avgDelta(min_mass)))`), which
shifts massBinMin by ~8e-6 in log space => +2 in every mass bin index across the
entire candidate-gen pipeline. Same accumulated mass_intensities VALUES but
indexed 2 bins higher than OpenMS. This cascaded into filterMassBins top-3
running-max selecting different charges per m/z bin for the 27909 mass bin, then
fewer peaks in candidate pg (146 vs 151), then 0.005 Da drift in candidate mono
(27889.737 vs 27889.726), then borderline iso-25 inclusion at z32 of 27889
flipping, then 27889@z32 SNR 8.78 vs 7.33, then 6970/6971 borderline overlap.

Fix: track `intens[k] * masses[k]` per re-binned iso slot, apply the same Trim+
Normalize, then compute `avgDelta = sum_kept(intens*mass) / sum_kept(intens) -
mono` matching OpenMS `iso.averageMass() - iso[0].getMZ()` (cpp:107). This uses
the ACTUAL coarse-isotope masses (per-element averaged), not k * iso_da_distance.

Result at mass 50: ours 0.025 vs OpenMS 0.025 (was 0.026, now matches);
mass 1500: ours 0.952 vs 0.953 (was 0.979).

Scan 2301 stage counts:
                       OURS     OpenMS
  candidates           5796     5840
  afterScoringGates    35       32
  afterRemoveCE        27       29
  afterRemoveOverlap   18       17

Truth recovery 15/17 (same count, different masses recovered): now picks up
7576.6, 8040.4, 12112.3, 27889.7 (high-mass) that the +2 bin shift had been
preserving accidentally. Still missing 6970.7, 6971.7 (the original +1 isotope
overlap-borderline pair; their fix likely involves a different downstream
threshold).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ctual masses -> fixes +2 mass-bin shift"

This reverts commit 1ec5383.
…9-bin region (uncovered the +2 bin-shift -> avgDelta(50) chain)
…actual masses -> fixes +2 mass-bin shift"

This reverts commit 548a242.
…iso-bin actual masses -> fixes +2 mass-bin shift""

This reverts commit ff93c10.
OpenMS PeakGroup::updateMonoMassAndIsotopeIntensities (PeakGroup.cpp:706-736)
sorts peaks by logMz then sums the intensity-weighted mono with a FLOAT
denominator (float pi, float intensity_). Our candidate-gen and scoring mono
both summed in double and in recruit order, drifting the 27889 protein mono
~5-9 mDa. That tipped one borderline z32 signal peak (m/z 873.348, iso 25) into
the envelope; it is shared with 6970.7/6971.7, and the extra shared intensity
pushed their removeChargeError overlap ratio just over 0.5, dropping both true
masses.

Match OpenMS exactly: sort by logMz then float intensity, float pi, float
denominator, exclude iso<0 from the mono. Densest real scan 2301 now matches
OpenMS at every stage: candidates 5840=5840, gates 32=32, removeChargeError
29=29, final 17=17, truth recovery 17/17 (was 15/17).

Relax the RealData peak-count invariant from MinIsotopicPeakCount(3) to 2:
OpenMS drops only empty groups (cpp:1126) and a candidate needs >=2 isotopes,
so a 2-peak envelope is faithful (commit 8af4e4d).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ComparePerSpectrumToRealFlashDeconv now samples every 20th MS1 scan (~160 across
the run) instead of every 80th (~40), for a tighter whole-run fidelity readout
against the real _ms1.msalign. Every-scan (~3200) is impractical (hours through
the full candidate-gen + scoring pipeline). Whole-run result at this density:
matched 6605/6767 = 97.6% (15 ppm), our/real 1.04x, low-mass recovery complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "do not change" notes at the fragile, differential-tested details that an
innocent refactor would silently break - distilled from the STATUS investigation log:

- IsoDaDistance55K = 1.002371 (ISOTOPE_MASSDIFF_55K_U), NOT Constants.C13MinusC12
- intensity-weighted mono: FLOAT pi + FLOAT denominator + logMz-sort + iso<0 excluded,
  identical in candidate-gen and scoring (the float/order detail behind 17/17)
- GetBinNumber = floor(x+0.5), not Math.Round (banker's); binOffsets need AwayFromZero
- scoring tolerance narrowed by tol_div_factor; empty-only post-gate (not >=3)
- removeChargeError 0.5 overlap threshold + per-charge-SNR >2x comparison
- getNoisePeakPower_ rounding must be AwayFromZero (caught 2 real bugs)
- mass_bin_min/avgDelta +2-bin shift is a known, deliberate accidental-clean state
- parameter defaults are literal OpenMS values, differential-tested, not free knobs
- LogMzPeak.Intensity is float-origin; constant-averagine ctor is test-only

Comments only; full suite 4041 pass / 0 fail / 5 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OpenMS MassFeatureTrace::findFeatures (MassFeatureTrace.cpp:84-143) drops every
mass trace whose summed per-isotope pattern has averagine cosine < 0.75. Our
independently-written native tracer never had this ("Phase 3b", deferred), so it
over-produced features ~4.7x (2845 vs the real FLASHDeconv 603 on rep2 fract10).

Port it verbatim:
- plumb each envelope's per-isotope vector (PeakGroup::getIsotopeIntensities,
  min-negative-isotope based) through IsotopicEnvelope to the tracer
- per trace, accumulate member per-isotope intensities in FLOAT (std::vector<float>),
  shifted by iso_off = (int)(0.5 + (mono-centroid)/isoDa) [truncate-toward-zero == C++ int()]
- cosine via the differential-tested getIsotopeCosineAndDetermineIsotopeIndex with
  the exact MassFeatureTrace args (iso_int_shift=0, window_width=0, min_iso_size=2),
  threshold 0.75 (MassFeatureTrace.cpp:32 default, NOT 0.85)

Differential-tested against the real OpenMS static function (massfeature_snip,
links built OpenMS): on 38 real traces, offsets identical, masses within 5e-6,
cosine within 2e-3 (averagine boundary), and zero traces flip the 0.75 gate.

Whole-file (rep2 fract10): 2845 -> 608 features vs real 603 (4.7x -> 1.008x).
Filter applies whenever per-isotope data is present (always true for real
MetaFlashDecon envelopes, as in OpenMS); skipped only for synthetic envelopes
that carry none. Full suite 4041 pass / 0 fail / 5 skip.

NOTE: feature-SET correspondence is still ~79% recall / ~57% precision vs OpenMS
because the trace DETECTION (our Kenar-style extender) is not yet the faithful
OpenMS MassTraceDetection (next: "piece C").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the independently-written Kenar-style trace extender with a verbatim port
of OpenMS MassTraceDetection::run_ (MassTraceDetection.cpp:449-663) — the algorithm
MassFeatureTrace actually runs (MassFeatureTrace.cpp:68-75):
- apices = every collapsed neutral-mass peak, stable-sorted by intensity, seeded
  most-intense-first
- bidirectional findNearest extension within +/-3*ftl_sd (ftl_sd = centroid/1e6 *
  mass_error_ppm), fixed (reestimate_mt_sd=false)
- exact multiplicative iterative weighted-mean centroid (not naive Sum(wv)/Sum(w))
- outlier termination at >trace_termination_outliers consecutive non-empty misses;
  empty scans advance the index without incrementing the miss counter
- peaks marked visited ONLY after isTraceValid_ (min_trace_length + min_sample_rate),
  so failed traces leave their peaks for other seeds

Also correct the params to the real values MassFeatureTrace uses (our independent
version had all three wrong): mass_error_ppm 20 (was effectively 10), outliers 5
(was 20), min_sample_rate 0.1 (was 0.05).

Differential-tested vs the REAL OpenMS MassTraceDetection (masstrace_snip, links
built OpenMS) on identical serialized per-scan peaks: 24/24 traces match both ways
(centroid 10 ppm + RT overlap + identical size). So the port is faithful; the
remaining whole-file feature-set divergence vs OpenMS is the upstream per-spectrum
decon residual (97.6%), not the tracer.

Whole-file (rep2 fract10): 488 features vs real 603. Full suite 4041 pass / 0 fail
/ 5 skip; tracer unit tests 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eal data

OpenMS MassFeatureTrace keys one Peak1D per PeakGroup with no ppm merging. Our
per-scan collapse has a within-ppm merge, but MetaFlashDecon emits one envelope per
neutral mass per scan and RemoveOverlapping keeps survivors apart, so the merge is
dormant on the real path. Confirmed empirically (Collapse_MergeStats): 0/912
envelopes merged over 60 real scans (0.00%) -> already 1:1 and faithful. The merge
only fires for non-faithful multi-same-mass-per-charge inputs (synthetic tests).
Documented; left as-is (forcing strict 1:1 would break the synthetic charge-collapse
test for zero real-path gain). Remaining whole-file feature-set gap vs OpenMS is the
upstream per-spectrum decon residual (97.6%), not the tracer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate/pg traces

Adds [Explicit] harnesses to localize per-spectrum divergence from real OpenMS at
identical params (the production _ms1.msalign uses different params, so it's not the
fidelity reference):
- DumpScansForSnip / CompareScansVsSnip: dump a sparse/medium/dense scan's peaks and
  compare our decon to flashdeconv_snip (real OpenMS). Result: dense/medium near-exact
  (scan 3551 135/135, 2625 26/27), but SPARSE scan 1332 = 15/20 (miss 5, extra 0).
- DiagnoseScan1332Misses: stage-by-stage (cand/scored/afterCE/final) per OpenMS truth.
- env-gated FD_GATETRACE_CS / FD_PGDUMP_CS in ScoreCandidatesViaPeakGroups (dormant
  unless set) dump per-candidate gate decisions + per-charge recruited peaks.

ROOT CAUSE of the sparse-scan misses (confirmed, not guessed): for masses like
8166.8, OpenMS builds a clean z9-10 envelope (cos 0.893); ours forms fragmented,
wrong-charge-range groups (z9-11 with a malformed z9 [iso 6-7 only] + spurious z11),
cos 0.81 < the 0.85 gate -> UpdateQscore returns 0 (rep/snr/q zeroed) -> dropped.
This per-spectrum loss disproportionately hurts FEATURE yield: a mass scoring just
under 0.85 on a feature's weaker scans breaks the trace. Next: differential-test the
recruit / updateChargeRange_ on scan 1332 vs OpenMS and copy the divergent step
(rather than reasoning about why ours fragments).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arse-scan masses

The bin-axis origin mass_bin_min = log(min_mass - avgDelta(min_mass)) (OpenMS
FLASHDeconvAlgorithm.cpp:1074) was using mzLib's Averagine value (~0.0251-0.0255)
instead of OpenMS's CoarseIsotopePatternGenerator value (0.02514582). That ~mDa
error shifts the whole mass-bin axis ~2 bins vs OpenMS. On DENSE scans it was a
harmless near-integer shift, but on SPARSE scans it lands candidates in the wrong
filterMassBins charge-range slot -> a spurious extra charge -> envelope cosine
< 0.85 -> dropped. That silently cut per-spectrum recall, which disproportionately
kills FEATURE yield (a mass scoring just under 0.85 on a feature's weaker scans
breaks its trace).

Copy-verified the full chain to this root via the OpenMS-linked snips: the dropped
mass 8166.8 on scan 1332 -> spurious z11 -> candidate charge range z1-11 vs OpenMS
z1-10 -> the 8170 bin sits ~4 mDa off -> the avgDelta axis. Recruit (z9/z10 peaks
identical to OpenMS) and UpdateChargeRange are verified-faithful; the only divergence
was the axis.

Fix: use the exact-OpenMS avgDelta at the FLASHDeconv min_mass (50) for mass_bin_min
(GetMassBinMinAvgDelta -> OpenMsAvgDeltaAtMass50 = 0.025145816488311823, from the
OpenMS-linked avg_snip / FD_SCALARTRACE; mzLib cannot reproduce it without OpenMS's
coarse isotope generator + abundances). avgDelta is used ONLY here.

Result (vs real OpenMS at our params, flashdeconv_snip): sparse scan 1332 15/20 ->
20/20 (all 5 misses recovered, including 2 that previously formed NO candidate);
dense scan 2301 preserved 17/17 (exact axis = zero shift, unlike a partial avgDelta
fix which gave a bad 0.3-bin shift and regressed to 13/17). Full suite 4041 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…our guesses

Faithfulness audit (vs topp/FLASHDeconv.cpp, the tool that made the ground-truth
_ms1.feature/_ms1.msalign) found four params where we'd diverged:

- decon max_charge: 60 -> 100. FLASHDeconv TOPP default is 100 (FLASHDeconv.cpp:180);
  the production _ms1.msalign contains charges up to 100. At 60 we silently dropped
  every species above z60.
- feature-tracer min_isotope_cosine: 0.75 -> 0.85. We used the MassFeatureTrace CLASS
  default (0.75); the TOPP tool sets it to -1 and resolves it to the MS1
  min_isotope_cosine = 0.85 (FLASHDeconv.cpp:198, 573-575).
- feature-tracer mass_error_ppm: 20 -> 10. We used the MassTraceDetection class default
  (20); the TOPP tool sets it to -1 and resolves it to the MS1 tol = 10
  (FLASHDeconv.cpp:209, 563-565).
- feature-tracer min_sample_rate: 0.1 -> 0.05. The TOPP tool overrides the
  MassFeatureTrace class default 0.1 to 0.05 (FLASHDeconv.cpp:210).

min_charge=1, tol=10, min_mass=50, max_mass=100000, decon min_isotope_cosine=0.85,
trace_termination_outliers=5 already matched the TOPP-resolved values.

Goal is a faithful FLASHDeconv, not an imitation: match the params the real tool runs
with. Updated the stale default-charge-range test (60 -> 100). Full suite green except
that one assertion (now fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native feature tracer used DefaultMaxOutlierScans=5 (the MassTraceDetection
class default), with a comment wrongly claiming the TOPP tool leaves it there.
In fact FLASHDeconv.cpp:570 hard-sets trace_termination_outliers to 20 at
runtime (lines 204-205 only remove it from the user-facing FeatureTracing param
tree; the later setValue(...,20) is operative).

At 5, a mass trace terminates after 5 consecutive non-empty scans with no
in-window peak, so long features whose mass deconvolves elsewhere across a
sparse elution tail collapse below the 10 s min_trace_length and are dropped.
Worked example 22550.5 (RT 2529-2575 s): present at the apex (scans 1790-1798)
and again ~36 s / 18 MS1 scans later (scan 1839); at 5 the trace dies at 9 s and
is dropped, at 20 it bridges the gap and survives (45 s).

Verified faithful, not guessed: our per-spectrum already matches FLASHDeconv's
unfiltered _ms1.tsv mass-for-mass on the affected scans (the SNR/500-cap msalign
is the wrong per-scan reference); this param was the only divergence. Whole-file
z1-100 vs the real FLASHDeconv _ms1.feature: features 233 -> 374 (FD 366),
recall 44% -> 68%, unique-mass precision 95.5%. Full suite green (4041 pass).

Also adds null-gated tracer trace-dump diagnostics and [Explicit] whole-file /
per-scan-window harnesses used to localize this; ComparePerSpectrum now runs at
the faithful z1-100 defaults (the production msalign is z1-100).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ful)

Ms1FeatureFile.FromMassFeatures emitted one row per contiguous charge run
(ToMs1Features), so a gapped charge set became several rows. That was
unfaithful: the real FLASHDeconv _ms1.feature writes exactly one row per mass
feature with an aggregated Min/Max charge range (verified -- every multi-row
mass in the ground-truth file differs in retention time, never only in charge),
and it inflated intensity by writing the full feature.SummedIntensity on every
split row (a 3-run feature reported 3x its real intensity).

Now emits one row per feature via ToMs1Feature. Re-expanding Min..Max on reload
may surface an unobserved intermediate charge, but that matches FLASHDeconv and
the charge set is metadata, not used for mass/RT pairing. Updated the gapped-set
test to pin the single-row, non-inflated-intensity contract. ToMs1Features kept
as a public API. Shared by the consensus tracer (smith-chem-wisc#1069) -- both now emit one row
per feature.

Co-Authored-By: Claude Opus 4.8 (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.

1 participant