Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public abstract class AverageResidue : IEquatable<AverageResidue>
public abstract double[] GetAllTheoreticalIntensities(int index);
public abstract double GetDiffToMonoisotopic(int index);

// Terminology for the offsets composed from this model:
// β€’ most-abundant offset = GetDiffToMonoisotopic(GetMostIntenseMassIndex(mono)) β€” the gap from the
// monoisotopic mass to the single tallest (most abundant) isotopologue. This is what the resolved
// most-abundant search uses; it is composed from the abstract methods above, so no dedicated
// method is needed here.
// β€’ average / centroid offset = the gap from the monoisotopic mass to the intensity-weighted mean
// of the whole envelope, for isotopically unresolved (high-mass) species. That helper
// (GetAverageOffset) is introduced separately with the unresolved-envelope work, not here.

#region IEquatable<AverageResidue>

public bool Equals(AverageResidue? other)
Expand Down
41 changes: 39 additions & 2 deletions mzLib/MassSpectrometry/MzSpectra/IsotopicEnvelope.cs
Comment thread
nbollis marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,43 @@ public class IsotopicEnvelope : IHasMass, IEquatable<IsotopicEnvelope>
public double MonoisotopicMass { get; private set; }

/// <summary>
/// Mass of most abundant observed isotopic peak, not accounting for addition or subtraction or protons due to ESI charge state induction
/// Most abundant observed isotopic peak as m/z Γ— |charge|, i.e. <b>not</b> proton-corrected to a
/// neutral mass (it does not subtract the charge-carrier protons added during ESI). This is a
/// charge-scaled m/z, not a neutral mass; for the proton-corrected neutral mass use
/// <see cref="MostAbundantObservedNeutralMass"/>. Retained for existing deconvolution-quality tests.
///
/// <b>Sentinel:</b> this value is only meaningful for an envelope that carries observed isotopic
/// peaks. Constructors that have no observed envelope (e.g. a neutral mass read from a
/// pre-deconvoluted file) set it to <c>-1</c> to mark "no most-abundant peak available", rather
/// than a misleading zero or a synthetic value.
/// </summary>
internal double MostAbundantObservedIsotopicMass { get; private set; }
public double MostAbundantObservedIsotopicMass { get; private set; }

/// <summary>
/// The <b>most-abundant observed neutral mass</b>: the neutral mass of the single most intense
/// (tallest) observed isotopic peak of this charge-state envelope β€” the highest-signal, directly
/// measured point of the envelope. The most intense peak's m/z is converted to a neutral mass via
/// the envelope charge (i.e. proton-corrected, <c>mz.ToMass(Charge)</c>), so it is directly
/// comparable to a theoretical proteoform neutral mass. It is the precursor mass used for candidate
/// selection in most-abundant mode. Computed from <see cref="Peaks"/> and <see cref="Charge"/>
/// (both readonly).
///
/// Terminology (the three masses are distinct):
/// β€’ <b>most-abundant (observed neutral) mass</b> β€” this property; the tallest single isotopic
/// peak, proton-corrected to a neutral mass.
/// β€’ <b>monoisotopic mass</b> (<see cref="MonoisotopicMass"/>) β€” the all-light-isotope mass; for
/// large proteoforms this isotopologue is rare and often undetectable.
/// β€’ <b>average / centroid mass</b> β€” the intensity-weighted mean over the whole envelope, used
/// for isotopically unresolved (high-mass) species. That property is added separately, with the
/// unresolved-envelope work, and is not part of this resolved most-abundant feature.
/// This is the neutral-mass form of <see cref="MostAbundantObservedIsotopicMass"/> (which is the
/// SAME tallest peak expressed as m/z Γ— |charge|, NOT proton-corrected): the two differ by exactly
/// <c>|charge| Γ— ProtonMass</c>. Deriving it from that single source means the two values can never
/// disagree, and it carries the same <c>-1</c> "no observed peak" sentinel.
/// </summary>
public double MostAbundantObservedNeutralMass =>
MostAbundantObservedIsotopicMass < 0 ? -1 : MostAbundantObservedIsotopicMass - Charge * Constants.ProtonMass;

public readonly int Charge;
public readonly double TotalIntensity;
public readonly int PrecursorId;
Expand Down Expand Up @@ -60,6 +94,9 @@ public IsotopicEnvelope(double monoisotopicMass, double intensity, int charge)
TotalIntensity = intensity;
Score = double.MaxValue;
Peaks = [(monoisotopicMass.ToMz(charge), intensity)];
// A neutral mass read from a pre-deconvoluted file has no observed isotopic envelope, so
// there is no most-abundant observed peak to report β€” use the -1 sentinel.
MostAbundantObservedIsotopicMass = -1;
}

/// <summary>
Expand Down
149 changes: 149 additions & 0 deletions mzLib/Test/Deconvolution/TestMostAbundantMass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using System.Linq;
using Chemistry;
using MassSpectrometry;
using NUnit.Framework;

namespace Test
{
/// <summary>
/// Unit tests for the resolved "most abundant mass" precursor-selection support (Strategy B).
///
/// Terminology pinned by these tests:
/// β€’ most-abundant mass = the neutral mass of the single most intense (tallest) isotopic peak,
/// proton-corrected (<see cref="IsotopicEnvelope.MostAbundantObservedNeutralMass"/>);
/// β€’ most-abundant offset = GetDiffToMonoisotopic(GetMostIntenseMassIndex(mono)) on the averagine
/// model β€” the gap from the monoisotopic mass to that tallest isotopologue.
/// The intensity-weighted average (centroid) mass and the isotopically-unresolved path are a
/// separate change and are tested with that work, not here. All tests build synthetic envelopes
/// from the Averagine model β€” no deconvolution is run.
/// </summary>
[TestFixture]
public sealed class TestMostAbundantMass
{
private static readonly AverageResidue Model = new Averagine();

// Most-abundant offset for a monoisotopic mass = the averagine diff-to-monoisotopic of the
// nearest mass bin (the mass-keyed composition consumers use in place of a dedicated method).
private static double MostAbundantOffset(double monoMass) => Model.GetDiffToMonoisotopic(Model.GetMostIntenseMassIndex(monoMass));

/// <summary>
/// Builds a perfect synthetic envelope: peaks at exact theoretical m/z with
/// Averagine-proportional intensities. (Same construction as TestDeconvolutionScorerUnit.)
/// </summary>
private static List<(double mz, double intensity)> BuildPerfectPeaks(double monoMass, int charge, double baseIntens = 1e6)
{
int avgIdx = Model.GetMostIntenseMassIndex(monoMass);
double[] rawMasses = Model.GetAllTheoreticalMasses(avgIdx);
double[] rawIntens = Model.GetAllTheoreticalIntensities(avgIdx);

var sorted = rawMasses.Zip(rawIntens).OrderBy(p => p.First).ToArray();

double isotopeStep = Constants.C13MinusC12 / charge;
double monoMz = monoMass.ToMz(charge);
var peaks = new List<(double mz, double intensity)>();
for (int n = 0; n < sorted.Length; n++)
{
double intensity = baseIntens * sorted[n].Second;
if (intensity < baseIntens * 0.001) continue;
peaks.Add((monoMz + n * isotopeStep, intensity));
}
return peaks;
}

private static IsotopicEnvelope BuildPerfectEnvelope(double monoMass, int charge, double baseIntens = 1e6)
{
var peaks = BuildPerfectPeaks(monoMass, charge, baseIntens);
return new IsotopicEnvelope(0, peaks, monoMass, charge, peaks.Sum(p => p.intensity), 0.999);
}

// ── AverageResidue most-abundant offset ─────────────────────────────────

[Test]
public void MostAbundantOffset_IsNonNegativeAndNonDecreasingWithMass()
{
double[] masses = { 500, 2000, 5000, 10000, 20000, 40000 };
double prev = double.NegativeInfinity;
foreach (double m in masses)
{
double offset = MostAbundantOffset(m);
Assert.That(offset, Is.GreaterThanOrEqualTo(-1e-6)); // ~0 at tiny mass (mono IS most abundant)
Assert.That(offset, Is.GreaterThanOrEqualTo(prev - 1e-6), $"offset decreased at mass {m}");
prev = offset;
}
}

[Test]
public void MostAbundantOffset_IsNearZeroForSmallMass()
{
// A small peptide's monoisotopic peak is (nearly) the most abundant.
Assert.That(MostAbundantOffset(500), Is.LessThan(0.5));
}

[Test]
public void MostAbundantOffset_GrowsRoughlyOneNeutronPer1600Da()
{
// ~1 13C neutron (~1.00235 Da) per ~1.6 kDa. Assert the offset at 16 kDa is in a
// physically plausible band (β‰ˆ 9–11 Da) rather than an exact value.
double offset = MostAbundantOffset(16000);
Assert.That(offset, Is.GreaterThan(8.0).And.LessThan(12.0));
}

// ── IsotopicEnvelope most-abundant observed mass ────────────────────────

[Test]
public void MostAbundantObservedNeutralMass_IsProtonCorrectedNeutralMass()
{
const int charge = 10;
var env = BuildPerfectEnvelope(15000, charge);

// The proton-corrected neutral mass equals the most intense peak's m/z .ToMass(charge)...
double mostIntenseMz = env.Peaks.MaxBy(p => p.intensity).mz;
Assert.That(env.MostAbundantObservedNeutralMass, Is.EqualTo(mostIntenseMz.ToMass(charge)).Within(1e-6));

// ...and it differs from the un-proton-corrected mz*|z| field by exactly z proton masses.
Assert.That(env.MostAbundantObservedIsotopicMass - env.MostAbundantObservedNeutralMass,
Is.EqualTo(charge * Constants.ProtonMass).Within(1e-6));
}

[Test]
public void MostAbundantObservedNeutralMass_MatchesMonoPlusAveragineOffset()
{
// Ties the two features together: the observed most-abundant neutral mass of a perfect
// envelope β‰ˆ candidate monoisotopic + averagine most-abundant offset.
const double mono = 12000;
const int charge = 12;
var env = BuildPerfectEnvelope(mono, charge);

double predicted = mono + MostAbundantOffset(mono);
Assert.That(env.MostAbundantObservedNeutralMass, Is.EqualTo(predicted).Within(0.15));
}

[Test]
public void DeconvolutionConstructor_ComputesMostAbundantObservedNeutralMass()
{
// The 5-arg mzLib-deconvolution constructor must compute the most-abundant observed mass.
const double mono = 12000;
const int charge = 12;
var peaks = BuildPerfectPeaks(mono, charge);
var env = new IsotopicEnvelope(peaks, mono, charge, peaks.Sum(p => p.intensity), 0.5);

double mostIntenseMz = peaks.MaxBy(p => p.intensity).mz;
Assert.That(env.MostAbundantObservedNeutralMass, Is.EqualTo(mostIntenseMz.ToMass(charge)).Within(1e-6));
}

[Test]
public void FileReadEnvelope_HasNoMostAbundantPeak_ReturnsSentinel()
{
// The file-read constructor carries a neutral mass but no observed isotopic envelope, so the
// most-abundant observed mass is undefined: both the m/zΓ—|charge| form and the proton-corrected
// form report the -1 sentinel rather than a synthetic value.
const double mono = 8000;
const int charge = 8;
var env = new IsotopicEnvelope(mono, 1e6, charge);

Assert.That(env.MostAbundantObservedIsotopicMass, Is.EqualTo(-1));
Assert.That(env.MostAbundantObservedNeutralMass, Is.EqualTo(-1));
}
}
}
Loading