diff --git a/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs b/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs index 3f9788bb8..7c07dc323 100644 --- a/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs +++ b/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs @@ -75,6 +75,48 @@ public float ReverseDotProduct { [Key(13)] public float AcurateMassSimilarity { get; set; } + /// The value the scoring functions return when there was nothing to compare. + private const float NotCompared = -1f; + + /// + /// True when the isotope-ratio comparison was attempted, so holds + /// a measurement. + /// + /// + /// MsScanMatching.GetIsotopeRatioSimilarity returns -1 when there is nothing to compare: either + /// side carries no isotopic peaks, or a monoisotopic abundance is not positive. Unlike the dot + /// products there is no clamping getter, so that -1 reaches every consumer unchanged. + /// + /// The test is equality with the sentinel rather than a sign test, because this score is 1 minus + /// an accumulated ratio difference and is genuinely signed: a negative value ordinarily means the + /// isotope patterns disagree, which is a measurement and a strong one. Only exactly -1 is the + /// sentinel, and a computed value of exactly -1 is indistinguishable from an unattempted + /// comparison. That ambiguity is resolved in favour of "not computed", because publishing a + /// sentinel as a measurement is the worse of the two errors, and it is narrow: reaching it needs + /// the accumulated difference to land on exactly 2. + /// + [IgnoreMember] + public bool IsIsotopeComparisonPerformed => IsotopeSimilarity != NotCompared; + + /// + /// True when holds a measurement from one of the Gaussian + /// similarity terms: retention time, retention index, collision cross-section or accurate mass. + /// + /// + /// A different rule applies to these than to , because a + /// different function produces them. MsScanMatching.GetGaussianSimilarity returns + /// exp(-0.5 * ((actual - reference) / tolerance)^2), which is strictly positive for any finite + /// argument until it underflows past roughly 38 tolerance widths, and returns -1 when either + /// value is missing or not positive. + /// + /// So a measured term is positive, a term with nothing to compare is -1, and a term the run never + /// enabled is left at the field's default 0. Exactly 0 is therefore either that unset default or a + /// difference so large the score underflowed, and neither is a measurement worth publishing. This + /// is the same test GetTotalScore already applies before adding a term to the total, so the export + /// convention and the scoring convention agree. + /// + public static bool IsGaussianTermMeasured(float similarity) => similarity > 0f; + // Link to database [Key(14)] public int LibraryID { get; set; } = -1; diff --git a/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs b/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs index 61831ce41..a023c3435 100644 --- a/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs +++ b/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs @@ -155,15 +155,18 @@ private void WriteCandidate( BooleanText(candidate.IsSpectrumMatch), BooleanText(spectrumScored), Format(candidate.TotalScore), - // The five similarity terms below have no equivalent of the -1 sentinel that marks an - // unattempted spectral comparison, so an unused term is stored as 0 and cannot be told apart - // from a term that was evaluated and scored 0. They are written as stored; a consumer that - // needs the distinction has to read which terms the run's parameter file enabled. - Format(candidate.AcurateMassSimilarity), - Format(candidate.RtSimilarity), - Format(candidate.RiSimilarity), - Format(candidate.CcsSimilarity), - Format(candidate.IsotopeSimilarity), + // These five do carry sentinels, and two different ones, because two different functions + // produce them. The four Gaussian terms are positive when measured, -1 when there was nothing + // to compare, and left at the field's default 0 when the run never enabled the term, so only + // a positive value is a measurement. The isotope term is 1 minus an accumulated ratio + // difference and is genuinely signed, so a negative value there IS a measurement -- it says + // the patterns disagree -- and only exactly -1 is the sentinel. Both rules live on + // MsScanMatchResult so the scoring side and the export side cannot drift apart. + GaussianTerm(candidate.AcurateMassSimilarity), + GaussianTerm(candidate.RtSimilarity), + GaussianTerm(candidate.RiSimilarity), + GaussianTerm(candidate.CcsSimilarity), + candidate.IsIsotopeComparisonPerformed ? Format(candidate.IsotopeSimilarity) : NotApplicable, Score(spectrumScored, candidate.SimpleDotProduct), Score(spectrumScored, candidate.WeightedDotProduct), Score(spectrumScored, candidate.ReverseDotProduct), @@ -190,6 +193,9 @@ private static void WriteRow(StreamWriter writer, string[] values) private static string Score(bool computed, float value) => computed ? Format(value) : NotApplicable; + private static string GaussianTerm(float similarity) + => MsScanMatchResult.IsGaussianTermMeasured(similarity) ? Format(similarity) : NotApplicable; + /// /// The stored score fields are Single, and G9 round-trips a Single exactly. The widening to double is /// deliberate and matches : .NET Framework formats a Single through diff --git a/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs b/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs index 53c0ed035..373ae06d5 100644 --- a/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs +++ b/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs @@ -40,11 +40,14 @@ public void EveryCandidateIsExportedOnceInRankOrder() Assert.AreEqual(3, lines.Length); Assert.AreEqual( "12\t10\t-1\t1\t2\ttrue\tmsp\t\tMspDB\t1\t7\tQuercetin\tC15H10O7\tflavonoid\tQUERCETIN-KEY\tc1cc(O)ccc1\t" + - "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5", + // rt, ri and ccs are empty: this run never enabled those terms, so the 0 sitting in the field + // is the unset default rather than a measurement. The isotope 0 is written, because only + // exactly -1 is that term's sentinel and 0 there is a real ratio agreement. + "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5", lines[1]); Assert.AreEqual( "12\t10\t-1\t2\t2\tfalse\tmsp\t\tMspDB\t1\t8\tMorin\tC15H10O7\tflavonoid\tMORIN-KEY\tc1cc(O)ccc1\t" + - "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5", + "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5", lines[2]); } @@ -98,6 +101,57 @@ public void AnUncomparedSpectrumPublishesNoSpectralScore() Assert.AreEqual("0.5", fields[25], "the aggregate total score is still a measurement"); } + /// + /// The isotope term has its own sentinel and its own rule. -1 means the comparison was never + /// attempted; every other negative value is a measurement saying the patterns disagree. + /// + [TestMethod] + public void AnUnattemptedIsotopeComparisonPublishesNoIsotopeSimilarity() + { + // GetIsotopeRatioSimilarity returns -1 when either side carries no isotopic peaks. Unlike the + // dot products there is no clamping getter, so the -1 reaches the export unchanged. On one real + // reference library 495 of 4972 candidate rows carried it, all of them in-house records with no + // isotopic pattern deposited. + var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f); + candidate.IsotopeSimilarity = -1f; + + var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t'); + + Assert.AreEqual("", fields[30], "isotope_similarity"); + } + + [TestMethod] + public void AnIsotopePatternThatDisagreesIsAMeasurement() + { + // 1 minus an accumulated ratio difference, so this is genuinely signed. Discarding it would + // throw away the strongest negative evidence the term produces. + var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f); + candidate.IsotopeSimilarity = -0.75f; + + var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t'); + + Assert.AreEqual("-0.75", fields[30], "isotope_similarity"); + } + + /// + /// The four Gaussian terms follow the opposite rule, because a different function produces them. + /// + [TestMethod] + public void AGaussianTermPublishesNothingUnlessItWasMeasured() + { + var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f); + candidate.AcurateMassSimilarity = 0.5f; // measured + candidate.RtSimilarity = 0f; // the run never enabled retention-time scoring + candidate.RiSimilarity = -1f; // attempted, nothing to compare + candidate.CcsSimilarity = 0.25f; // measured + + var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t'); + + CollectionAssert.AreEqual( + new[] { "0.5", "", "", "0.25" }, + new[] { fields[26], fields[27], fields[28], fields[29] }); + } + [TestMethod] public void AnUnannotatedSpotContributesNoRow() {