@datagrok/proteomics 1.0.0 → 1.2.0

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 +87 -62
  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 +2055 -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,152 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {SEMTYPE} from '../utils/proteomics-types';
4
+ import {
5
+ detectDelimiter,
6
+ autoSuggestProteinIdColumn,
7
+ autoSuggestIntensityColumns,
8
+ detectLog2Status,
9
+ log2TransformColumns,
10
+ copyAsLog2Columns,
11
+ addPrimaryColumnIfNeeded,
12
+ } from '../parsers/shared-utils';
13
+
14
+ /** Builds a CSV or TSV string for testing. */
15
+ function makeGenericCsv(params: {headers: string[]; rows: string[][]; delimiter?: string}): string {
16
+ const d = params.delimiter ?? ',';
17
+ return [params.headers.join(d), ...params.rows.map((r) => r.join(d))].join('\n');
18
+ }
19
+
20
+ category('Generic Parser', () => {
21
+ test('Generic: parses CSV with comma delimiter', async () => {
22
+ const text = makeGenericCsv({
23
+ headers: ['Protein', 'Intensity A', 'Intensity B', 'Intensity C'],
24
+ rows: [
25
+ ['P12345', '1000', '2000', '3000'],
26
+ ['P67890', '4000', '5000', '6000'],
27
+ ],
28
+ });
29
+ const delimiter = detectDelimiter(text);
30
+ expect(delimiter, ',');
31
+ const df = DG.DataFrame.fromCsv(text, {delimiter: ','});
32
+ expect(df.columns.length, 4);
33
+ expect(df.rowCount, 2);
34
+ });
35
+
36
+ test('Generic: parses TSV with tab delimiter', async () => {
37
+ const text = makeGenericCsv({
38
+ headers: ['Protein', 'Intensity A', 'Intensity B', 'Intensity C'],
39
+ rows: [
40
+ ['P12345', '1000', '2000', '3000'],
41
+ ['P67890', '4000', '5000', '6000'],
42
+ ],
43
+ delimiter: '\t',
44
+ });
45
+ const delimiter = detectDelimiter(text);
46
+ expect(delimiter, '\t');
47
+ const df = DG.DataFrame.fromCsv(text, {delimiter: '\t'});
48
+ expect(df.columns.length, 4);
49
+ expect(df.rowCount, 2);
50
+ });
51
+
52
+ test('Generic: auto-suggests protein ID column', async () => {
53
+ const df = DG.DataFrame.fromColumns([
54
+ DG.Column.fromStrings('Protein IDs', ['P12345']),
55
+ DG.Column.fromStrings('Gene names', ['BRCA1']),
56
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'Intensity A', [1000]),
57
+ ]);
58
+ const suggested = autoSuggestProteinIdColumn(df);
59
+ expect(suggested !== null, true);
60
+ expect(suggested!.name, 'Protein IDs');
61
+ });
62
+
63
+ test('Generic: auto-suggests intensity columns', async () => {
64
+ const df = DG.DataFrame.fromColumns([
65
+ DG.Column.fromStrings('id', ['P12345']),
66
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'Intensity Sample1', [1000]),
67
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'LFQ Sample2', [2000]),
68
+ DG.Column.fromStrings('notes', ['some note']),
69
+ ]);
70
+ const suggested = autoSuggestIntensityColumns(df);
71
+ expect(suggested.length, 2);
72
+ expect(suggested.includes('Intensity Sample1'), true);
73
+ expect(suggested.includes('LFQ Sample2'), true);
74
+ });
75
+
76
+ test('Generic: detects raw intensities for log2 toggle', async () => {
77
+ const df = DG.DataFrame.fromColumns([
78
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'vals', [50000, 100000, 200000]),
79
+ ]);
80
+ const result = detectLog2Status(df, ['vals']);
81
+ expect(result.isLog2, false);
82
+ });
83
+
84
+ test('Generic: detects already-log2 data', async () => {
85
+ const df = DG.DataFrame.fromColumns([
86
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'vals', [15.2, 18.7, 12.1]),
87
+ ]);
88
+ const result = detectLog2Status(df, ['vals']);
89
+ expect(result.isLog2, true);
90
+ });
91
+
92
+ test('Generic: log2TransformColumns produces correct values', async () => {
93
+ const df = DG.DataFrame.fromColumns([
94
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'Intensity A', [1024, 0, 2048]),
95
+ ]);
96
+ log2TransformColumns(df, ['Intensity A']);
97
+ const log2Col = df.col('log2(Intensity A)');
98
+ expect(log2Col !== null, true);
99
+ if (log2Col) {
100
+ expect(Math.abs(log2Col.get(0)! - 10.0) < 0.001, true);
101
+ expect(log2Col.isNone(1), true); // zero input -> FLOAT_NULL
102
+ expect(Math.abs(log2Col.get(2)! - 11.0) < 0.001, true);
103
+ expect(log2Col.semType, SEMTYPE.INTENSITY);
104
+ }
105
+ const origCol = df.col('Intensity A');
106
+ expect(origCol!.semType, SEMTYPE.INTENSITY);
107
+ });
108
+
109
+ test('Generic: copyAsLog2Columns copies values without transform', async () => {
110
+ const df = DG.DataFrame.fromColumns([
111
+ DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, 'Intensity A', [15.2, 18.7]),
112
+ ]);
113
+ copyAsLog2Columns(df, ['Intensity A']);
114
+ const log2Col = df.col('log2(Intensity A)');
115
+ expect(log2Col !== null, true);
116
+ if (log2Col) {
117
+ expect(Math.abs(log2Col.get(0)! - 15.2) < 0.001, true);
118
+ expect(Math.abs(log2Col.get(1)! - 18.7) < 0.001, true);
119
+ }
120
+ });
121
+
122
+ test('Generic: addPrimaryColumnIfNeeded creates column for semicolons', async () => {
123
+ const df = DG.DataFrame.fromColumns([
124
+ DG.Column.fromStrings('Protein', ['P12345;Q67890', 'P11111']),
125
+ ]);
126
+ addPrimaryColumnIfNeeded(df, 'Protein', 'Primary Protein', SEMTYPE.PROTEIN_ID);
127
+ const primaryCol = df.col('Primary Protein');
128
+ expect(primaryCol !== null, true);
129
+ if (primaryCol) {
130
+ expect(primaryCol.get(0), 'P12345');
131
+ expect(primaryCol.get(1), 'P11111');
132
+ }
133
+ });
134
+
135
+ test('Generic: addPrimaryColumnIfNeeded skips when no semicolons', async () => {
136
+ const df = DG.DataFrame.fromColumns([
137
+ DG.Column.fromStrings('Protein', ['P12345', 'P11111']),
138
+ ]);
139
+ addPrimaryColumnIfNeeded(df, 'Protein', 'Primary Protein', SEMTYPE.PROTEIN_ID);
140
+ const primaryCol = df.col('Primary Protein');
141
+ expect(primaryCol, null);
142
+ });
143
+
144
+ test('Generic: assigns semantic types to selected columns', async () => {
145
+ const df = DG.DataFrame.fromColumns([
146
+ DG.Column.fromStrings('Protein', ['P12345']),
147
+ ]);
148
+ const col = df.col('Protein')!;
149
+ col.semType = SEMTYPE.PROTEIN_ID;
150
+ expect(col.semType, SEMTYPE.PROTEIN_ID);
151
+ });
152
+ });
@@ -0,0 +1,139 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {
4
+ createGroupMeanCorrelation,
5
+ computeGroupMeans,
6
+ pearson,
7
+ spearman,
8
+ } from '../viewers/group-mean-correlation';
9
+ import {setGroups} from '../analysis/experiment-setup';
10
+ import {SEMTYPE} from '../utils/proteomics-types';
11
+
12
+ /** Builds a 5-row, 4-sample fixture DataFrame with a `direction` column so the
13
+ * correlation viewer's color binding has something to point at. */
14
+ function makeFixtureDf(): DG.DataFrame {
15
+ const cols: DG.Column[] = [];
16
+ const pid = DG.Column.fromStrings('Primary Protein ID',
17
+ ['P1', 'P2', 'P3', 'P4', 'P5']);
18
+ pid.semType = SEMTYPE.PROTEIN_ID;
19
+ cols.push(pid);
20
+
21
+ const display = DG.Column.fromStrings('Display Name',
22
+ ['Gene1', 'Gene2', 'Gene3', 'Gene4', 'Gene5']);
23
+ display.semType = SEMTYPE.DISPLAY_NAME;
24
+ cols.push(display);
25
+
26
+ // Intensities — known per-row mean of [g1,g2] is monotonic for groupMeanColumnsCreated.
27
+ // Row 0: g1={1,3}→mean=2, g2={5,7}→mean=6
28
+ // Row 1: g1={2,4}→mean=3, g2={6,8}→mean=7
29
+ // Row 2: g1={3,5}→mean=4, g2={7,9}→mean=8
30
+ // Row 3: g1={4,6}→mean=5, g2={8,10}→mean=9
31
+ // Row 4: g1={5,7}→mean=6, g2={9,11}→mean=10
32
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S1', [1, 2, 3, 4, 5]));
33
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S2', [3, 4, 5, 6, 7]));
34
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S3', [5, 6, 7, 8, 9]));
35
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S4', [7, 8, 9, 10, 11]));
36
+
37
+ // direction column — the viewer reads this for color binding (set by Plan 14-02 ensureDirectionColumn).
38
+ cols.push(DG.Column.fromStrings('direction',
39
+ ['Enriched in Control', 'Enriched in Treatment', 'Not significant',
40
+ 'Enriched in Control', 'Enriched in Treatment']));
41
+
42
+ const df = DG.DataFrame.fromColumns(cols);
43
+ setGroups(df, {
44
+ group1: {name: 'Control', columns: ['S1', 'S2']},
45
+ group2: {name: 'Treatment', columns: ['S3', 'S4']},
46
+ });
47
+ df.name = `correlation-fixture-${Math.random().toString(36).slice(2, 8)}`;
48
+ return df;
49
+ }
50
+
51
+ category('Proteomics: 14-04', () => {
52
+ test('groupMeanColumnsCreated', async () => {
53
+ const df = makeFixtureDf();
54
+ computeGroupMeans(df, {
55
+ group1: {name: 'Control', columns: ['S1', 'S2']},
56
+ group2: {name: 'Treatment', columns: ['S3', 'S4']},
57
+ });
58
+ const num = df.col('Numerator Mean');
59
+ const den = df.col('Denominator Mean');
60
+ expect(num !== null, true);
61
+ expect(den !== null, true);
62
+ expect(num!.semType, SEMTYPE.NUMERATOR_MEAN);
63
+ expect(den!.semType, SEMTYPE.DENOMINATOR_MEAN);
64
+ expect(num!.length, df.rowCount);
65
+ expect(den!.length, df.rowCount);
66
+ // Row 0 means: g1=(1+3)/2=2, g2=(5+7)/2=6.
67
+ expect(Math.abs((num!.get(0) as number) - 2) < 1e-6, true);
68
+ expect(Math.abs((den!.get(0) as number) - 6) < 1e-6, true);
69
+ // Row 4 means: g1=(5+7)/2=6, g2=(9+11)/2=10.
70
+ expect(Math.abs((num!.get(4) as number) - 6) < 1e-6, true);
71
+ expect(Math.abs((den!.get(4) as number) - 10) < 1e-6, true);
72
+ });
73
+
74
+ test('groupMeanColumnsReRunSafe', async () => {
75
+ const df = makeFixtureDf();
76
+ const groups = {
77
+ group1: {name: 'Control', columns: ['S1', 'S2']},
78
+ group2: {name: 'Treatment', columns: ['S3', 'S4']},
79
+ };
80
+ computeGroupMeans(df, groups);
81
+ computeGroupMeans(df, groups);
82
+ // No duplicates after a second invocation.
83
+ const numCount = df.columns.toList().filter((c) => c.name === 'Numerator Mean').length;
84
+ const denCount = df.columns.toList().filter((c) => c.name === 'Denominator Mean').length;
85
+ expect(numCount, 1);
86
+ expect(denCount, 1);
87
+ // Values still correct.
88
+ const num = df.col('Numerator Mean')!;
89
+ expect(Math.abs((num.get(0) as number) - 2) < 1e-6, true);
90
+ });
91
+
92
+ test('pearsonPerfectCorrelation', async () => {
93
+ const r = pearson([1, 2, 3, 4, 5], [2, 4, 6, 8, 10]);
94
+ expect(Math.abs(r - 1) < 1e-9, true);
95
+ });
96
+
97
+ test('pearsonNoCorrelation', async () => {
98
+ const r = pearson([1, 2, 3, 4, 5], [3, 1, 4, 1, 5]);
99
+ // Not exactly 0 — just within a reasonable tolerance away from 1.
100
+ expect(Math.abs(r) < 0.6, true);
101
+ });
102
+
103
+ test('spearmanRankTies', async () => {
104
+ const rho = spearman([1, 2, 2, 3, 4], [1, 2, 2, 3, 4]);
105
+ expect(Math.abs(rho - 1) < 1e-9, true);
106
+ });
107
+
108
+ test('createGroupMeanCorrelationFactory', async () => {
109
+ const df = makeFixtureDf();
110
+ const sp = createGroupMeanCorrelation(df);
111
+ expect(sp !== null, true);
112
+ const opts: any = (sp as any).getOptions?.();
113
+ const title: string = opts?.look?.title ?? '';
114
+ expect(title.includes('r='), true);
115
+ expect(title.includes('ρ='), true);
116
+ expect(sp.props.xColumnName, 'Numerator Mean');
117
+ expect(sp.props.yColumnName, 'Denominator Mean');
118
+ expect(sp.props.colorColumnName, 'direction');
119
+ });
120
+
121
+ test('correlationDiagonalLine', async () => {
122
+ const df = makeFixtureDf();
123
+ createGroupMeanCorrelation(df);
124
+ const lines = df.meta.formulaLines.items;
125
+ const diagonal = lines.find((l: any) => {
126
+ const f = (l.formula ?? '') as string;
127
+ return typeof f === 'string' &&
128
+ f.includes('Numerator Mean') && f.includes('Denominator Mean');
129
+ });
130
+ expect(diagonal !== undefined, true);
131
+ expect((diagonal as any).color, '#888888');
132
+ });
133
+
134
+ test('correlationLabelBindsToDisplayName', async () => {
135
+ const df = makeFixtureDf();
136
+ const sp = createGroupMeanCorrelation(df);
137
+ expect((sp.props.labelColumnNames as string[])?.includes('Display Name'), true);
138
+ });
139
+ });
@@ -0,0 +1,93 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {SEMTYPE} from '../utils/proteomics-types';
4
+ import {log2TransformColumns, copyAsLog2Columns} from '../parsers/shared-utils';
5
+ import {getIntensityOriginals, detectCurrentLog2Applied, applyLog2Scale}
6
+ from '../analysis/log2-scale';
7
+
8
+ /** DataFrame with two raw intensity samples (semType INTENSITY, no log2 columns
9
+ * yet). Values are large so they unambiguously round-trip through log2. */
10
+ function makeRawDf(): DG.DataFrame {
11
+ const df = DG.DataFrame.fromColumns([
12
+ DG.Column.fromStrings('Protein ID', ['P1', 'P2', 'P3']),
13
+ DG.Column.fromFloat32Array('Intensity_S1', new Float32Array([1000, 4000, 16000])),
14
+ DG.Column.fromFloat32Array('Intensity_S2', new Float32Array([2000, 8000, 32000])),
15
+ ]);
16
+ df.col('Protein ID')!.semType = SEMTYPE.PROTEIN_ID;
17
+ df.col('Intensity_S1')!.semType = SEMTYPE.INTENSITY;
18
+ df.col('Intensity_S2')!.semType = SEMTYPE.INTENSITY;
19
+ return df;
20
+ }
21
+
22
+ category('Log2 Scale', () => {
23
+ test('getIntensityOriginals: only the pristine (non-log2) intensity columns', async () => {
24
+ const df = makeRawDf();
25
+ log2TransformColumns(df, ['Intensity_S1', 'Intensity_S2']);
26
+ const originals = getIntensityOriginals(df);
27
+ expect(originals.length, 2, 'two originals');
28
+ expect(originals.includes('Intensity_S1') && originals.includes('Intensity_S2'), true,
29
+ 'includes both raw columns');
30
+ expect(originals.some((n) => n.startsWith('log2(')), false, 'excludes the log2 copies');
31
+ });
32
+
33
+ test('detectCurrentLog2Applied: transform vs copy is read from the values', async () => {
34
+ const t = makeRawDf();
35
+ log2TransformColumns(t, ['Intensity_S1', 'Intensity_S2']);
36
+ expect(detectCurrentLog2Applied(t, getIntensityOriginals(t)), 'transform',
37
+ 'log2(x) columns read as transform');
38
+
39
+ const c = makeRawDf();
40
+ copyAsLog2Columns(c, ['Intensity_S1', 'Intensity_S2']);
41
+ expect(detectCurrentLog2Applied(c, getIntensityOriginals(c)), 'copy',
42
+ 'copied columns read as copy');
43
+ });
44
+
45
+ test('applyLog2Scale: transform→copy rebuilds values, clears stale tags, is idempotent', async () => {
46
+ const df = makeRawDf();
47
+ log2TransformColumns(df, ['Intensity_S1', 'Intensity_S2']);
48
+ // Simulate a pipeline that already ran on the (wrong-scale) data.
49
+ df.setTag('proteomics.normalized', 'true');
50
+ df.setTag('proteomics.de_complete', 'true');
51
+ df.setTag('proteomics.de_method', 't-test');
52
+
53
+ // log2 transform is currently applied; before flip the copy equals log2(1000).
54
+ expect(Math.abs(df.col('log2(Intensity_S1)')!.get(0) - Math.log2(1000)) < 1e-3, true,
55
+ 'starts transformed');
56
+
57
+ // Flip to "already log2" → copies the raw value straight through.
58
+ const changed = applyLog2Scale(df, true);
59
+ expect(changed, true, 'a real scale change returns true');
60
+ expect(df.col('log2(Intensity_S1)')!.get(0), 1000, 'value is now the raw copy');
61
+ expect(detectCurrentLog2Applied(df, getIntensityOriginals(df)), 'copy', 'now reads as copy');
62
+
63
+ // Stale downstream state is cleared so viewers re-gate.
64
+ expect(df.getTag('proteomics.normalized') || '', '', 'normalized tag cleared');
65
+ expect(df.getTag('proteomics.de_complete') || '', '', 'de_complete tag cleared');
66
+ expect(df.getTag('proteomics.de_method') || '', '', 'de_method tag cleared');
67
+
68
+ // No original columns were duplicated by the rebuild.
69
+ expect(getIntensityOriginals(df).length, 2, 'still exactly two originals');
70
+
71
+ // Re-applying the same scale is a no-op.
72
+ expect(applyLog2Scale(df, true), false, 'no-op when scale already matches returns false');
73
+ });
74
+
75
+ test('applyLog2Scale: copy→transform restores the log2 transform', async () => {
76
+ const df = makeRawDf();
77
+ copyAsLog2Columns(df, ['Intensity_S1', 'Intensity_S2']);
78
+ const changed = applyLog2Scale(df, false);
79
+ expect(changed, true, 'scale change returns true');
80
+ expect(Math.abs(df.col('log2(Intensity_S2)')!.get(2) - Math.log2(32000)) < 1e-3, true,
81
+ 'transform re-applied to raw value');
82
+ expect(detectCurrentLog2Applied(df, getIntensityOriginals(df)), 'transform', 'reads as transform');
83
+ });
84
+
85
+ test('getIntensityOriginals: empty when no intensity columns', async () => {
86
+ const df = DG.DataFrame.fromColumns([
87
+ DG.Column.fromStrings('Protein ID', ['P1', 'P2']),
88
+ ]);
89
+ df.col('Protein ID')!.semType = SEMTYPE.PROTEIN_ID;
90
+ expect(getIntensityOriginals(df).length, 0, 'no originals');
91
+ expect(applyLog2Scale(df, true), false, 'applyLog2Scale is a no-op with nothing to rescale');
92
+ });
93
+ });
@@ -0,0 +1,56 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {resolveOrganismCode, detectOrganismCode, ORGANISM_LIST} from '../utils/organisms';
4
+
5
+ function dfWithOrganismColumn(name: string, values: string[]): DG.DataFrame {
6
+ return DG.DataFrame.fromColumns([DG.Column.fromStrings(name, values)]);
7
+ }
8
+
9
+ category('Organisms', () => {
10
+ test('resolveOrganismCode maps scientific names (incl. strain-qualified) to codes', async () => {
11
+ expect(resolveOrganismCode('Homo sapiens'), 'hsapiens');
12
+ expect(resolveOrganismCode('Rattus norvegicus'), 'rnorvegicus');
13
+ // Strain-qualified values still resolve via the scientific-name prefix.
14
+ expect(resolveOrganismCode('Escherichia coli (strain K12)'), 'ecoli');
15
+ expect(resolveOrganismCode('Saccharomyces cerevisiae (strain ATCC 204508 / S288c)'), 'scerevisiae');
16
+ });
17
+
18
+ test('resolveOrganismCode returns undefined for unsupported / empty', async () => {
19
+ expect(resolveOrganismCode('Sus scrofa') === undefined, true);
20
+ expect(resolveOrganismCode('') === undefined, true);
21
+ expect(resolveOrganismCode(null) === undefined, true);
22
+ expect(resolveOrganismCode(undefined) === undefined, true);
23
+ });
24
+
25
+ test('every ORGANISM_LIST display resolves back to its own code', async () => {
26
+ for (const o of ORGANISM_LIST)
27
+ expect(resolveOrganismCode(o.display), o.code);
28
+ });
29
+
30
+ test('detectOrganismCode: single-species PG.Organisms column → that code', async () => {
31
+ const df = dfWithOrganismColumn('PG.Organisms',
32
+ ['Rattus norvegicus', 'Rattus norvegicus', 'Rattus norvegicus']);
33
+ expect(detectOrganismCode(df), 'rnorvegicus');
34
+ });
35
+
36
+ test('detectOrganismCode: works on a generically named "Organism" column', async () => {
37
+ const df = dfWithOrganismColumn('Organism', ['Homo sapiens', 'Homo sapiens']);
38
+ expect(detectOrganismCode(df), 'hsapiens');
39
+ });
40
+
41
+ test('detectOrganismCode: mixed empties/unknowns but one species → that code', async () => {
42
+ const df = dfWithOrganismColumn('PG.Organisms', ['', 'Homo sapiens', 'Sus scrofa', '']);
43
+ expect(detectOrganismCode(df), 'hsapiens');
44
+ });
45
+
46
+ test('detectOrganismCode: multi-species (HYE-style mix) → undefined (do not guess)', async () => {
47
+ const df = dfWithOrganismColumn('PG.Organisms',
48
+ ['Homo sapiens', 'Escherichia coli (strain K12)', 'Saccharomyces cerevisiae']);
49
+ expect(detectOrganismCode(df) === undefined, true);
50
+ });
51
+
52
+ test('detectOrganismCode: no organism column → undefined', async () => {
53
+ const df = DG.DataFrame.fromColumns([DG.Column.fromStrings('Gene', ['CDK1', 'CCNB1'])]);
54
+ expect(detectOrganismCode(df) === undefined, true);
55
+ });
56
+ });
@@ -0,0 +1,182 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
3
+ import {parseMaxQuantText} from '../parsers/maxquant-parser';
4
+ import {SEMTYPE} from '../utils/proteomics-types';
5
+
6
+ // Helper: build a minimal proteinGroups TSV for testing
7
+ function makeTsv(rows: string[][], headers: string[]): string {
8
+ return [headers.join('\t'), ...rows.map((r) => r.join('\t'))].join('\n');
9
+ }
10
+
11
+ const HEADERS = [
12
+ 'Protein IDs', 'Majority protein IDs', 'Gene names',
13
+ 'Potential contaminant', 'Reverse', 'Only identified by site',
14
+ 'LFQ intensity Sample1', 'LFQ intensity Sample2', 'iBAQ',
15
+ ];
16
+
17
+ category('Parsers', () => {
18
+ test('MaxQuant: filters contaminant rows', async () => {
19
+ const tsv = makeTsv([
20
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
21
+ ['P99999', 'P99999', 'TP53', '+', '', '', '3000', '4000', '600'],
22
+ ], HEADERS);
23
+ const df = await parseMaxQuantText(tsv);
24
+ expect(df.rowCount, 1);
25
+ });
26
+
27
+ test('MaxQuant: filters reverse rows', async () => {
28
+ const tsv = makeTsv([
29
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
30
+ ['P99999', 'P99999', 'TP53', '', '+', '', '3000', '4000', '600'],
31
+ ], HEADERS);
32
+ const df = await parseMaxQuantText(tsv);
33
+ expect(df.rowCount, 1);
34
+ });
35
+
36
+ test('MaxQuant: filters only-by-site rows', async () => {
37
+ const tsv = makeTsv([
38
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
39
+ ['P99999', 'P99999', 'TP53', '', '', '+', '3000', '4000', '600'],
40
+ ], HEADERS);
41
+ const df = await parseMaxQuantText(tsv);
42
+ expect(df.rowCount, 1);
43
+ });
44
+
45
+ test('MaxQuant: filters CON__ prefix rows', async () => {
46
+ const tsv = makeTsv([
47
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
48
+ ['CON__P99999', 'CON__P99999', 'KRT1', '', '', '', '3000', '4000', '600'],
49
+ ], HEADERS);
50
+ const df = await parseMaxQuantText(tsv);
51
+ expect(df.rowCount, 1);
52
+ });
53
+
54
+ test('MaxQuant: filters REV__ prefix rows', async () => {
55
+ const tsv = makeTsv([
56
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
57
+ ['REV__P99999', 'REV__P99999', 'TP53', '', '', '', '3000', '4000', '600'],
58
+ ], HEADERS);
59
+ const df = await parseMaxQuantText(tsv);
60
+ expect(df.rowCount, 1);
61
+ });
62
+
63
+ test('MaxQuant: detects LFQ intensity columns', async () => {
64
+ const tsv = makeTsv([
65
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
66
+ ], HEADERS);
67
+ const df = await parseMaxQuantText(tsv);
68
+ expect(df.col('LFQ intensity Sample1') !== null, true);
69
+ expect(df.col('log2(LFQ intensity Sample1)') !== null, true);
70
+ expect(df.col('log2(LFQ intensity Sample2)') !== null, true);
71
+ });
72
+
73
+ test('MaxQuant: log2 transforms intensity values correctly', async () => {
74
+ const tsv = makeTsv([
75
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1024', '2048', '500'],
76
+ ], HEADERS);
77
+ const df = await parseMaxQuantText(tsv);
78
+ const log2Col = df.col('log2(LFQ intensity Sample1)');
79
+ expect(log2Col !== null, true);
80
+ if (log2Col)
81
+ expect(Math.abs(log2Col.get(0)! - 10.0) < 0.001, true);
82
+ });
83
+
84
+ test('MaxQuant: zero intensity produces FLOAT_NULL in log2', async () => {
85
+ const tsv = makeTsv([
86
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '0', '2048', '500'],
87
+ ], HEADERS);
88
+ const df = await parseMaxQuantText(tsv);
89
+ const log2Col = df.col('log2(LFQ intensity Sample1)');
90
+ expect(log2Col !== null, true);
91
+ if (log2Col)
92
+ expect(log2Col.isNone(0), true);
93
+ });
94
+
95
+ test('MaxQuant: assigns semantic types', async () => {
96
+ const tsv = makeTsv([
97
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
98
+ ], HEADERS);
99
+ const df = await parseMaxQuantText(tsv);
100
+ expect(df.col('Protein IDs')?.semType, SEMTYPE.PROTEIN_ID);
101
+ expect(df.col('Gene names')?.semType, SEMTYPE.GENE_SYMBOL);
102
+ expect(df.col('LFQ intensity Sample1')?.semType, SEMTYPE.INTENSITY);
103
+ expect(df.col('log2(LFQ intensity Sample1)')?.semType, SEMTYPE.INTENSITY);
104
+ });
105
+
106
+ test('MaxQuant: parses primary protein ID from semicolon-delimited', async () => {
107
+ const tsv = makeTsv([
108
+ ['P12345;Q67890;R11111', 'P12345', 'BRCA1;BRCA2', '', '', '', '1000', '2000', '500'],
109
+ ], HEADERS);
110
+ const df = await parseMaxQuantText(tsv);
111
+ const primaryId = df.col('Primary Protein ID');
112
+ expect(primaryId !== null, true);
113
+ if (primaryId)
114
+ expect(primaryId.get(0), 'P12345');
115
+ const primaryGene = df.col('Primary Gene Name');
116
+ expect(primaryGene !== null, true);
117
+ if (primaryGene)
118
+ expect(primaryGene.get(0), 'BRCA1');
119
+ });
120
+
121
+ test('MaxQuant: no contaminant IDs remain after filtering', async () => {
122
+ const tsv = makeTsv([
123
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
124
+ ['P22222', 'P22222', 'TP53', '', '', '', '3000', '4000', '600'],
125
+ ['CON__P99999', 'CON__P99999', 'KRT1', '+', '', '', '5000', '6000', '700'],
126
+ ['REV__P88888', 'REV__P88888', 'FAKE1', '', '+', '', '7000', '8000', '800'],
127
+ ['P77777', 'P77777', 'FAKE2', '', '', '+', '9000', '1000', '900'],
128
+ ], HEADERS);
129
+ const df = await parseMaxQuantText(tsv);
130
+ expect(df.rowCount, 2);
131
+ const idCol = df.col('Protein IDs')!;
132
+ for (let i = 0; i < df.rowCount; i++) {
133
+ const val = idCol.get(i) as string;
134
+ if (val.startsWith('CON__') || val.startsWith('REV__'))
135
+ throw new Error(`Found filtered ID in output: ${val}`);
136
+ }
137
+ });
138
+
139
+ test('MaxQuant: assigns intensity semantic type to log2 columns', async () => {
140
+ const tsv = makeTsv([
141
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
142
+ ], HEADERS);
143
+ const df = await parseMaxQuantText(tsv);
144
+ expect(df.col('log2(LFQ intensity Sample1)')?.semType, SEMTYPE.INTENSITY);
145
+ expect(df.col('log2(LFQ intensity Sample2)')?.semType, SEMTYPE.INTENSITY);
146
+ expect(df.col('log2(iBAQ)')?.semType, SEMTYPE.INTENSITY);
147
+ });
148
+
149
+ test('MaxQuant: handles MQ 2.x dot column names', async () => {
150
+ const headers2x = [
151
+ 'Protein IDs', 'Majority protein IDs', 'Gene names',
152
+ 'Potential.contaminant', 'Reverse', 'Only.identified.by.site',
153
+ 'LFQ intensity Sample1',
154
+ ];
155
+ const tsv = makeTsv([
156
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000'],
157
+ ['P99999', 'P99999', 'TP53', '+', '', '', '3000'],
158
+ ], headers2x);
159
+ const df = await parseMaxQuantText(tsv);
160
+ expect(df.rowCount, 1);
161
+ });
162
+
163
+ test('MaxQuant: parser tags proteomics.source', async () => {
164
+ // parseMaxQuantText does NOT set df.name — that's the caller's job (importMaxQuant
165
+ // derives it from the filename). The parser DOES tag proteomics.source so
166
+ // downstream pipeline steps can identify the import origin.
167
+ const tsv = makeTsv([
168
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
169
+ ], HEADERS);
170
+ const df = await parseMaxQuantText(tsv);
171
+ expect(df.getTag('proteomics.source'), 'maxquant');
172
+ });
173
+
174
+ test('MaxQuant: iBAQ columns detected as intensity', async () => {
175
+ const tsv = makeTsv([
176
+ ['P12345', 'P12345', 'BRCA1', '', '', '', '1000', '2000', '500'],
177
+ ], HEADERS);
178
+ const df = await parseMaxQuantText(tsv);
179
+ expect(df.col('iBAQ')?.semType, SEMTYPE.INTENSITY);
180
+ expect(df.col('log2(iBAQ)') !== null, true);
181
+ });
182
+ });