Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 130 additions & 0 deletions dist/features/lc-ms/entities/timeAxis.js
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions dist/layer_init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)) {
Expand Down
120 changes: 120 additions & 0 deletions src/__tests__/units/features/lc-ms/entities/timeAxis.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading