Skip to content

Commit 00bc5cf

Browse files
trishortsclaudenbollis
authored
Upgrade Thermo CommonCore RawFileReader to 8.0.37 (net8) (#1091)
* chore(readers): upgrade Thermo CommonCore RawFileReader to 8.0.37 (net8) Replace the netstandard2.0 5.0.0.7 Thermo CommonCore binaries with the native .NET 8 8.0.37 build - Thermo's current, recommended target. Brings newer instrument/scan-filter support, memory-mapped raw I/O, and aligns the reader with mzLib's net8.0 target. The CommonCore API surface mzLib uses is unchanged (verified against Thermo's own net8 example and the test suite). Pin OpenMcdf back to the 2.x line (2.3.1): the RawFileReader binary is compiled against OpenMcdf 2.3.0 and 3.x is an API-incompatible rewrite that breaks .raw reading. GHSA-5qwm-7pvp-w988 (fixed in 3.1.4) is a DoS on crafted/untrusted CFB files and does not apply - we only read trusted local instrument files. Overlaps #1089. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(readers): guard Thermo CommonCore 8.x upgrade with compat tests Add TestRawFileReaderVersionCompat: a deployed-version canary plus invariant sweeps that exercise every CommonCore API surface mzLib's ThermoRawFileReader depends on - filter enums (MS order/polarity/mass analyzer), centroid and PreferredMasses spectrum extraction, trailer-extra parsing, scan-event reactions, and static/dynamic read parity - so any behavioral regression from this or a future reader bump fails loudly instead of silently corrupting data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Nic Bollis <nbollis@comcast.net>
1 parent de91a40 commit 00bc5cf

6 files changed

Lines changed: 202 additions & 0 deletions

mzLib/Readers/Readers.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="CsvHelper" Version="32.0.3" />
13+
<!-- OpenMcdf is intentionally pinned to the 2.x line. DO NOT upgrade to 3.x.
14+
The Thermo CommonCore RawFileReader binary is compiled against OpenMcdf 2.x
15+
(v8.0.37 targets OpenMcdf 2.3.0); OpenMcdf 3.x is an API-incompatible rewrite
16+
that breaks Thermo .raw reading. Thermo's latest release still ships against
17+
OpenMcdf 2.x, so there is no 3.x-based option today. GHSA-5qwm-7pvp-w988
18+
(fixed in 3.1.4) is a DoS on crafted/untrusted CFB files and does not apply -
19+
we only read trusted local instrument files. -->
1320
<PackageReference Include="OpenMcdf" Version="2.3.1" />
1421
<PackageReference Include="OpenMcdf.Extensions" Version="2.3.1" />
1522
<PackageReference Include="System.Data.SQLite" Version="1.0.118" />
Binary file not shown.
98 KB
Binary file not shown.
Binary file not shown.
111 KB
Binary file not shown.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using MassSpectrometry;
6+
using NUnit.Framework;
7+
using Readers;
8+
using Assert = NUnit.Framework.Legacy.ClassicAssert;
9+
10+
namespace Test.FileReadingTests.SpectraFileReading
11+
{
12+
/// <summary>
13+
/// Guards the Thermo CommonCore RawFileReader dependency upgrade (5.0.0.7 -> 8.0.37, net8).
14+
///
15+
/// These tests deliberately exercise every ThermoFisher.CommonCore API surface that mzLib's
16+
/// <see cref="Readers.ThermoRawFileReader"/> relies on, and assert version-independent invariants,
17+
/// so that a behavioral regression introduced by this (or any future) CommonCore bump fails loudly
18+
/// here instead of silently corrupting parsed spectra. API surfaces covered:
19+
/// RawFileReaderAdapter.FileFactory, RawFileReaderFactory.CreateThreadManager/CreateThreadAccessor,
20+
/// RunHeaderEx, GetFilterForScanNumber (+ MSOrder/Polarity/MassAnalyzer enums),
21+
/// GetScanStatsForScanNumber, GetCentroidStream, Scan.FromFile (PreferredMasses fallback),
22+
/// GetTrailerExtraInformation, GetScanEventForScanNumber + GetReaction, RetentionTimeFromScanNumber.
23+
/// </summary>
24+
[TestFixture]
25+
public sealed class TestRawFileReaderVersionCompat
26+
{
27+
private static string DataPath(string fileName) =>
28+
Path.Combine(TestContext.CurrentContext.TestDirectory, "DataFiles", fileName);
29+
30+
// Representative Thermo .raw files already committed to the Test project, spanning
31+
// MS1-only, MS1/MS2, FAIMS (compensation voltage), EThcD, and scan-description data.
32+
private static readonly string[] RawFiles =
33+
{
34+
"small.raw",
35+
"testFileWMS2.raw",
36+
"05-13-16_cali_MS_60K-res_MS.raw",
37+
"sliced_ethcd.raw",
38+
"ScanDescriptionTestData.raw",
39+
"TestCompensationVoltageReading.raw",
40+
};
41+
42+
/// <summary>
43+
/// Canary: the CommonCore assemblies actually deployed to the test output must be the
44+
/// upgraded 8.x build. If the DLL swap did not propagate (stale bin, bad HintPath/copy),
45+
/// this fails immediately rather than letting the rest of the suite validate the old reader.
46+
/// Read via the deployed file version so the Test project needs no direct Thermo reference.
47+
/// </summary>
48+
[Test]
49+
[TestCase("ThermoFisher.CommonCore.RawFileReader.dll")]
50+
[TestCase("ThermoFisher.CommonCore.Data.dll")]
51+
public void ThermoCommonCoreAssembliesAreUpgradedToMajor8(string dllName)
52+
{
53+
string dllPath = Directory
54+
.GetFiles(TestContext.CurrentContext.TestDirectory, dllName, SearchOption.AllDirectories)
55+
.FirstOrDefault();
56+
57+
Assert.IsNotNull(dllPath, $"{dllName} was not deployed to the test output directory.");
58+
59+
var fileVersion = System.Diagnostics.FileVersionInfo.GetVersionInfo(dllPath);
60+
Assert.GreaterOrEqual(fileVersion.FileMajorPart, 8,
61+
$"{dllName} deployed version is {fileVersion.FileVersion}, expected >= 8.0. " +
62+
"The upgraded Thermo DLL did not reach the output directory.");
63+
}
64+
65+
/// <summary>
66+
/// Static load path: every scan must parse into a coherent MsDataScan. Exercises the
67+
/// thread-manager accessor, filter-enum mappings (MS order / polarity / mass analyzer),
68+
/// centroid/PreferredMasses spectrum extraction, and MS2 precursor resolution.
69+
/// </summary>
70+
[Test]
71+
[TestCaseSource(nameof(RawFiles))]
72+
public void StaticLoad_ProducesCoherentScans(string fileName)
73+
{
74+
var reader = MsDataFileReader.GetDataFile(DataPath(fileName));
75+
reader.LoadAllStaticData(null, maxThreads: 1);
76+
List<MsDataScan> scans = reader.GetAllScansList();
77+
78+
Assert.IsTrue(scans.Count > 0, $"{fileName}: no scans loaded");
79+
80+
foreach (MsDataScan scan in scans)
81+
{
82+
// MS order comes straight from the scan filter enum; out-of-range means a broken mapping.
83+
Assert.IsTrue(scan.MsnOrder >= 1 && scan.MsnOrder <= 10,
84+
$"{fileName} scan {scan.OneBasedScanNumber}: implausible MS order {scan.MsnOrder}");
85+
86+
// Filter string is the backbone of nearly all downstream parsing.
87+
Assert.IsFalse(string.IsNullOrWhiteSpace(scan.ScanFilter),
88+
$"{fileName} scan {scan.OneBasedScanNumber}: empty scan filter");
89+
90+
// MassAnalyzerType -> MZAnalyzerType: Unknown means the FilterEnums mapping regressed.
91+
Assert.AreNotEqual(MZAnalyzerType.Unknown, scan.MzAnalyzer,
92+
$"{fileName} scan {scan.OneBasedScanNumber}: mass analyzer resolved to Unknown");
93+
94+
// GetPolarity throws on anything but +/-, so reaching here already proves resolution;
95+
// assert explicitly to document the invariant.
96+
Assert.IsTrue(scan.Polarity == Polarity.Positive || scan.Polarity == Polarity.Negative,
97+
$"{fileName} scan {scan.OneBasedScanNumber}: unresolved polarity");
98+
99+
Assert.IsTrue(scan.RetentionTime >= 0,
100+
$"{fileName} scan {scan.OneBasedScanNumber}: negative retention time");
101+
102+
// Centroided reads must never carry zero-intensity peaks (GetSpectrum strips them).
103+
Assert.IsFalse(scan.MassSpectrum.YArray.Any(y => y == 0),
104+
$"{fileName} scan {scan.OneBasedScanNumber}: zero-intensity peak survived centroiding");
105+
106+
// TIC is the sum of intensities; a populated spectrum must have positive TIC.
107+
if (scan.MassSpectrum.Size > 0)
108+
Assert.Greater(scan.TotalIonCurrent, 0,
109+
$"{fileName} scan {scan.OneBasedScanNumber}: non-empty spectrum with non-positive TIC");
110+
111+
if (scan.MsnOrder > 1)
112+
{
113+
// These come from GetScanEventForScanNumber().GetReaction(0) + the trailer, and from
114+
// the precursor-scan back-walk; a null here means MS2 metadata resolution broke.
115+
Assert.IsTrue(scan.IsolationMz.HasValue,
116+
$"{fileName} scan {scan.OneBasedScanNumber}: MS2 without isolation m/z");
117+
Assert.IsTrue(scan.OneBasedPrecursorScanNumber.HasValue,
118+
$"{fileName} scan {scan.OneBasedScanNumber}: MS2 without precursor scan linkage");
119+
Assert.IsTrue(scan.OneBasedPrecursorScanNumber.Value < scan.OneBasedScanNumber,
120+
$"{fileName} scan {scan.OneBasedScanNumber}: precursor scan is not earlier in the run");
121+
}
122+
}
123+
}
124+
125+
/// <summary>
126+
/// Trailer-extra parsing (GetTrailerExtraInformation Labels/Values) is the most format-sensitive
127+
/// surface across CommonCore versions. Assert that it is actually being read: across the corpus,
128+
/// at least one scan must expose an ion injection time, and at least one MS2 scan must expose a
129+
/// charge-state or monoisotopic-guess pulled from the trailer.
130+
/// </summary>
131+
[Test]
132+
public void TrailerExtraInformation_IsParsedAcrossCorpus()
133+
{
134+
int scansWithInjectionTime = 0;
135+
int ms2WithTrailerDerivedPrecursorInfo = 0;
136+
137+
foreach (string fileName in RawFiles)
138+
{
139+
var reader = MsDataFileReader.GetDataFile(DataPath(fileName));
140+
reader.LoadAllStaticData(null, maxThreads: 1);
141+
142+
foreach (MsDataScan scan in reader.GetAllScansList())
143+
{
144+
if (scan.InjectionTime.HasValue)
145+
scansWithInjectionTime++;
146+
147+
if (scan.MsnOrder > 1 &&
148+
(scan.SelectedIonChargeStateGuess.HasValue || scan.SelectedIonMonoisotopicGuessMz.HasValue))
149+
ms2WithTrailerDerivedPrecursorInfo++;
150+
}
151+
}
152+
153+
Assert.Greater(scansWithInjectionTime, 0,
154+
"No scan across the corpus reported an ion injection time - trailer parsing likely broke.");
155+
Assert.Greater(ms2WithTrailerDerivedPrecursorInfo, 0,
156+
"No MS2 scan across the corpus reported a trailer-derived charge state or monoisotopic m/z.");
157+
}
158+
159+
/// <summary>
160+
/// Static vs. dynamic (RawFileReaderAdapter.FileFactory) reads of the same file must agree
161+
/// scan-for-scan. This is a strong version-independent oracle: the two independent CommonCore
162+
/// code paths can only match if both parse the file identically.
163+
/// </summary>
164+
[Test]
165+
[TestCase("testFileWMS2.raw")]
166+
[TestCase("sliced_ethcd.raw")]
167+
public void StaticAndDynamicReadsAgree(string fileName)
168+
{
169+
var staticReader = MsDataFileReader.GetDataFile(DataPath(fileName));
170+
staticReader.LoadAllStaticData(null, maxThreads: 1);
171+
staticReader.InitiateDynamicConnection();
172+
173+
try
174+
{
175+
foreach (MsDataScan staticScan in staticReader.GetAllScansList())
176+
{
177+
MsDataScan dynamicScan =
178+
staticReader.GetOneBasedScanFromDynamicConnection(staticScan.OneBasedScanNumber);
179+
180+
Assert.IsNotNull(dynamicScan,
181+
$"{fileName}: dynamic read returned null for scan {staticScan.OneBasedScanNumber}");
182+
Assert.AreEqual(staticScan.MsnOrder, dynamicScan.MsnOrder);
183+
Assert.AreEqual(staticScan.ScanFilter, dynamicScan.ScanFilter);
184+
Assert.AreEqual(staticScan.DissociationType, dynamicScan.DissociationType);
185+
Assert.AreEqual(staticScan.MassSpectrum.XArray.Length, dynamicScan.MassSpectrum.XArray.Length,
186+
$"{fileName} scan {staticScan.OneBasedScanNumber}: peak count differs static vs dynamic");
187+
}
188+
}
189+
finally
190+
{
191+
staticReader.CloseDynamicConnection();
192+
}
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)