Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
162dbbf
Tagged Waters lockmass scans as calibration spectra (MS:1000928)
bspratt Jul 28, 2026
249389c
Regenerated the Waters reference mzMLs affected by the calibration sp…
bspratt Jul 28, 2026
5753f6b
Stopped guessing at the Waters lockmass function for waters_connect mzML
bspratt Jul 28, 2026
38d8115
Restored the original source file paths in the regenerated Waters ref…
bspratt Jul 28, 2026
33062ad
Regenerated the LockmassRefiner references affected by the calibratio…
bspratt Jul 28, 2026
b91cd88
Made the msLevel filter independent of spectrum type cvParam order
bspratt Jul 28, 2026
4f811eb
Stopped UpdateMseLevel latching _mseLevel to zero
bspratt Jul 28, 2026
579351f
Forwarded calibrationSpectraAreOmitted through SpectrumListWrapper
bspratt Jul 28, 2026
497cb26
Read the Waters function number from the merged DDA nativeID layout
bspratt Jul 28, 2026
87d22f1
Covered the MS:1000928 branch that the lockmass test could not detect
bspratt Jul 28, 2026
a85e176
Removed the waters_connect nativeID check, which had no effect
bspratt Jul 28, 2026
48f63c2
Resolved the Waters lockmass function once at construction
bspratt Jul 28, 2026
2909928
Stopped parsing Waters nativeIDs for every vendor's spectra
bspratt Jul 28, 2026
9a23cda
Used hasCVParamChild rather than cvParamChildren in the msLevel filter
bspratt Jul 29, 2026
c7c96d3
Confined IsWatersLockmassSpectrum to Waters files
bspratt Jul 29, 2026
4df5021
Corrected comments that overstated what the code guarantees
bspratt Jul 29, 2026
4945a60
Covered ignoreCalibrationScans against a file that has a lockspray fu…
bspratt Jul 29, 2026
18b0084
Merge remote-tracking branch 'origin/master' into Skyline/work/202607…
bspratt Jul 29, 2026
8f20b37
Scrubbed absolute paths from the Waters lockmass test fixtures
bspratt Jul 29, 2026
887bf25
Kept the lockmass function out of the global TIC when its scans are e…
bspratt Jul 29, 2026
df90ce8
Addressed review feedback on PR #4498
bspratt Jul 29, 2026
85d99ed
Stopped inferring a Waters lockmass function from waters_connect mzML
bspratt Jul 30, 2026
34d192e
MSe_Short_tagged.mzML incorrectly declare a spectrum as both MS1 and …
bspratt Jul 30, 2026
d4254c3
Described the tagged fixture as declaring calibration spectrum as the…
bspratt Jul 30, 2026
5020ed4
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Jul 30, 2026
49f8442
Sorted spectrum peaks by m/z when the writer did not
bspratt Jul 31, 2026
1f87c03
Merge remote-tracking branch 'origin/Skyline/work/20260727_Waters_dat…
bspratt Jul 31, 2026
10e6a64
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Jul 31, 2026
5f88ea9
Settled the m/z sort check from the first few spectra, not every one
bspratt Aug 4, 2026
19702b5
Trimmed comments in MsDataFileImpl
bspratt Aug 4, 2026
b7fb330
Revert "Settled the m/z sort check from the first few spectra, not ev…
bspratt Aug 4, 2026
591445b
Revert "Sorted spectrum peaks by m/z when the writer did not"
bspratt Aug 4, 2026
34d2e59
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Aug 4, 2026
0ad9391
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Aug 11, 2026
feea902
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Aug 20, 2026
3bebf25
Merge branch 'master' into Skyline/work/20260727_Waters_dataconvert_m…
bspratt Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions pwiz/analysis/spectrum_processing/SpectrumList_Filter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,25 @@ PWIZ_API_DECL SpectrumList_FilterPredicate_MSLevelSet::SpectrumList_FilterPredic

PWIZ_API_DECL boost::logic::tribool SpectrumList_FilterPredicate_MSLevelSet::accept(const msdata::Spectrum& spectrum) const
{
CVParam param = spectrum.cvParamChild(MS_spectrum_type);
if (param.cvid == CVID_Unknown) return boost::logic::indeterminate;
if (!cvIsA(param.cvid, MS_mass_spectrum))
return msLevelSet_.contains(0); // non-MS spectra are considered ms level 0
param = spectrum.cvParam(MS_ms_level);
if (param.cvid == CVID_Unknown) return boost::logic::indeterminate;
if (!spectrum.hasCVParamChild(MS_spectrum_type))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be reverted since it was only need for multiple spectrum types?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a different change than the one you have in mind - the multiple-spectrum-types one was reverted, back in Phase 6 when the writing half of this PR was withdrawn. Nothing in pwiz emits two children of spectrum type any more, so the order-independence fix went out with it.

What is here now fixes a calibration-spectrum-only spectrum, which is what SpectrumList_UIMF.cpp:106 writes today on master, with no help from this branch:

CVID spectrumType = rawIndexEntry.frameType == FrameType_Calibration ? MS_calibration_spectrum : ...
result->set(MS_ms_level, msLevel);
result->set(spectrumType);

MS_calibration_spectrum's only CV parent is MS_spectrum_type (cv.cpp:7055) - it is not under MS_mass_spectrum. So master's !cvIsA(param.cvid, MS_mass_spectrum) returned msLevelSet_.contains(0) and threw away a perfectly good declared ms level: msconvert --filter "msLevel 1-" drops UIMF calibration frames, and msLevel 0 keeps them.

Worth noting that restoring the reverted hunk would not have fixed this - it asked hasCVParamChild(MS_mass_spectrum), which is equally false for a calibration-only spectrum. It only ever helped when both terms were present, which was the situation our own additive writer created. Asking for the declared ms level first is the superset: it fixes the calibration case and gets order-independence for free, without needing an additive writer to exist.

It does become moot, but only once psi-ms #539 lands and UIMF is updated to write MS1 spectrum plus the attribute. Until then this is the only thing keeping a UIMF calibration frame's declared ms level from being discarded.

Mutation-verified: restoring the type-first ordering fails testMSLevelSetCalibrationSpectrum at SpectrumList_FilterTest.cpp:561, expected "2" but got "1". Happy to pull it into its own PR if you would rather it not ride along with the Waters work - it is a pwiz change in a skyline-prefixed PR either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof, AI slop. I shouldn't let Claude handle the responses directly

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to break with the new CV, when calibration spectrum is no longer a spectrum type. (Also the comment is wrong here about multiple spectrum types.) Fixing UIMF in this PR is the right path I think, which doesn't fix existing UIMF files, but we could fix those broken UIMF files in the mzML reader. Which I'm inclined say we should do so since they are OUR broken files. In untested code:
if (spectrum.hasCVParam(MS_calibration_spectrum) && !spectrum.hasCVParamChild(MS_spectrum_type))
{
int msLevel = spectrum.cvParamValueOrDefault(MS_ms_level, 0);
if (msLevel > 0) spectrum.set(msLevel == 1 ? MS_MS1_spectrum : MS_MSn_spectrum);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I guess it's not just mzML. Someone could have converted to any of the open mzML formats like mz5, mzMLb, perhaps even mzXML. Tricky. Maybe a centralized workaround location (I don't think we've had a case like this before) and then call it in mzML, mz5, and mzMLb right before they return a spectrum.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is possibly more effort than the UIMF format warrants, as I haven't heard of anybody using it recently (a similar fate to mz5 and mzMLb, sigh). I'm sure multi-parquet-in-a-zip mzPeak will change things though. /s

We could put some opt-in telemetry into pwiz that tracks what formats people use, input and output.

return boost::logic::indeterminate;

// A declared ms level decides this, whatever the spectrum type says. Not every child of
// "spectrum type" is under "mass spectrum" - "calibration spectrum" is not, and the UIMF reader
// writes it as the sole type on frames that carry a perfectly good ms level - so keying off the
// type first discarded that level and called such a spectrum ms level 0.
//
// Ask by child rather than for the first one: a spectrum may carry more than one child of
// "spectrum type", and no rule fixes the order a writer emits them in.
CVParam param = spectrum.cvParam(MS_ms_level);
if (param.cvid == CVID_Unknown)
{
// Nothing declared, so the type is all there is to go on. A spectrum that is not a mass
// spectrum at all - an emission spectrum, say - is considered ms level 0.
if (!spectrum.hasCVParamChild(MS_mass_spectrum))
return msLevelSet_.contains(0);
return boost::logic::indeterminate;
}
int msLevel = param.valueAs<int>();
bool result = msLevelSet_.contains(msLevel);
return result;
Expand Down
59 changes: 59 additions & 0 deletions pwiz/analysis/spectrum_processing/SpectrumList_FilterTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,64 @@ void testMSLevelSet(SpectrumListPtr sl)
}
}


// "calibration spectrum" is a child of "spectrum type" but not of "mass spectrum", and the UIMF
// reader writes it as the sole type on a frame that still carries an ms level. Such a spectrum must
// be filtered on the level it declares, not treated as ms level 0 the way a type-only spectrum is.
// Uses its own list, since the shared one is pinned by exact sizes and ids throughout this file.
void testMSLevelSetCalibrationSpectrum()
{
if (os_) *os_ << "testMSLevelSetCalibrationSpectrum:\n";

SpectrumListSimplePtr sl(new SpectrumListSimple);

SpectrumPtr ms1(new Spectrum);
ms1->index = 0;
ms1->id = "scan=1";
ms1->set(MS_ms_level, 1);
ms1->set(MS_MS1_spectrum);
sl->spectra.push_back(ms1);

SpectrumPtr calibration(new Spectrum);
calibration->index = 1;
calibration->id = "scan=2";
calibration->set(MS_ms_level, 1);
calibration->set(MS_calibration_spectrum);
sl->spectra.push_back(calibration);

// A spectrum type with no ms level at all is what the level 0 rule is for
SpectrumPtr emission(new Spectrum);
emission->index = 2;
emission->id = "scan=3";
emission->set(MS_emission_spectrum);
sl->spectra.push_back(emission);

{
SpectrumList_Filter filter(sl, SpectrumList_FilterPredicate_MSLevelSet(IntegerSet(1)));
if (os_)
{
printSpectrumList(filter, *os_);
*os_ << endl;
}
// The calibration spectrum declares ms level 1, so asking for level 1 must keep it
unit_assert_operator_equal(2, filter.size());
unit_assert(filter.spectrumIdentity(0).id == "scan=1");
unit_assert(filter.spectrumIdentity(1).id == "scan=2");
}

{
SpectrumList_Filter filter(sl, SpectrumList_FilterPredicate_MSLevelSet(IntegerSet(0)));
if (os_)
{
printSpectrumList(filter, *os_);
*os_ << endl;
}
// And asking for level 0 must not, leaving only the spectrum that declares no level
unit_assert_operator_equal(1, filter.size());
unit_assert(filter.spectrumIdentity(0).id == "scan=3");
}
}

void testMS2Activation(SpectrumListPtr sl)
{
if (os_) *os_ << "testMS2Activation:\n";
Expand Down Expand Up @@ -840,6 +898,7 @@ void test()
testScanEventSet(sl);
testScanTimeRange(sl);
testMSLevelSet(sl);
testMSLevelSetCalibrationSpectrum();
testMS2Activation(sl);
testMassAnalyzerFilter(sl);
testMZPresentFilter(sl);
Expand Down
6 changes: 6 additions & 0 deletions pwiz/data/msdata/SpectrumListWrapper.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ class PWIZ_API_DECL SpectrumListWrapper : public SpectrumListBase
virtual IndexList findNameValue(const std::string& name, const std::string& value) const {return size() == inner_->size() ? inner_->findNameValue(name, value) : SpectrumList::findNameValue(name, value);}
virtual IndexList findSpotID(const std::string& spotID) const {return size() == inner_->size() ? inner_->findSpotID(spotID) : SpectrumList::findSpotID(spotID);}

// A wrapper only ever filters or transforms what the inner list presents, so if the inner list is
// omitting calibration spectra then so is the wrapper. Without forwarding this, the base
// implementation answers false and callers cannot tell a wrapped vendor list from one with no
// calibration spectra at all.
virtual bool calibrationSpectraAreOmitted() const {return inner_->calibrationSpectraAreOmitted();}

// no default implementation, because otherwise subclasses could override the DetailLevel overload and the getBinaryData overload would be inconsistent
virtual SpectrumPtr spectrum(size_t index, bool getBinaryData = false) const = 0;

Expand Down
11 changes: 11 additions & 0 deletions pwiz/data/vendor_readers/Waters/ChromatogramList_Waters.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,19 @@ PWIZ_API_DECL ChromatogramPtr ChromatogramList_Waters::chromatogram(size_t index

multimap<double, pair<int, double>> fullFileTIC;

// When ignoreCalibrationScans is set the lockmass function is kept out of the spectrum list,
// so it must not be summed into the global TIC either - otherwise the TIC carries points that
// no spectrum in the file accounts for.
int lockmassFunction = -1;
bool excludeLockmassFunction = config_.ignoreCalibrationScans &&
rawdata_->Info.TryGetLockMassFunction(lockmassFunction) &&
lockmassFunction >= 0;

for(int function : rawdata_->FunctionIndexList())
{
if (excludeLockmassFunction && function == lockmassFunction)
continue;

if (config_.globalChromatogramsAreMs1Only)
{
int msLevel;
Expand Down
9 changes: 9 additions & 0 deletions pwiz/data/vendor_readers/Waters/Reader_Waters_Test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ int main(int argc, char* argv[])
result += pwiz::util::testReader(reader, testArgs, testAcceptOnly, requireUnicodeSupport, pwiz::util::IsNamedRawFile({ "HDDDA_Short_noLM.raw" }), newConfig);
}

// test ignoreCalibrationScans on a file that actually has a lockspray function. HDDDA_Short_noLM
// above has none, so it cannot show that the calibration spectra are dropped, nor that the
// global TIC then stops summing them - MSe_Short has function 3.
{
auto newConfig = config;
newConfig.ignoreCalibrationScans = true;
result += pwiz::util::testReader(reader, testArgs, testAcceptOnly, requireUnicodeSupport, pwiz::util::IsNamedRawFile({ "MSe_Short.raw" }), newConfig);
}

// test ddaProcessing
{
auto newConfig = config;
Expand Down

Large diffs are not rendered by default.

29 changes: 19 additions & 10 deletions pwiz/data/vendor_readers/Waters/SpectrumList_Waters.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,21 @@ namespace detail {
using namespace Waters;

SpectrumList_Waters::SpectrumList_Waters(MSData& msd, RawDataPtr rawdata, const Reader::Config& config)
: msd_(msd), rawdata_(rawdata), config_(config), lockmassFunction_(LOCKMASS_FUNCTION_UNINIT)
: msd_(msd), rawdata_(rawdata), config_(config), lockmassFunction_(LOCKMASS_FUNCTION_UNKNOWN)
{
useDDAProcessor_ = config_.ddaProcessing;
rawdata_->EnableProcessing(useDDAProcessor_);

// Resolve the lockmass function once, here, while still single threaded. Doing it lazily would let
// spectrum() (which holds readMutex) and calibrationSpectraAreOmitted() (which does not) race on
// the member and enter the MassLynx SDK unserialized. Note the out-parameter goes to a local: the
// SDK wrapper leaves it untouched when the call fails, so handing it the member directly would
// cache whatever happened to be there.
int lockmassFunction = LOCKMASS_FUNCTION_UNKNOWN;
if (!rawdata_->Info.TryGetLockMassFunction(lockmassFunction) || lockmassFunction < 0)
lockmassFunction = LOCKMASS_FUNCTION_UNKNOWN;
lockmassFunction_ = lockmassFunction;

if (useDDAProcessor_)
{
createDDAIndex();
Expand Down Expand Up @@ -107,16 +117,14 @@ PWIZ_API_DECL SpectrumPtr SpectrumList_Waters::spectrum(size_t index, bool getBi
return spectrum(index, getBinaryData ? DetailLevel_FullData : DetailLevel_FullMetadata, lockmassMzPosScans, lockmassMzNegScans, lockmassTolerance, msLevelsToCentroid);
}

PWIZ_API_DECL int SpectrumList_Waters::lockMassFunction() const
{
return lockmassFunction_; // resolved in the constructor, so this is safe to call from any thread
}

PWIZ_API_DECL bool SpectrumList_Waters::isLockMassFunction(int function) const
{
if (lockmassFunction_ == LOCKMASS_FUNCTION_UNINIT)
{
if (!rawdata_->Info.TryGetLockMassFunction(lockmassFunction_))
{
lockmassFunction_ = LOCKMASS_FUNCTION_UNKNOWN;
}
}
return function == lockmassFunction_;
return function == lockMassFunction();
}

PWIZ_API_DECL SpectrumPtr SpectrumList_Waters::spectrum(size_t index, DetailLevel detailLevel, double lockmassMzPosScans, double lockmassMzNegScans, double lockmassTolerance, const pwiz::util::IntegerSet& msLevelsToCentroid) const
Expand Down Expand Up @@ -629,7 +637,7 @@ PWIZ_API_DECL void SpectrumList_Waters::calculatePeakMetadata(SpectrumPtr& spect

PWIZ_API_DECL bool SpectrumList_Waters::calibrationSpectraAreOmitted() const
{
return config_.ignoreCalibrationScans && lockmassFunction_ >= 0;
return config_.ignoreCalibrationScans && lockMassFunction() >= 0;
}

PWIZ_API_DECL void SpectrumList_Waters::createIndex()
Expand Down Expand Up @@ -834,6 +842,7 @@ bool SpectrumList_Waters::hasCombinedIonMobility() const {return false;}
bool SpectrumList_Waters::canConvertIonMobilityAndCCS() const {return false;}
double SpectrumList_Waters::ionMobilityToCCS(double ionMobility, double mz, int charge) const {return 0;}
double SpectrumList_Waters::ccsToIonMobility(double ccs, double mz, int charge) const {return 0;}
int SpectrumList_Waters::lockMassFunction() const {return LOCKMASS_FUNCTION_UNKNOWN;}
bool SpectrumList_Waters::isLockMassFunction(int function) const {return false;}
bool SpectrumList_Waters::calibrationSpectraAreOmitted() const {return false;}
SpectrumPtr SpectrumList_Waters::spectrum(size_t index, bool getBinaryData) const {return SpectrumPtr();}
Expand Down
10 changes: 8 additions & 2 deletions pwiz/data/vendor_readers/Waters/SpectrumList_Waters.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,15 @@ class PWIZ_API_DECL SpectrumList_Waters : public SpectrumListIonMobilityBase

#endif // PWIZ_READER_WATERS

mutable int lockmassFunction_; // 0-based. Special values: -1=uninitialized -2=unknown
int lockmassFunction_; // 0-based, or LOCKMASS_FUNCTION_UNKNOWN. Resolved by the constructor.
#define LOCKMASS_FUNCTION_UNKNOWN -2
#define LOCKMASS_FUNCTION_UNINIT -1

/// The 0-based lockmass function number, or LOCKMASS_FUNCTION_UNKNOWN if the source has none.
/// Resolved once by the constructor, so this is a plain read and is safe from any thread. Do not
/// make it lazy again: spectrum() calls it holding readMutex while calibrationSpectraAreOmitted()
/// calls it without, so a lazy write would race and would also let two threads into the MassLynx
/// SDK at once.
int lockMassFunction() const;
};

} // detail
Expand Down
11 changes: 11 additions & 0 deletions pwiz_tools/BiblioSpec/src/Jamfile.jam
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,17 @@ exe LibToSqlite3
<library>blib_v_1
;

unit-test-if-exists SpectrumTest
: # sources
SpectrumTest.cpp
blib
$(PWIZ_ROOT_PATH)/libraries/SQLite//sqlite3
: # requirements
<link>static
<threading>multi
<library>/ext/zlib//z
;

# for make targets below, <relevant> features make <location> be calculated properly

alias all-relevant : : <relevant>cxxstd <relevant>cxxstd-dialect <relevant>target-os <relevant>toolset <relevant>variant <relevant>debug-symbols <relevant>runtime-link <relevant>link <relevant>address-model <relevant>architecture <relevant>asynch-exceptions <relevant>threading <relevant>debug-store <relevant>embed-manifest <relevant>exception-handling <relevant>extern-c-nothrow <relevant>inlining <relevant>instruction-set <relevant>optimization <relevant>pch <relevant>rtti <relevant>runtime-debugging <relevant>threading <relevant>warnings <relevant>warnings-as-errors <relevant>windows-api ;
Expand Down
21 changes: 20 additions & 1 deletion pwiz_tools/BiblioSpec/src/PwizReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ bool PwizReader::getSpectrum(int identifier,
}
unique_ptr<SpectrumInfo> specInfo(new SpectrumInfo());
specInfo->SpectrumInfo::update(*foundSpec, getPeaks);
ensureMzAscending(*specInfo);

// confirm that it's an ms/ms spectrum
if( getPeaks && specInfo->msLevel != 2 ){
Expand Down Expand Up @@ -303,6 +304,7 @@ bool PwizReader::getSpectrum(int identifier,
}
unique_ptr<SpectrumInfo> specInfo(new SpectrumInfo());
specInfo->SpectrumInfo::update(*foundSpec, getPeaks);
ensureMzAscending(*specInfo);

// confirm that it's an ms/ms spectrum
if( specInfo->msLevel != 2 ){
Expand Down Expand Up @@ -426,11 +428,28 @@ void PwizReader::addCharges(BiblioSpec::Spectrum& returnSpectrum,
} // next precursor
}

/**
* Put a spectrum's peaks in ascending m/z order if its writer did not. Ascending m/z is nowhere
* required of a writer, but everything downstream assumes it - and this has to happen here, on the
* SpectrumInfo, because it is the only point both transfer paths share. BlibBuild never builds a
* BiblioSpec::Spectrum at all: it goes BuildParser -> getSpectrum(int, SpecData&) -> transferSpec,
* and hands the raw arrays to insertPeaks, so a library built from a writer that presented some
* other order would be stored in that order and stay that way for every consumer of the .blib.
*/
void PwizReader::ensureMzAscending(SpectrumInfo& specInfo)
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems potentially expensive for an extremely rare case. How about we do it for the first spectrum and then assume the rest follow suit?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix in 5f88ea9 - PwizReader now holds a MzOrderVerdict (MzOrderVerdict.h, mirroring the Skyline-side class) and resets it in openFile, so the question is settled from the first few spectra rather than re-asked for every one. Same asymmetry: out of order condemns the file at any peak count, in order is only believed from a spectrum with more than 10 peaks.

The state machine is unit tested in SpectrumTest, which is why it is a small class rather than a pair of member bools - ensureMzAscending itself needs a file to reach, but the rule it encodes is the part worth pinning, including that no later spectrum can talk a condemned file back round.

One caveat specific to this side: openFile sets combineIonMobilitySpectra = true, and a combined spectrum is only m/z ordered within each mobility bin, so it fails the check and condemns the file immediately. That is the same work Phase 9 already did and it is correct here - SpectrumInfo::data is a flat vector<MZIntensityPair> with no mobility axis to shred, and binPeaks merges only adjacent equal bins, so flattening to m/z order is what makes the binning right. The latch cannot spare that cost, but for IMS input the sort is doing real work rather than looking for something that is not there.

Spectrum::setRawPeaks is left checking every time: it serves the .blib re-read and copy paths, where there is no file being streamed and so nothing to form a verdict about.

if (is_sorted(specInfo.data.begin(), specInfo.data.end(),
[](const MZIntensityPair& a, const MZIntensityPair& b) { return a.mz < b.mz; }))
return;
sort(specInfo.data.begin(), specInfo.data.end(),
[](const MZIntensityPair& a, const MZIntensityPair& b) { return a.mz < b.mz; });
}

/**
* Copy the information from the Pwiz spectrum to the BiblioSpec
* SpecData.
*/
void PwizReader::transferSpec(BiblioSpec::SpecData& returnData,
void PwizReader::transferSpec(BiblioSpec::SpecData& returnData,
unique_ptr<SpectrumInfo>& specInfo){

returnData.id = specInfo->scanNumber;
Expand Down
6 changes: 5 additions & 1 deletion pwiz_tools/BiblioSpec/src/PwizReader.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ class PwizReader : public BiblioSpec::SpecFileReader {
* Copy the information from the Pwiz spectrum to the BiblioSpec
* SpecData.
*/
void transferSpec(BiblioSpec::SpecData& returnData,
// Ascending m/z is nowhere required of a writer but is assumed everywhere downstream; both
// transfer paths below share the SpectrumInfo, so it is corrected there.
static void ensureMzAscending(SpectrumInfo& specInfo);

void transferSpec(BiblioSpec::SpecData& returnData,
unique_ptr<SpectrumInfo>& specInfo);

/**
Expand Down
18 changes: 17 additions & 1 deletion pwiz_tools/BiblioSpec/src/Spectrum.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,23 @@ double Spectrum::getSignalToNoise() {

// deletes existing peaks
void Spectrum::setRawPeaks(const vector<PEAK_T>& newpeaks) {
rawPeaks_.assign(newpeaks.begin(), newpeaks.end());
rawPeaks_.assign(newpeaks.begin(), newpeaks.end());

// Ascending m/z is nowhere required of a writer, but everything downstream assumes it:
// binPeaks() merges a peak into the previous bin only when the two are adjacent in the array,
// and removePrecursorPeaks() binary searches the m/z axis. Peaks presented in some other order
// therefore leave same-bin peaks unmerged and erase the wrong range around the precursor, with
// nothing reported either way.
// This covers spectra arriving from a .blib and from Spectrum copies. Spectra read through
// PwizReader are ordered there instead, on the SpectrumInfo, because BlibBuild never builds a
// Spectrum at all - see PwizReader::ensureMzAscending.
// The check is a single pass; the sort only runs for a writer that did not present m/z order.
if (!is_sorted(rawPeaks_.begin(), rawPeaks_.end(),
[](const PEAK_T& a, const PEAK_T& b) { return a.mz < b.mz; }))
{
sort(rawPeaks_.begin(), rawPeaks_.end(),
[](const PEAK_T& a, const PEAK_T& b) { return a.mz < b.mz; });
}
}

void Spectrum::setProcessedPeaks(const vector<PEAK_T>& newpeaks) {
Expand Down
Loading
Loading