Skip to content

Commit dc94f52

Browse files
authored
Merge branch 'master' into mostAbundantMass
2 parents 673b4fe + e5ab254 commit dc94f52

23 files changed

Lines changed: 2823 additions & 9 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace MassSpectrometry.Deconvolution.Consensus
2+
{
3+
/// <summary>
4+
/// One envelope inside a <see cref="CorrectedTrace"/>, carrying both its
5+
/// original (algorithm-reported) mass and the post-correction mass.
6+
/// <see cref="WasCorrected"/> is true iff <see cref="TraceCorrector"/>
7+
/// flagged this envelope as an off-by-one outlier and snapped its
8+
/// mass to the trace consensus.
9+
/// </summary>
10+
public sealed class CorrectedEnvelope
11+
{
12+
public int ScanIndex;
13+
public int ScanNumber;
14+
public double RT;
15+
public double OriginalMass;
16+
public double CorrectedMass;
17+
public int Charge;
18+
public double Intensity;
19+
public bool WasCorrected;
20+
}
21+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace MassSpectrometry.Deconvolution.Consensus
5+
{
6+
/// <summary>
7+
/// A <see cref="MassTrace"/> after off-by-one correction by
8+
/// <see cref="TraceCorrector"/>. <see cref="ConsensusMass"/> is the
9+
/// per-trace (weighted) median; envelopes whose original mass differed
10+
/// by approximately +/-1.00335 Da from it have been snapped to the
11+
/// consensus and have <see cref="CorrectedEnvelope.WasCorrected"/> set.
12+
/// Original masses are preserved on each envelope for diagnostic
13+
/// inspection.
14+
/// </summary>
15+
public sealed class CorrectedTrace
16+
{
17+
public int Id;
18+
public int Charge;
19+
public double ConsensusMass;
20+
public List<CorrectedEnvelope> Envelopes = new();
21+
public double OriginalSpread;
22+
public double CorrectedSpread;
23+
public int CorrectionCount => Envelopes.Count(e => e.WasCorrected);
24+
25+
public double FirstRT => Envelopes[0].RT;
26+
public double LastRT => Envelopes[^1].RT;
27+
public double TotalIntensity => Envelopes.Sum(e => e.Intensity);
28+
}
29+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace MassSpectrometry.Deconvolution.Consensus
5+
{
6+
/// <summary>
7+
/// CONSENSUS PIPELINE OVERVIEW (the type hierarchy, narrowest to widest).
8+
///
9+
/// The consensus mass-tracing pipeline turns raw per-scan deconvolution
10+
/// output into cross-charge features through four nested levels. Each level
11+
/// groups the one below it:
12+
///
13+
/// IsotopicEnvelope one deconvolved species (mass, intensity, charge)
14+
/// | reported in a SINGLE MS1 scan. Raw input; not a
15+
/// | type in this namespace.
16+
/// | grouped by <see cref="MassTraceBuilder"/> (charge-locked,
17+
/// | anchor-mass + scan-adjacency)
18+
/// v
19+
/// <see cref="MassTrace"/> the same species followed across adjacent
20+
/// | scans at ONE charge state. Its envelopes are the
21+
/// | raw per-scan tuples above.
22+
/// | corrected by <see cref="TraceCorrector"/> (off-by-one rescue +
23+
/// | resolution-aware splitting)
24+
/// v
25+
/// <see cref="CorrectedTrace"/> a MassTrace after correction, holding
26+
/// | <see cref="CorrectedEnvelope"/> entries (original
27+
/// | + corrected mass per envelope) and a per-trace
28+
/// | <see cref="CorrectedTrace.ConsensusMass"/>. Still
29+
/// | one charge state.
30+
/// | stitched by <see cref="MassFeatureBuilder"/> (cross-charge,
31+
/// | ppm mass agreement + RT overlap)
32+
/// v
33+
/// MassFeature THIS type: one species across ALL of its charge
34+
/// states. The widest grouping, and the unit the
35+
/// writer turns into an Ms1Feature row.
36+
///
37+
/// So: envelopes nest inside traces, traces (after correction) nest inside
38+
/// features. Charge is fixed within a trace and varies across a feature.
39+
///
40+
/// A cross-charge-state consensus feature: a group of
41+
/// <see cref="CorrectedTrace"/> entries (each at one charge state) whose
42+
/// consensus masses agree within a ppm tolerance and whose RT windows
43+
/// overlap.
44+
///
45+
/// A real proteoform or peptide produces envelopes at several charge
46+
/// states (BU peptides commonly +2/+3; TD proteoforms across +10..+15
47+
/// or wider). The trace builder is charge-locked, so each charge gets
48+
/// its own trace. <see cref="MassFeatureBuilder"/> stitches those
49+
/// per-charge traces back together. Charge multiplicity then becomes
50+
/// a confidence signal: a feature seen at multiple charges is
51+
/// corroborated by independent charge calculations; a single-charge,
52+
/// single-envelope feature is more likely to be noise.
53+
///
54+
/// Mutation policy: callers append to <see cref="Traces"/> during
55+
/// construction, then call <see cref="Finalise"/> exactly once to
56+
/// derive the aggregate fields. After Finalise, the feature is
57+
/// treated as read-only by downstream consumers.
58+
/// </summary>
59+
public sealed class MassFeature
60+
{
61+
public int Id;
62+
public List<CorrectedTrace> Traces = new();
63+
public double ConsensusMass;
64+
public HashSet<int> Charges = new();
65+
public int ChargeCount => Charges.Count;
66+
public int MaxTraceLength;
67+
public double RTStart;
68+
public double RTEnd;
69+
public double SummedIntensity;
70+
71+
/// <summary>
72+
/// Populate derived fields from the current <see cref="Traces"/>
73+
/// list. Idempotent; safe to re-run after the trace list changes.
74+
/// <see cref="ConsensusMass"/> is the intensity-weighted mean of
75+
/// per-trace consensus masses (heavier traces contribute more).
76+
/// </summary>
77+
public void Finalise()
78+
{
79+
if (Traces.Count == 0)
80+
throw new System.InvalidOperationException("Cannot Finalise a MassFeature with no traces.");
81+
82+
Charges = new HashSet<int>(Traces.Select(t => t.Charge));
83+
SummedIntensity = Traces.Sum(t => t.TotalIntensity);
84+
RTStart = Traces.Min(t => t.FirstRT);
85+
RTEnd = Traces.Max(t => t.LastRT);
86+
MaxTraceLength = Traces.Max(t => t.Envelopes.Count);
87+
88+
// Intensity-weighted mean of per-trace consensus masses. Heavier
89+
// traces (more intense, more confident) contribute more.
90+
double w = Traces.Sum(t => t.TotalIntensity);
91+
ConsensusMass = w == 0
92+
? Traces.Average(t => t.ConsensusMass)
93+
: Traces.Sum(t => t.ConsensusMass * t.TotalIntensity) / w;
94+
}
95+
}
96+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace MassSpectrometry.Deconvolution.Consensus
5+
{
6+
/// <summary>
7+
/// Stitches per-charge <see cref="CorrectedTrace"/> entries back into
8+
/// cross-charge <see cref="MassFeature"/> objects.
9+
///
10+
/// Algorithm:
11+
/// 1. Sort the corrected traces by ConsensusMass.
12+
/// 2. Sweep with a ppm-wide window; for each pair of traces inside
13+
/// the window, if (a) their charges differ and (b) their RT
14+
/// ranges overlap, union-find them.
15+
/// 3. Each connected component becomes a <see cref="MassFeature"/>.
16+
///
17+
/// Same-charge co-mass traces are intentionally NOT merged here: if
18+
/// two traces share a charge and mass, the trace builder would already
19+
/// have merged them when their scan ranges overlapped. If they didn't
20+
/// overlap (co-eluting twins or a Phase-2 split), they remain separate
21+
/// features.
22+
/// </summary>
23+
public static class MassFeatureBuilder
24+
{
25+
public static List<MassFeature> BuildFeatures(
26+
IReadOnlyList<CorrectedTrace> corrected,
27+
double massPpm)
28+
{
29+
// Sort traces by consensus mass to enable a single sliding-window pass.
30+
var sorted = corrected.OrderBy(t => t.ConsensusMass).ToArray();
31+
int n = sorted.Length;
32+
33+
// Union-find over trace indices.
34+
int[] parent = new int[n];
35+
for (int i = 0; i < n; i++) parent[i] = i;
36+
int Find(int x)
37+
{
38+
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
39+
return x;
40+
}
41+
void Union(int x, int y)
42+
{
43+
int rx = Find(x), ry = Find(y);
44+
if (rx != ry) parent[rx] = ry;
45+
}
46+
47+
for (int i = 0; i < n; i++)
48+
{
49+
double anchorMass = sorted[i].ConsensusMass;
50+
double window = anchorMass * massPpm * 1e-6;
51+
for (int j = i + 1; j < n; j++)
52+
{
53+
double dm = sorted[j].ConsensusMass - anchorMass;
54+
if (dm > window) break;
55+
if (sorted[i].Charge == sorted[j].Charge) continue;
56+
if (!RtOverlap(sorted[i], sorted[j])) continue;
57+
Union(i, j);
58+
}
59+
}
60+
61+
// Collect connected components.
62+
var byRoot = new Dictionary<int, List<CorrectedTrace>>();
63+
for (int i = 0; i < n; i++)
64+
{
65+
int r = Find(i);
66+
if (!byRoot.TryGetValue(r, out var bucket))
67+
{
68+
bucket = new List<CorrectedTrace>();
69+
byRoot[r] = bucket;
70+
}
71+
bucket.Add(sorted[i]);
72+
}
73+
74+
// Finalise each component, then assign IDs in a deterministic order. Dictionary
75+
// enumeration is not order-stable, so without this sort the feature IDs -- and the
76+
// row order of the written _ms1.feature file -- would vary run-to-run on identical
77+
// input. Order by (consensus mass, then min charge, then RT start).
78+
var features = new List<MassFeature>(byRoot.Count);
79+
foreach (var (_, traces) in byRoot)
80+
{
81+
var f = new MassFeature { Traces = traces };
82+
f.Finalise();
83+
features.Add(f);
84+
}
85+
features.Sort((a, b) =>
86+
{
87+
int c = a.ConsensusMass.CompareTo(b.ConsensusMass);
88+
if (c != 0) return c;
89+
c = a.Charges.Min().CompareTo(b.Charges.Min());
90+
if (c != 0) return c;
91+
return a.RTStart.CompareTo(b.RTStart);
92+
});
93+
for (int i = 0; i < features.Count; i++)
94+
features[i].Id = i + 1;
95+
return features;
96+
}
97+
98+
private static bool RtOverlap(CorrectedTrace a, CorrectedTrace b)
99+
=> a.FirstRT <= b.LastRT && b.FirstRT <= a.LastRT;
100+
}
101+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace MassSpectrometry.Deconvolution.Consensus
5+
{
6+
/// <summary>
7+
/// A single mass trace: a sequence of per-scan envelopes that the grouper
8+
/// believes belong to the same species. Same charge across all entries,
9+
/// scan-adjacency &lt;= MaxGap, mass within tolerance of <see cref="AnchorMass"/>.
10+
///
11+
/// <see cref="MassTraceBuilder"/> populates these in scan-order. Off-by-one
12+
/// correction is then handled by <see cref="TraceCorrector"/>, which wraps
13+
/// each trace in a <see cref="CorrectedTrace"/> rather than mutating it.
14+
/// Mutation policy on the trace itself: contents are appended during
15+
/// construction and read-only thereafter.
16+
/// </summary>
17+
public sealed class MassTrace
18+
{
19+
public int Id;
20+
public int Charge;
21+
22+
/// <summary>
23+
/// First envelope's mass; never updated after trace creation. Keeping
24+
/// the anchor fixed bounds the trace to AnchorMass +/- tolerance and
25+
/// prevents drift over long traces.
26+
/// </summary>
27+
public double AnchorMass;
28+
29+
public List<(int ScanIndex, int ScanNumber, double RT, double Mass, double Intensity)> Envelopes
30+
= new();
31+
32+
public int LastScanIndex => Envelopes[^1].ScanIndex;
33+
public double MinMass => Envelopes.Min(e => e.Mass);
34+
public double MaxMass => Envelopes.Max(e => e.Mass);
35+
public double MassSpread => MaxMass - MinMass;
36+
}
37+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System.Collections.Generic;
2+
3+
namespace MassSpectrometry.Deconvolution.Consensus
4+
{
5+
/// <summary>
6+
/// Greedy mass-trace builder, charge-locked.
7+
///
8+
/// For each MS1 scan in input order, every envelope is offered to the
9+
/// open traces. An envelope joins a trace if (a) charges match, (b) the
10+
/// trace has been touched within MaxGap+1 scans of the current one, and
11+
/// (c) the envelope's mass is within tolerance of the trace's anchor.
12+
/// Best match (closest mass) wins ties. No drift: the anchor is the
13+
/// first envelope's mass, fixed for the life of the trace.
14+
///
15+
/// Inputs: per-scan envelope lists in scan order, aligned to a parallel
16+
/// list of <see cref="MsDataScan"/>. Output: every trace (open or closed
17+
/// at end-of-input) in a flat list. Length-1 singletons are included --
18+
/// downstream code applies length filters per its own taste.
19+
/// </summary>
20+
public static class MassTraceBuilder
21+
{
22+
public static List<MassTrace> BuildTraces(
23+
IReadOnlyList<MsDataScan> ms1Scans,
24+
IReadOnlyList<IReadOnlyList<IsotopicEnvelope>> perScanEnvelopes,
25+
double toleranceDa,
26+
int maxGap)
27+
{
28+
if (perScanEnvelopes.Count != ms1Scans.Count)
29+
throw new System.ArgumentException(
30+
$"perScanEnvelopes ({perScanEnvelopes.Count}) must align 1:1 with ms1Scans ({ms1Scans.Count}).",
31+
nameof(perScanEnvelopes));
32+
33+
var open = new List<MassTrace>();
34+
var closed = new List<MassTrace>();
35+
int nextId = 1;
36+
37+
for (int scanIdx = 0; scanIdx < ms1Scans.Count; scanIdx++)
38+
{
39+
// Retire open traces whose last-touched scan is too old.
40+
for (int i = open.Count - 1; i >= 0; i--)
41+
{
42+
int gap = scanIdx - open[i].LastScanIndex - 1;
43+
if (gap > maxGap)
44+
{
45+
closed.Add(open[i]);
46+
open.RemoveAt(i);
47+
}
48+
}
49+
50+
foreach (var env in perScanEnvelopes[scanIdx])
51+
{
52+
MassTrace best = null!;
53+
double bestDelta = double.MaxValue;
54+
foreach (var t in open)
55+
{
56+
if (t.Charge != env.Charge) continue;
57+
// Don't add a second envelope from the same scan to the same trace.
58+
if (t.LastScanIndex == scanIdx) continue;
59+
double d = System.Math.Abs(env.MonoisotopicMass - t.AnchorMass);
60+
if (d <= toleranceDa && d < bestDelta)
61+
{
62+
best = t;
63+
bestDelta = d;
64+
}
65+
}
66+
67+
var entry = (scanIdx,
68+
ms1Scans[scanIdx].OneBasedScanNumber,
69+
ms1Scans[scanIdx].RetentionTime,
70+
env.MonoisotopicMass,
71+
env.TotalIntensity);
72+
73+
if (best != null)
74+
{
75+
best.Envelopes.Add(entry);
76+
}
77+
else
78+
{
79+
var nt = new MassTrace
80+
{
81+
Id = nextId++,
82+
Charge = env.Charge,
83+
AnchorMass = env.MonoisotopicMass,
84+
};
85+
nt.Envelopes.Add(entry);
86+
open.Add(nt);
87+
}
88+
}
89+
}
90+
91+
closed.AddRange(open);
92+
return closed;
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)