@datagrok/proteomics 1.0.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +88 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2073 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,658 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {setGroups, getGroups, GroupAssignment, seedAnnotationDialogInputs,
4
+ setOrganism, getOrganism, applyAnnotation} from '../analysis/experiment-setup';
5
+ import {medianNormalize, quantileNormalize, vsnNormalize} from '../analysis/normalization';
6
+ import {imputeMinProb, imputeKnn, imputeZero, imputeMean, imputeMedian} from '../analysis/imputation';
7
+ import {runDifferentialExpression, copyDEResultsToFrame, getDefaultComparison}
8
+ from '../analysis/differential-expression';
9
+ import {SEMTYPE} from '../utils/proteomics-types';
10
+
11
+ /** Build a test DataFrame with protein IDs and intensity columns for two groups. */
12
+ function makeTestDf(
13
+ nProteins: number,
14
+ group1Names: string[],
15
+ group2Names: string[],
16
+ group1Values?: number[][],
17
+ group2Values?: number[][],
18
+ ): DG.DataFrame {
19
+ const cols: DG.Column[] = [];
20
+ cols.push(DG.Column.fromStrings('Protein ID',
21
+ Array.from({length: nProteins}, (_, i) => `P${i}`)));
22
+
23
+ const allNames = [...group1Names, ...group2Names];
24
+ const allValues = group1Values && group2Values ?
25
+ [...group1Values, ...group2Values] : undefined;
26
+
27
+ for (let c = 0; c < allNames.length; c++) {
28
+ const values = new Float32Array(nProteins);
29
+ if (allValues && allValues[c]) {
30
+ for (let r = 0; r < nProteins; r++)
31
+ values[r] = allValues[c][r];
32
+ }
33
+ const col = DG.Column.fromFloat32Array(allNames[c], values);
34
+ col.semType = SEMTYPE.INTENSITY;
35
+ cols.push(col);
36
+ }
37
+ return DG.DataFrame.fromColumns(cols);
38
+ }
39
+
40
+ // ── Experiment Setup ────────────────────────────────────────────────
41
+
42
+ category('Experiment Setup', () => {
43
+ test('setGroups persists group assignments as tag', async () => {
44
+ const df = DG.DataFrame.fromColumns([
45
+ DG.Column.fromStrings('id', ['P0']),
46
+ DG.Column.fromFloat32Array('s1', new Float32Array([1])),
47
+ ]);
48
+ const groups: GroupAssignment = {
49
+ group1: {name: 'Control', columns: ['s1']},
50
+ group2: {name: 'Treatment', columns: ['s2']},
51
+ };
52
+ setGroups(df, groups);
53
+ const raw = df.getTag('proteomics.groups');
54
+ expect(raw !== null && raw !== '', true);
55
+ const parsed = JSON.parse(raw!);
56
+ expect(parsed.group1.name, 'Control');
57
+ expect(parsed.group2.name, 'Treatment');
58
+ });
59
+
60
+ test('getGroups returns null for untagged DataFrame', async () => {
61
+ const df = DG.DataFrame.fromColumns([
62
+ DG.Column.fromStrings('id', ['P0']),
63
+ ]);
64
+ const result = getGroups(df);
65
+ expect(result, null);
66
+ });
67
+
68
+ test('getOrganism returns undefined until set, then round-trips the code', async () => {
69
+ const df = DG.DataFrame.fromColumns([DG.Column.fromStrings('id', ['P0'])]);
70
+ expect(getOrganism(df) === undefined, true);
71
+ setOrganism(df, 'rnorvegicus');
72
+ expect(getOrganism(df), 'rnorvegicus');
73
+ });
74
+
75
+ test('applyAnnotation persists the organism alongside the groups', async () => {
76
+ const df = DG.DataFrame.fromColumns([DG.Column.fromStrings('id', ['P0'])]);
77
+ applyAnnotation(df, {
78
+ group1: {name: 'Control', columns: ['s1', 's2']},
79
+ group2: {name: 'Treatment', columns: ['s3', 's4']},
80
+ organism: 'rnorvegicus',
81
+ });
82
+ expect(getOrganism(df), 'rnorvegicus');
83
+ expect(getGroups(df)!.group1.name, 'Control');
84
+ // Organism is optional — omitting it leaves the tag untouched.
85
+ const df2 = DG.DataFrame.fromColumns([DG.Column.fromStrings('id', ['P0'])]);
86
+ applyAnnotation(df2, {
87
+ group1: {name: 'A', columns: ['s1']},
88
+ group2: {name: 'B', columns: ['s2']},
89
+ });
90
+ expect(getOrganism(df2) === undefined, true);
91
+ });
92
+
93
+ test('getGroups round-trips GroupAssignment correctly', async () => {
94
+ const df = DG.DataFrame.fromColumns([
95
+ DG.Column.fromStrings('id', ['P0']),
96
+ ]);
97
+ const groups: GroupAssignment = {
98
+ group1: {name: 'Control', columns: ['s1', 's2', 's3']},
99
+ group2: {name: 'Treatment', columns: ['s4', 's5', 's6']},
100
+ };
101
+ setGroups(df, groups);
102
+ const retrieved = getGroups(df);
103
+ expect(retrieved !== null, true);
104
+ expect(retrieved!.group1.name, 'Control');
105
+ expect(retrieved!.group2.name, 'Treatment');
106
+ expect(retrieved!.group1.columns.length, 3);
107
+ expect(retrieved!.group2.columns.length, 3);
108
+ expect(retrieved!.group1.columns[0], 's1');
109
+ expect(retrieved!.group2.columns[2], 's6');
110
+ });
111
+
112
+ test('seedAnnotationDialogInputs returns Control/Treatment defaults when no tag', async () => {
113
+ const df = makeTestDf(3, ['s1', 's2'], ['s3', 's4']);
114
+ const seed = seedAnnotationDialogInputs(df, ['s1', 's2', 's3', 's4']);
115
+ expect(seed.group1Name, 'Control');
116
+ expect(seed.group2Name, 'Treatment');
117
+ expect(seed.group1Cols.length, 0);
118
+ expect(seed.group2Cols.length, 0);
119
+ });
120
+
121
+ test('seedAnnotationDialogInputs reads names + columns from existing groups', async () => {
122
+ const df = makeTestDf(3, ['s1', 's2'], ['s3', 's4']);
123
+ setGroups(df, {
124
+ group1: {name: 'DMD', columns: ['s1', 's2']},
125
+ group2: {name: 'WT', columns: ['s3', 's4']},
126
+ });
127
+ const seed = seedAnnotationDialogInputs(df, ['s1', 's2', 's3', 's4']);
128
+ expect(seed.group1Name, 'DMD');
129
+ expect(seed.group2Name, 'WT');
130
+ expect(seed.group1Cols.length, 2);
131
+ expect(seed.group2Cols.length, 2);
132
+ expect(seed.group1Cols[0].name, 's1');
133
+ expect(seed.group2Cols[1].name, 's4');
134
+ });
135
+
136
+ test('seedAnnotationDialogInputs drops column refs not in the DataFrame', async () => {
137
+ const df = makeTestDf(3, ['s1', 's2'], ['s3', 's4']);
138
+ setGroups(df, {
139
+ group1: {name: 'DMD', columns: ['s1', 'gone', 's2']},
140
+ group2: {name: 'WT', columns: ['also-gone', 's3', 's4']},
141
+ });
142
+ const seed = seedAnnotationDialogInputs(df, ['s1', 's2', 's3', 's4']);
143
+ expect(seed.group1Cols.length, 2);
144
+ expect(seed.group2Cols.length, 2);
145
+ expect(seed.group1Cols.map((c) => c.name).join(','), 's1,s2');
146
+ expect(seed.group2Cols.map((c) => c.name).join(','), 's3,s4');
147
+ });
148
+
149
+ test('seedAnnotationDialogInputs drops column refs not in available list', async () => {
150
+ const df = makeTestDf(3, ['s1', 's2'], ['s3', 's4']);
151
+ setGroups(df, {
152
+ group1: {name: 'A', columns: ['s1', 's2']},
153
+ group2: {name: 'B', columns: ['s3', 's4']},
154
+ });
155
+ const seed = seedAnnotationDialogInputs(df, ['s1', 's3']);
156
+ expect(seed.group1Cols.length, 1);
157
+ expect(seed.group2Cols.length, 1);
158
+ expect(seed.group1Cols[0].name, 's1');
159
+ expect(seed.group2Cols[0].name, 's3');
160
+ });
161
+
162
+ test('seedAnnotationDialogInputs falls back to defaults when stored names are empty', async () => {
163
+ const df = makeTestDf(3, ['s1', 's2'], ['s3', 's4']);
164
+ setGroups(df, {
165
+ group1: {name: '', columns: ['s1', 's2']},
166
+ group2: {name: '', columns: ['s3', 's4']},
167
+ });
168
+ const seed = seedAnnotationDialogInputs(df, ['s1', 's2', 's3', 's4']);
169
+ expect(seed.group1Name, 'Control');
170
+ expect(seed.group2Name, 'Treatment');
171
+ });
172
+ });
173
+
174
+ // ── Normalization ───────────────────────────────────────────────────
175
+
176
+ category('Normalization', () => {
177
+ test('median normalization shifts column medians to zero', async () => {
178
+ const col = DG.Column.fromFloat32Array('intensity', new Float32Array([2, 4, 6, 8, 10]));
179
+ col.semType = SEMTYPE.INTENSITY;
180
+ const df = DG.DataFrame.fromColumns([
181
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4']),
182
+ col,
183
+ ]);
184
+ // Median of [2,4,6,8,10] is 6
185
+ medianNormalize(df, ['intensity']);
186
+ const c = df.col('intensity')!;
187
+ // Values should be shifted by -6: [-4, -2, 0, 2, 4]
188
+ expect(Math.abs(c.get(0) - (-4)) < 0.01, true);
189
+ expect(Math.abs(c.get(1) - (-2)) < 0.01, true);
190
+ expect(Math.abs(c.get(2) - 0) < 0.01, true);
191
+ expect(Math.abs(c.get(3) - 2) < 0.01, true);
192
+ expect(Math.abs(c.get(4) - 4) < 0.01, true);
193
+ });
194
+
195
+ test('median normalization skips null values', async () => {
196
+ // 5 values: [2, null, 6, 8, null] -- non-null median of [2,6,8] = 6
197
+ const col = DG.Column.fromFloat32Array('intensity',
198
+ new Float32Array([2, DG.FLOAT_NULL, 6, 8, DG.FLOAT_NULL]));
199
+ col.semType = SEMTYPE.INTENSITY;
200
+ const df = DG.DataFrame.fromColumns([
201
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4']),
202
+ col,
203
+ ]);
204
+ medianNormalize(df, ['intensity']);
205
+ const c = df.col('intensity')!;
206
+ // Nulls should remain null
207
+ expect(c.isNone(1), true);
208
+ expect(c.isNone(4), true);
209
+ // Non-null values shifted by -median
210
+ expect(!c.isNone(0), true);
211
+ expect(!c.isNone(2), true);
212
+ expect(!c.isNone(3), true);
213
+ });
214
+
215
+ test('normalization sets proteomics.normalized tag', async () => {
216
+ const col = DG.Column.fromFloat32Array('intensity', new Float32Array([1, 2, 3]));
217
+ col.semType = SEMTYPE.INTENSITY;
218
+ const df = DG.DataFrame.fromColumns([
219
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
220
+ col,
221
+ ]);
222
+ medianNormalize(df, ['intensity']);
223
+ expect(df.getTag('proteomics.normalized'), 'true');
224
+ });
225
+
226
+ test('quantile normalization aligns column distributions', async () => {
227
+ // Three columns with different distributions -- after quantile norm, sorted non-null values should match
228
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([5, 2, 3, 6]));
229
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([4, 14, 8, 2]));
230
+ const c3 = DG.Column.fromFloat32Array('s3', new Float32Array([3, 1, 9, 7]));
231
+ c1.semType = SEMTYPE.INTENSITY;
232
+ c2.semType = SEMTYPE.INTENSITY;
233
+ c3.semType = SEMTYPE.INTENSITY;
234
+ const df = DG.DataFrame.fromColumns([
235
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
236
+ c1, c2, c3,
237
+ ]);
238
+ quantileNormalize(df, ['s1', 's2', 's3']);
239
+ // After quantile normalization, sorted values across columns should be the same
240
+ const vals1 = [df.col('s1')!.get(0), df.col('s1')!.get(1), df.col('s1')!.get(2), df.col('s1')!.get(3)];
241
+ const vals2 = [df.col('s2')!.get(0), df.col('s2')!.get(1), df.col('s2')!.get(2), df.col('s2')!.get(3)];
242
+ const vals3 = [df.col('s3')!.get(0), df.col('s3')!.get(1), df.col('s3')!.get(2), df.col('s3')!.get(3)];
243
+ vals1.sort((a, b) => a - b);
244
+ vals2.sort((a, b) => a - b);
245
+ vals3.sort((a, b) => a - b);
246
+ for (let i = 0; i < 4; i++) {
247
+ expect(Math.abs(vals1[i] - vals2[i]) < 0.01, true);
248
+ expect(Math.abs(vals1[i] - vals3[i]) < 0.01, true);
249
+ }
250
+ });
251
+
252
+ test('quantile normalization preserves null values', async () => {
253
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([5, DG.FLOAT_NULL, 3, 6]));
254
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([4, 14, 8, DG.FLOAT_NULL]));
255
+ c1.semType = SEMTYPE.INTENSITY;
256
+ c2.semType = SEMTYPE.INTENSITY;
257
+ const df = DG.DataFrame.fromColumns([
258
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
259
+ c1, c2,
260
+ ]);
261
+ quantileNormalize(df, ['s1', 's2']);
262
+ expect(df.col('s1')!.isNone(1), true);
263
+ expect(df.col('s2')!.isNone(3), true);
264
+ // Non-null values should still be present
265
+ expect(!df.col('s1')!.isNone(0), true);
266
+ expect(!df.col('s2')!.isNone(0), true);
267
+ });
268
+
269
+ test('quantile normalization sets proteomics.normalized tag', async () => {
270
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([1, 2, 3]));
271
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([4, 5, 6]));
272
+ c1.semType = SEMTYPE.INTENSITY;
273
+ c2.semType = SEMTYPE.INTENSITY;
274
+ const df = DG.DataFrame.fromColumns([
275
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
276
+ c1, c2,
277
+ ]);
278
+ quantileNormalize(df, ['s1', 's2']);
279
+ expect(df.getTag('proteomics.normalized'), 'true');
280
+ });
281
+
282
+ test('quantile normalization with < 2 columns returns without error', async () => {
283
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([1, 2, 3]));
284
+ c1.semType = SEMTYPE.INTENSITY;
285
+ const df = DG.DataFrame.fromColumns([
286
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
287
+ c1,
288
+ ]);
289
+ // Should not throw
290
+ quantileNormalize(df, ['s1']);
291
+ // Values should be unchanged
292
+ expect(Math.abs(df.col('s1')!.get(0) - 1) < 0.01, true);
293
+ });
294
+
295
+ test('vsnNormalize falls back to quantile on R failure', async () => {
296
+ const c1 = DG.Column.fromFloat32Array('log2(s1)', new Float32Array([5, 2, 3, 6]));
297
+ const c2 = DG.Column.fromFloat32Array('log2(s2)', new Float32Array([4, 14, 8, 2]));
298
+ c1.semType = SEMTYPE.INTENSITY;
299
+ c2.semType = SEMTYPE.INTENSITY;
300
+ // Also add raw columns so VSN can find them
301
+ const r1 = DG.Column.fromFloat32Array('s1', new Float32Array([32, 4, 8, 64]));
302
+ const r2 = DG.Column.fromFloat32Array('s2', new Float32Array([16, 16384, 256, 4]));
303
+ const df = DG.DataFrame.fromColumns([
304
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
305
+ r1, r2, c1, c2,
306
+ ]);
307
+ // In test environment, R is unavailable, so vsnNormalize should fall back to quantile
308
+ await vsnNormalize(df, ['log2(s1)', 'log2(s2)']);
309
+ // Should still set the tag even via fallback
310
+ expect(df.getTag('proteomics.normalized'), 'true');
311
+ });
312
+ });
313
+
314
+ // ── Imputation ──────────────────────────────────────────────────────
315
+
316
+ category('Imputation', () => {
317
+ test('MinProb imputation fills all NaN values', async () => {
318
+ const values = new Float32Array([10, DG.FLOAT_NULL, 14, DG.FLOAT_NULL, 12]);
319
+ const col = DG.Column.fromFloat32Array('intensity', values);
320
+ col.semType = SEMTYPE.INTENSITY;
321
+ const df = DG.DataFrame.fromColumns([
322
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4']),
323
+ col,
324
+ ]);
325
+ imputeMinProb(df, ['intensity']);
326
+ const c = df.col('intensity')!;
327
+ for (let i = 0; i < c.length; i++)
328
+ expect(c.isNone(i), false);
329
+ });
330
+
331
+ test('imputed values are below the observed mean', async () => {
332
+ // Known values: [10, 12, 14, 16] mean=13, with nulls to impute
333
+ const values = new Float32Array([10, 12, 14, 16, DG.FLOAT_NULL, DG.FLOAT_NULL]);
334
+ const col = DG.Column.fromFloat32Array('intensity', values);
335
+ col.semType = SEMTYPE.INTENSITY;
336
+ const df = DG.DataFrame.fromColumns([
337
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4', 'P5']),
338
+ col,
339
+ ]);
340
+ const observedMean = 13; // (10+12+14+16)/4
341
+ imputeMinProb(df, ['intensity'], 1.8, 0.3);
342
+ const c = df.col('intensity')!;
343
+ // Imputed values (indices 4 and 5) should be well below the observed mean
344
+ expect(c.get(4) < observedMean, true);
345
+ expect(c.get(5) < observedMean, true);
346
+ });
347
+
348
+ test('imputation sets proteomics.imputed tag', async () => {
349
+ const col = DG.Column.fromFloat32Array('intensity',
350
+ new Float32Array([1, DG.FLOAT_NULL, 3]));
351
+ col.semType = SEMTYPE.INTENSITY;
352
+ const df = DG.DataFrame.fromColumns([
353
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
354
+ col,
355
+ ]);
356
+ imputeMinProb(df, ['intensity']);
357
+ expect(df.getTag('proteomics.imputed'), 'true');
358
+ });
359
+
360
+ test('kNN imputation fills missing values using nearest neighbors', async () => {
361
+ // 5 rows, 3 columns. Row 2 (P2) has missing col s2.
362
+ // P0: [10, 20, 30], P1: [11, 21, 31], P3: [12, 22, 32], P4: [100, 200, 300]
363
+ // P2: [10.5, null, 30.5] -- nearest neighbors are P0, P1, P3 (not P4)
364
+ // Expected imputed s2 for P2: average of neighbors' s2 values ~ (20+21+22)/3 = 21
365
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([10, 11, 10.5, 12, 100]));
366
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([20, 21, DG.FLOAT_NULL, 22, 200]));
367
+ const c3 = DG.Column.fromFloat32Array('s3', new Float32Array([30, 31, 30.5, 32, 300]));
368
+ c1.semType = SEMTYPE.INTENSITY;
369
+ c2.semType = SEMTYPE.INTENSITY;
370
+ c3.semType = SEMTYPE.INTENSITY;
371
+ const df = DG.DataFrame.fromColumns([
372
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4']),
373
+ c1, c2, c3,
374
+ ]);
375
+ const count = imputeKnn(df, ['s1', 's2', 's3'], 3);
376
+ expect(count, 1);
377
+ // The imputed value should be close to 21 (average of 3 nearest neighbors' s2 values)
378
+ const imputed = df.col('s2')!.get(2);
379
+ expect(Math.abs(imputed - 21) < 1, true);
380
+ });
381
+
382
+ test('kNN imputation falls back to column mean when no neighbors have values', async () => {
383
+ // All rows missing in s2 except row 0
384
+ // Row 1 and 2 are missing s2, and row 0 has it
385
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([10, DG.FLOAT_NULL, DG.FLOAT_NULL]));
386
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([20, DG.FLOAT_NULL, DG.FLOAT_NULL]));
387
+ c1.semType = SEMTYPE.INTENSITY;
388
+ c2.semType = SEMTYPE.INTENSITY;
389
+ const df = DG.DataFrame.fromColumns([
390
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
391
+ c1, c2,
392
+ ]);
393
+ const count = imputeKnn(df, ['s1', 's2'], 10);
394
+ // Should have imputed 4 values (rows 1 and 2, both columns)
395
+ // Row 1 and 2 are all-missing, so they should get column means
396
+ expect(count >= 2, true);
397
+ });
398
+
399
+ test('kNN imputation returns correct count and sets tag', async () => {
400
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([1, 2, DG.FLOAT_NULL, 4]));
401
+ const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([5, DG.FLOAT_NULL, 7, 8]));
402
+ c1.semType = SEMTYPE.INTENSITY;
403
+ c2.semType = SEMTYPE.INTENSITY;
404
+ const df = DG.DataFrame.fromColumns([
405
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
406
+ c1, c2,
407
+ ]);
408
+ const count = imputeKnn(df, ['s1', 's2'], 3);
409
+ expect(count, 2);
410
+ expect(df.getTag('proteomics.imputed'), 'true');
411
+ // All values should be filled
412
+ for (let i = 0; i < 4; i++) {
413
+ expect(df.col('s1')!.isNone(i), false);
414
+ expect(df.col('s2')!.isNone(i), false);
415
+ }
416
+ });
417
+
418
+ test('imputeZero replaces all nulls with 0', async () => {
419
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([1, DG.FLOAT_NULL, 3, DG.FLOAT_NULL]));
420
+ c1.semType = SEMTYPE.INTENSITY;
421
+ const df = DG.DataFrame.fromColumns([
422
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
423
+ c1,
424
+ ]);
425
+ const count = imputeZero(df, ['s1']);
426
+ expect(count, 2);
427
+ expect(Math.abs(df.col('s1')!.get(1) - 0) < 0.01, true);
428
+ expect(Math.abs(df.col('s1')!.get(3) - 0) < 0.01, true);
429
+ expect(df.getTag('proteomics.imputed'), 'true');
430
+ });
431
+
432
+ test('imputeMean replaces all nulls with column mean', async () => {
433
+ // Values: [2, null, 6, null] -- mean of non-null = (2+6)/2 = 4
434
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([2, DG.FLOAT_NULL, 6, DG.FLOAT_NULL]));
435
+ c1.semType = SEMTYPE.INTENSITY;
436
+ const df = DG.DataFrame.fromColumns([
437
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
438
+ c1,
439
+ ]);
440
+ const count = imputeMean(df, ['s1']);
441
+ expect(count, 2);
442
+ expect(Math.abs(df.col('s1')!.get(1) - 4) < 0.01, true);
443
+ expect(Math.abs(df.col('s1')!.get(3) - 4) < 0.01, true);
444
+ expect(df.getTag('proteomics.imputed'), 'true');
445
+ });
446
+
447
+ test('imputeMedian replaces all nulls with column median', async () => {
448
+ // Values: [2, null, 6, 10, null] -- median of [2,6,10] = 6
449
+ const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([2, DG.FLOAT_NULL, 6, 10, DG.FLOAT_NULL]));
450
+ c1.semType = SEMTYPE.INTENSITY;
451
+ const df = DG.DataFrame.fromColumns([
452
+ DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3', 'P4']),
453
+ c1,
454
+ ]);
455
+ const count = imputeMedian(df, ['s1']);
456
+ expect(count, 2);
457
+ expect(Math.abs(df.col('s1')!.get(1) - 6) < 0.01, true);
458
+ expect(Math.abs(df.col('s1')!.get(4) - 6) < 0.01, true);
459
+ expect(df.getTag('proteomics.imputed'), 'true');
460
+ });
461
+ });
462
+
463
+ // ── Differential Expression ─────────────────────────────────────────
464
+
465
+ category('Differential Expression', () => {
466
+ test('DE produces correct log2FC sign for known groups', async () => {
467
+ // Group 1 (control) values ~5, Group 2 (treatment) values ~10
468
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
469
+ const g2Names = ['treat1', 'treat2', 'treat3'];
470
+ const nProteins = 5;
471
+ const g1Vals = g1Names.map(() => Array.from({length: nProteins}, () => 5.0));
472
+ const g2Vals = g2Names.map(() => Array.from({length: nProteins}, () => 10.0));
473
+ const df = makeTestDf(nProteins, g1Names, g2Names, g1Vals, g2Vals);
474
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment');
475
+ const fcCol = df.col('log2FC')!;
476
+ // log2FC = mean(group2) - mean(group1) = 10 - 5 = 5 (positive)
477
+ for (let i = 0; i < nProteins; i++)
478
+ expect(fcCol.get(i) > 0, true);
479
+ });
480
+
481
+ test('DE produces valid p-values between 0 and 1', async () => {
482
+ // Use slightly varied values so t-test produces real p-values
483
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
484
+ const g2Names = ['treat1', 'treat2', 'treat3'];
485
+ const nProteins = 3;
486
+ const g1Vals = [
487
+ [4.8, 5.1, 5.3],
488
+ [4.9, 5.0, 5.2],
489
+ [5.1, 4.8, 5.0],
490
+ ];
491
+ const g2Vals = [
492
+ [9.8, 10.1, 10.3],
493
+ [9.9, 10.0, 10.2],
494
+ [10.1, 9.8, 10.0],
495
+ ];
496
+ const df = makeTestDf(nProteins, g1Names, g2Names, g1Vals, g2Vals);
497
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment');
498
+ const pCol = df.col('p-value')!;
499
+ const adjCol = df.col('adj.p-value')!;
500
+ for (let i = 0; i < nProteins; i++) {
501
+ expect(pCol.get(i) >= 0 && pCol.get(i) <= 1, true);
502
+ expect(adjCol.get(i) >= 0 && adjCol.get(i) <= 1, true);
503
+ }
504
+ });
505
+
506
+ test('proteins with <2 replicates get null p-values', async () => {
507
+ // Protein 0: only 1 non-null value in group1
508
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
509
+ const g2Names = ['treat1', 'treat2', 'treat3'];
510
+ const df = DG.DataFrame.fromColumns([
511
+ DG.Column.fromStrings('Protein ID', ['P0']),
512
+ DG.Column.fromFloat32Array('ctrl1', new Float32Array([5.0])),
513
+ DG.Column.fromFloat32Array('ctrl2', new Float32Array([DG.FLOAT_NULL])),
514
+ DG.Column.fromFloat32Array('ctrl3', new Float32Array([DG.FLOAT_NULL])),
515
+ DG.Column.fromFloat32Array('treat1', new Float32Array([10.0])),
516
+ DG.Column.fromFloat32Array('treat2', new Float32Array([10.5])),
517
+ DG.Column.fromFloat32Array('treat3', new Float32Array([9.5])),
518
+ ]);
519
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment');
520
+ const pCol = df.col('p-value')!;
521
+ expect(pCol.isNone(0), true);
522
+ });
523
+
524
+ test('significant column marks proteins passing both thresholds', async () => {
525
+ // Protein 0: large FC, small p (should be significant)
526
+ // Protein 1: small FC, large p (should not be significant)
527
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
528
+ const g2Names = ['treat1', 'treat2', 'treat3'];
529
+ const df = DG.DataFrame.fromColumns([
530
+ DG.Column.fromStrings('Protein ID', ['P_significant', 'P_not_significant']),
531
+ // Group 1 control values
532
+ DG.Column.fromFloat32Array('ctrl1', new Float32Array([5.0, 5.0])),
533
+ DG.Column.fromFloat32Array('ctrl2', new Float32Array([5.1, 5.1])),
534
+ DG.Column.fromFloat32Array('ctrl3', new Float32Array([4.9, 4.9])),
535
+ // Group 2: Protein 0 has large difference, Protein 1 is the same
536
+ DG.Column.fromFloat32Array('treat1', new Float32Array([12.0, 5.2])),
537
+ DG.Column.fromFloat32Array('treat2', new Float32Array([12.1, 4.8])),
538
+ DG.Column.fromFloat32Array('treat3', new Float32Array([11.9, 5.0])),
539
+ ]);
540
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment', 1.0, 0.05);
541
+ const sigCol = df.col('significant')!;
542
+ // Protein 0: FC ~ 7, very significant
543
+ expect(sigCol.get(0), true);
544
+ // Protein 1: FC ~ 0, not significant
545
+ expect(sigCol.get(1), false);
546
+ });
547
+
548
+ test('DE sets proteomics.de_complete tag', async () => {
549
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
550
+ const g2Names = ['treat1', 'treat2', 'treat3'];
551
+ const nProteins = 2;
552
+ const g1Vals = g1Names.map(() => Array.from({length: nProteins}, () => 5.0));
553
+ const g2Vals = g2Names.map(() => Array.from({length: nProteins}, () => 10.0));
554
+ const df = makeTestDf(nProteins, g1Names, g2Names, g1Vals, g2Vals);
555
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment');
556
+ expect(df.getTag('proteomics.de_complete'), 'true');
557
+ });
558
+
559
+ // R3/D-09: report-import DE direction default (13-05)
560
+
561
+ test('D-09: DE dialog defaults to the declared (group1 = numerator) contrast', async () => {
562
+ // Spectronaut report order: group1 = the parser's first condition = the
563
+ // declared Numerator (e.g. DMD), group2 = WT. The dialog default must be
564
+ // `${g1} vs ${g2}` (which the OK-handler maps to numerator = g1), NOT the
565
+ // alphabetical `${g2} vs ${g1}` that previously caused the mirror defect.
566
+ expect(getDefaultComparison('DMD', 'WT'), 'DMD vs WT');
567
+ expect(getDefaultComparison('WT', 'DMD'), 'WT vs DMD');
568
+ });
569
+
570
+ test('D-09: contrast is direction-only — reversing flips sign, not |log2FC|', async () => {
571
+ const g1 = ['dmd1', 'dmd2', 'dmd3'];
572
+ const g2 = ['wt1', 'wt2', 'wt3'];
573
+ const n = 3;
574
+ const g1v = g1.map(() => Array.from({length: n}, () => 9.0)); // group1 high
575
+ const g2v = g2.map(() => Array.from({length: n}, () => 4.0)); // group2 low
576
+
577
+ // Declared default (numerator = group1 = DMD): OK-handler calls
578
+ // runDifferentialExpression(df, denominatorCols, numeratorCols, ...) and
579
+ // log2FC = mean(numerator) - mean(denominator).
580
+ const dfDeclared = makeTestDf(n, g1, g2, g1v, g2v);
581
+ runDifferentialExpression(dfDeclared, g2, g1, 'WT', 'DMD');
582
+ const declared = dfDeclared.col('log2FC')!.get(0) as number;
583
+
584
+ // Reversed selection (manual override): numerator = group2 = WT.
585
+ const dfReversed = makeTestDf(n, g1, g2, g1v, g2v);
586
+ runDifferentialExpression(dfReversed, g1, g2, 'DMD', 'WT');
587
+ const reversed = dfReversed.col('log2FC')!.get(0) as number;
588
+
589
+ expect(declared > 0, true); // DMD higher → positive in declared orientation
590
+ expect(reversed < 0, true); // sign flips under reversal
591
+ expect(Math.abs(Math.abs(declared) - Math.abs(reversed)) < 1e-6, true); // |unchanged|
592
+ });
593
+
594
+ test('DE assigns correct semantic types', async () => {
595
+ const g1Names = ['ctrl1', 'ctrl2', 'ctrl3'];
596
+ const g2Names = ['treat1', 'treat2', 'treat3'];
597
+ const nProteins = 2;
598
+ const g1Vals = g1Names.map(() => Array.from({length: nProteins}, () => 5.0));
599
+ const g2Vals = g2Names.map(() => Array.from({length: nProteins}, () => 10.0));
600
+ const df = makeTestDf(nProteins, g1Names, g2Names, g1Vals, g2Vals);
601
+ runDifferentialExpression(df, g1Names, g2Names, 'Control', 'Treatment');
602
+ expect(df.col('log2FC')!.semType, SEMTYPE.LOG2FC);
603
+ expect(df.col('p-value')!.semType, SEMTYPE.P_VALUE);
604
+ expect(df.col('adj.p-value')!.semType, SEMTYPE.P_VALUE);
605
+ });
606
+ });
607
+
608
+ // ── R-result alignment (DEqMS scramble regression) ──────────────────
609
+
610
+ category('DE result alignment', () => {
611
+ test('copyDEResultsToFrame realigns by row key, not position', async () => {
612
+ // Simulates an R result returned SORTED by significance (the DEqMS bug):
613
+ // result row j belongs to df protein index `row[j]-1`, NOT j.
614
+ const n = 4;
615
+ const df = DG.DataFrame.fromColumns([
616
+ DG.Column.fromStrings('Protein ID', ['P0', 'P1', 'P2', 'P3']),
617
+ ]);
618
+ const rowKey = [3, 1, 4, 2]; // result rows map to df idx 2,0,3,1
619
+ // Encode each row's intended df index as its value, so a correct realign
620
+ // makes df row i hold value i. A positional copy would fail this.
621
+ const result = DG.DataFrame.fromColumns([
622
+ DG.Column.fromList('int', 'row', rowKey),
623
+ DG.Column.fromList('double', 'log2FC', rowKey.map((r) => r - 1)),
624
+ DG.Column.fromList('double', 'p.value', rowKey.map((r) => r - 1)),
625
+ DG.Column.fromList('double', 'adj.p.value', rowKey.map((r) => r - 1)),
626
+ DG.Column.fromList('bool', 'significant', rowKey.map((r) => r - 1 === 0)),
627
+ ]);
628
+
629
+ const sigCount = copyDEResultsToFrame(df, result);
630
+
631
+ for (let i = 0; i < n; i++) {
632
+ expect(df.col('log2FC')!.get(i), i);
633
+ expect(df.col('p-value')!.get(i), i);
634
+ expect(df.col('adj.p-value')!.get(i), i);
635
+ expect(df.col('significant')!.get(i), i === 0);
636
+ }
637
+ expect(sigCount, 1);
638
+ });
639
+
640
+ test('copyDEResultsToFrame falls back to positional without row key', async () => {
641
+ const df = DG.DataFrame.fromColumns([
642
+ DG.Column.fromStrings('Protein ID', ['P0', 'P1', 'P2']),
643
+ ]);
644
+ const result = DG.DataFrame.fromColumns([
645
+ DG.Column.fromList('double', 'log2FC', [10, 20, 30]),
646
+ DG.Column.fromList('double', 'p.value', [1, 2, 3]),
647
+ DG.Column.fromList('double', 'adj.p.value', [4, 5, 6]),
648
+ DG.Column.fromList('bool', 'significant', [false, true, false]),
649
+ ]);
650
+
651
+ copyDEResultsToFrame(df, result);
652
+
653
+ expect(df.col('log2FC')!.get(1), 20);
654
+ expect(df.col('adj.p-value')!.get(2), 6);
655
+ expect(df.col('significant')!.get(1), true);
656
+ expect(df.col('significant')!.get(0), false);
657
+ });
658
+ });