diff --git a/src/Common/CommonStandard/Algorithm/Function/MoleculerNetworking.cs b/src/Common/CommonStandard/Algorithm/Function/MoleculerNetworking.cs index 67d45503e..5b5a48d6d 100644 --- a/src/Common/CommonStandard/Algorithm/Function/MoleculerNetworking.cs +++ b/src/Common/CommonStandard/Algorithm/Function/MoleculerNetworking.cs @@ -3,6 +3,7 @@ using CompMs.Common.Enum; using CompMs.Common.Extension; using CompMs.Common.Interfaces; +using CompMs.Common.Utility; using System; using System.Collections.Generic; using System.IO; @@ -83,7 +84,7 @@ private static Node GetSimpleNode(PeakScanPair peakScanPair, double minVal } private static string GetOntologyColor(T spot) where T : IMoleculeProperty, IChromatogramPeak { - var isCharacterized = !spot.Name.IsEmptyOrNull() && !spot.Name.Contains("Unknown") && !spot.Name.Contains("w/o MS2") && !spot.Name.Contains("RIKEN"); + var isCharacterized = AnnotationName.IsReferenceMatched(spot.Name); if (isCharacterized && MetaboliteColorCode.metabolite_colorcode.TryGetValue(spot.Ontology, out var backgroundcolor)) { return backgroundcolor; } diff --git a/src/Common/CommonStandard/Utility/AnnotationName.cs b/src/Common/CommonStandard/Utility/AnnotationName.cs new file mode 100644 index 000000000..1d25bf024 --- /dev/null +++ b/src/Common/CommonStandard/Utility/AnnotationName.cs @@ -0,0 +1,114 @@ +using System; + +namespace CompMs.Common.Utility +{ + /// + /// Owns the prefixes MS-DIAL writes into a feature name when an annotation was + /// not accepted as a reference match, and the predicate that recognizes them. + /// + /// + /// The suggestion prefixes are written by + /// DataAccess.SetMoleculeMsPropertyAsSuggested and + /// DataAccess.SetPeptideMsPropertyAsSuggested in MsdialCore. Both are + /// reached only from the annotation branches where + /// MsScanMatchResult.IsReferenceMatched is false, so every prefixed name is + /// a suggestion rather than an accepted identification. + /// + /// Writers and the predicate live together here so a renamed prefix cannot leave a + /// reader stale. MS-DIAL 4 wrote a single "w/o MS2:" prefix for its precursor-only + /// suggestions; MS-DIAL 5 splits that bucket into and + /// , and a reader that knows only the MS-DIAL 4 + /// spelling silently treats both MS-DIAL 5 shapes as characterized. + /// + /// This type lives in CommonStandard because the vocabulary is shared by + /// CommonStandard (molecular networking) and MsdialCore (export), and the project + /// dependency runs MsdialCore -> CommonStandard only. + /// + public static class AnnotationName + { + /// Separator between a prefix and the reference name. + public const string PrefixSeparator = ": "; + + /// + /// MS-DIAL 5 prefix for a precursor-only suggestion: the feature has no + /// product-ion spectrum at all (MS2RawSpectrumID < 0). + /// + public const string NoMs2Prefix = "no MS2"; + + /// + /// MS-DIAL 5 prefix for a suggestion that does have a product-ion spectrum but + /// did not meet the reference-search acceptance criteria. + /// + public const string LowScorePrefix = "low score"; + + /// + /// Prefix written by the MS-DIAL 5 peptide suggestion path, and by every + /// MS-DIAL 4 precursor-only suggestion. + /// + public const string WithoutMs2Prefix = "w/o MS2"; + + /// Prefix of an unannotated feature. + public const string UnknownPrefix = "Unknown"; + + /// Placeholder written when a reference field is absent. + public const string NullPrefix = "null"; + + /// Placeholder written when a reference field is present but empty. + public const string EmptyPrefix = "empty"; + + /// Prefix of an in-house RIKEN placeholder record. + public const string RikenPrefix = "RIKEN"; + + /// + /// Matched instead of so that the MS-DIAL 4 + /// "w/o MS2:" spelling, which has no space before the colon, is also covered. + /// + private const string WITHOUT_MS2_MATCH_PREFIX = "w/o"; + + private static readonly string[] NOT_REFERENCE_MATCHED_PREFIXES = { + UnknownPrefix, + NullPrefix, + EmptyPrefix, + NoMs2Prefix, + LowScorePrefix, + WITHOUT_MS2_MATCH_PREFIX, + RikenPrefix, + }; + + /// Builds the name of a precursor-only suggestion. + public static string AsNoMs2(string referenceName) { + return NoMs2Prefix + PrefixSeparator + referenceName; + } + + /// Builds the name of a suggestion that failed the search criteria. + public static string AsLowScore(string referenceName) { + return LowScorePrefix + PrefixSeparator + referenceName; + } + + /// Builds the name of a peptide suggestion without product-ion evidence. + public static string AsWithoutMs2(string referenceName) { + return WithoutMs2Prefix + PrefixSeparator + referenceName; + } + + /// + /// Returns true when reads as an accepted reference + /// match rather than an unannotated feature or a suggestion. + /// + /// + /// This is a predicate on the exported name only. Where the authoritative + /// MsScanMatchResult is available, evaluate that instead. + /// + public static bool IsReferenceMatched(string name) { + if (string.IsNullOrWhiteSpace(name)) { + return false; + } + var value = name.TrimStart(); + foreach (var prefix in NOT_REFERENCE_MATCHED_PREFIXES) { + if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + return false; + } + } + return true; + } + } +} diff --git a/src/MSDIAL5/MsdialCore/Utility/DataAccess.cs b/src/MSDIAL5/MsdialCore/Utility/DataAccess.cs index bf8cccd0d..a3af51809 100644 --- a/src/MSDIAL5/MsdialCore/Utility/DataAccess.cs +++ b/src/MSDIAL5/MsdialCore/Utility/DataAccess.cs @@ -1197,7 +1197,7 @@ public static void SetPeptideMsPropertyAsSuggested(ChromatogramPeakFeature featu var type = AdductIon.GetAdductIon(adductString); feature.SetAdductType(type); - feature.Name = "w/o MS2: " + result.Name; + feature.Name = AnnotationName.AsWithoutMs2(result.Name); } public static void SetMoleculeMsProperty(ChromatogramPeakFeature feature, MoleculeMsReference reference, MsScanMatchResult result, bool isTextDB = false) { @@ -1218,10 +1218,10 @@ public static void SetMoleculeMsPropertyAsSuggested(ChromatogramPeakFeature feat SetMoleculePropertyCore(feature, reference); feature.SetAdductType(reference.AdductType); if (feature.MS2RawSpectrumID < 0) { - feature.Name = "no MS2: " + result.Name; + feature.Name = AnnotationName.AsNoMs2(result.Name); } else { - feature.Name = "low score: " + result.Name; + feature.Name = AnnotationName.AsLowScore(result.Name); } } @@ -1428,15 +1428,7 @@ public static double GetSpotValue(AlignmentChromPeakFeature spotProperty, string } public static bool IsReferenceMatchedName(string name) { - if (string.IsNullOrWhiteSpace(name)) { - return false; - } - var value = name.TrimStart(); - return !value.StartsWith("Unknown", StringComparison.OrdinalIgnoreCase) - && !value.StartsWith("null", StringComparison.OrdinalIgnoreCase) - && !value.StartsWith("empty", StringComparison.OrdinalIgnoreCase) - && !value.StartsWith("w/o", StringComparison.OrdinalIgnoreCase) - && !value.StartsWith("RIKEN", StringComparison.OrdinalIgnoreCase); + return AnnotationName.IsReferenceMatched(name); } public static List GetChromPeakFeatureObjectsIntegratingRtAndDriftData(List features) { diff --git a/tests/Common/CommonStandardTests/Algorithm/Function/MoleculerNetworkingTests.cs b/tests/Common/CommonStandardTests/Algorithm/Function/MoleculerNetworkingTests.cs new file mode 100644 index 000000000..ff9fce04c --- /dev/null +++ b/tests/Common/CommonStandardTests/Algorithm/Function/MoleculerNetworkingTests.cs @@ -0,0 +1,78 @@ +using CompMs.Common.Components; +using CompMs.Common.DataObj.NodeEdge; +using CompMs.Common.DataObj.Property; +using CompMs.Common.Enum; +using CompMs.Common.Interfaces; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; + +namespace CompMs.Common.Algorithm.Function.Tests +{ + [TestClass] + public class MoleculerNetworkingTests + { + private const string ONTOLOGY = "SM"; + private static readonly string ONTOLOGY_COLOR = MetaboliteColorCode.metabolite_colorcode[ONTOLOGY]; + private const string UNCHARACTERIZED_COLOR = "rgb(0,0,0)"; + + private sealed class TestSpot : IMoleculeProperty, IChromatogramPeak + { + public int ID { get; set; } + public ChromXs ChromXs { get; set; } = new ChromXs(1d); + public double Mass { get; set; } = 700d; + public double Intensity { get; set; } + public string Name { get; set; } + public Formula Formula { get; set; } + public string Ontology { get; set; } + public string SMILES { get; set; } + public string InChIKey { get; set; } + } + + /// + /// Every node carries an ontology MetaboliteColorCode knows, so the background + /// colour is decided by the name shape alone. + /// + private static Dictionary GetNodeColorsByName(params string[] names) { + var spots = names + .Select((name, i) => new TestSpot { ID = i, Name = name, Ontology = ONTOLOGY, Intensity = 100d * (i + 1), }) + .ToList(); + var scans = spots + .Select(spot => (IMSScanProperty)new MSScanProperty(spot.ID, spot.Mass, new RetentionTime(1d), IonMode.Positive)) + .ToList(); + + var instance = new MoleculerNetworkingBase() + .GetMolecularNetworkInstance(spots, scans, new MolecularNetworkingQuery(), report: null); + + return instance.Root.nodes.ToDictionary(node => node.data.Name, node => node.data.backgroundcolor); + } + + /// + /// Regression test for the ontology colour of the four MS-DIAL 5 name shapes. + /// The predicate used to test only the MS-DIAL 4 "w/o MS2" spelling, so + /// "no MS2: " and "low score: " suggestions were coloured as if they were + /// accepted annotations. + /// + [TestMethod] + public void OntologyColorIsGivenOnlyToAcceptedAnnotations() { + var colors = GetNodeColorsByName( + "SM 18:1;O2/16:0", + "no MS2: SM 18:1;O2/16:0", + "low score: SM 18:1;O2/16:0", + "Unknown"); + + Assert.AreEqual(ONTOLOGY_COLOR, colors["SM 18:1;O2/16:0"], "an accepted reference match keeps its ontology colour"); + Assert.AreEqual(UNCHARACTERIZED_COLOR, colors["no MS2: SM 18:1;O2/16:0"], "a precursor-only suggestion has no product-ion evidence"); + Assert.AreEqual(UNCHARACTERIZED_COLOR, colors["low score: SM 18:1;O2/16:0"], "a low-score suggestion failed the search criteria"); + Assert.AreEqual(UNCHARACTERIZED_COLOR, colors["Unknown"], "an unannotated feature has no ontology"); + } + + [TestMethod] + public void OntologyColorIsWithheldFromPeptideSuggestions() { + var colors = GetNodeColorsByName("SM 18:1;O2/16:0", "w/o MS2: SM 18:1;O2/16:0"); + + Assert.AreEqual(ONTOLOGY_COLOR, colors["SM 18:1;O2/16:0"]); + Assert.AreEqual(UNCHARACTERIZED_COLOR, colors["w/o MS2: SM 18:1;O2/16:0"]); + } + } +} diff --git a/tests/Common/CommonStandardTests/Utility/AnnotationNameTests.cs b/tests/Common/CommonStandardTests/Utility/AnnotationNameTests.cs new file mode 100644 index 000000000..82a1ac01f --- /dev/null +++ b/tests/Common/CommonStandardTests/Utility/AnnotationNameTests.cs @@ -0,0 +1,75 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CompMs.Common.Utility.Tests +{ + [TestClass] + public class AnnotationNameTests + { + [TestMethod] + public void IsReferenceMatchedAcceptsPlainName() { + Assert.IsTrue(AnnotationName.IsReferenceMatched("PC 34:1")); + Assert.IsTrue(AnnotationName.IsReferenceMatched("FA 16:0")); + Assert.IsTrue(AnnotationName.IsReferenceMatched("Reference compound")); + } + + [TestMethod] + public void IsReferenceMatchedRejectsNoMs2Suggestion() { + // MS-DIAL 5 precursor-only suggestion: no product-ion spectrum at all. + Assert.IsFalse(AnnotationName.IsReferenceMatched("no MS2: PC 34:1")); + } + + [TestMethod] + public void IsReferenceMatchedRejectsLowScoreSuggestion() { + // MS-DIAL 5 suggestion that has a product-ion spectrum but failed the + // reference-search acceptance criteria. + Assert.IsFalse(AnnotationName.IsReferenceMatched("low score: PC 34:1")); + } + + [TestMethod] + public void IsReferenceMatchedRejectsUnknown() { + Assert.IsFalse(AnnotationName.IsReferenceMatched("Unknown")); + Assert.IsFalse(AnnotationName.IsReferenceMatched("unknown feature")); + } + + [TestMethod] + public void IsReferenceMatchedRejectsWithoutMs2Suggestion() { + // Still written by the MS-DIAL 5 peptide suggestion path, and by MS-DIAL 4. + Assert.IsFalse(AnnotationName.IsReferenceMatched("w/o MS2: PC 34:1")); + Assert.IsFalse(AnnotationName.IsReferenceMatched("w/o MS2:PC 34:1")); + } + + [TestMethod] + public void IsReferenceMatchedRejectsPlaceholders() { + Assert.IsFalse(AnnotationName.IsReferenceMatched(null)); + Assert.IsFalse(AnnotationName.IsReferenceMatched("")); + Assert.IsFalse(AnnotationName.IsReferenceMatched(" ")); + Assert.IsFalse(AnnotationName.IsReferenceMatched("null")); + Assert.IsFalse(AnnotationName.IsReferenceMatched("empty")); + Assert.IsFalse(AnnotationName.IsReferenceMatched("RIKEN MS/MS")); + } + + [TestMethod] + public void IsReferenceMatchedIgnoresLeadingWhitespace() { + Assert.IsFalse(AnnotationName.IsReferenceMatched(" no MS2: PC 34:1")); + Assert.IsFalse(AnnotationName.IsReferenceMatched(" low score: PC 34:1")); + } + + [TestMethod] + public void WritersProduceTheHistoricalSpellings() { + Assert.AreEqual("no MS2: PC 34:1", AnnotationName.AsNoMs2("PC 34:1")); + Assert.AreEqual("low score: PC 34:1", AnnotationName.AsLowScore("PC 34:1")); + Assert.AreEqual("w/o MS2: PC 34:1", AnnotationName.AsWithoutMs2("PC 34:1")); + } + + /// + /// The invariant that the stale "w/o MS2"-only predicate violated: every name a + /// suggestion writer produces must be rejected by the reader. + /// + [TestMethod] + public void EverySuggestionWriterProducesANonMatchedName() { + Assert.IsFalse(AnnotationName.IsReferenceMatched(AnnotationName.AsNoMs2("PC 34:1"))); + Assert.IsFalse(AnnotationName.IsReferenceMatched(AnnotationName.AsLowScore("PC 34:1"))); + Assert.IsFalse(AnnotationName.IsReferenceMatched(AnnotationName.AsWithoutMs2("PC 34:1"))); + } + } +} diff --git a/tests/MSDIAL5/MsdialCoreTests/Utility/DataAccessTests.cs b/tests/MSDIAL5/MsdialCoreTests/Utility/DataAccessTests.cs index f4c023aca..881490d7f 100644 --- a/tests/MSDIAL5/MsdialCoreTests/Utility/DataAccessTests.cs +++ b/tests/MSDIAL5/MsdialCoreTests/Utility/DataAccessTests.cs @@ -120,7 +120,9 @@ public void GetAverageSpectrumTest() { [TestMethod()] public void ReferenceMatchedExportUsesAnnotationName() { - foreach (var name in new[] { "", " ", "Unknown", "unknown feature", "null", "empty", "w/o MS2: compound", "RIKEN MS/MS" }) { + // "no MS2: " and "low score: " are the two shapes SetMoleculeMsPropertyAsSuggested + // writes in MS-DIAL 5; "w/o MS2: " is still written by the peptide path. + foreach (var name in new[] { "", " ", "Unknown", "unknown feature", "null", "empty", "no MS2: compound", "low score: compound", "w/o MS2: compound", "RIKEN MS/MS" }) { var peak = new AlignmentChromPeakFeature { Name = name }; Assert.AreEqual("FALSE", DataAccess.GetSpotValueAsString(peak, "Reference matched"), name); Assert.AreEqual(0d, DataAccess.GetSpotValue(peak, "Reference matched"), name);