@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,604 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+ import {SEMTYPE} from '../utils/proteomics-types';
4
+ import {
5
+ log2TransformColumns, copyAsLog2Columns, detectLog2Status,
6
+ addPrimaryColumnIfNeeded,
7
+ } from './shared-utils';
8
+ import {setGroups} from '../analysis/experiment-setup';
9
+ import {resolveGeneLabels} from '../utils/gene-label-resolver';
10
+
11
+ /** Required Spectronaut columns for detection. */
12
+ const REQUIRED_COLUMNS = ['R.Condition', 'R.Replicate', 'PG.ProteinGroups'];
13
+
14
+ /** Protein-group quantity column candidates, in preference order. `PG.IBAQ` is the
15
+ * historical Spectronaut export; modern long-form reports use `PG.Quantity` (MaxLFQ
16
+ * or top-N peptide sum). Both are constant per (protein-group, run). */
17
+ const QUANTITY_COLUMNS = ['PG.IBAQ', 'PG.Quantity'];
18
+
19
+ /** Result of pivoting Spectronaut long-format data. */
20
+ interface PivotResult {
21
+ proteinMap: Map<string, Map<string, number>>;
22
+ sampleKeys: string[];
23
+ sampleFileNames: Map<string, string>;
24
+ organisms: Map<string, string>;
25
+ /** Phase 16 SPC seed (D-01): earliest observed R.RunDate as ISO-8601, null if absent/unparseable. */
26
+ earliestRunDate?: string | null;
27
+ /** Phase 16 SPC seed (D-01): first non-empty R.InstrumentMethod cell, null if absent. */
28
+ firstInstrumentMethod?: string | null;
29
+ }
30
+
31
+ /** Tries to parse a raw R.RunDate cell into a comparable epoch-ms; returns
32
+ * null when the value is empty or `Date` cannot parse it. */
33
+ function tryParseRunDate(raw: string): number | null {
34
+ if (!raw || raw.trim().length === 0) return null;
35
+ const t = Date.parse(raw);
36
+ return Number.isNaN(t) ? null : t;
37
+ }
38
+
39
+ /** Pivots Spectronaut long-format rows into protein -> sample -> quantity map.
40
+ * Filters CON__/REV__ proteins and rows exceeding q-value threshold.
41
+ * Non-numeric q-values (e.g., 'Profiled', 'NaN') are treated as passing. */
42
+ function pivotSpectronaut(
43
+ longDf: DG.DataFrame,
44
+ quantityColName: string,
45
+ qValueThreshold: number,
46
+ ): PivotResult {
47
+ const condCol = longDf.col('R.Condition')!;
48
+ const replCol = longDf.col('R.Replicate')!;
49
+ const protCol = longDf.col('PG.ProteinGroups')!;
50
+ const ibaqCol = longDf.col(quantityColName)!;
51
+ const qvalCol = longDf.col('EG.Qvalue');
52
+ const fileCol = longDf.col('R.FileName');
53
+ const orgCol = longDf.col('PG.Organisms');
54
+ // Phase 16 SPC-seed columns (D-01): OPTIONAL; never added to REQUIRED_COLUMNS
55
+ // so v1.3 inputs without them still parse.
56
+ const runDateCol = longDf.col('R.RunDate');
57
+ const instrMethodCol = longDf.col('R.InstrumentMethod');
58
+
59
+ const proteinMap = new Map<string, Map<string, number>>();
60
+ const sampleSet = new Set<string>();
61
+ const sampleFileNames = new Map<string, string>();
62
+ const organisms = new Map<string, string>();
63
+ let earliestRunDateMs: number | null = null;
64
+ let earliestRunDateIso: string | null = null;
65
+ let firstInstrumentMethod: string | null = null;
66
+
67
+ for (let i = 0; i < longDf.rowCount; i++) {
68
+ // Q-value filter: numeric values > threshold are excluded; non-numeric pass
69
+ if (qvalCol) {
70
+ if (qvalCol.isNone(i)) {
71
+ // null q-value: treat as passing
72
+ } else {
73
+ const raw = qvalCol.get(i);
74
+ const qval = Number(raw);
75
+ if (!isNaN(qval) && qval > qValueThreshold)
76
+ continue;
77
+ }
78
+ }
79
+
80
+ const protein = protCol.get(i) as string;
81
+ if (!protein) continue;
82
+ if (protein.startsWith('CON__') || protein.startsWith('REV__')) continue;
83
+
84
+ const condition = String(condCol.get(i));
85
+ const replicate = String(replCol.get(i));
86
+ const sampleKey = `${condition}_${replicate}`;
87
+ sampleSet.add(sampleKey);
88
+
89
+ if (!proteinMap.has(protein))
90
+ proteinMap.set(protein, new Map<string, number>());
91
+
92
+ // First-encountered value wins (PG.IBAQ is constant per protein+sample)
93
+ if (!proteinMap.get(protein)!.has(sampleKey) && !ibaqCol.isNone(i))
94
+ proteinMap.get(protein)!.set(sampleKey, Number(ibaqCol.get(i)));
95
+
96
+ if (fileCol && !sampleFileNames.has(sampleKey) && !fileCol.isNone(i))
97
+ sampleFileNames.set(sampleKey, fileCol.get(i) as string);
98
+ if (orgCol && !organisms.has(protein) && !orgCol.isNone(i))
99
+ organisms.set(protein, orgCol.get(i) as string);
100
+
101
+ // SPC seed: single-pass accumulators (Plan 16-04 Perf Decision Option A).
102
+ if (runDateCol && !runDateCol.isNone(i)) {
103
+ const raw = String(runDateCol.get(i));
104
+ const ms = tryParseRunDate(raw);
105
+ if (ms !== null && (earliestRunDateMs === null || ms < earliestRunDateMs)) {
106
+ earliestRunDateMs = ms;
107
+ earliestRunDateIso = new Date(ms).toISOString();
108
+ }
109
+ }
110
+ if (instrMethodCol && firstInstrumentMethod === null && !instrMethodCol.isNone(i)) {
111
+ const raw = String(instrMethodCol.get(i));
112
+ if (raw.trim().length > 0) firstInstrumentMethod = raw;
113
+ }
114
+ }
115
+
116
+ return {
117
+ proteinMap,
118
+ sampleKeys: Array.from(sampleSet).sort(),
119
+ sampleFileNames,
120
+ organisms,
121
+ earliestRunDate: earliestRunDateIso,
122
+ firstInstrumentMethod,
123
+ };
124
+ }
125
+
126
+ /** Builds wide protein-by-sample DataFrame from pivot result. */
127
+ function buildWideDataFrame(result: PivotResult): DG.DataFrame {
128
+ const proteins = Array.from(result.proteinMap.keys());
129
+ const n = proteins.length;
130
+
131
+ const proteinCol = DG.Column.fromStrings('PG.ProteinGroups', proteins);
132
+ proteinCol.semType = SEMTYPE.PROTEIN_ID;
133
+
134
+ const cols: DG.Column[] = [proteinCol];
135
+
136
+ // Add organisms column if any were collected
137
+ if (result.organisms.size > 0) {
138
+ const orgValues = proteins.map((p) => result.organisms.get(p) ?? '');
139
+ const orgCol = DG.Column.fromStrings('PG.Organisms', orgValues);
140
+ cols.push(orgCol);
141
+ }
142
+
143
+ for (const sampleKey of result.sampleKeys) {
144
+ const values = new Float32Array(n);
145
+ for (let i = 0; i < n; i++) {
146
+ const sampleMap = result.proteinMap.get(proteins[i])!;
147
+ values[i] = sampleMap.has(sampleKey) ? sampleMap.get(sampleKey)! : DG.FLOAT_NULL;
148
+ }
149
+ const col = DG.Column.fromFloat32Array(sampleKey, values);
150
+ col.semType = SEMTYPE.INTENSITY;
151
+ if (result.sampleFileNames.has(sampleKey))
152
+ col.setTag('spectronaut.fileName', result.sampleFileNames.get(sampleKey)!);
153
+ cols.push(col);
154
+ }
155
+
156
+ return DG.DataFrame.fromColumns(cols);
157
+ }
158
+
159
+ /** Auto-populates experimental groups from R.Condition when exactly 2 conditions exist.
160
+ * Groups use log2-prefixed column names for downstream analysis compatibility. */
161
+ function autoPopulateGroups(df: DG.DataFrame, sampleKeys: string[]): void {
162
+ const conditionMap = new Map<string, string[]>();
163
+ for (const key of sampleKeys) {
164
+ const lastUnderscore = key.lastIndexOf('_');
165
+ const condition = key.substring(0, lastUnderscore);
166
+ if (!conditionMap.has(condition))
167
+ conditionMap.set(condition, []);
168
+ conditionMap.get(condition)!.push(`log2(${key})`);
169
+ }
170
+
171
+ const conditions = Array.from(conditionMap.keys());
172
+ if (conditions.length === 2) {
173
+ setGroups(df, {
174
+ group1: {name: conditions[0], columns: conditionMap.get(conditions[0])!},
175
+ group2: {name: conditions[1], columns: conditionMap.get(conditions[1])!},
176
+ });
177
+ }
178
+ }
179
+
180
+ /** Shared post-pivot tail for BOTH the text and streaming Spectronaut paths.
181
+ *
182
+ * Builds the wide DataFrame, derives the primary protein column, detects/applies
183
+ * the log2 transform, sets the `proteomics.*` tags, and auto-populates the two
184
+ * experimental groups. `parseSpectronautText` and `parseSpectronautStream` both
185
+ * end by handing their `PivotResult` here, so their output is byte-identical in
186
+ * shape, semTypes, log2 columns, tags, and groups (R3).
187
+ *
188
+ * NOTE: this is the verbatim former tail of `parseSpectronautText` extracted
189
+ * unchanged — do not edit tag strings, semType assignments, the log2 branch, or
190
+ * column construction here without re-locking the existing Spectronaut tests. */
191
+ async function finalizeSpectronaut(result: PivotResult): Promise<DG.DataFrame> {
192
+ // Build wide DataFrame
193
+ const df = buildWideDataFrame(result);
194
+
195
+ // Parse semicolon-delimited protein groups into primary column
196
+ addPrimaryColumnIfNeeded(df, 'PG.ProteinGroups', 'Primary Protein ID', SEMTYPE.PROTEIN_ID);
197
+
198
+ // Get intensity column names (the sample columns)
199
+ const intensityColNames = result.sampleKeys;
200
+
201
+ // Detect log2 status
202
+ const log2Status = detectLog2Status(df, intensityColNames);
203
+
204
+ if (log2Status.isLog2) {
205
+ // Pre-normalized: copy as log2 columns without transformation
206
+ copyAsLog2Columns(df, intensityColNames);
207
+ } else {
208
+ // Raw: apply log2 transformation, keeping originals
209
+ log2TransformColumns(df, intensityColNames);
210
+ }
211
+
212
+ // Spectronaut always performs its own normalization regardless of export format
213
+ df.setTag('proteomics.preNormalized', 'true');
214
+
215
+ // Tag source
216
+ df.setTag('proteomics.source', 'spectronaut');
217
+
218
+ // Auto-populate groups from R.Condition
219
+ autoPopulateGroups(df, result.sampleKeys);
220
+
221
+ await resolveGeneLabels(df);
222
+
223
+ return df;
224
+ }
225
+
226
+ /** Mirrors duckdb `nullstr=['','NaN','NA']` + `TRY_CAST(... AS DOUBLE)` from
227
+ * tools/spectronaut-aggregate.sql. Returns `null` for the duckdb null tokens
228
+ * and for anything that is not a finite number; otherwise the parsed number.
229
+ *
230
+ * This is intentionally STRICTER than `pivotSpectronaut`'s loose
231
+ * `Number(raw)`/`isNaN` (which treats `''` as `0` via `Number('')`): the
232
+ * streaming path must match the duckdb D-04 golden oracle exactly. The two
233
+ * paths only diverge on degenerate inputs (`''` q-value/quantity) that the
234
+ * real Spectronaut exports and the synthetic fixture never contain — the text
235
+ * path stays byte-identical because it does not route precursor reports. */
236
+ function tryCastDouble(s: string | null | undefined): number | null {
237
+ if (s === null || s === undefined) return null;
238
+ const t = s.trim();
239
+ if (t === '' || t === 'NaN' || t === 'NA') return null;
240
+ const n = Number(t);
241
+ return Number.isFinite(n) ? n : null;
242
+ }
243
+
244
+ /** Outcome of feeding one already-split streamed data line to `handleFields`:
245
+ *
246
+ * - `'kept'` — the row was aggregated into `agg` (counts toward `rowCount`).
247
+ * - `'malformed'` — the row is structurally unparseable (too few tab-separated
248
+ * fields). ONLY this category is surfaced to the user as
249
+ * "malformed/unparseable line(s)".
250
+ * - `'filtered'` — the row was dropped by a correct, by-design duckdb-parity
251
+ * filter (empty `PG.ProteinGroups`, CON__/REV__ decoy/
252
+ * contaminant, numeric `EG.Qvalue` > threshold, or both
253
+ * numeric casts null). This is NOT malformed and is SILENT —
254
+ * exactly as the text path (`pivotSpectronaut`) is for the
255
+ * identical drops.
256
+ */
257
+ type LineOutcome = 'kept' | 'malformed' | 'filtered';
258
+
259
+ /** Per-(protein × condition × replicate) running aggregate accumulated by the
260
+ * streaming path. Mirrors the duckdb GROUP BY: `max(quantity)`, `min(qvalue)`,
261
+ * first-non-null `R.FileName` / `PG.Organisms`. */
262
+ interface AggRow {
263
+ protein: string;
264
+ condition: string;
265
+ replicate: string;
266
+ maxQuantity: number;
267
+ minQvalue: number;
268
+ fileName: string | null;
269
+ organism: string | null;
270
+ }
271
+
272
+ /** Group-key field separator. ASCII Unit Separator (0x1F) is not a TSV-legal
273
+ * character, so it cannot collide with any condition/replicate/protein value. */
274
+ const AGG_KEY_SEP = '\x1f';
275
+
276
+ /** Folds the streaming aggregate map into the same `PivotResult` shape that
277
+ * `pivotSpectronaut` produces, so `finalizeSpectronaut` is path-agnostic.
278
+ * `sampleKey` MUST be `${condition}_${replicate}` to match `pivotSpectronaut`
279
+ * (L65) and `autoPopulateGroups`'s `lastIndexOf('_')` split. */
280
+ function aggToPivotResult(agg: Map<string, AggRow>): PivotResult {
281
+ const proteinMap = new Map<string, Map<string, number>>();
282
+ const sampleSet = new Set<string>();
283
+ const sampleFileNames = new Map<string, string>();
284
+ const organisms = new Map<string, string>();
285
+
286
+ for (const row of agg.values()) {
287
+ // Never-set groups (no surviving numeric quantity) carry -Infinity; skip
288
+ // them exactly as duckdb would emit no quantity for an all-null group.
289
+ if (!Number.isFinite(row.maxQuantity)) continue;
290
+
291
+ const sampleKey = `${row.condition}_${row.replicate}`;
292
+ sampleSet.add(sampleKey);
293
+
294
+ if (!proteinMap.has(row.protein))
295
+ proteinMap.set(row.protein, new Map<string, number>());
296
+ proteinMap.get(row.protein)!.set(sampleKey, row.maxQuantity);
297
+
298
+ if (row.fileName !== null && !sampleFileNames.has(sampleKey))
299
+ sampleFileNames.set(sampleKey, row.fileName);
300
+ if (row.organism !== null && !organisms.has(row.protein))
301
+ organisms.set(row.protein, row.organism);
302
+ }
303
+
304
+ return {
305
+ proteinMap,
306
+ sampleKeys: Array.from(sampleSet).sort(),
307
+ sampleFileNames,
308
+ organisms,
309
+ };
310
+ }
311
+
312
+ /** Streams a precursor/fragment-level Spectronaut long-format TSV `File` and
313
+ * single-pass aggregates it down to the same wide protein-by-sample DataFrame
314
+ * `parseSpectronautText` produces — without ever materializing the whole file
315
+ * as a string (the V8 ~512 MB string ceiling is why a 2.6 GB report OOMs the
316
+ * text path). Only state retained is the bounded carry-over line buffer and the
317
+ * aggregate Map keyed by (protein × condition × replicate); raw input rows are
318
+ * NEVER buffered (T-12-05).
319
+ *
320
+ * Filter / aggregate parity with tools/spectronaut-aggregate.sql:
321
+ * - drop empty / `CON__` / `REV__` `PG.ProteinGroups`
322
+ * - drop a row only if `tryCastDouble(EG.Qvalue) > threshold` (null/non-numeric
323
+ * passes — duckdb `IS NULL OR <= 0.01`)
324
+ * - per group: `max(tryCastDouble(quantity))`, `min(tryCastDouble(EG.Qvalue))`,
325
+ * first-non-null `R.FileName` / `PG.Organisms`
326
+ * - the DMD↔WT `R.Condition` flip in the SQL is REFERENCE-FILE-ONLY and is
327
+ * deliberately NOT ported here (RESEARCH Pitfall 1).
328
+ *
329
+ * Malformed lines (too few fields, or both numeric casts null) are skipped and
330
+ * counted (duckdb `ignore_errors=true` parity), surfaced via the progress
331
+ * message rather than aborting. A `TaskBarProgressIndicator` advances by
332
+ * bytes-read and an explicit `setTimeout(0)` macrotask yield runs on a ~16 ms
333
+ * cadence so the tab stays responsive (T-12-09; the stream-read await alone
334
+ * does not yield on OS-buffered data). */
335
+ export async function parseSpectronautStream(
336
+ file: File, qValueThreshold: number = 0.01,
337
+ ): Promise<DG.DataFrame> {
338
+ const pi = DG.TaskBarProgressIndicator.create('Streaming Spectronaut report...');
339
+ try {
340
+ const reader = file.stream()
341
+ .pipeThrough(new TextDecoderStream('utf-8'))
342
+ .getReader();
343
+
344
+ const agg = new Map<string, AggRow>();
345
+ let buffer = '';
346
+ let headerParsed = false;
347
+ let colIdx: Map<string, number> = new Map();
348
+ let protI = -1; let condI = -1; let repI = -1;
349
+ let qvalI = -1; let qtyI = -1; let fileI = -1; let orgI = -1;
350
+ // Phase 16 SPC seed (D-01) — OPTIONAL columns, never required.
351
+ let runDateI = -1; let instrMethodI = -1;
352
+ let expectedFields = 0;
353
+ let earliestRunDateMs: number | null = null;
354
+ let earliestRunDateIso: string | null = null;
355
+ let firstInstrumentMethod: string | null = null;
356
+
357
+ let bytesRead = 0;
358
+ let rowCount = 0;
359
+ // Structurally unparseable lines (too few tab-separated fields). ONLY this
360
+ // counter feeds the user-facing "malformed line(s)" message.
361
+ let malformed = 0;
362
+ // Rows dropped by a correct by-design duckdb-parity filter (CON__/REV__ or
363
+ // numeric q > threshold). Tracked for completeness but SILENT to the user —
364
+ // the text path (pivotSpectronaut) drops the identical rows with no message.
365
+ let filtered = 0;
366
+ let lastYield = performance.now();
367
+ const fileSize = Math.max(1, file.size);
368
+
369
+ /** Aggregate one already-split data line into `agg` (parity with the
370
+ * duckdb WHERE + GROUP BY). Returns a discriminated outcome:
371
+ * `'malformed'` for a structurally unparseable row (too few tab-separated
372
+ * fields), `'filtered'` for a correct by-design duckdb-parity drop (empty
373
+ * `PG.ProteinGroups`, CON__/REV__ decoy/contaminant, numeric `EG.Qvalue` >
374
+ * threshold, or both numeric casts null), `'kept'` once the row is
375
+ * aggregated. Empty-protein and both-casts-null are duckdb-silent drops
376
+ * that the text path (`pivotSpectronaut`) also drops without messaging —
377
+ * categorizing them `'filtered'` keeps streaming↔text user-message parity.
378
+ * The predicates below are duckdb-parity-correct and UNCHANGED — only the
379
+ * outcome value carries the malformed-vs-filtered distinction. */
380
+ const handleFields = (f: string[]): LineOutcome => {
381
+ if (f.length < expectedFields) return 'malformed';
382
+ const protein = f[protI];
383
+ // Drop empty / decoy / contaminant protein groups — all silent, matching
384
+ // `pivotSpectronaut`'s `if (!protein) continue` + CON__/REV__ skip.
385
+ if (!protein) return 'filtered';
386
+ if (protein.startsWith('CON__') || protein.startsWith('REV__')) return 'filtered';
387
+
388
+ const q = qvalI >= 0 ? tryCastDouble(f[qvalI]) : null;
389
+ // duckdb: (qvalue IS NULL OR qvalue <= threshold) — null/non-numeric pass.
390
+ if (q !== null && q > qValueThreshold) return 'filtered';
391
+
392
+ const qty = tryCastDouble(f[qtyI]);
393
+ // Both numeric casts null → unusable for aggregation; drop silently
394
+ // (duckdb `ignore_errors` semantics — the text path likewise omits this row).
395
+ if (qty === null && q === null) return 'filtered';
396
+
397
+ const condition = f[condI] ?? '';
398
+ const replicate = f[repI] ?? '';
399
+ const key = protein + AGG_KEY_SEP + condition + AGG_KEY_SEP + replicate;
400
+ let row = agg.get(key);
401
+ if (row === undefined) {
402
+ row = {
403
+ protein, condition, replicate,
404
+ maxQuantity: -Infinity, minQvalue: Infinity,
405
+ fileName: null, organism: null,
406
+ };
407
+ agg.set(key, row);
408
+ }
409
+ if (qty !== null && qty > row.maxQuantity) row.maxQuantity = qty;
410
+ if (q !== null && q < row.minQvalue) row.minQvalue = q;
411
+ if (row.fileName === null && fileI >= 0) {
412
+ const fn = f[fileI];
413
+ if (fn) row.fileName = fn;
414
+ }
415
+ if (row.organism === null && orgI >= 0) {
416
+ const og = f[orgI];
417
+ if (og) row.organism = og;
418
+ }
419
+ // Phase 16 SPC seed (D-01) — accumulate inline so the streaming-path adds
420
+ // no second pass over the file.
421
+ if (runDateI >= 0) {
422
+ const rd = f[runDateI];
423
+ if (rd) {
424
+ const ms = tryParseRunDate(rd);
425
+ if (ms !== null && (earliestRunDateMs === null || ms < earliestRunDateMs)) {
426
+ earliestRunDateMs = ms;
427
+ earliestRunDateIso = new Date(ms).toISOString();
428
+ }
429
+ }
430
+ }
431
+ if (instrMethodI >= 0 && firstInstrumentMethod === null) {
432
+ const im = f[instrMethodI];
433
+ if (im && im.trim().length > 0) firstInstrumentMethod = im;
434
+ }
435
+ return 'kept';
436
+ };
437
+
438
+ /** Parse the first complete line as the header; resolve column indices and
439
+ * the single quantity column once, matching the text-path validation. */
440
+ const parseHeader = (line: string): void => {
441
+ const cols = line.split('\t');
442
+ colIdx = new Map();
443
+ for (let i = 0; i < cols.length; i++)
444
+ colIdx.set(cols[i], i);
445
+ for (const colName of REQUIRED_COLUMNS) {
446
+ if (!colIdx.has(colName))
447
+ throw new Error(`Missing required Spectronaut column: ${colName}`);
448
+ }
449
+ const quantityColName = QUANTITY_COLUMNS.find((n) => colIdx.has(n));
450
+ if (!quantityColName) {
451
+ throw new Error(`Missing protein-group quantity column ` +
452
+ `(expected one of ${QUANTITY_COLUMNS.join(', ')})`);
453
+ }
454
+ protI = colIdx.get('PG.ProteinGroups')!;
455
+ condI = colIdx.get('R.Condition')!;
456
+ repI = colIdx.get('R.Replicate')!;
457
+ qtyI = colIdx.get(quantityColName)!;
458
+ qvalI = colIdx.has('EG.Qvalue') ? colIdx.get('EG.Qvalue')! : -1;
459
+ fileI = colIdx.has('R.FileName') ? colIdx.get('R.FileName')! : -1;
460
+ orgI = colIdx.has('PG.Organisms') ? colIdx.get('PG.Organisms')! : -1;
461
+ // Phase 16 SPC seed: optional indices, contribute to expectedFields ONLY
462
+ // when present so v1.3 headers without them still parse.
463
+ runDateI = colIdx.has('R.RunDate') ? colIdx.get('R.RunDate')! : -1;
464
+ instrMethodI = colIdx.has('R.InstrumentMethod') ? colIdx.get('R.InstrumentMethod')! : -1;
465
+ // Highest index we read; a shorter split means a truncated/malformed row.
466
+ expectedFields = Math.max(
467
+ protI, condI, repI, qtyI, qvalI, fileI, orgI, runDateI, instrMethodI,
468
+ ) + 1;
469
+ headerParsed = true;
470
+ };
471
+
472
+ while (true) {
473
+ const {value, done} = await reader.read();
474
+ if (done) break;
475
+ bytesRead += value.length;
476
+ buffer += value;
477
+
478
+ let nl: number;
479
+ while ((nl = buffer.indexOf('\n')) >= 0) {
480
+ let line = buffer.slice(0, nl);
481
+ buffer = buffer.slice(nl + 1);
482
+ if (line.endsWith('\r')) line = line.slice(0, -1);
483
+ if (!headerParsed) {
484
+ if (line.length === 0) continue;
485
+ parseHeader(line);
486
+ continue;
487
+ }
488
+ if (line.length === 0) continue;
489
+ switch (handleFields(line.split('\t'))) {
490
+ case 'kept': rowCount++; break;
491
+ case 'malformed': malformed++; break;
492
+ case 'filtered': filtered++; break;
493
+ }
494
+ }
495
+
496
+ // ~16 ms wall-clock yield cadence (RESEARCH Q3 resolution / D-02). The
497
+ // explicit setTimeout(0) macrotask yield is REQUIRED — await reader.read()
498
+ // does not reliably yield on OS-buffered data (T-12-09).
499
+ const now = performance.now();
500
+ if (now - lastYield >= 16) {
501
+ const pct = Math.min(99, (bytesRead / fileSize) * 100);
502
+ // Only the genuine-malformed count is surfaced; by-design-filtered rows
503
+ // (CON__/REV__, q > threshold) stay silent — text-path-consistent.
504
+ const msg = malformed > 0
505
+ ? `${rowCount} rows (${malformed} malformed skipped)`
506
+ : `${rowCount} rows`;
507
+ pi.update(pct, msg);
508
+ await new Promise<void>((r) => setTimeout(r, 0));
509
+ lastYield = performance.now();
510
+ }
511
+ }
512
+
513
+ // Flush a trailing partial line (file without a final newline).
514
+ if (buffer.length > 0) {
515
+ let line = buffer;
516
+ if (line.endsWith('\r')) line = line.slice(0, -1);
517
+ if (line.length > 0) {
518
+ if (!headerParsed) {
519
+ parseHeader(line);
520
+ } else {
521
+ switch (handleFields(line.split('\t'))) {
522
+ case 'kept': rowCount++; break;
523
+ case 'malformed': malformed++; break;
524
+ case 'filtered': filtered++; break;
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ if (!headerParsed)
531
+ throw new Error('Missing required Spectronaut column: R.Condition');
532
+
533
+ // Genuine-malformed only. By-design-filtered rows (CON__/REV__, q >
534
+ // threshold) are intentionally SILENT — pivotSpectronaut drops the
535
+ // identical rows with no message; matching that silence is the locked,
536
+ // path-consistent UX decision. `filtered` is tracked but never surfaced.
537
+ void filtered;
538
+ if (malformed > 0)
539
+ grok.shell.info(`Spectronaut import: skipped ${malformed} malformed line(s)`);
540
+
541
+ const pivot = aggToPivotResult(agg);
542
+ pivot.earliestRunDate = earliestRunDateIso;
543
+ pivot.firstInstrumentMethod = firstInstrumentMethod;
544
+ const df = await finalizeSpectronaut(pivot);
545
+ // Phase 16 D-01: write the `proteomics.spc_run_meta_seed` tag when either
546
+ // capture was made (streaming path).
547
+ applySpcRunMetaSeed(df, pivot);
548
+ return df;
549
+ } finally {
550
+ pi.close();
551
+ }
552
+ }
553
+
554
+ /** Parses Spectronaut long-format TSV text into a wide protein-by-sample DataFrame.
555
+ *
556
+ * Applies:
557
+ * - Q-value filtering (default threshold 0.01; non-numeric q-values pass)
558
+ * - CON__/REV__ contaminant/decoy filtering
559
+ * - Long-to-wide pivot using PG.IBAQ (preferred) or PG.Quantity (newer exports)
560
+ * as protein-level intensity
561
+ * - Semantic type assignment (PROTEIN_ID, INTENSITY)
562
+ * - Log2 transformation (or copy if pre-normalized)
563
+ * - Auto-group population from R.Condition when exactly 2 conditions
564
+ * - Pre-normalization detection and tagging */
565
+ export async function parseSpectronautText(text: string, qValueThreshold: number = 0.01): Promise<DG.DataFrame> {
566
+ // Parse long-format TSV
567
+ const longDf = DG.DataFrame.fromCsv(text, {delimiter: '\t'});
568
+
569
+ // Validate required columns
570
+ for (const colName of REQUIRED_COLUMNS) {
571
+ if (!longDf.col(colName))
572
+ throw new Error(`Missing required Spectronaut column: ${colName}`);
573
+ }
574
+
575
+ // Pick the protein-group quantity column (PG.IBAQ preferred, PG.Quantity accepted).
576
+ const quantityColName = QUANTITY_COLUMNS.find((n) => longDf.col(n) !== null);
577
+ if (!quantityColName) {
578
+ throw new Error(`Missing protein-group quantity column ` +
579
+ `(expected one of ${QUANTITY_COLUMNS.join(', ')})`);
580
+ }
581
+
582
+ // Pivot long-to-wide with filtering
583
+ const result = pivotSpectronaut(longDf, quantityColName, qValueThreshold);
584
+
585
+ // Shared post-pivot tail (identical for the streaming path).
586
+ const df = await finalizeSpectronaut(result);
587
+ // Phase 16 D-01: write the `proteomics.spc_run_meta_seed` tag when either
588
+ // capture was made (text path).
589
+ applySpcRunMetaSeed(df, result);
590
+ return df;
591
+ }
592
+
593
+ /** Phase 16 D-01: Attach the SPC run-meta seed to the parsed df when either
594
+ * R.RunDate or R.InstrumentMethod was captured. Plan 16-05's Annotate
595
+ * Experiment dialog reads this seed to prefill its two new inputs. */
596
+ function applySpcRunMetaSeed(df: DG.DataFrame, result: PivotResult): void {
597
+ const hasDate = result.earliestRunDate != null;
598
+ const hasInstr = result.firstInstrumentMethod != null;
599
+ if (!hasDate && !hasInstr) return;
600
+ df.setTag('proteomics.spc_run_meta_seed', JSON.stringify({
601
+ instrument_id: result.firstInstrumentMethod ?? '',
602
+ acquisition_datetime: result.earliestRunDate ?? '',
603
+ }));
604
+ }