From 3ca0fba378d734e385a882802ada68555ebe2a8b Mon Sep 17 00:00:00 2001 From: Adrian Herrmann Date: Thu, 20 Aug 2026 13:27:35 +0200 Subject: [PATCH 1/3] fix(lcms): stop plain MS jcamp from being detected as Chemstation LC/MS A plain chem-spectra-generated MS export carries a MASS SPECTRUM root DATA TYPE that isChemstationLcms mistook for a genuine Chemstation LC/MS file. Bail out early on signatures unique to the plain export (SPECTRUM category, ##$CSSCANAUTOTARGET, the M/Z units triplet, or ##NTUPLES = MASS SPECTRUM), and tighten the remaining heuristic to require TIC/UVVIS category or multi-page metadata. Co-Authored-By: Claude Sonnet 5 --- src/__tests__/units/actions/lcms.test.tsx | 6 ++++++ src/features/lc-ms/parsing/chemstation.js | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/__tests__/units/actions/lcms.test.tsx b/src/__tests__/units/actions/lcms.test.tsx index d1e68c68..e6c47fa5 100644 --- a/src/__tests__/units/actions/lcms.test.tsx +++ b/src/__tests__/units/actions/lcms.test.tsx @@ -15,6 +15,7 @@ import hplcMsTicPosJcamp from "../../fixtures/lc_ms_jcamp_tic_pos"; import hplcMsTicNegJcamp from "../../fixtures/lc_ms_jcamp_tic_neg"; import hplcMsUvvisJcamp from "../../fixtures/lc_ms_jcamp_uvvis"; import lcMsMzChemstationJcamp from "../../fixtures/lc_ms_jcamp_mz_chemstation"; +import msJcamp from "../../fixtures/ms_jcamp"; const hasSpectrumData = (entity: any) => { const data = entity?.spectra?.[0]?.data?.[0]; @@ -170,6 +171,11 @@ describe('LCMS ExtractJcamp', () => { const pages = extractPages(entity); expect(new Set(pages).size).toBeGreaterThan(1); }); + + it('Extract a plain MS jcamp keeps the MS layout instead of LC/MS', () => { + const entity: any = ExtractJcamp(msJcamp); + expect(entity.layout).toEqual(LIST_LAYOUT.MS); + }); }); describe('LCMS grouping', () => { diff --git a/src/features/lc-ms/parsing/chemstation.js b/src/features/lc-ms/parsing/chemstation.js index 806d272c..7411395b 100644 --- a/src/features/lc-ms/parsing/chemstation.js +++ b/src/features/lc-ms/parsing/chemstation.js @@ -102,6 +102,20 @@ export const isChemstationLcms = (source, jcamp) => { const hasMultipleSpectra = spectra.length > 1; const hasPageMetadata = spectra.some((s) => s?.page != null || s?.pageValue != null); + // A plain chem-spectra-generated MS jcamp carries a signature that never appears on a + // genuine Chemstation LC/MS export; bail out before its MASS SPECTRUM root DATA TYPE + // gets mistaken for one. + const hasCsCategorySpectrum = categories.some((c) => c === 'SPECTRUM'); + const hasCsScanAutoTarget = /##\$CSSCANAUTOTARGET\s*=/i.test(source); + const hasMsUnitsTriplet = /##UNITS\s*=\s*M\/Z,\s*RELATIVE ABUNDANCE,\s*SECONDS/i.test(source); + const hasNtuplesMassSpectrum = /##NTUPLES\s*=\s*MASS SPECTRUM\b/i.test(source); + const looksLikePlainMsExport = ( + hasCsCategorySpectrum || hasCsScanAutoTarget || hasMsUnitsTriplet || hasNtuplesMassSpectrum + ); + if (looksLikePlainMsExport) { + return false; + } + const hasNtuplesPageHeader = /##NTUPLES_PAGE_HEADER\s*=/.test(source); if (hasNtuplesPageHeader && ( hasTicOrUvvisCategory @@ -123,7 +137,13 @@ export const isChemstationLcms = (source, jcamp) => { if ( hasMassSpectrumRootDataType - && (hasMassSpectrumDataType || hasScanModeHint || hasTypeHint || hasSoftwareHint) + && ( + hasScanModeHint + || hasTypeHint + || hasSoftwareHint + || hasTicOrUvvisCategory + || (hasMultipleSpectra && hasPageMetadata) + ) ) { return true; } From a492bedfdbbb5544273587da59c31ba956e7b471 Mon Sep 17 00:00:00 2001 From: Adrian Herrmann Date: Fri, 21 Aug 2026 12:45:49 +0200 Subject: [PATCH 2/3] fix(lcms): replace unsound MS-vs-Chemstation blacklist with a scoped veto The prior fix's early-return blacklist (SPECTRUM category, ##$CSSCANAUTOTARGET, ##NTUPLES=MASS SPECTRUM) ran before all positive LC/MS evidence, so it could override a genuine Chemstation export, and it relied on $CSCATEGORY parsing that silently dropped single (non-array) values. Its added (hasMultipleSpectra && hasPageMetadata) clause was also self-defeating: the original plain-MS fixture has multi-page structure too, so that clause alone reclassified it as LC/MS again. Fix the array-vs-string $CSCATEGORY read, drop the blanket blacklist, and narrow the veto to the one genuinely ambiguous case (multi-page MASS SPECTRUM root with no other chromatographic hint), gated only on the MS-specific units triplet and placed after all positive-evidence branches so it can never override them. Co-Authored-By: Claude Sonnet 5 --- src/__tests__/units/actions/lcms.test.tsx | 9 ++++++ src/features/lc-ms/parsing/chemstation.js | 39 ++++++++--------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/__tests__/units/actions/lcms.test.tsx b/src/__tests__/units/actions/lcms.test.tsx index e6c47fa5..35e52b33 100644 --- a/src/__tests__/units/actions/lcms.test.tsx +++ b/src/__tests__/units/actions/lcms.test.tsx @@ -176,6 +176,15 @@ describe('LCMS ExtractJcamp', () => { const entity: any = ExtractJcamp(msJcamp); expect(entity.layout).toEqual(LIST_LAYOUT.MS); }); + + it('Extract Chemstation MZ still resolves LC/MS when it also carries generic chem-spectra markers', () => { + const injected = lcMsMzChemstationJcamp.replace( + '##DATA TYPE=MASS SPECTRUM', + '##DATA TYPE=MASS SPECTRUM\n##$CSCATEGORY=SPECTRUM\n##$CSSCANAUTOTARGET=1', + ); + const entity: any = ExtractJcamp(injected); + expect(entity.layout).toEqual(LIST_LAYOUT.LC_MS); + }); }); describe('LCMS grouping', () => { diff --git a/src/features/lc-ms/parsing/chemstation.js b/src/features/lc-ms/parsing/chemstation.js index 7411395b..12d6bb8a 100644 --- a/src/features/lc-ms/parsing/chemstation.js +++ b/src/features/lc-ms/parsing/chemstation.js @@ -67,10 +67,9 @@ export const isChemstationLcms = (source, jcamp) => { const scanMode = String(info.SCAN_MODE || info.SCANMODE || '').toUpperCase(); const type = String(info.TYPE || '').toUpperCase(); const software = String(info.SOFTWARE || '').toUpperCase(); - const csCategory = jcamp?.info?.$CSCATEGORY; - const categories = Array.isArray(csCategory) - ? csCategory.map((c) => String(c).toUpperCase()) - : []; + const categories = [] + .concat(jcamp?.info?.$CSCATEGORY || []) + .map((c) => String(c).toUpperCase()); const hasPolarityCategory = categories.some( (c) => c.includes('POSITIVE') || c.includes('NEGATIVE') || c.includes('NEUTRAL'), @@ -102,20 +101,6 @@ export const isChemstationLcms = (source, jcamp) => { const hasMultipleSpectra = spectra.length > 1; const hasPageMetadata = spectra.some((s) => s?.page != null || s?.pageValue != null); - // A plain chem-spectra-generated MS jcamp carries a signature that never appears on a - // genuine Chemstation LC/MS export; bail out before its MASS SPECTRUM root DATA TYPE - // gets mistaken for one. - const hasCsCategorySpectrum = categories.some((c) => c === 'SPECTRUM'); - const hasCsScanAutoTarget = /##\$CSSCANAUTOTARGET\s*=/i.test(source); - const hasMsUnitsTriplet = /##UNITS\s*=\s*M\/Z,\s*RELATIVE ABUNDANCE,\s*SECONDS/i.test(source); - const hasNtuplesMassSpectrum = /##NTUPLES\s*=\s*MASS SPECTRUM\b/i.test(source); - const looksLikePlainMsExport = ( - hasCsCategorySpectrum || hasCsScanAutoTarget || hasMsUnitsTriplet || hasNtuplesMassSpectrum - ); - if (looksLikePlainMsExport) { - return false; - } - const hasNtuplesPageHeader = /##NTUPLES_PAGE_HEADER\s*=/.test(source); if (hasNtuplesPageHeader && ( hasTicOrUvvisCategory @@ -137,13 +122,7 @@ export const isChemstationLcms = (source, jcamp) => { if ( hasMassSpectrumRootDataType - && ( - hasScanModeHint - || hasTypeHint - || hasSoftwareHint - || hasTicOrUvvisCategory - || (hasMultipleSpectra && hasPageMetadata) - ) + && (hasScanModeHint || hasTypeHint || hasSoftwareHint || hasTicOrUvvisCategory) ) { return true; } @@ -154,5 +133,15 @@ export const isChemstationLcms = (source, jcamp) => { return true; } + // A multi-page MASS SPECTRUM root with no other chromatographic signal is ambiguous: + // both a genuine Chemstation m/z export and a plain multi-page MS NTUPLES export look + // like this. The only field that reliably tells them apart is this units triplet, which + // is unique to the plain export - so it only vetoes this weak fallback, never the + // stronger positive evidence handled above. + if (hasMassSpectrumRootDataType && hasMultipleSpectra && hasPageMetadata) { + const hasMsUnitsTriplet = /##UNITS\s*=\s*M\/Z,\s*RELATIVE ABUNDANCE,\s*SECONDS/i.test(source); + return !hasMsUnitsTriplet; + } + return false; }; From bcc8f11437278ff292545a04c60a5f29986ba57b Mon Sep 17 00:00:00 2001 From: PiTrem Date: Mon, 24 Aug 2026 21:52:31 +0200 Subject: [PATCH 3/3] fix(lcms): discriminate Chemstation LC/MS structurally, not by units text isChemstationLcms decided the MS-vs-LC/MS layout with a veto on a literal ##UNITS triplet. That veto was defeated by any writer whose units line differed by a character (a space before a comma, a doubled space, MINUTES for SECONDS, MASS/INTENSITY vocabulary), so a plain MS export still rendered as LC/MS; and because it scanned the raw source rather than the parsed record, the same line inside a $$ comment demoted a genuine Chemstation file. Decide on structure instead. A Chemstation LC/MS export indexes its m/z scans by a retention-time PAGE variable (##VAR_TYPE= PAGE, X, Y); a plain chem-spectra MS NTUPLES export declares its time axis as a third INDEPENDENT variable and never declares a PAGE. That distinction is immune to spacing and vocabulary, and it is checked before any of the weaker evidence, so vendor metadata can no longer promote a plain file. Also: - restore single-scan Chemstation m/z exports, which regressed when hasMassSpectrumDataType was dropped from the root-MS branch: the classifier runs before the ntuples->spectra expansion in ExtractJcamp, so a genuine single-scan file arrives with one spectrum and must not be judged on spectrum count; - match \bTIC\b rather than includes('TIC'), so STATIC / KINETIC / SYNTHETIC / OPTIC no longer read as a total-ion chromatogram, and drop the `|| spectra.length > 0` tautology that made that branch unconditional; - read repeated JCAMP labels as lists, so an array-valued ##DATA TYPE or ##$CSCATEGORY is handled the same as a scalar one; - reuse normalizeLcMsMode instead of a second copy of the polarity vocabulary, and keep the four-term evidence disjunction in one place rather than two; - name VAR_TYPE explicitly in keepRecordsRegExp; it was retained only because a bare TYPE alternative happened to substring-match it. Vendor identity (SOFTWARE=openlab, SCAN_MODE) is kept as a fallback for exports without a page axis, but no longer accepts a MASS SPECTRUM block or ##TYPE= MS SPECTRUM on its own -- a plain MS export carries both. Adds direct unit tests for the classifier, which had none, plus fixture-level layout assertions for the single-scan, comment-injection, units-variant and $CSCATEGORY cases. --- src/__tests__/units/actions/lcms.test.tsx | 52 +++++ .../lc-ms/parsing/chemstation.test.js | 195 ++++++++++++++++++ src/features/lc-ms/parsing/chemstation.js | 140 ++++++------- src/helpers/chem.js | 2 +- 4 files changed, 311 insertions(+), 78 deletions(-) create mode 100644 src/__tests__/units/features/lc-ms/parsing/chemstation.test.js diff --git a/src/__tests__/units/actions/lcms.test.tsx b/src/__tests__/units/actions/lcms.test.tsx index 35e52b33..b9207f72 100644 --- a/src/__tests__/units/actions/lcms.test.tsx +++ b/src/__tests__/units/actions/lcms.test.tsx @@ -16,6 +16,8 @@ import hplcMsTicNegJcamp from "../../fixtures/lc_ms_jcamp_tic_neg"; import hplcMsUvvisJcamp from "../../fixtures/lc_ms_jcamp_uvvis"; import lcMsMzChemstationJcamp from "../../fixtures/lc_ms_jcamp_mz_chemstation"; import msJcamp from "../../fixtures/ms_jcamp"; +import gcJcamp from "../../fixtures/gc_1_jcamp"; +import hplcUvvisJcamp from "../../fixtures/hplc_uvvis_jcamp"; const hasSpectrumData = (entity: any) => { const data = entity?.spectra?.[0]?.data?.[0]; @@ -185,6 +187,56 @@ describe('LCMS ExtractJcamp', () => { const entity: any = ExtractJcamp(injected); expect(entity.layout).toEqual(LIST_LAYOUT.LC_MS); }); + + it('Extract a single-scan Chemstation MZ still resolves LC/MS', () => { + // The classifier runs before the ntuples->spectra expansion, so a genuine + // single-scan export reaches it with one spectrum. + const parts = lcMsMzChemstationJcamp.split('##PAGE='); + const single = `${parts[0]}##PAGE=${parts[1]}##END=\n`; + expect(single).not.toEqual(lcMsMzChemstationJcamp); + const entity: any = ExtractJcamp(single); + expect(entity.layout).toEqual(LIST_LAYOUT.LC_MS); + }); + + it('Extract Chemstation MZ is not demoted by an MS units line inside a comment', () => { + const injected = lcMsMzChemstationJcamp.replace( + '##DATA TYPE=MASS SPECTRUM', + '$$ ##UNITS= M/Z, RELATIVE ABUNDANCE, SECONDS\n##DATA TYPE=MASS SPECTRUM', + ); + expect(injected).not.toEqual(lcMsMzChemstationJcamp); + const entity: any = ExtractJcamp(injected); + expect(entity.layout).toEqual(LIST_LAYOUT.LC_MS); + }); + + it.each([ + ['MINUTES rather than SECONDS', 'M/Z, RELATIVE ABUNDANCE, MINUTES'], + ['a doubled space', 'M/Z, RELATIVE ABUNDANCE, SECONDS'], + ['a different vocabulary', 'MASS, INTENSITY, SECONDS'], + ])('Extract a plain MS jcamp keeps the MS layout with %s', (_label, units) => { + const varied = msJcamp.replace('M/Z, RELATIVE ABUNDANCE, SECONDS', units); + expect(varied).not.toEqual(msJcamp); + const entity: any = ExtractJcamp(varied); + expect(entity.layout).toEqual(LIST_LAYOUT.MS); + }); + + it.each([ + ['POSITIVE'], + ['STATIC'], + ['SPECTRUM'], + ])('Extract a plain MS jcamp keeps the MS layout with $CSCATEGORY=%s', (category) => { + const injected = msJcamp.replace( + '##DATA TYPE= MASS SPECTRUM', + `##$CSCATEGORY= ${category}\n##DATA TYPE= MASS SPECTRUM`, + ); + expect(injected).not.toEqual(msJcamp); + const entity: any = ExtractJcamp(injected); + expect(entity.layout).toEqual(LIST_LAYOUT.MS); + }); + + it('Extract neighbouring chromatography layouts is unaffected', () => { + expect(ExtractJcamp(gcJcamp).layout).not.toEqual(LIST_LAYOUT.LC_MS); + expect(ExtractJcamp(hplcUvvisJcamp).layout).not.toEqual(LIST_LAYOUT.LC_MS); + }); }); describe('LCMS grouping', () => { diff --git a/src/__tests__/units/features/lc-ms/parsing/chemstation.test.js b/src/__tests__/units/features/lc-ms/parsing/chemstation.test.js new file mode 100644 index 00000000..a3a463c5 --- /dev/null +++ b/src/__tests__/units/features/lc-ms/parsing/chemstation.test.js @@ -0,0 +1,195 @@ +import { + isChemstationLcms, + parseChemstationPages, +} from '../../../../../features/lc-ms/parsing/chemstation'; + +// A plain chem-spectra MS NTUPLES export: the time axis is a third INDEPENDENT +// variable and there is no PAGE. Shaped after src/__tests__/fixtures/ms_jcamp.js. +const msJcamp = (info = {}) => ({ + info: { + DATATYPE: 'MASS SPECTRUM', + VARTYPE: 'INDEPENDENT, DEPENDENT, INDEPENDENT', + SYMBOL: 'X, Y, T', + UNITS: 'M/Z, RELATIVE ABUNDANCE, SECONDS', + ...info, + }, + spectra: [ + { dataType: 'MASS SPECTRUM', page: 'T= 272', pageValue: 272 }, + { dataType: 'MASS SPECTRUM', page: 'T= 301', pageValue: 301 }, + ], +}); + +// A Chemstation LC/MS m/z export: scans are indexed by a retention-time PAGE +// variable. Shaped after src/__tests__/fixtures/lc_ms_jcamp_mz_chemstation.js. +const chemstationJcamp = (info = {}, spectra = null) => ({ + info: { + DATATYPE: 'MASS SPECTRUM', + VARTYPE: 'PAGE, X, Y', + SYMBOL: 'T, X, Y', + UNITS: ', m/z, Intensity', + XUNITS: 'm/z', + YUNITS: 'Intensity', + ...info, + }, + spectra: spectra || [ + { dataType: 'MASS SPECTRUM', page: 'T= 1.122', pageValue: 1.122 }, + { dataType: 'MASS SPECTRUM', page: 'T= 1.138', pageValue: 1.138 }, + ], +}); + +const SRC = '##TITLE=Spectrum\n##DATA TYPE=MASS SPECTRUM\n'; + +describe('lc-ms/parsing chemstation', () => { + describe('isChemstationLcms guards', () => { + it('returns false when the source is not a string', () => { + expect(isChemstationLcms(null, chemstationJcamp())).toBe(false); + expect(isChemstationLcms(undefined, chemstationJcamp())).toBe(false); + }); + }); + + describe('self-declared LC/MS content', () => { + it('accepts an LC/MS or MASS TIC root data type', () => { + expect(isChemstationLcms(SRC, { info: { DATATYPE: 'LC/MS' }, spectra: [] })).toBe(true); + expect(isChemstationLcms(SRC, { info: { DATATYPE: 'MASS TIC' }, spectra: [] })).toBe(true); + }); + + it('accepts an array-valued data type, as a repeated ##DATA TYPE produces', () => { + const jcamp = { info: { DATATYPE: ['LC/MS', 'LC/MS'] }, spectra: [] }; + expect(isChemstationLcms(SRC, jcamp)).toBe(true); + }); + + it('accepts a TIC block without needing any other hint', () => { + // Regression for the `|| spectra.length > 0` tautology: some() already + // implies a non-empty array, so that term made the branch unconditional. + const jcamp = { info: {}, spectra: [{ dataType: 'MASS TIC' }] }; + expect(isChemstationLcms(SRC, jcamp)).toBe(true); + }); + + it('does not read STATIC / KINETIC as TIC', () => { + expect(isChemstationLcms(SRC, { info: {}, spectra: [{ dataType: 'STATIC SPECTRUM' }] })).toBe(false); + expect(isChemstationLcms(SRC, msJcamp({ $CSCATEGORY: 'KINETIC' }))).toBe(false); + expect(isChemstationLcms(SRC, msJcamp({ $CSCATEGORY: 'OPTIC' }))).toBe(false); + }); + }); + + describe('structural discrimination by PAGE axis', () => { + it('keeps a plain MS NTUPLES export out of the LC/MS layout', () => { + expect(isChemstationLcms(SRC, msJcamp())).toBe(false); + }); + + it('accepts a Chemstation m/z export', () => { + expect(isChemstationLcms(SRC, chemstationJcamp())).toBe(true); + }); + + it('accepts a single-scan Chemstation export', () => { + // The classifier runs before the ntuples->spectra expansion in + // ExtractJcamp, so a genuine single-scan export arrives with one + // spectrum. Classification must not depend on the spectrum count. + const single = [{ dataType: 'MASS SPECTRUM', page: 'T= 1.122', pageValue: 1.122 }]; + expect(isChemstationLcms(SRC, chemstationJcamp({}, single))).toBe(true); + }); + + it('requires more than a PAGE axis, so 2D NMR is not swept in', () => { + const nmr = { + info: { DATATYPE: 'NMR SPECTRUM', VARTYPE: 'PAGE, X, Y' }, + spectra: [{ dataType: 'NMR SPECTRUM' }], + }; + expect(isChemstationLcms(SRC, nmr)).toBe(false); + }); + + it('treats an ##NTUPLES_PAGE_HEADER record as a page axis', () => { + const source = `${SRC}##NTUPLES_PAGE_HEADER= T\n`; + const jcamp = { + info: { DATATYPE: 'MASS SPECTRUM' }, + spectra: [{ dataType: 'MASS SPECTRUM' }], + }; + expect(isChemstationLcms(source, jcamp)).toBe(true); + }); + }); + + describe('units vocabulary does not decide the layout', () => { + // Each variant is a plain MS export whose units line differs only in + // spacing or wording. A classifier keyed on a units literal accepts them + // all as LC/MS; a structural one does not. + const variants = [ + 'M/Z , RELATIVE ABUNDANCE , SECONDS', + 'M/Z, RELATIVE ABUNDANCE, SECONDS', + 'M/Z, RELATIVE ABUNDANCE, MINUTES', + 'MASS, INTENSITY, SECONDS', + 'm/z, relative abundance, seconds', + ]; + + variants.forEach((units) => { + it(`keeps the MS layout for ##UNITS= ${units}`, () => { + const source = `${SRC}##UNITS= ${units}\n`; + expect(isChemstationLcms(source, msJcamp({ UNITS: units }))).toBe(false); + }); + }); + + it('is not vetoed by the MS units line appearing in a comment', () => { + const source = `${SRC}$$ ##UNITS= M/Z, RELATIVE ABUNDANCE, SECONDS\n`; + expect(isChemstationLcms(source, chemstationJcamp())).toBe(true); + }); + }); + + describe('$CSCATEGORY cannot promote a plain MS export', () => { + const categories = [ + 'POSITIVE', 'NEGATIVE', 'NEUTRAL', 'STATIC', 'KINETIC', 'OPTIC', 'SPECTRUM', + ]; + + categories.forEach((category) => { + it(`keeps the MS layout for a scalar $CSCATEGORY of ${category}`, () => { + expect(isChemstationLcms(SRC, msJcamp({ $CSCATEGORY: category }))).toBe(false); + }); + }); + + it('keeps the MS layout for array-valued categories too', () => { + expect(isChemstationLcms(SRC, msJcamp({ $CSCATEGORY: ['POSITIVE'] }))).toBe(false); + expect(isChemstationLcms(SRC, msJcamp({ $CSCATEGORY: ['SPECTRUM', 'EDIT_PEAK'] }))).toBe(false); + }); + + it('still reads a TIC or UVVIS category on a page-indexed file', () => { + expect(isChemstationLcms(SRC, chemstationJcamp({ $CSCATEGORY: 'TIC POSITIVE' }))).toBe(true); + expect(isChemstationLcms(SRC, chemstationJcamp({ $CSCATEGORY: ['UVVIS PEAK TABLE'] }))).toBe(true); + }); + }); + + describe('vendor fallback for exports without a page axis', () => { + // Retained deliberately: Chemstation exports that predate the page-indexed + // layout are identified by vendor metadata alone. + it('accepts a MASS SPECTRUM root carrying OpenLab or a scan mode', () => { + expect(isChemstationLcms(SRC, msJcamp({ SOFTWARE: 'openlab' }))).toBe(true); + expect(isChemstationLcms(SRC, msJcamp({ SCANMODE: 'positiv' }))).toBe(true); + }); + + it('does not accept a MASS SPECTRUM block or ##TYPE= MS SPECTRUM on its own', () => { + // These are exactly what a plain MS export carries, which is why they + // are not evidence of chromatography. + expect(isChemstationLcms(SRC, msJcamp())).toBe(false); + expect(isChemstationLcms(SRC, msJcamp({ TYPE: 'ms spectrum' }))).toBe(false); + }); + }); +}); + +describe('lc-ms/parsing parseChemstationPages', () => { + it('returns an empty list when there are no pages', () => { + expect(parseChemstationPages(null, {})).toEqual([]); + expect(parseChemstationPages('##TITLE=x\n', {})).toEqual([]); + }); + + it('splits a multi-page source into one spectrum per page', () => { + const source = [ + '##TITLE=Spectrum', + '##PAGE=T= 1.5', + '##XYDATA=(XY..XY)', + '100.0, 5.0', + '##PAGE=T= 2.5', + '##XYDATA=(XY..XY)', + '200.0, 6.0', + '', + ].join('\n'); + const pages = parseChemstationPages(source, { info: {}, spectra: [] }); + expect(pages).toHaveLength(2); + expect(pages.map((p) => p.pageValue)).toEqual([1.5, 2.5]); + }); +}); diff --git a/src/features/lc-ms/parsing/chemstation.js b/src/features/lc-ms/parsing/chemstation.js index 12d6bb8a..74c0e79e 100644 --- a/src/features/lc-ms/parsing/chemstation.js +++ b/src/features/lc-ms/parsing/chemstation.js @@ -1,4 +1,5 @@ import { parsePageValue } from './lcmsMsPage'; +import { normalizeLcMsMode } from './lcmsCategory'; export const parseChemstationPages = (source, jcamp) => { if (typeof source !== 'string') return []; @@ -57,91 +58,76 @@ export const parseChemstationPages = (source, jcamp) => { return spectra; }; +// `\bTIC\b` rather than includes('TIC'), so STATIC / KINETIC / SYNTHETIC / OPTIC +// do not read as a total-ion chromatogram. +const TIC_TOKEN = /\bTIC\b/; + +// A JCAMP label repeated across blocks arrives from jcampconverter as an array +// rather than a string, so every record read here is normalised to a list. +const upperList = (value) => [] + .concat(value == null ? [] : value) + .map((entry) => String(entry).toUpperCase()); + +const upperTokens = (value) => upperList(value) + .reduce((acc, entry) => acc.concat(entry.split(/[,;]/)), []) + .map((token) => token.trim()) + .filter(Boolean); + export const isChemstationLcms = (source, jcamp) => { if (typeof source !== 'string') return false; - const dt = String(jcamp?.dataType || jcamp?.info?.DATATYPE || '').toUpperCase(); - if (dt.includes('LC/MS') || dt.includes('MASS TIC')) return true; - const spectra = Array.isArray(jcamp?.spectra) ? jcamp.spectra : []; const info = jcamp?.info || {}; - const scanMode = String(info.SCAN_MODE || info.SCANMODE || '').toUpperCase(); + const spectra = Array.isArray(jcamp?.spectra) ? jcamp.spectra : []; + // jcampconverter leaves the root `dataType` undefined and canonicalises + // `##DATA TYPE` to info.DATATYPE, which may be an array on repeat. + const dataTypes = upperList(jcamp?.dataType ?? info.DATATYPE); + const spectrumDataTypes = spectra.map((s) => String(s?.dataType || '').toUpperCase()); const type = String(info.TYPE || '').toUpperCase(); - const software = String(info.SOFTWARE || '').toUpperCase(); - const categories = [] - .concat(jcamp?.info?.$CSCATEGORY || []) - .map((c) => String(c).toUpperCase()); - - const hasPolarityCategory = categories.some( - (c) => c.includes('POSITIVE') || c.includes('NEGATIVE') || c.includes('NEUTRAL'), - ); - const hasTicOrUvvisCategory = categories.some( - (c) => c.includes('TIC') || c.includes('UVVIS'), - ); - const hasHplcUvvisSpectrumDataType = spectra.some((s) => { - const sdt = String(s?.dataType || '').toUpperCase(); - return sdt.includes('HPLC UV-VIS') || sdt.includes('UVVIS'); - }); - const hasMassTicSpectrumDataType = spectra.some((s) => { - const sdt = String(s?.dataType || '').toUpperCase(); - return sdt.includes('MASS TIC') || sdt.includes('TIC'); - }); - const hasMassSpectrumDataType = spectra.some((s) => { - const sdt = String(s?.dataType || '').toUpperCase(); - return sdt.includes('MASS SPECTRUM'); - }); - const hasMassSpectrumRootDataType = dt.includes('MASS SPECTRUM'); - const hasScanModeHint = ( - scanMode.includes('POSITIVE') - || scanMode.includes('NEGATIVE') - || scanMode.includes('POSITIV') - || scanMode.includes('NEGATIV') - ); - const hasTypeHint = type.includes('MS SPECTRUM') || type.includes('MS CHROMATOGRAM'); - const hasSoftwareHint = software.includes('OPENLAB'); - const hasMultipleSpectra = spectra.length > 1; - const hasPageMetadata = spectra.some((s) => s?.page != null || s?.pageValue != null); - - const hasNtuplesPageHeader = /##NTUPLES_PAGE_HEADER\s*=/.test(source); - if (hasNtuplesPageHeader && ( - hasTicOrUvvisCategory - || hasHplcUvvisSpectrumDataType - || hasMassTicSpectrumDataType - || (hasMassSpectrumDataType && hasPolarityCategory) - )) { - return true; - } - if (hasMultipleSpectra && hasPageMetadata && ( - hasTicOrUvvisCategory - || hasHplcUvvisSpectrumDataType - || hasMassTicSpectrumDataType - || (hasMassSpectrumDataType && hasPolarityCategory) - )) { - return true; - } + // 1. The file declares itself an LC/MS run or a total-ion chromatogram. + if (dataTypes.some((d) => d.includes('LC/MS') || d.includes('MASS TIC'))) return true; - if ( - hasMassSpectrumRootDataType - && (hasScanModeHint || hasTypeHint || hasSoftwareHint || hasTicOrUvvisCategory) - ) { - return true; - } - if ( - hasMassTicSpectrumDataType - && (hasTypeHint || hasSoftwareHint || hasScanModeHint || spectra.length > 0) - ) { - return true; - } + // 2. A block declares chromatogram content. + if (spectrumDataTypes.some((d) => TIC_TOKEN.test(d))) return true; + if (type.includes('MS CHROMATOGRAM')) return true; - // A multi-page MASS SPECTRUM root with no other chromatographic signal is ambiguous: - // both a genuine Chemstation m/z export and a plain multi-page MS NTUPLES export look - // like this. The only field that reliably tells them apart is this units triplet, which - // is unique to the plain export - so it only vetoes this weak fallback, never the - // stronger positive evidence handled above. - if (hasMassSpectrumRootDataType && hasMultipleSpectra && hasPageMetadata) { - const hasMsUnitsTriplet = /##UNITS\s*=\s*M\/Z,\s*RELATIVE ABUNDANCE,\s*SECONDS/i.test(source); - return !hasMsUnitsTriplet; + const hasMassSpectrumRootDataType = dataTypes.some((d) => d.includes('MASS SPECTRUM')); + const scanMode = normalizeLcMsMode(info.SCAN_MODE ?? info.SCANMODE); + const software = String(info.SOFTWARE || '').toUpperCase(); + + // 3. Structural discriminator. A Chemstation LC/MS export indexes its m/z scans + // by a retention-time PAGE variable (`##VAR_TYPE= PAGE, X, Y`); that page axis + // is what makes the file chromatographic. A plain chem-spectra MS NTUPLES + // export declares its time axis as a third INDEPENDENT variable + // (`##VAR_TYPE= INDEPENDENT, DEPENDENT, INDEPENDENT`, `##SYMBOL= X, Y, T`) and + // never declares a PAGE. Deciding on structure rather than on a units literal + // means writer-to-writer differences in spacing or vocabulary cannot flip it. + // Note `##VAR_TYPE` reaches us as info.VARTYPE - canonicDataLabels strips the + // underscore and uppercases - and `##NTUPLES` is not retained at all. + const hasPageAxis = upperTokens(info.VARTYPE).includes('PAGE') + || /##NTUPLES_PAGE_HEADER\s*=/.test(source); + + if (hasPageAxis) { + // A PAGE axis alone is not conclusive - 2D NMR is page-indexed too - so the + // page-indexed content still has to look like LC/MS. + const categories = upperList(info.$CSCATEGORY); + return ( + hasMassSpectrumRootDataType + || spectrumDataTypes.some((d) => d.includes('MASS SPECTRUM')) + || spectrumDataTypes.some((d) => d.includes('HPLC UV-VIS') || d.includes('UVVIS')) + || categories.some((c) => TIC_TOKEN.test(c) || c.includes('UVVIS')) + || categories.some((c) => ( + c.includes('POSITIVE') || c.includes('NEGATIVE') || c.includes('NEUTRAL') + )) + || scanMode !== 'NEUTRAL' + || software.includes('OPENLAB') + ); } - return false; + // 4. No page axis: fall back to vendor identity for the Chemstation exports that + // predate the page-indexed layout. Deliberately excludes a MASS SPECTRUM + // per-block dataType and `##TYPE= MS SPECTRUM` - a plain MS export carries + // both, and treating them as chromatographic evidence is what made a plain + // file render as LC/MS in the first place. + return hasMassSpectrumRootDataType && (software.includes('OPENLAB') || scanMode !== 'NEUTRAL'); }; diff --git a/src/helpers/chem.js b/src/helpers/chem.js index 338338ed..a2cd9985 100644 --- a/src/helpers/chem.js +++ b/src/helpers/chem.js @@ -1091,7 +1091,7 @@ const ExtractJcamp = (source) => { source, { xy: true, - keepRecordsRegExp: /(\$CSTHRESHOLD|\$CSSCANAUTOTARGET|\$CSSCANEDITTARGET|\$CSSCANCOUNT|\$CSSOLVENTNAME|\$CSSOLVENTVALUE|\$CSSOLVENTX|\$CSCATEGORY|\$CSITAREA|\$CSITFACTOR|\$OBSERVEDINTEGRALS|\$OBSERVEDINTEGRALSGROUPS|\$OBSERVEDMULTIPLETS|\$OBSERVEDMULTIPLETSPEAKS|\.SOLVENTNAME|\.OBSERVEFREQUENCY|\$CSSIMULATIONPEAKS|\$CSUPPERTHRESHOLD|\$CSLOWERTHRESHOLD|\$CSCYCLICVOLTAMMETRYDATA|UNITS|SYMBOL|\$CSAUTOMETADATA|\$DETECTOR|MN|MW|D|MP|MELTINGPOINT|TG|\$CSSCANRATE|\$CSSPECTRUMDIRECTION|\$CSWEAREAVALUE|\$CSWEAREAUNIT|\$CSCURRENTMODE|\$CSLCMSMZPAGE|SCAN_MODE|SCANMODE|TYPE|SOFTWARE|DATATYPE)/, // eslint-disable-line + keepRecordsRegExp: /(\$CSTHRESHOLD|\$CSSCANAUTOTARGET|\$CSSCANEDITTARGET|\$CSSCANCOUNT|\$CSSOLVENTNAME|\$CSSOLVENTVALUE|\$CSSOLVENTX|\$CSCATEGORY|\$CSITAREA|\$CSITFACTOR|\$OBSERVEDINTEGRALS|\$OBSERVEDINTEGRALSGROUPS|\$OBSERVEDMULTIPLETS|\$OBSERVEDMULTIPLETSPEAKS|\.SOLVENTNAME|\.OBSERVEFREQUENCY|\$CSSIMULATIONPEAKS|\$CSUPPERTHRESHOLD|\$CSLOWERTHRESHOLD|\$CSCYCLICVOLTAMMETRYDATA|UNITS|SYMBOL|\$CSAUTOMETADATA|\$DETECTOR|MN|MW|D|MP|MELTINGPOINT|TG|\$CSSCANRATE|\$CSSPECTRUMDIRECTION|\$CSWEAREAVALUE|\$CSWEAREAUNIT|\$CSCURRENTMODE|\$CSLCMSMZPAGE|SCAN_MODE|SCANMODE|VAR_TYPE|VARTYPE|TYPE|SOFTWARE|DATATYPE)/, // eslint-disable-line }, ); const isChemstation = isChemstationLcms(source, jcamp);