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; }