From 655cd9bfc426d6c8f945ae66205dbe63421dbf5e Mon Sep 17 00:00:00 2001 From: Yuki Matsuzawa Date: Sun, 18 Oct 2026 16:57:43 +0900 Subject: [PATCH] Load a deconvolution result for every LC-IM-MS drift spot in the console The lcimms command crashed with ArgumentOutOfRangeException in AlignmentCSVExporter.Export once alignment finished. The exporter (and MztabFormatExporter) index the deconvolution results by MasterAlignmentID for the drift spots as well as the RT spots, but the console loaded one result per RT spot only, so the first drift spot overflowed the list. Read each RT spot followed by its drift spots, the order LcimmsAlignmentRefiner numbers them in and the GUI's AlignmentFileBeanModel.LoadMSDecResultsFromEachFiles reads them in, through RepresentativeDeconvolutionReader as LcmsProcess does. Co-Authored-By: Claude Opus 5.5 --- .../Process/LcimmsProcess.cs | 37 ++++----- .../Process/LcimmsProcessTests.cs | 82 +++++++++++++++++++ 2 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 tests/MSDIAL5/MsdialCoreTestAppTests/Process/LcimmsProcessTests.cs diff --git a/tests/MSDIAL5/MsdialCoreTestApp/Process/LcimmsProcess.cs b/tests/MSDIAL5/MsdialCoreTestApp/Process/LcimmsProcess.cs index 7ceb7b504..130a06145 100644 --- a/tests/MSDIAL5/MsdialCoreTestApp/Process/LcimmsProcess.cs +++ b/tests/MSDIAL5/MsdialCoreTestApp/Process/LcimmsProcess.cs @@ -137,7 +137,7 @@ private async Task ExecuteAsync(IMsdialDataStorage s aligner.ProviderFactory = providerFactory; var result = aligner.Alignment(files, alignmentFile, serializer); result.Save(alignmentFile); - var align_decResults = LoadRepresentativeDeconvolutions(storage, result.AlignmentSpotProperties).ToList(); + var align_decResults = LoadRepresentativeDeconvolutions(files, result.AlignmentSpotProperties).ToList(); MsdecResultsWriter.Write(alignmentFile.SpectraFilePath, align_decResults); var align_accessor = new LcimmsMetadataAccessor(storage.DataBaseMapper, storage.Parameter, false); @@ -184,29 +184,20 @@ private async Task ExecuteAsync(IMsdialDataStorage s return 0; } - private static IEnumerable LoadRepresentativeDeconvolutions(IMsdialDataStorage storage, IReadOnlyList spots) { - var files = storage.AnalysisFiles; - - var pointerss = new List<(int version, List pointers, bool isAnnotationInfo)>(); - foreach (var file in files) { - MsdecResultsReader.GetSeekPointers(file.DeconvolutionFilePath, out var version, out var pointers, out var isAnnotationInfo); - pointerss.Add((version, pointers, isAnnotationInfo)); - } - - var streams = new List(); - try { - streams = files.Select(file => File.OpenRead(file.DeconvolutionFilePath)).ToList(); - foreach (var spot in spots.OrEmptyIfNull()) { - var repID = spot.RepresentativeFileID; - var peakID = spot.AlignedPeakProperties[repID].MasterPeakID; - var decResult = MsdecResultsReader.ReadMSDecResult( - streams[repID], pointerss[repID].pointers[peakID], - pointerss[repID].version, pointerss[repID].isAnnotationInfo); - yield return decResult; + /// + /// One deconvolution result per alignment spot, indexed by MasterAlignmentID: each RT spot followed by its + /// drift spots, the order LcimmsAlignmentRefiner numbers them in and the GUI's + /// AlignmentFileBeanModel.LoadMSDecResultsFromEachFiles reads them in. AlignmentCSVExporter and + /// MztabFormatExporter index this list by the drift spots' MasterAlignmentID too, so a list of the RT + /// spots alone overflowed on the first drift spot. + /// + internal static IEnumerable LoadRepresentativeDeconvolutions(IReadOnlyList files, IReadOnlyList? spots) { + using var reader = new RepresentativeDeconvolutionReader(files); + foreach (var spot in spots.OrEmptyIfNull()) { + yield return reader.Read(spot.AlignedPeakProperties[spot.RepresentativeFileID]); + foreach (var driftSpot in spot.AlignmentDriftSpotFeatures.OrEmptyIfNull()) { + yield return reader.Read(driftSpot.AlignedPeakProperties[driftSpot.RepresentativeFileID]); } } - finally { - streams.ForEach(stream => stream.Close()); - } } } diff --git a/tests/MSDIAL5/MsdialCoreTestAppTests/Process/LcimmsProcessTests.cs b/tests/MSDIAL5/MsdialCoreTestAppTests/Process/LcimmsProcessTests.cs new file mode 100644 index 000000000..5056bd828 --- /dev/null +++ b/tests/MSDIAL5/MsdialCoreTestAppTests/Process/LcimmsProcessTests.cs @@ -0,0 +1,82 @@ +using CompMs.App.MsdialConsole.Process; +using CompMs.MsdialCore.DataObj; +using CompMs.MsdialCore.MSDec; +using CompMs.MsdialCore.Parser; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MsdialCoreTestAppTests.Process; + +/// +/// The LC-IM-MS alignment export indexes the deconvolution results by MasterAlignmentID for the drift spots as +/// well as the RT spots. The Console loaded one result per RT spot only, so AlignmentCSVExporter.Export threw +/// ArgumentOutOfRangeException on the first drift spot. +/// +[TestClass] +public sealed class LcimmsProcessTests +{ + private string _scratch = null!; + + [TestInitialize] + public void Initialize() { + _scratch = Path.Combine(Path.GetTempPath(), $"msdial-lcimms-dcl-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_scratch); + } + + [TestCleanup] + public void Cleanup() { + if (Directory.Exists(_scratch)) { + Directory.Delete(_scratch, recursive: true); + } + } + + [TestMethod] + public void EveryRtAndDriftSpotHasItsResultAtItsMasterAlignmentId() { + var file0 = NewFile(0, "file0", peakCount: 6); + var file1 = NewFile(1, "file1", peakCount: 6); + // Numbered as LcimmsAlignmentRefiner numbers them: each RT spot, then its drift spots. + var spots = new List + { + Spot(0, representative: 0, masterPeakId: 0, Spot(1, representative: 1, masterPeakId: 1), Spot(2, representative: 0, masterPeakId: 2)), + Spot(3, representative: 1, masterPeakId: 3), + Spot(4, representative: 1, masterPeakId: 4, Spot(5, representative: 0, masterPeakId: 5)), + }; + + var results = LcimmsProcess.LoadRepresentativeDeconvolutions([file0, file1], spots).ToList(); + + Assert.AreEqual(6, results.Count); + foreach (var spot in spots.SelectMany(s => s.AlignmentDriftSpotFeatures.Prepend(s))) { + var peak = spot.AlignedPeakProperties[spot.RepresentativeFileID]; + Assert.AreEqual(Marker(peak.FileID, peak.MasterPeakID), results[spot.MasterAlignmentID].PrecursorMz, $"spot {spot.MasterAlignmentID}"); + } + } + + private AnalysisFileBean NewFile(int id, string name, int peakCount) { + var file = new AnalysisFileBean { + AnalysisFileId = id, + AnalysisFileName = name, + AnalysisFilePath = Path.Combine(_scratch, name + ".d"), + DeconvolutionFilePath = Path.Combine(_scratch, name + "_202610181.dcl"), + }; + var results = Enumerable.Range(0, peakCount).Select(i => new MSDecResult { ScanID = i, PrecursorMz = Marker(id, i), }).ToList(); + MsdecResultsWriter.Write(file.DeconvolutionFilePath, results); + return file; + } + + private static AlignmentSpotProperty Spot(int masterAlignmentId, int representative, int masterPeakId, params AlignmentSpotProperty[] driftSpots) { + return new AlignmentSpotProperty { + MasterAlignmentID = masterAlignmentId, + RepresentativeFileID = representative, + AlignedPeakProperties = Enumerable.Range(0, 2) + .Select(fileId => new AlignmentChromPeakFeature { FileID = fileId, MasterPeakID = fileId == representative ? masterPeakId : -1, }) + .ToList(), + AlignmentDriftSpotFeatures = driftSpots.ToList(), + }; + } + + // Which file and which peak a result came from. + private static double Marker(int fileId, int peak) => fileId * 1000d + peak; +}