@datagrok/proteomics 1.0.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +88 -63
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2073 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- 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
|
+
}
|