@datagrok/proteomics 1.0.1 → 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 -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 +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,450 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+ import {tTest} from '@datagrok-libraries/statistics/src/tests';
5
+ import {fdrcorrection} from '@datagrok-libraries/statistics/src/multiple-tests';
6
+ import {SEMTYPE, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
7
+ import {getGroups} from './experiment-setup';
8
+
9
+ /** Guard for menu/dialog handlers that need DE results. Returns true if DE has been
10
+ * run on `df`; otherwise warns the user via `grok.shell.warning` and returns false.
11
+ * Each caller passes its own warning text so existing user-visible copy is preserved.
12
+ * Use as: `if (!requireDifferentialExpression(df, '...')) return;` */
13
+ export function requireDifferentialExpression(df: DG.DataFrame, message: string): boolean {
14
+ if (df.getTag('proteomics.de_complete') === 'true')
15
+ return true;
16
+ grok.shell.warning(message);
17
+ return false;
18
+ }
19
+
20
+ /** Extract non-null float values across named columns for a single row. */
21
+ function getGroupValues(df: DG.DataFrame, colNames: string[], rowIdx: number): number[] {
22
+ const values: number[] = [];
23
+ for (const name of colNames) {
24
+ const col = df.columns.byName(name);
25
+ if (col && !col.isNone(rowIdx))
26
+ values.push(col.get(rowIdx));
27
+ }
28
+ return values;
29
+ }
30
+
31
+ /** Compute arithmetic mean of a number array. */
32
+ function mean(vals: number[]): number {
33
+ let sum = 0;
34
+ for (let i = 0; i < vals.length; i++)
35
+ sum += vals[i];
36
+ return sum / vals.length;
37
+ }
38
+
39
+ /** Run client-side Welch's t-test differential expression with BH FDR correction.
40
+ * Compares group2 (treatment) vs group1 (control). Adds log2FC, p-value, adj.p-value,
41
+ * and significant columns to the DataFrame in-place. */
42
+ export function runDifferentialExpression(
43
+ df: DG.DataFrame,
44
+ group1Cols: string[],
45
+ group2Cols: string[],
46
+ group1Name: string,
47
+ group2Name: string,
48
+ fcThreshold: number = DEFAULT_FC_THRESHOLD,
49
+ pThreshold: number = DEFAULT_P_THRESHOLD,
50
+ ): {tested: number; untestable: number} {
51
+ // Compute per-row stats into typed arrays first, then bulk-init the columns.
52
+ // Cheaper than column.set() per row, which hops the JS/native bridge each cell.
53
+ const nRows = df.rowCount;
54
+ const fcArr = new Float32Array(nRows);
55
+ const pArr = new Float32Array(nRows);
56
+ const adjArr = new Float32Array(nRows);
57
+ fcArr.fill(DG.FLOAT_NULL);
58
+ pArr.fill(DG.FLOAT_NULL);
59
+ adjArr.fill(DG.FLOAT_NULL);
60
+
61
+ const testableIndices: number[] = [];
62
+ const rawPValues: number[] = [];
63
+
64
+ for (let i = 0; i < nRows; i++) {
65
+ const vals1 = getGroupValues(df, group1Cols, i);
66
+ const vals2 = getGroupValues(df, group2Cols, i);
67
+ if (vals1.length < 2 || vals2.length < 2) continue;
68
+
69
+ fcArr[i] = mean(vals2) - mean(vals1);
70
+ const pVal = tTest(vals1, vals2)['p-value'];
71
+ pArr[i] = pVal;
72
+ testableIndices.push(i);
73
+ rawPValues.push(pVal);
74
+ }
75
+
76
+ // BH FDR correction on testable proteins only
77
+ if (rawPValues.length > 0) {
78
+ // alpha is passed for intent/consistency with the user's cutoff; it only
79
+ // affects the (discarded) reject array — BH-adjusted p-values are
80
+ // alpha-independent, so this does not change `corrected`.
81
+ const [, corrected] = fdrcorrection(new Float32Array(rawPValues), pThreshold, 'i');
82
+ for (let j = 0; j < testableIndices.length; j++)
83
+ adjArr[testableIndices[j]] = corrected[j];
84
+ }
85
+
86
+ const log2fcCol = df.columns.addNewFloat('log2FC');
87
+ const pValCol = df.columns.addNewFloat('p-value');
88
+ const adjPValCol = df.columns.addNewFloat('adj.p-value');
89
+ log2fcCol.init((i) => fcArr[i]);
90
+ pValCol.init((i) => pArr[i]);
91
+ adjPValCol.init((i) => adjArr[i]);
92
+
93
+ const sigCol = df.columns.addNewBool('significant');
94
+ sigCol.init((i) => {
95
+ const adjP = adjArr[i];
96
+ const fc = fcArr[i];
97
+ if (adjP === DG.FLOAT_NULL || fc === DG.FLOAT_NULL) return false;
98
+ return Math.abs(fc) >= fcThreshold && adjP <= pThreshold;
99
+ });
100
+
101
+ // Assign semantic types
102
+ log2fcCol.semType = SEMTYPE.LOG2FC;
103
+ pValCol.semType = SEMTYPE.P_VALUE;
104
+ adjPValCol.semType = SEMTYPE.P_VALUE;
105
+
106
+ // Mark DE as complete
107
+ df.setTag('proteomics.de_complete', 'true');
108
+ df.fireValuesChanged();
109
+
110
+ const tested = testableIndices.length;
111
+ const untestable = df.rowCount - tested;
112
+ return {tested, untestable};
113
+ }
114
+
115
+ /** Build a clean expression dataframe with simple column names (s1, s2, ...)
116
+ * to avoid encoding issues with special characters in column names.
117
+ * Bulk-copies via getRawData/fromFloat32Array — per-row set() crosses the JS/native
118
+ * bridge once per cell and stalls the main thread on real-size frames (see
119
+ * feedback_dg_column_bulk_init memory note). */
120
+ function buildExpressionDf(
121
+ df: DG.DataFrame, group1Cols: string[], group2Cols: string[],
122
+ ): DG.DataFrame {
123
+ const allCols = [...group1Cols, ...group2Cols];
124
+ const cols: DG.Column[] = [];
125
+ for (let i = 0; i < allCols.length; i++) {
126
+ const src = df.columns.byName(allCols[i]);
127
+ // getRawData returns the underlying Float32/Float64Array; nulls are already
128
+ // encoded as DG.FLOAT_NULL, so a straight copy preserves missingness.
129
+ const raw = src.getRawData();
130
+ const buf = new Float32Array(raw.length);
131
+ buf.set(raw);
132
+ cols.push(DG.Column.fromFloat32Array(`s${i + 1}`, buf));
133
+ }
134
+ return DG.DataFrame.fromColumns(cols);
135
+ }
136
+
137
+ /** Copy log2FC / p.value / adj.p.value / significant columns from an R-script
138
+ * result frame into `df` as new columns, returning the count of TRUE
139
+ * significant rows.
140
+ *
141
+ * Alignment is by the result's `row` column (1-based input index emitted by
142
+ * the R scripts), NOT by row position. This is load-bearing: DEqMS's
143
+ * `outputResult()` returns rows sorted by significance, so a positional copy
144
+ * silently assigns every protein another protein's statistics. When `row` is
145
+ * absent (defensive fallback for any R output that doesn't emit it) the copy
146
+ * degrades to positional.
147
+ *
148
+ * R serialization sometimes flattens booleans to 0/1 or "TRUE"/"FALSE"
149
+ * strings — the truthiness check covers all three forms. */
150
+ export function copyDEResultsToFrame(df: DG.DataFrame, result: DG.DataFrame): number {
151
+ const nRows = df.rowCount;
152
+ const m = result.rowCount;
153
+
154
+ const fcRaw = result.columns.byName('log2FC').getRawData() as Float32Array | Float64Array;
155
+ const pRaw = result.columns.byName('p.value').getRawData() as Float32Array | Float64Array;
156
+ const aRaw = result.columns.byName('adj.p.value').getRawData() as Float32Array | Float64Array;
157
+ const rSig = result.columns.byName('significant');
158
+
159
+ // result row j -> df row index. With the `row` key, realign by input index
160
+ // (1-based); without it, identity (positional) as a defensive fallback.
161
+ const rowCol = result.col('row');
162
+ const target = new Int32Array(m);
163
+ if (rowCol) {
164
+ const rowRaw = rowCol.getRawData();
165
+ for (let j = 0; j < m; j++) target[j] = (rowRaw[j] | 0) - 1;
166
+ } else {
167
+ for (let j = 0; j < m; j++) target[j] = j;
168
+ }
169
+
170
+ // Pre-fill with FLOAT_NULL so any df row the result doesn't cover stays null
171
+ // rather than a spurious 0 (positional copy used to assume full coverage).
172
+ const log2fcBuf = new Float32Array(nRows); log2fcBuf.fill(DG.FLOAT_NULL);
173
+ const pBuf = new Float32Array(nRows); pBuf.fill(DG.FLOAT_NULL);
174
+ const aBuf = new Float32Array(nRows); aBuf.fill(DG.FLOAT_NULL);
175
+ const sigArr = new Uint8Array(nRows);
176
+ let sigCount = 0;
177
+
178
+ for (let j = 0; j < m; j++) {
179
+ const t = target[j];
180
+ if (t < 0 || t >= nRows) continue;
181
+ log2fcBuf[t] = fcRaw[j];
182
+ pBuf[t] = pRaw[j];
183
+ aBuf[t] = aRaw[j];
184
+ const v = rSig.get(j);
185
+ if (v === true || v === 1 || v === '1' || v === 'TRUE') {
186
+ sigArr[t] = 1;
187
+ sigCount++;
188
+ }
189
+ }
190
+
191
+ const log2fcCol = DG.Column.fromFloat32Array('log2FC', log2fcBuf);
192
+ const pValCol = DG.Column.fromFloat32Array('p-value', pBuf);
193
+ const adjPValCol = DG.Column.fromFloat32Array('adj.p-value', aBuf);
194
+ const sigCol = df.columns.addNewBool('significant');
195
+ sigCol.init((i) => sigArr[i] === 1);
196
+
197
+ log2fcCol.semType = SEMTYPE.LOG2FC;
198
+ pValCol.semType = SEMTYPE.P_VALUE;
199
+ adjPValCol.semType = SEMTYPE.P_VALUE;
200
+
201
+ df.columns.add(log2fcCol);
202
+ df.columns.add(pValCol);
203
+ df.columns.add(adjPValCol);
204
+
205
+ return sigCount;
206
+ }
207
+
208
+ /** Run limma moderated t-test via server-side R script.
209
+ * Returns the number of significant proteins found. */
210
+ async function runLimmaDE(
211
+ df: DG.DataFrame,
212
+ group1Cols: string[],
213
+ group2Cols: string[],
214
+ fcThreshold: number,
215
+ pThreshold: number,
216
+ ): Promise<number> {
217
+ const exprDf = buildExpressionDf(df, group1Cols, group2Cols);
218
+
219
+ const result: DG.DataFrame = await grok.functions.call('Proteomics:LimmaDE', {
220
+ exprDf: exprDf,
221
+ nGroup1: group1Cols.length,
222
+ fcThreshold: fcThreshold,
223
+ pThreshold: pThreshold,
224
+ });
225
+
226
+ const sigCount = copyDEResultsToFrame(df, result);
227
+
228
+ df.setTag('proteomics.de_complete', 'true');
229
+ df.fireValuesChanged();
230
+
231
+ return sigCount;
232
+ }
233
+
234
+ /** Run DEqMS peptide-count-weighted differential expression via server-side R script.
235
+ * Returns the number of significant proteins found. */
236
+ async function runDeqmsDE(
237
+ df: DG.DataFrame,
238
+ group1Cols: string[],
239
+ group2Cols: string[],
240
+ peptideCountCol: DG.Column,
241
+ fcThreshold: number,
242
+ pThreshold: number,
243
+ ): Promise<number> {
244
+ const exprDf = buildExpressionDf(df, group1Cols, group2Cols);
245
+
246
+ // Build single-column peptide count DataFrame
247
+ const peptideDf = DG.DataFrame.create(df.rowCount);
248
+ const countCol = peptideDf.columns.addNewInt('count');
249
+ for (let r = 0; r < df.rowCount; r++)
250
+ countCol.set(r, peptideCountCol.isNone(r) ? 1 : Math.round(peptideCountCol.get(r) as number));
251
+
252
+ const result: DG.DataFrame = await grok.functions.call('Proteomics:DeqmsDE', {
253
+ exprDf: exprDf,
254
+ nGroup1: group1Cols.length,
255
+ peptideDf: peptideDf,
256
+ fcThreshold: fcThreshold,
257
+ pThreshold: pThreshold,
258
+ });
259
+
260
+ const sigCount = copyDEResultsToFrame(df, result);
261
+
262
+ df.setTag('proteomics.de_complete', 'true');
263
+ df.setTag('proteomics.de_method', 'deqms');
264
+ df.fireValuesChanged();
265
+
266
+ return sigCount;
267
+ }
268
+
269
+ /**
270
+ * R3/D-09: the DE dialog's default Comparison. Returns the declared/intended
271
+ * contrast `${g1} vs ${g2}` — group1 is the parser's first condition, which is
272
+ * Spectronaut's declared Numerator (13-WAVE0-FINDINGS.md A3). The OK-handler's
273
+ * `value === pairs[1]` index logic maps this string to numerator = group1.
274
+ * Previously the default was the alphabetical `pairs[0]` (`${g2} vs ${g1}`),
275
+ * the reverse of the declared contrast — the documented mirror defect
276
+ * (memory project_proteomics_spectronaut_de_direction_default). The dropdown
277
+ * still offers both orientations; only this default changed (direction-only).
278
+ */
279
+ export function getDefaultComparison(g1Name: string, g2Name: string): string {
280
+ return `${g1Name} vs ${g2Name}`;
281
+ }
282
+
283
+ /** Show differential expression dialog with prerequisite checks.
284
+ * @param onComplete Optional callback invoked after DE completes successfully. */
285
+ export function showDEDialog(df: DG.DataFrame, onComplete?: () => void): void {
286
+ const groups = getGroups(df);
287
+ if (!groups) {
288
+ grok.shell.warning('Please annotate experimental groups first (Proteomics | Annotate Experiment)');
289
+ return;
290
+ }
291
+
292
+ if (df.getTag('proteomics.de_complete') === 'true') {
293
+ grok.shell.warning('Differential expression already performed');
294
+ return;
295
+ }
296
+
297
+ const g1 = groups.group1;
298
+ const g2 = groups.group2;
299
+
300
+ const infoText = ui.divText(
301
+ `Group 1: ${g1.name} (${g1.columns.length} samples), ` +
302
+ `Group 2: ${g2.name} (${g2.columns.length} samples)`,
303
+ );
304
+
305
+ // Comparison direction picker
306
+ const pairs = [`${g2.name} vs ${g1.name}`, `${g1.name} vs ${g2.name}`];
307
+ const comparisonInput = ui.input.choice('Comparison', {
308
+ value: getDefaultComparison(g1.name, g2.name), // D-09: declared contrast, not pairs[0]
309
+ items: pairs,
310
+ nullable: false,
311
+ });
312
+
313
+ // Dynamic hint text for FC interpretation. Derive direction from pairs index
314
+ // (not string-splitting) so group names containing " vs " stay intact.
315
+ const hintDiv = ui.div();
316
+ hintDiv.style.cssText = 'font-style:italic; color:#888; font-size:12px; margin-bottom:8px;';
317
+ const updateHint = () => {
318
+ const isReversed = comparisonInput.value === pairs[1];
319
+ const numerator = isReversed ? g1.name : g2.name;
320
+ const denominator = isReversed ? g2.name : g1.name;
321
+ hintDiv.textContent = `Positive log2FC = higher in ${numerator}, Negative log2FC = higher in ${denominator}`;
322
+ };
323
+ comparisonInput.onChanged.subscribe(updateHint);
324
+ updateHint();
325
+
326
+ const methodInput = ui.input.choice('Method', {
327
+ value: 'limma',
328
+ items: ['limma', 'DEqMS', 't-test'],
329
+ nullable: false,
330
+ });
331
+ methodInput.setTooltip('limma: moderated t-test; DEqMS: peptide-count-weighted variance; t-test: client-side Welch\'s t-test');
332
+
333
+ // Find default peptide count column
334
+ const defaultPeptideCol = df.columns.toList().find((c) =>
335
+ c.name === 'Unique peptides' || c.name === 'Peptides',
336
+ ) ?? undefined;
337
+
338
+ const peptideColInput = ui.input.column('Peptide count column', {
339
+ table: df,
340
+ value: defaultPeptideCol,
341
+ filter: (col: DG.Column) => col.type === DG.COLUMN_TYPE.INT || col.type === DG.COLUMN_TYPE.FLOAT,
342
+ nullable: false,
343
+ });
344
+ peptideColInput.setTooltip('Column with peptide/spectra counts per protein');
345
+
346
+ // Show/hide peptide count picker based on method selection
347
+ const peptideRow = peptideColInput.root;
348
+ peptideRow.style.display = 'none';
349
+ methodInput.onChanged.subscribe(() => {
350
+ peptideRow.style.display = methodInput.value === 'DEqMS' ? '' : 'none';
351
+ });
352
+
353
+ const fcInput = ui.input.float('|log2FC| threshold', {value: DEFAULT_FC_THRESHOLD});
354
+ fcInput.setTooltip('Minimum absolute fold change for significance');
355
+
356
+ const pInput = ui.input.float('Adj. p-value threshold', {value: DEFAULT_P_THRESHOLD});
357
+ pInput.setTooltip('Maximum adjusted p-value for significance');
358
+
359
+ ui.dialog('Differential Expression')
360
+ .add(infoText)
361
+ .add(comparisonInput)
362
+ .add(hintDiv)
363
+ .add(methodInput)
364
+ .add(peptideColInput)
365
+ .add(fcInput)
366
+ .add(pInput)
367
+ .onOK(async () => {
368
+ const fc = fcInput.value ?? DEFAULT_FC_THRESHOLD;
369
+ const p = pInput.value ?? DEFAULT_P_THRESHOLD;
370
+ const method = methodInput.value;
371
+
372
+ // Determine numerator/denominator from pairs index, not string-splitting,
373
+ // so group names containing " vs " don't break the comparison.
374
+ const reversed = comparisonInput.value === pairs[1];
375
+ const numeratorCols = reversed ? g1.columns : g2.columns;
376
+ const denominatorCols = reversed ? g2.columns : g1.columns;
377
+ const numeratorName = reversed ? g1.name : g2.name;
378
+ const denominatorName = reversed ? g2.name : g1.name;
379
+
380
+ const pi = DG.TaskBarProgressIndicator.create(`Running ${method} analysis...`);
381
+
382
+ try {
383
+ if (method === 't-test') {
384
+ const result = runDifferentialExpression(
385
+ df, denominatorCols, numeratorCols, denominatorName, numeratorName, fc, p,
386
+ );
387
+ df.setTag('proteomics.de_method', 't-test');
388
+ grok.shell.info(
389
+ `DE complete (t-test): ${result.tested} proteins tested, ${result.untestable} untestable`,
390
+ );
391
+ } else if (method === 'DEqMS') {
392
+ const pepCol = peptideColInput.value;
393
+ if (!pepCol) {
394
+ grok.shell.error('Please select a peptide count column for DEqMS');
395
+ return;
396
+ }
397
+ try {
398
+ const sigCount = await runDeqmsDE(df, denominatorCols, numeratorCols, pepCol, fc, p);
399
+ df.setTag('proteomics.de_method', 'deqms');
400
+ grok.shell.info(`DE complete (DEqMS): ${sigCount} significant proteins`);
401
+ } catch (deqmsErr: any) {
402
+ console.warn('DEqMS failed, trying limma:', deqmsErr);
403
+ pi.update(50, 'DEqMS unavailable, trying limma...');
404
+ grok.shell.warning('DEqMS unavailable, trying limma...');
405
+ try {
406
+ const sigCount = await runLimmaDE(df, denominatorCols, numeratorCols, fc, p);
407
+ df.setTag('proteomics.de_method', 'limma');
408
+ grok.shell.info(`DE complete (limma fallback): ${sigCount} significant proteins`);
409
+ } catch (limmaErr: any) {
410
+ console.warn('Limma DE also failed, using client-side fallback:', limmaErr);
411
+ pi.update(75, 'Server-side DE unavailable, using client-side t-test...');
412
+ grok.shell.warning('R environment unavailable — using client-side t-test');
413
+ const result = runDifferentialExpression(
414
+ df, denominatorCols, numeratorCols, denominatorName, numeratorName, fc, p,
415
+ );
416
+ df.setTag('proteomics.de_method', 't-test');
417
+ grok.shell.info(
418
+ `DE complete (t-test): ${result.tested} proteins tested, ` +
419
+ `${result.untestable} untestable`,
420
+ );
421
+ }
422
+ }
423
+ } else {
424
+ try {
425
+ const sigCount = await runLimmaDE(df, denominatorCols, numeratorCols, fc, p);
426
+ df.setTag('proteomics.de_method', 'limma');
427
+ grok.shell.info(`DE complete (limma): ${sigCount} significant proteins`);
428
+ } catch (e: any) {
429
+ pi.update(50, 'Limma unavailable, using client-side t-test...');
430
+ grok.shell.warning('R environment unavailable — using client-side t-test');
431
+ console.warn('Limma DE failed, using client-side fallback:', e);
432
+ const result = runDifferentialExpression(
433
+ df, denominatorCols, numeratorCols, denominatorName, numeratorName, fc, p,
434
+ );
435
+ df.setTag('proteomics.de_method', 't-test');
436
+ grok.shell.info(
437
+ `DE complete (t-test): ${result.tested} proteins tested, ` +
438
+ `${result.untestable} untestable`,
439
+ );
440
+ }
441
+ }
442
+ } finally {
443
+ pi.close();
444
+ }
445
+
446
+ if (onComplete)
447
+ onComplete();
448
+ })
449
+ .show();
450
+ }
@@ -0,0 +1,101 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+
5
+ import {findProteomicsColumns} from '../utils/column-detection';
6
+ import {DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
7
+ import {splitGenesByDirection, countSignificantProteins} from './enrichment';
8
+
9
+ /**
10
+ * Exports the exact inputs an enrichment run would use — the up-regulated gene
11
+ * list, the down-regulated gene list, and the background (every detected gene) —
12
+ * so the analyst can reproduce or hand off the g:Profiler query outside Datagrok
13
+ * (CK-omics CKomics_tool2.py:4531-4640 wrote the same three lists).
14
+ *
15
+ * Deliberately network-free: it reads the resolved gene-name column (falling
16
+ * back to protein IDs) rather than re-running g:Convert, mirroring the pipeline's
17
+ * non-mapping path via the SAME `splitGenesByDirection` so the exported lists
18
+ * match what Enrichment Analysis would query at identical thresholds.
19
+ */
20
+
21
+ /** Tidy long-format CSV: `gene,list` with list ∈ {up, down, background}. Each
22
+ * list is filter-and-copy friendly for g:Profiler; a gene appears under
23
+ * `background` and (if significant) also under `up`/`down` — faithful to
24
+ * g:Profiler semantics where the background is the full universe. */
25
+ export function buildEnrichmentInputsCsv(
26
+ df: DG.DataFrame, fcThreshold: number, pThreshold: number,
27
+ ): {csv: string; counts: {up: number; down: number; background: number}} {
28
+ const cols = findProteomicsColumns(df);
29
+ if (!cols.log2fc || !cols.pValue)
30
+ throw new Error('No log2FC or adjusted p-value columns found. Run Differential Expression first.');
31
+
32
+ // Prefer resolved gene symbols; fall back to protein IDs so the export still
33
+ // works before gene mapping (honest — the header says what was used).
34
+ const idCol = cols.geneName ?? cols.proteinId;
35
+ if (!idCol)
36
+ throw new Error('No gene-name or protein-ID column found to export.');
37
+
38
+ const geneForRow = new Map<number, string>();
39
+ for (let i = 0; i < df.rowCount; i++) {
40
+ const v = idCol.get(i) as string | null;
41
+ if (v) geneForRow.set(i, String(v));
42
+ }
43
+
44
+ const fcRaw = cols.log2fc.getRawData() as Float32Array | Float64Array;
45
+ const pRaw = cols.pValue.getRawData() as Float32Array | Float64Array;
46
+ const {upGenes, downGenes, background} =
47
+ splitGenesByDirection(geneForRow, fcRaw, pRaw, fcThreshold, pThreshold);
48
+
49
+ const esc = (s: string): string =>
50
+ /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
51
+ const rows: string[] = ['gene,list'];
52
+ for (const g of upGenes) rows.push(`${esc(g)},up`);
53
+ for (const g of downGenes) rows.push(`${esc(g)},down`);
54
+ for (const g of background) rows.push(`${esc(g)},background`);
55
+
56
+ return {
57
+ csv: rows.join('\n'),
58
+ counts: {up: upGenes.length, down: downGenes.length, background: background.length},
59
+ };
60
+ }
61
+
62
+ /** Small dialog to pick significance thresholds (seeded from the package
63
+ * defaults, matching the Enrichment dialog) and download the input lists as one
64
+ * CSV. Immediate download on OK via `DG.Utils.download`. */
65
+ export function showEnrichmentInputExportDialog(df: DG.DataFrame): void {
66
+ const cols = findProteomicsColumns(df);
67
+ if (!cols.log2fc || !cols.pValue) {
68
+ grok.shell.warning('No log2FC or p-value columns found. Run Differential Expression first.');
69
+ return;
70
+ }
71
+
72
+ const fcInput = ui.input.float('|log2FC| threshold', {value: DEFAULT_FC_THRESHOLD});
73
+ fcInput.setTooltip('Minimum absolute fold change for a gene to count as up/down.');
74
+ const pInput = ui.input.float('Adj. p-value threshold', {value: DEFAULT_P_THRESHOLD});
75
+ pInput.setTooltip('Maximum adjusted p-value for a gene to count as up/down.');
76
+
77
+ const countDiv = ui.divText('');
78
+ const updateCount = () => {
79
+ const r = countSignificantProteins(df, fcInput.value ?? DEFAULT_FC_THRESHOLD,
80
+ pInput.value ?? DEFAULT_P_THRESHOLD, cols.log2fc!, cols.pValue!);
81
+ countDiv.textContent = `${r.significant} of ${r.total.toLocaleString()} proteins significant`;
82
+ };
83
+ updateCount();
84
+ fcInput.onChanged.subscribe(updateCount);
85
+ pInput.onChanged.subscribe(updateCount);
86
+
87
+ ui.dialog('Export Enrichment Inputs')
88
+ .add(countDiv)
89
+ .add(fcInput)
90
+ .add(pInput)
91
+ .onOK(() => {
92
+ const fc = fcInput.value ?? DEFAULT_FC_THRESHOLD;
93
+ const p = pInput.value ?? DEFAULT_P_THRESHOLD;
94
+ const {csv, counts} = buildEnrichmentInputsCsv(df, fc, p);
95
+ const base = df.name ? `${df.name} — enrichment inputs` : 'enrichment inputs';
96
+ DG.Utils.download(`${base}.csv`, csv, 'text/csv');
97
+ grok.shell.info(
98
+ `Exported ${counts.up} up, ${counts.down} down, ${counts.background} background genes.`);
99
+ })
100
+ .show();
101
+ }