From 0ff6702e46cd749f0603fa55a56e49d495a621be Mon Sep 17 00:00:00 2001 From: PiTrem Date: Wed, 26 Aug 2026 16:36:02 +0200 Subject: [PATCH] fix(lcms): correct a UV/VIS axis that arrives in seconds behind a MINUTES label An LC/MS dataset rendered its UV/VIS trace across 600 "minutes" while its TIC covered 10. Both describe the same injection, so one of them was wrong: 599.825 seconds is 9.997 minutes, matching the TIC's 9.9994 to three decimals. chemotion-converter-app 1.9.3 emits that UV/VIS axis in seconds while labelling it ##XUNITS=MINUTES, and the ##UNITS X slot agrees, so nothing inside the file contradicts the label. chem.js's seconds-to-minutes pass is vetoed by the explicit MINUTES, and its magnitude check - which would have caught a 600-long axis - never runs. No per-file heuristic can catch this, because the file is internally consistent and simply wrong. The sibling TIC is independent evidence. Reconcile the group's time axes before the curves are dispatched: when a UV/VIS span is 30-120x its TIC's, that is a unit mismatch rather than a longer run, so rescale it. Healthy exports from the same instrument sit near 1 (a second dataset here measures 19.96 against 15.98, a ratio of 1.25), so the window is far from any plausible true value. Only the UV/VIS is adjusted, and only against a TIC. A TIC that is itself in seconds is chem.js's job at parse time, where the file's own units still say so; the m/z entity is never touched, since its x is m/z rather than time. Applied in layer_init before setAllCurves rather than inside a reducer, because reducer_curve and reducer_hplc_ms both consume that one action's payload and must see the same corrected entities. Scaling covers every x-bearing field a curve carries - data blocks, peaks, integrations and the cached extrema - so a curve corrected here is indistinguishable from one that arrived correct, and the xUnit is left agreeing with the data. Verified against both real converter outputs: the mislabelled dataset's UV/VIS goes from 0..599.825 to 0..9.997 beside its 0.005..9.999 TIC, and the consistent dataset is returned untouched, by reference. The converter remains the root cause and is worth fixing there too; this keeps already-converted datasets rendering correctly meanwhile. --- dist/features/lc-ms/entities/timeAxis.js | 130 ++++++++++++++++++ dist/layer_init.js | 5 +- .../features/lc-ms/entities/timeAxis.test.js | 120 ++++++++++++++++ src/features/lc-ms/entities/timeAxis.js | 128 +++++++++++++++++ src/layer_init.js | 5 +- 5 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 dist/features/lc-ms/entities/timeAxis.js create mode 100644 src/__tests__/units/features/lc-ms/entities/timeAxis.test.js create mode 100644 src/features/lc-ms/entities/timeAxis.js diff --git a/dist/features/lc-ms/entities/timeAxis.js b/dist/features/lc-ms/entities/timeAxis.js new file mode 100644 index 00000000..99d34ae0 --- /dev/null +++ b/dist/features/lc-ms/entities/timeAxis.js @@ -0,0 +1,130 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.reconcileLcMsTimeAxes = exports.maxTimeOf = exports.default = void 0; +var _extractEntityLCMS = require("./extractEntityLCMS"); +const SECONDS_PER_MINUTE = 60; + +// Every member of an LC/MS group describes one injection, so their retention-time axes +// must mean the same thing. A converter can get that wrong for a single file: +// chemotion-converter-app 1.9.3 emits the UV/VIS axis in seconds while labelling it +// `##XUNITS=MINUTES` (and the `##UNITS` X slot agrees), so nothing inside that file +// contradicts the label and no per-file heuristic can catch it - chem.js's own +// seconds-to-minutes pass is vetoed by the explicit MINUTES it declares. +// +// The sibling TIC is the independent evidence. Both cover the same run, so their spans +// should be comparable; a UV/VIS span some 60x its TIC's is a unit mismatch rather than a +// longer run. Observed: a UV/VIS of 0..599.825 "minutes" beside a TIC of 0.005..9.999, +// a ratio of 59.99, where the same instrument's healthy exports sit near 1 (19.96 vs +// 15.98 = 1.25). The window below is therefore far from any plausible true ratio. +const MIN_SECONDS_RATIO = 30; +const MAX_SECONDS_RATIO = 120; +const finite = value => { + const num = Number(value); + return Number.isFinite(num) ? num : null; +}; + +// The largest x across a curve's blocks. Reads features and spectra alike, since an entity +// carries the same data under both and either may be the populated one. +const maxTimeOf = entity => { + const blocks = [].concat(Array.isArray(entity?.features) ? entity.features : []).concat(Array.isArray(entity?.spectra) ? entity.spectra : []); + let max = null; + blocks.forEach(block => { + const xs = block?.data?.[0]?.x; + if (!Array.isArray(xs)) return; + xs.forEach(value => { + const num = finite(value); + if (num !== null && (max === null || num > max)) max = num; + }); + }); + return max; +}; + +// Scales every x-bearing field a curve carries. Mirrors the field list chem.js already +// scales when it converts at parse time (data blocks, peaks, integrations and the cached +// extrema), so a curve corrected here is indistinguishable from one that arrived correct. +exports.maxTimeOf = maxTimeOf; +const scaleBlock = (block, factor) => { + if (!block) return block; + const scale = value => { + const num = finite(value); + return num === null ? value : num * factor; + }; + const next = { + ...block + }; + if (Array.isArray(block.data)) { + next.data = block.data.map(d => Array.isArray(d?.x) ? { + ...d, + x: d.x.map(scale) + } : d); + } + if (Array.isArray(block.peaks)) { + next.peaks = block.peaks.map(p => ({ + ...p, + x: scale(p.x) + })); + } + if (Array.isArray(block.integrations)) { + next.integrations = block.integrations.map(integ => ({ + ...integ, + xL: scale(integ.xL), + xU: scale(integ.xU), + xExtent: integ.xExtent ? { + ...integ.xExtent, + xL: scale(integ.xExtent.xL), + xU: scale(integ.xExtent.xU) + } : integ.xExtent + })); + } + if (block.maxX !== undefined) next.maxX = scale(block.maxX); + if (block.minX !== undefined) next.minX = scale(block.minX); + // The declared unit was the thing that was wrong; leave it agreeing with the data. + if (typeof block.xUnit === 'string' && /SECOND|MINUTE|TIME/i.test(block.xUnit)) { + next.xUnit = 'MINUTES'; + } + return next; +}; +const scaleEntityTime = (entity, factor) => { + const next = { + ...entity + }; + if (Array.isArray(entity.features)) { + next.features = entity.features.map(f => scaleBlock(f, factor)); + } + if (Array.isArray(entity.spectra)) { + next.spectra = entity.spectra.map(s => scaleBlock(s, factor)); + } + return next; +}; + +/** + * Reconciles the retention-time axes of an LC/MS group against each other, correcting a + * UV/VIS trace that arrived in seconds behind a MINUTES label. + * + * Deliberately one-directional: only the UV/VIS is adjusted, and only against a TIC. A TIC + * that is itself in seconds is chem.js's job, at parse time, where the file's own units + * still say so. Returns the input untouched when there is nothing to reconcile, so callers + * can apply it unconditionally. + */ +const reconcileLcMsTimeAxes = (entities = []) => { + if (!Array.isArray(entities) || entities.length < 2) return entities; + const kinds = entities.map(e => (0, _extractEntityLCMS.getLcMsInfo)(e).kind); + const ticMax = entities.filter((_, i) => kinds[i] === 'tic').map(maxTimeOf).filter(v => v !== null && v > 0).reduce((acc, v) => acc === null || v > acc ? v : acc, null); + if (ticMax === null) return entities; + let changed = false; + const next = entities.map((entity, i) => { + if (kinds[i] !== 'uvvis') return entity; + const uvvisMax = maxTimeOf(entity); + if (uvvisMax === null || uvvisMax <= 0) return entity; + const ratio = uvvisMax / ticMax; + if (ratio < MIN_SECONDS_RATIO || ratio > MAX_SECONDS_RATIO) return entity; + changed = true; + return scaleEntityTime(entity, 1 / SECONDS_PER_MINUTE); + }); + return changed ? next : entities; +}; +exports.reconcileLcMsTimeAxes = reconcileLcMsTimeAxes; +var _default = exports.default = reconcileLcMsTimeAxes; \ No newline at end of file diff --git a/dist/layer_init.js b/dist/layer_init.js index 17fd8df5..f9b369d3 100644 --- a/dist/layer_init.js +++ b/dist/layer_init.js @@ -18,6 +18,7 @@ var _jcamp = require("./actions/jcamp"); var _layer_prism = _interopRequireDefault(require("./layer_prism")); var _format = _interopRequireDefault(require("./helpers/format")); var _extractEntityLCMS = require("./helpers/extractEntityLCMS"); +var _timeAxis = require("./features/lc-ms/entities/timeAxis"); var _multi_jcamps_viewer = _interopRequireDefault(require("./components/multi_jcamps_viewer")); var _hplc_viewer = _interopRequireDefault(require("./components/hplc_viewer")); var _curve = require("./actions/curve"); @@ -196,12 +197,12 @@ class LayerInit extends _react.default.Component { const isMultiSpectra = Array.isArray(multiEntities) && multiEntities.length > 1; if (isMultiSpectra) { const meta = _format.default.isLCMsLayout(entity.layout) ? lcmsCurveMeta() : undefined; - setAllCurvesAct(multiEntities, meta); + setAllCurvesAct((0, _timeAxis.reconcileLcMsTimeAxes)(multiEntities), meta); return; } if (_format.default.isLCMsLayout(entity.layout)) { const payload = Array.isArray(multiEntities) && multiEntities.length > 0 ? multiEntities : [entity]; - setAllCurvesAct(payload, lcmsCurveMeta()); + setAllCurvesAct((0, _timeAxis.reconcileLcMsTimeAxes)(payload), lcmsCurveMeta()); return; } if (_format.default.isCyclicVoltaLayout(entity.layout)) { diff --git a/src/__tests__/units/features/lc-ms/entities/timeAxis.test.js b/src/__tests__/units/features/lc-ms/entities/timeAxis.test.js new file mode 100644 index 00000000..0d1f60a7 --- /dev/null +++ b/src/__tests__/units/features/lc-ms/entities/timeAxis.test.js @@ -0,0 +1,120 @@ +import { reconcileLcMsTimeAxes, maxTimeOf } from '../../../../../features/lc-ms/entities/timeAxis'; + +// Shaped after the real converter output. example2 (X32962) emits a UV/VIS axis in seconds +// labelled `##XUNITS=MINUTES` beside a TIC in genuine minutes; example1 (SVS-486F3) is +// consistent. The numbers below are the measured spans of those two datasets. +const uvvis = (maxX, extra = {}) => ({ + layout: 'LC/MS', + spectra: [{ + dataType: 'HPLC UV-VIS', + csCategory: 'UVVIS PEAK TABLE', + xUnit: 'MINUTES', + data: [{ x: [0, maxX / 2, maxX], y: [1, 2, 3] }], + maxX, + minX: 0, + ...extra, + }], +}); + +const tic = (maxX) => ({ + layout: 'LC/MS', + spectra: [{ + dataType: 'MASS TIC', + xUnit: 'MINUTES', + data: [{ x: [0, maxX / 2, maxX], y: [10, 20, 30] }], + }], +}); + +const mz = (maxX) => ({ + layout: 'LC/MS', + spectra: [{ + dataType: 'MASS SPECTRUM', + xUnit: 'm/z', + page: 'T= 1.0', + pageValue: 1.0, + data: [{ x: [100, maxX / 2, maxX], y: [5, 6, 7] }], + }], +}); + +const xOf = (entity) => entity.spectra[0].data[0].x; + +describe('reconcileLcMsTimeAxes', () => { + it('rescales a UV/VIS trace that is ~60x its TIC, the example2 case', () => { + const [fixedUvvis, fixedTic] = reconcileLcMsTimeAxes([uvvis(599.825), tic(9.9994)]); + expect(xOf(fixedUvvis)[2]).toBeCloseTo(9.997, 3); + // the TIC is the reference and must not move + expect(xOf(fixedTic)[2]).toBeCloseTo(9.9994, 4); + }); + + it('leaves a consistent group alone, the example1 case', () => { + const entities = [uvvis(19.9613), tic(15.98)]; + const out = reconcileLcMsTimeAxes(entities); + // returned by reference, so an unnecessary re-render is not triggered either + expect(out).toBe(entities); + expect(xOf(out[0])[2]).toBeCloseTo(19.9613, 4); + }); + + it('never rescales the m/z entity, whose x is m/z rather than time', () => { + // m/z runs to ~1450 against a 10-minute TIC; only the kind gate keeps it safe if the + // ratio window is ever widened. + const entities = [mz(1448.2), tic(9.9994)]; + const out = reconcileLcMsTimeAxes(entities); + expect(xOf(out[0])[2]).toBeCloseTo(1448.2, 3); + }); + + it('does nothing without a TIC to compare against', () => { + const entities = [uvvis(599.825), mz(1448.2)]; + expect(reconcileLcMsTimeAxes(entities)).toBe(entities); + }); + + it('does nothing for a lone entity, or a non-array input', () => { + const one = [uvvis(599.825)]; + expect(reconcileLcMsTimeAxes(one)).toBe(one); + expect(reconcileLcMsTimeAxes([])).toEqual([]); + expect(reconcileLcMsTimeAxes(undefined)).toEqual([]); + }); + + it('ignores a ratio that is merely large but not a unit mismatch', () => { + // 20x is not 60x: a real span difference, however odd, is not evidence of seconds. + const entities = [uvvis(200), tic(10)]; + expect(reconcileLcMsTimeAxes(entities)).toBe(entities); + }); + + it('scales peaks, integrations and the cached extrema, not just the data block', () => { + const entities = [ + uvvis(599.825, { + peaks: [{ x: 300, y: 5 }], + integrations: [{ xL: 60, xU: 120, xExtent: { xL: 60, xU: 120 } }], + }), + tic(9.9994), + ]; + const [fixed] = reconcileLcMsTimeAxes(entities); + const s = fixed.spectra[0]; + expect(s.peaks[0].x).toBeCloseTo(5, 6); + expect(s.integrations[0].xL).toBeCloseTo(1, 6); + expect(s.integrations[0].xU).toBeCloseTo(2, 6); + expect(s.integrations[0].xExtent).toEqual({ xL: 1, xU: 2 }); + expect(s.maxX).toBeCloseTo(9.997, 3); + expect(s.minX).toBeCloseTo(0, 6); + // the label was the thing that lied; leave it agreeing with the data + expect(s.xUnit).toEqual('MINUTES'); + }); + + it('does not mutate the entities it was given', () => { + const entities = [uvvis(599.825), tic(9.9994)]; + reconcileLcMsTimeAxes(entities); + expect(xOf(entities[0])[2]).toBeCloseTo(599.825, 3); + }); +}); + +describe('maxTimeOf', () => { + it('reads the largest x across data blocks', () => { + expect(maxTimeOf(tic(9.9994))).toBeCloseTo(9.9994, 4); + }); + + it('returns null when there is no usable x', () => { + expect(maxTimeOf({})).toBeNull(); + expect(maxTimeOf({ spectra: [{ data: [{ x: [] }] }] })).toBeNull(); + expect(maxTimeOf({ spectra: [{ data: [{ x: [NaN, undefined] }] }] })).toBeNull(); + }); +}); diff --git a/src/features/lc-ms/entities/timeAxis.js b/src/features/lc-ms/entities/timeAxis.js new file mode 100644 index 00000000..0ba7d0ee --- /dev/null +++ b/src/features/lc-ms/entities/timeAxis.js @@ -0,0 +1,128 @@ +import { getLcMsInfo } from './extractEntityLCMS'; + +const SECONDS_PER_MINUTE = 60; + +// Every member of an LC/MS group describes one injection, so their retention-time axes +// must mean the same thing. A converter can get that wrong for a single file: +// chemotion-converter-app 1.9.3 emits the UV/VIS axis in seconds while labelling it +// `##XUNITS=MINUTES` (and the `##UNITS` X slot agrees), so nothing inside that file +// contradicts the label and no per-file heuristic can catch it - chem.js's own +// seconds-to-minutes pass is vetoed by the explicit MINUTES it declares. +// +// The sibling TIC is the independent evidence. Both cover the same run, so their spans +// should be comparable; a UV/VIS span some 60x its TIC's is a unit mismatch rather than a +// longer run. Observed: a UV/VIS of 0..599.825 "minutes" beside a TIC of 0.005..9.999, +// a ratio of 59.99, where the same instrument's healthy exports sit near 1 (19.96 vs +// 15.98 = 1.25). The window below is therefore far from any plausible true ratio. +const MIN_SECONDS_RATIO = 30; +const MAX_SECONDS_RATIO = 120; + +const finite = (value) => { + const num = Number(value); + return Number.isFinite(num) ? num : null; +}; + +// The largest x across a curve's blocks. Reads features and spectra alike, since an entity +// carries the same data under both and either may be the populated one. +export const maxTimeOf = (entity) => { + const blocks = [] + .concat(Array.isArray(entity?.features) ? entity.features : []) + .concat(Array.isArray(entity?.spectra) ? entity.spectra : []); + let max = null; + blocks.forEach((block) => { + const xs = block?.data?.[0]?.x; + if (!Array.isArray(xs)) return; + xs.forEach((value) => { + const num = finite(value); + if (num !== null && (max === null || num > max)) max = num; + }); + }); + return max; +}; + +// Scales every x-bearing field a curve carries. Mirrors the field list chem.js already +// scales when it converts at parse time (data blocks, peaks, integrations and the cached +// extrema), so a curve corrected here is indistinguishable from one that arrived correct. +const scaleBlock = (block, factor) => { + if (!block) return block; + const scale = (value) => { + const num = finite(value); + return num === null ? value : num * factor; + }; + const next = { ...block }; + + if (Array.isArray(block.data)) { + next.data = block.data.map((d) => ( + Array.isArray(d?.x) ? { ...d, x: d.x.map(scale) } : d + )); + } + if (Array.isArray(block.peaks)) { + next.peaks = block.peaks.map((p) => ({ ...p, x: scale(p.x) })); + } + if (Array.isArray(block.integrations)) { + next.integrations = block.integrations.map((integ) => ({ + ...integ, + xL: scale(integ.xL), + xU: scale(integ.xU), + xExtent: integ.xExtent + ? { ...integ.xExtent, xL: scale(integ.xExtent.xL), xU: scale(integ.xExtent.xU) } + : integ.xExtent, + })); + } + if (block.maxX !== undefined) next.maxX = scale(block.maxX); + if (block.minX !== undefined) next.minX = scale(block.minX); + // The declared unit was the thing that was wrong; leave it agreeing with the data. + if (typeof block.xUnit === 'string' && /SECOND|MINUTE|TIME/i.test(block.xUnit)) { + next.xUnit = 'MINUTES'; + } + return next; +}; + +const scaleEntityTime = (entity, factor) => { + const next = { ...entity }; + if (Array.isArray(entity.features)) { + next.features = entity.features.map((f) => scaleBlock(f, factor)); + } + if (Array.isArray(entity.spectra)) { + next.spectra = entity.spectra.map((s) => scaleBlock(s, factor)); + } + return next; +}; + +/** + * Reconciles the retention-time axes of an LC/MS group against each other, correcting a + * UV/VIS trace that arrived in seconds behind a MINUTES label. + * + * Deliberately one-directional: only the UV/VIS is adjusted, and only against a TIC. A TIC + * that is itself in seconds is chem.js's job, at parse time, where the file's own units + * still say so. Returns the input untouched when there is nothing to reconcile, so callers + * can apply it unconditionally. + */ +export const reconcileLcMsTimeAxes = (entities = []) => { + if (!Array.isArray(entities) || entities.length < 2) return entities; + + const kinds = entities.map((e) => getLcMsInfo(e).kind); + const ticMax = entities + .filter((_, i) => kinds[i] === 'tic') + .map(maxTimeOf) + .filter((v) => v !== null && v > 0) + .reduce((acc, v) => (acc === null || v > acc ? v : acc), null); + if (ticMax === null) return entities; + + let changed = false; + const next = entities.map((entity, i) => { + if (kinds[i] !== 'uvvis') return entity; + const uvvisMax = maxTimeOf(entity); + if (uvvisMax === null || uvvisMax <= 0) return entity; + + const ratio = uvvisMax / ticMax; + if (ratio < MIN_SECONDS_RATIO || ratio > MAX_SECONDS_RATIO) return entity; + + changed = true; + return scaleEntityTime(entity, 1 / SECONDS_PER_MINUTE); + }); + + return changed ? next : entities; +}; + +export default reconcileLcMsTimeAxes; diff --git a/src/layer_init.js b/src/layer_init.js index f9804c5a..7456c183 100644 --- a/src/layer_init.js +++ b/src/layer_init.js @@ -17,6 +17,7 @@ import { addOthers } from './actions/jcamp'; import LayerPrism from './layer_prism'; import Format from './helpers/format'; import { getLcMsInfo, isLcMsGroup } from './helpers/extractEntityLCMS'; +import { reconcileLcMsTimeAxes } from './features/lc-ms/entities/timeAxis'; import MultiJcampsViewer from './components/multi_jcamps_viewer'; import HPLCViewer from './components/hplc_viewer'; import { setAllCurves } from './actions/curve'; @@ -182,7 +183,7 @@ class LayerInit extends React.Component { const isMultiSpectra = Array.isArray(multiEntities) && multiEntities.length > 1; if (isMultiSpectra) { const meta = Format.isLCMsLayout(entity.layout) ? lcmsCurveMeta() : undefined; - setAllCurvesAct(multiEntities, meta); + setAllCurvesAct(reconcileLcMsTimeAxes(multiEntities), meta); return; } @@ -190,7 +191,7 @@ class LayerInit extends React.Component { const payload = (Array.isArray(multiEntities) && multiEntities.length > 0) ? multiEntities : [entity]; - setAllCurvesAct(payload, lcmsCurveMeta()); + setAllCurvesAct(reconcileLcMsTimeAxes(payload), lcmsCurveMeta()); return; }