diff --git a/tests/MSDIAL5/MsdialCoreTestApp/Parser/ConfigParser.cs b/tests/MSDIAL5/MsdialCoreTestApp/Parser/ConfigParser.cs index 0ba9829be..e2467701e 100644 --- a/tests/MSDIAL5/MsdialCoreTestApp/Parser/ConfigParser.cs +++ b/tests/MSDIAL5/MsdialCoreTestApp/Parser/ConfigParser.cs @@ -28,7 +28,7 @@ private ConfigParser() { } public static MsdialGcmsParameter ReadForGcms(string filepath) { var param = new MsdialGcmsParameter(); - using (var sr = new StreamReader(filepath, Encoding.ASCII)) + using (var sr = new StreamReader(filepath, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { while (sr.Peek() > -1) { @@ -44,6 +44,7 @@ public static MsdialGcmsParameter ReadForGcms(string filepath) param.MassSliceWidth = 0.5F; param.CentroidMs1Tolerance = 0.5F; } + ResolveGcmsFilePaths(param, filepath); return param; } @@ -416,38 +417,83 @@ private static void readFieldValues(string? line, out string method, out string method = string.Empty; value = string.Empty; isReadable = false; if (string.IsNullOrEmpty(line)) return; if (line!.Length < 2) return; - if (line[0] == '#') return; - - var lineArray = line.Split(':'); - if (lineArray.Length < 2) return; - method = lineArray[0].Trim(); - value = line.Substring(line.Split(':')[0].Length + 1).Trim(); + if (line.TrimStart().StartsWith("#", StringComparison.Ordinal)) return; + + var colonIndex = line.IndexOf(':'); + var equalsIndex = line.IndexOf('='); + var separatorIndex = colonIndex < 0 + ? equalsIndex + : equalsIndex < 0 + ? colonIndex + : Math.Min(colonIndex, equalsIndex); + if (separatorIndex < 0) return; + + method = line.Substring(0, separatorIndex).Trim(); + value = line.Substring(separatorIndex + 1).Trim(); + if (value.Length >= 2 + && ((value[0] == '"' && value[value.Length - 1] == '"') + || (value[0] == '\'' && value[value.Length - 1] == '\''))) { + value = value.Substring(1, value.Length - 2).Trim(); + } isReadable = true; } + private static void ResolveGcmsFilePaths(MsdialGcmsParameter param, string methodFilePath) { + param.MspFilePath = ResolvePathFromMethodFile(param.MspFilePath, methodFilePath); + param.LbmFilePath = ResolvePathFromMethodFile(param.LbmFilePath, methodFilePath); + param.TextDBFilePath = ResolvePathFromMethodFile(param.TextDBFilePath, methodFilePath); + param.IsotopeTextDBFilePath = ResolvePathFromMethodFile(param.IsotopeTextDBFilePath, methodFilePath); + param.CompoundListInTargetModePath = ResolvePathFromMethodFile(param.CompoundListInTargetModePath, methodFilePath); + param.CompoundListForRtCorrectionPath = ResolvePathFromMethodFile(param.CompoundListForRtCorrectionPath, methodFilePath); + param.ReferenceFileParam.RtCorrectionPeakSelectionFilePath = ResolvePathFromMethodFile(param.ReferenceFileParam.RtCorrectionPeakSelectionFilePath, methodFilePath); + param.RiDictionaryFilePath = ResolvePathFromMethodFile(param.RiDictionaryFilePath, methodFilePath); + } + + private static string ResolvePathFromMethodFile(string? path, string methodFilePath) { + if (path.IsEmptyOrNull()) { + return string.Empty; + } + + var expanded = Environment.ExpandEnvironmentVariables(path!.Trim()); + if (Path.IsPathRooted(expanded)) { + return Path.GetFullPath(expanded); + } + + var methodDirectory = Path.GetDirectoryName(Path.GetFullPath(methodFilePath)) ?? Environment.CurrentDirectory; + return Path.GetFullPath(Path.Combine(methodDirectory, expanded)); + } + public static bool ReadGcmsSpecificParameter(MsdialGcmsParameter param, string method, string value) { if (value.IsEmptyOrNull()) return false; if (method.IsEmptyOrNull()) return false; method = method.ToLower(); - value = value.ToLower(); + var valueLower = value.ToLower(); switch (method) { - case "ri index file pathes": param.RiDictionaryFilePath = value; return true; + case "ri index file pathes": + case "ri index file paths": + case "ri dictionary file path": + case "ri dictionary file paths": + param.RiDictionaryFilePath = value; + return true; case "retention type": - if (value == "rt" || value == "ri") - param.RetentionType = (RetentionType)Enum.Parse(typeof(RetentionType), value, true); + if (valueLower == "rt" || valueLower == "ri") + param.RetentionType = (RetentionType)Enum.Parse(typeof(RetentionType), valueLower, true); return true; case "ri compound": case "ri compound type": - if (value == "fames" || value == "alkanes") - param.RiCompoundType = (RiCompoundType)Enum.Parse(typeof(RiCompoundType), value, true); + if (valueLower == "fames" || valueLower == "alkanes") + param.RiCompoundType = (RiCompoundType)Enum.Parse(typeof(RiCompoundType), valueLower, true); + return true; + case "alignment index type": if (valueLower == "ri") param.AlignmentIndexType = AlignmentIndexType.RI; else param.AlignmentIndexType = AlignmentIndexType.RT; return true; + case "retention index tolerance for alignment": + case "retention index alignment tolerance": + if (float.TryParse(valueLower, out float ritol_align)) param.RetentionIndexAlignmentTolerance = ritol_align; return true; - case "alignment index type": if (value == "ri") param.AlignmentIndexType = AlignmentIndexType.RI; else param.AlignmentIndexType = AlignmentIndexType.RT; return true; - case "retention index tolerance for alignment": if (float.TryParse(value, out float ritol_align)) param.RetentionIndexAlignmentTolerance = ritol_align; return true; case "replace quant mass by user defined value": - if (value == "true") + if (valueLower == "true") param.IsReplaceQuantmassByUserDefinedValue = true; return true; case "is quant mass based on base peak mz": - if (value == "true") + if (valueLower == "true") param.IsRepresentativeQuantMassBasedOnBasePeakMz = true; return true; default: return false; } @@ -682,7 +728,11 @@ public static bool ReadCommonParameter(ParameterBase param, string method, strin //Identification case "rt tolerance for msp-based annotation": if (float.TryParse(valueLower, out float rttol_ident)) param.MspSearchParam.RtTolerance = rttol_ident; return true; - case "ri tolerance for msp-based annotation": if (float.TryParse(valueLower, out float ritol_ident)) param.MspSearchParam.RiTolerance = ritol_ident; return true; + case "ri tolerance for msp-based annotation": + case "ri tolerance for identification": + case "retention index tolerance for identification": + if (float.TryParse(valueLower, out float ritol_ident)) param.MspSearchParam.RiTolerance = ritol_ident; + return true; case "ccs tolerance for msp-based annotation": if (float.TryParse(valueLower, out float ccstol_ident)) param.MspSearchParam.CcsTolerance = ccstol_ident; return true; case "mass range begin for msp-based annotation": if (float.TryParse(valueLower, out float msbegin_ident)) param.MspSearchParam.MassRangeBegin = msbegin_ident; return true; case "mass range end for msp-based annotation": if (float.TryParse(valueLower, out float msend_ident)) param.MspSearchParam.MassRangeEnd = msend_ident; return true; @@ -691,11 +741,26 @@ public static bool ReadCommonParameter(ParameterBase param, string method, strin case "weighted dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float sqdotproduct)) param.MspSearchParam.SquaredWeightedDotProductCutOff = sqdotproduct; return true; case "simple dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float sqsimpleproduct)) param.MspSearchParam.SquaredSimpleDotProductCutOff = sqsimpleproduct; return true; case "reverse dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float sqrevdotproduct)) param.MspSearchParam.SquaredReverseDotProductCutOff = sqrevdotproduct; return true; - case "square root of weighted dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float dotproduct)) param.MspSearchParam.WeightedDotProductCutOff = dotproduct; return true; - case "square root of simple dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float simpleproduct)) param.MspSearchParam.SimpleDotProductCutOff = simpleproduct; return true; - case "square root of reverse dot product cutoff for msp-based annotation": if (float.TryParse(valueLower, out float revdotproduct)) param.MspSearchParam.ReverseDotProductCutOff = revdotproduct; return true; - case "matched peaks percentage cutoff for msp-based annotation": if (float.TryParse(valueLower, out float matchedpeakspercent)) param.MspSearchParam.MatchedPeaksPercentageCutOff = matchedpeakspercent; return true; - case "minimum spectrum match for msp-based annotation": if (float.TryParse(valueLower, out float minpeakmatch)) param.MspSearchParam.MinimumSpectrumMatch = minpeakmatch; return true; + case "weighted dot product cutoff": + case "square root of weighted dot product cutoff for msp-based annotation": + if (float.TryParse(valueLower, out float dotproduct)) param.MspSearchParam.WeightedDotProductCutOff = dotproduct; + return true; + case "simple dot product cutoff": + case "square root of simple dot product cutoff for msp-based annotation": + if (float.TryParse(valueLower, out float simpleproduct)) param.MspSearchParam.SimpleDotProductCutOff = simpleproduct; + return true; + case "reverse dot product cutoff": + case "square root of reverse dot product cutoff for msp-based annotation": + if (float.TryParse(valueLower, out float revdotproduct)) param.MspSearchParam.ReverseDotProductCutOff = revdotproduct; + return true; + case "matched peaks percentage cutoff": + case "matched peaks percentage cutoff for msp-based annotation": + if (float.TryParse(valueLower, out float matchedpeakspercent)) param.MspSearchParam.MatchedPeaksPercentageCutOff = matchedpeakspercent; + return true; + case "minimum spectrum match": + case "minimum spectrum match for msp-based annotation": + if (float.TryParse(valueLower, out float minpeakmatch)) param.MspSearchParam.MinimumSpectrumMatch = minpeakmatch; + return true; case "total score cutoff for msp-based annotation": if (float.TryParse(valueLower, out float cutoff_ident)) param.MspSearchParam.TotalScoreCutoff = cutoff_ident; return true; case "ms1 tolerance for msp-based annotation": if (float.TryParse(valueLower, out float ms1tol_ident)) param.MspSearchParam.Ms1Tolerance = ms1tol_ident; return true; case "ms2 tolerance for msp-based annotation": if (float.TryParse(valueLower, out float ms2tol_ident)) param.MspSearchParam.Ms2Tolerance = ms2tol_ident; return true; @@ -704,7 +769,10 @@ public static bool ReadCommonParameter(ParameterBase param, string method, strin case "use ccs for msp-based annotation scoring": if (valueLower == "true" || valueLower == "false") param.MspSearchParam.IsUseCcsForAnnotationScoring = bool.Parse(valueLower); return true; case "use ccs for msp-based annotation filtering": if (valueLower == "true" || valueLower == "false") param.MspSearchParam.IsUseCcsForAnnotationFiltering = bool.Parse(valueLower); return true; case "only report top hit for msp-based annotation": if (valueLower == "true" || valueLower == "false") param.OnlyReportTopHitInMspSearch = bool.Parse(valueLower); return true; - case "execute annotation process only for alignment file for msp-based annotation": if (valueLower == "true" || valueLower == "false") param.IsIdentificationOnlyPerformedForAlignmentFile = bool.Parse(valueLower); return true; + case "execute annotation process only for alignment file": + case "execute annotation process only for alignment file for msp-based annotation": + if (valueLower == "true" || valueLower == "false") param.IsIdentificationOnlyPerformedForAlignmentFile = bool.Parse(valueLower); + return true; //Identification case "rt tolerance for lbm-based annotation": if (float.TryParse(valueLower, out float rttol_lbm_ident)) param.LbmSearchParam.RtTolerance = rttol_lbm_ident; return true; diff --git a/tests/MSDIAL5/MsdialCoreTestApp/Process/GcmsProcess.cs b/tests/MSDIAL5/MsdialCoreTestApp/Process/GcmsProcess.cs index 367f6812f..168c7b00c 100644 --- a/tests/MSDIAL5/MsdialCoreTestApp/Process/GcmsProcess.cs +++ b/tests/MSDIAL5/MsdialCoreTestApp/Process/GcmsProcess.cs @@ -41,6 +41,13 @@ public int Run(string inputFolder, string outputFolder, string methodFile, bool return -1; } + if (!param.MspFilePath.IsEmptyOrNull() && !File.Exists(param.MspFilePath)) { + throw new FileNotFoundException( + $"The GC-MS MSP reference library was not found. Parsed path: '{param.MspFilePath}'. " + + "Check the 'Msp file path' entry in the method file.", + param.MspFilePath); + } + if (param.RiDictionaryFilePath != string.Empty) { if (!File.Exists(param.RiDictionaryFilePath)) { @@ -48,7 +55,7 @@ public int Run(string inputFolder, string outputFolder, string methodFile, bool } if (!CheckRiDicionaryFiles(analysisFiles, param.RiDictionaryFilePath, out var errorMessage)) { - throw new FileNotFoundException(string.Format(errorMessage, param.RiDictionaryFilePath)); + throw new FileNotFoundException(errorMessage, param.RiDictionaryFilePath); } //probably, at least in fiehn lab, this has to be automatically set from GCMS raw data. @@ -79,11 +86,27 @@ public int Run(string inputFolder, string outputFolder, string methodFile, bool } } + Console.WriteLine( + $"GC-MS retention matching: {param.RetentionType}; RI compound type: {param.RiCompoundType}; " + + $"RI dictionaries: {param.FileIdRiInfoDictionary?.Count ?? 0}"); + CommonProcess.ParseLibraries(param, -1, out IupacDatabase iupacDB, out var mspDB, out var txtDB, out List isotopeTextDB, out List compoundsInTargetMode, out var lbmDB); + if (!param.MspFilePath.IsEmptyOrNull() && (mspDB is null || mspDB.Database.Count == 0)) { + throw new InvalidDataException( + $"The GC-MS MSP reference library contained no readable records: '{param.MspFilePath}'. " + + "Check that the file is a valid MSP library."); + } + if (mspDB is { Database.Count: > 0 }) { + Console.WriteLine($"GC-MS MSP reference library: {param.MspFilePath} ({mspDB.Database.Count} records)"); + } + else { + Console.WriteLine("GC-MS reference matching is disabled because 'Msp file path' is empty."); + } + var container = new MsdialGcmsDataStorage() { @@ -145,18 +168,19 @@ private bool IsFamesContanesMatch(Dictionary? riDictionary) private bool CheckRiDicionaryFiles(List analysisFiles, string riDictionaryFile, out string errorMessage) { errorMessage = string.Empty; - using (var sr = new StreamReader(riDictionaryFile, Encoding.ASCII)) { + var mappingDirectory = Path.GetDirectoryName(Path.GetFullPath(riDictionaryFile)) ?? Environment.CurrentDirectory; + using (var sr = new StreamReader(riDictionaryFile, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { while (sr.Peek() > -1) { var line = sr.ReadLine(); if (string.IsNullOrEmpty(line)) continue; var lineArray = line.Split('\t'); if (lineArray.Length < 2) continue; - var analysisFilePath = lineArray[0]; - var riFilePath = lineArray[1]; + var analysisFilePath = ResolveMappingPath(lineArray[0], mappingDirectory); + var riFilePath = ResolveMappingPath(lineArray[1], mappingDirectory); foreach (var file in analysisFiles) { - if (file.AnalysisFilePath == analysisFilePath) { + if (IsSamePath(file.AnalysisFilePath, analysisFilePath)) { file.RiDictionaryFilePath = riFilePath; break; } @@ -179,6 +203,22 @@ private bool CheckRiDicionaryFiles(List analysisFiles, string } } + private static string ResolveMappingPath(string path, string mappingDirectory) + { + var normalized = path.Trim().Trim('"', '\''); + return Path.GetFullPath(Path.IsPathRooted(normalized) + ? normalized + : Path.Combine(mappingDirectory, normalized)); + } + + private static bool IsSamePath(string first, string second) + { + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(Path.GetFullPath(first), Path.GetFullPath(second), comparison); + } + private async Task ExecuteAsync(MsdialGcmsDataStorage storage, string outputFolder, bool isProjectSaved) { var projectDataStorage = new ProjectDataStorage(new ProjectParameter(DateTime.Now, storage.MsdialGcmsParameter.ProjectParam.ProjectFolderPath, Path.ChangeExtension(storage.MsdialGcmsParameter.ProjectParam.ProjectFileName, ".mdproject"))); projectDataStorage.AddStorage(storage); diff --git a/tests/MSDIAL5/MsdialCoreTestAppTests/Parser/ConfigParserTests.cs b/tests/MSDIAL5/MsdialCoreTestAppTests/Parser/ConfigParserTests.cs new file mode 100644 index 000000000..dde704e3c --- /dev/null +++ b/tests/MSDIAL5/MsdialCoreTestAppTests/Parser/ConfigParserTests.cs @@ -0,0 +1,98 @@ +using CompMs.App.MsdialConsole.Parser; +using CompMs.Common.Enum; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; + +namespace MsdialCoreTestAppTests.Parser; + +[TestClass] +public sealed class ConfigParserTests +{ + [TestMethod] + public void ReadForGcms_AcceptsEqualsSyntaxQuotesAndGuiFieldNames() + { + using var directory = new TemporaryDirectory(); + var methodFile = directory.CreateFile( + "method.txt", + """ + Msp file path = "references\Fiehn Library.msp" + RI dictionary file path: "ri\FAME Mapping.txt" + RI compound type = Fames + Retention type: RI + Alignment index type = RI + Retention index alignment tolerance: 1234.5 + Retention index tolerance for identification = 567.5 + Weighted dot product cutoff: 0.55 + Simple dot product cutoff = 0.56 + Reverse dot product cutoff: 0.57 + Matched peaks percentage cutoff = 0.58 + Minimum spectrum match: 4 + """); + + var parameter = ConfigParser.ReadForGcms(methodFile); + + Assert.AreEqual( + Path.GetFullPath(Path.Combine(directory.Path, "references", "Fiehn Library.msp")), + parameter.MspFilePath); + Assert.AreEqual( + Path.GetFullPath(Path.Combine(directory.Path, "ri", "FAME Mapping.txt")), + parameter.RiDictionaryFilePath); + Assert.AreEqual(RiCompoundType.Fames, parameter.RiCompoundType); + Assert.AreEqual(RetentionType.RI, parameter.RetentionType); + Assert.AreEqual(AlignmentIndexType.RI, parameter.AlignmentIndexType); + Assert.AreEqual(1234.5f, parameter.RetentionIndexAlignmentTolerance); + Assert.AreEqual(567.5f, parameter.MspSearchParam.RiTolerance); + Assert.AreEqual(0.55f, parameter.MspSearchParam.WeightedDotProductCutOff); + Assert.AreEqual(0.56f, parameter.MspSearchParam.SimpleDotProductCutOff); + Assert.AreEqual(0.57f, parameter.MspSearchParam.ReverseDotProductCutOff); + Assert.AreEqual(0.58f, parameter.MspSearchParam.MatchedPeaksPercentageCutOff); + Assert.AreEqual(4f, parameter.MspSearchParam.MinimumSpectrumMatch); + } + + [TestMethod] + public void ReadForGcms_AcceptsLegacyRiPathAndAnnotationFieldNames() + { + using var directory = new TemporaryDirectory(); + var methodFile = directory.CreateFile( + "legacy-method.txt", + """ + RI index file pathes: dictionaries.txt + RI tolerance for MSP-based annotation: 2000 + """); + + var parameter = ConfigParser.ReadForGcms(methodFile); + + Assert.AreEqual( + Path.GetFullPath(Path.Combine(directory.Path, "dictionaries.txt")), + parameter.RiDictionaryFilePath); + Assert.AreEqual(2000f, parameter.MspSearchParam.RiTolerance); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "MsdialCoreTestAppTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string CreateFile(string name, string content) + { + var path = System.IO.Path.Combine(Path, name); + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + return path; + } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } +}