Skip to content

Commit 0ff6702

Browse files
committed
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.
1 parent 1abc65d commit 0ff6702

5 files changed

Lines changed: 384 additions & 4 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use strict";
2+
3+
Object.defineProperty(exports, "__esModule", {
4+
value: true
5+
});
6+
exports.reconcileLcMsTimeAxes = exports.maxTimeOf = exports.default = void 0;
7+
var _extractEntityLCMS = require("./extractEntityLCMS");
8+
const SECONDS_PER_MINUTE = 60;
9+
10+
// Every member of an LC/MS group describes one injection, so their retention-time axes
11+
// must mean the same thing. A converter can get that wrong for a single file:
12+
// chemotion-converter-app 1.9.3 emits the UV/VIS axis in seconds while labelling it
13+
// `##XUNITS=MINUTES` (and the `##UNITS` X slot agrees), so nothing inside that file
14+
// contradicts the label and no per-file heuristic can catch it - chem.js's own
15+
// seconds-to-minutes pass is vetoed by the explicit MINUTES it declares.
16+
//
17+
// The sibling TIC is the independent evidence. Both cover the same run, so their spans
18+
// should be comparable; a UV/VIS span some 60x its TIC's is a unit mismatch rather than a
19+
// longer run. Observed: a UV/VIS of 0..599.825 "minutes" beside a TIC of 0.005..9.999,
20+
// a ratio of 59.99, where the same instrument's healthy exports sit near 1 (19.96 vs
21+
// 15.98 = 1.25). The window below is therefore far from any plausible true ratio.
22+
const MIN_SECONDS_RATIO = 30;
23+
const MAX_SECONDS_RATIO = 120;
24+
const finite = value => {
25+
const num = Number(value);
26+
return Number.isFinite(num) ? num : null;
27+
};
28+
29+
// The largest x across a curve's blocks. Reads features and spectra alike, since an entity
30+
// carries the same data under both and either may be the populated one.
31+
const maxTimeOf = entity => {
32+
const blocks = [].concat(Array.isArray(entity?.features) ? entity.features : []).concat(Array.isArray(entity?.spectra) ? entity.spectra : []);
33+
let max = null;
34+
blocks.forEach(block => {
35+
const xs = block?.data?.[0]?.x;
36+
if (!Array.isArray(xs)) return;
37+
xs.forEach(value => {
38+
const num = finite(value);
39+
if (num !== null && (max === null || num > max)) max = num;
40+
});
41+
});
42+
return max;
43+
};
44+
45+
// Scales every x-bearing field a curve carries. Mirrors the field list chem.js already
46+
// scales when it converts at parse time (data blocks, peaks, integrations and the cached
47+
// extrema), so a curve corrected here is indistinguishable from one that arrived correct.
48+
exports.maxTimeOf = maxTimeOf;
49+
const scaleBlock = (block, factor) => {
50+
if (!block) return block;
51+
const scale = value => {
52+
const num = finite(value);
53+
return num === null ? value : num * factor;
54+
};
55+
const next = {
56+
...block
57+
};
58+
if (Array.isArray(block.data)) {
59+
next.data = block.data.map(d => Array.isArray(d?.x) ? {
60+
...d,
61+
x: d.x.map(scale)
62+
} : d);
63+
}
64+
if (Array.isArray(block.peaks)) {
65+
next.peaks = block.peaks.map(p => ({
66+
...p,
67+
x: scale(p.x)
68+
}));
69+
}
70+
if (Array.isArray(block.integrations)) {
71+
next.integrations = block.integrations.map(integ => ({
72+
...integ,
73+
xL: scale(integ.xL),
74+
xU: scale(integ.xU),
75+
xExtent: integ.xExtent ? {
76+
...integ.xExtent,
77+
xL: scale(integ.xExtent.xL),
78+
xU: scale(integ.xExtent.xU)
79+
} : integ.xExtent
80+
}));
81+
}
82+
if (block.maxX !== undefined) next.maxX = scale(block.maxX);
83+
if (block.minX !== undefined) next.minX = scale(block.minX);
84+
// The declared unit was the thing that was wrong; leave it agreeing with the data.
85+
if (typeof block.xUnit === 'string' && /SECOND|MINUTE|TIME/i.test(block.xUnit)) {
86+
next.xUnit = 'MINUTES';
87+
}
88+
return next;
89+
};
90+
const scaleEntityTime = (entity, factor) => {
91+
const next = {
92+
...entity
93+
};
94+
if (Array.isArray(entity.features)) {
95+
next.features = entity.features.map(f => scaleBlock(f, factor));
96+
}
97+
if (Array.isArray(entity.spectra)) {
98+
next.spectra = entity.spectra.map(s => scaleBlock(s, factor));
99+
}
100+
return next;
101+
};
102+
103+
/**
104+
* Reconciles the retention-time axes of an LC/MS group against each other, correcting a
105+
* UV/VIS trace that arrived in seconds behind a MINUTES label.
106+
*
107+
* Deliberately one-directional: only the UV/VIS is adjusted, and only against a TIC. A TIC
108+
* that is itself in seconds is chem.js's job, at parse time, where the file's own units
109+
* still say so. Returns the input untouched when there is nothing to reconcile, so callers
110+
* can apply it unconditionally.
111+
*/
112+
const reconcileLcMsTimeAxes = (entities = []) => {
113+
if (!Array.isArray(entities) || entities.length < 2) return entities;
114+
const kinds = entities.map(e => (0, _extractEntityLCMS.getLcMsInfo)(e).kind);
115+
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);
116+
if (ticMax === null) return entities;
117+
let changed = false;
118+
const next = entities.map((entity, i) => {
119+
if (kinds[i] !== 'uvvis') return entity;
120+
const uvvisMax = maxTimeOf(entity);
121+
if (uvvisMax === null || uvvisMax <= 0) return entity;
122+
const ratio = uvvisMax / ticMax;
123+
if (ratio < MIN_SECONDS_RATIO || ratio > MAX_SECONDS_RATIO) return entity;
124+
changed = true;
125+
return scaleEntityTime(entity, 1 / SECONDS_PER_MINUTE);
126+
});
127+
return changed ? next : entities;
128+
};
129+
exports.reconcileLcMsTimeAxes = reconcileLcMsTimeAxes;
130+
var _default = exports.default = reconcileLcMsTimeAxes;

‎dist/layer_init.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var _jcamp = require("./actions/jcamp");
1818
var _layer_prism = _interopRequireDefault(require("./layer_prism"));
1919
var _format = _interopRequireDefault(require("./helpers/format"));
2020
var _extractEntityLCMS = require("./helpers/extractEntityLCMS");
21+
var _timeAxis = require("./features/lc-ms/entities/timeAxis");
2122
var _multi_jcamps_viewer = _interopRequireDefault(require("./components/multi_jcamps_viewer"));
2223
var _hplc_viewer = _interopRequireDefault(require("./components/hplc_viewer"));
2324
var _curve = require("./actions/curve");
@@ -196,12 +197,12 @@ class LayerInit extends _react.default.Component {
196197
const isMultiSpectra = Array.isArray(multiEntities) && multiEntities.length > 1;
197198
if (isMultiSpectra) {
198199
const meta = _format.default.isLCMsLayout(entity.layout) ? lcmsCurveMeta() : undefined;
199-
setAllCurvesAct(multiEntities, meta);
200+
setAllCurvesAct((0, _timeAxis.reconcileLcMsTimeAxes)(multiEntities), meta);
200201
return;
201202
}
202203
if (_format.default.isLCMsLayout(entity.layout)) {
203204
const payload = Array.isArray(multiEntities) && multiEntities.length > 0 ? multiEntities : [entity];
204-
setAllCurvesAct(payload, lcmsCurveMeta());
205+
setAllCurvesAct((0, _timeAxis.reconcileLcMsTimeAxes)(payload), lcmsCurveMeta());
205206
return;
206207
}
207208
if (_format.default.isCyclicVoltaLayout(entity.layout)) {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { reconcileLcMsTimeAxes, maxTimeOf } from '../../../../../features/lc-ms/entities/timeAxis';
2+
3+
// Shaped after the real converter output. example2 (X32962) emits a UV/VIS axis in seconds
4+
// labelled `##XUNITS=MINUTES` beside a TIC in genuine minutes; example1 (SVS-486F3) is
5+
// consistent. The numbers below are the measured spans of those two datasets.
6+
const uvvis = (maxX, extra = {}) => ({
7+
layout: 'LC/MS',
8+
spectra: [{
9+
dataType: 'HPLC UV-VIS',
10+
csCategory: 'UVVIS PEAK TABLE',
11+
xUnit: 'MINUTES',
12+
data: [{ x: [0, maxX / 2, maxX], y: [1, 2, 3] }],
13+
maxX,
14+
minX: 0,
15+
...extra,
16+
}],
17+
});
18+
19+
const tic = (maxX) => ({
20+
layout: 'LC/MS',
21+
spectra: [{
22+
dataType: 'MASS TIC',
23+
xUnit: 'MINUTES',
24+
data: [{ x: [0, maxX / 2, maxX], y: [10, 20, 30] }],
25+
}],
26+
});
27+
28+
const mz = (maxX) => ({
29+
layout: 'LC/MS',
30+
spectra: [{
31+
dataType: 'MASS SPECTRUM',
32+
xUnit: 'm/z',
33+
page: 'T= 1.0',
34+
pageValue: 1.0,
35+
data: [{ x: [100, maxX / 2, maxX], y: [5, 6, 7] }],
36+
}],
37+
});
38+
39+
const xOf = (entity) => entity.spectra[0].data[0].x;
40+
41+
describe('reconcileLcMsTimeAxes', () => {
42+
it('rescales a UV/VIS trace that is ~60x its TIC, the example2 case', () => {
43+
const [fixedUvvis, fixedTic] = reconcileLcMsTimeAxes([uvvis(599.825), tic(9.9994)]);
44+
expect(xOf(fixedUvvis)[2]).toBeCloseTo(9.997, 3);
45+
// the TIC is the reference and must not move
46+
expect(xOf(fixedTic)[2]).toBeCloseTo(9.9994, 4);
47+
});
48+
49+
it('leaves a consistent group alone, the example1 case', () => {
50+
const entities = [uvvis(19.9613), tic(15.98)];
51+
const out = reconcileLcMsTimeAxes(entities);
52+
// returned by reference, so an unnecessary re-render is not triggered either
53+
expect(out).toBe(entities);
54+
expect(xOf(out[0])[2]).toBeCloseTo(19.9613, 4);
55+
});
56+
57+
it('never rescales the m/z entity, whose x is m/z rather than time', () => {
58+
// m/z runs to ~1450 against a 10-minute TIC; only the kind gate keeps it safe if the
59+
// ratio window is ever widened.
60+
const entities = [mz(1448.2), tic(9.9994)];
61+
const out = reconcileLcMsTimeAxes(entities);
62+
expect(xOf(out[0])[2]).toBeCloseTo(1448.2, 3);
63+
});
64+
65+
it('does nothing without a TIC to compare against', () => {
66+
const entities = [uvvis(599.825), mz(1448.2)];
67+
expect(reconcileLcMsTimeAxes(entities)).toBe(entities);
68+
});
69+
70+
it('does nothing for a lone entity, or a non-array input', () => {
71+
const one = [uvvis(599.825)];
72+
expect(reconcileLcMsTimeAxes(one)).toBe(one);
73+
expect(reconcileLcMsTimeAxes([])).toEqual([]);
74+
expect(reconcileLcMsTimeAxes(undefined)).toEqual([]);
75+
});
76+
77+
it('ignores a ratio that is merely large but not a unit mismatch', () => {
78+
// 20x is not 60x: a real span difference, however odd, is not evidence of seconds.
79+
const entities = [uvvis(200), tic(10)];
80+
expect(reconcileLcMsTimeAxes(entities)).toBe(entities);
81+
});
82+
83+
it('scales peaks, integrations and the cached extrema, not just the data block', () => {
84+
const entities = [
85+
uvvis(599.825, {
86+
peaks: [{ x: 300, y: 5 }],
87+
integrations: [{ xL: 60, xU: 120, xExtent: { xL: 60, xU: 120 } }],
88+
}),
89+
tic(9.9994),
90+
];
91+
const [fixed] = reconcileLcMsTimeAxes(entities);
92+
const s = fixed.spectra[0];
93+
expect(s.peaks[0].x).toBeCloseTo(5, 6);
94+
expect(s.integrations[0].xL).toBeCloseTo(1, 6);
95+
expect(s.integrations[0].xU).toBeCloseTo(2, 6);
96+
expect(s.integrations[0].xExtent).toEqual({ xL: 1, xU: 2 });
97+
expect(s.maxX).toBeCloseTo(9.997, 3);
98+
expect(s.minX).toBeCloseTo(0, 6);
99+
// the label was the thing that lied; leave it agreeing with the data
100+
expect(s.xUnit).toEqual('MINUTES');
101+
});
102+
103+
it('does not mutate the entities it was given', () => {
104+
const entities = [uvvis(599.825), tic(9.9994)];
105+
reconcileLcMsTimeAxes(entities);
106+
expect(xOf(entities[0])[2]).toBeCloseTo(599.825, 3);
107+
});
108+
});
109+
110+
describe('maxTimeOf', () => {
111+
it('reads the largest x across data blocks', () => {
112+
expect(maxTimeOf(tic(9.9994))).toBeCloseTo(9.9994, 4);
113+
});
114+
115+
it('returns null when there is no usable x', () => {
116+
expect(maxTimeOf({})).toBeNull();
117+
expect(maxTimeOf({ spectra: [{ data: [{ x: [] }] }] })).toBeNull();
118+
expect(maxTimeOf({ spectra: [{ data: [{ x: [NaN, undefined] }] }] })).toBeNull();
119+
});
120+
});

0 commit comments

Comments
 (0)