@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,431 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+
3
+ /**
4
+ * Shared UniProt subcellular-location service.
5
+ *
6
+ * The classifier, keyword map, and hex palette are ported VERBATIM from the
7
+ * locked CK-omics contract (~/Downloads/ck/CKomics_tool2.py:1384-1436
8
+ * `parse_subcellular_location` / `get_location_colors` and
9
+ * Subcellular_Location_Classification_README.txt). Any divergence is a parity
10
+ * regression — do not re-derive. Pure functions do no I/O.
11
+ */
12
+
13
+ // --- Verbatim CK-omics 11-category keyword map (dict insertion order matters:
14
+ // it is the tie-break for equal-position matches). ---
15
+
16
+ export const LOCATION_KEYWORDS: Record<string, string[]> = {
17
+ 'Nucleus': ['nucleus', 'nuclear', 'nucleoplasm', 'chromatin', 'nucleolus',
18
+ 'nuclear envelope', 'nuclear membrane', 'nuclear pore', 'nuclear speck',
19
+ 'cajal body', 'nuclear body'],
20
+ 'Cytoplasm': ['cytoplasm', 'cytosol', 'cytoplasmic', 'cytoskeleton',
21
+ 'microtubule', 'actin', 'intermediate filament', 'sarcomere', 'myofibril',
22
+ 'z disc', 'z disk', 'spindle', 'centriole', 'centrosome', 'cilium',
23
+ 'flagellum', 'stress fiber'],
24
+ 'Mitochondria': ['mitochondria', 'mitochondrial', 'mitochondrion',
25
+ 'mitochondrial matrix', 'mitochondrial membrane',
26
+ 'mitochondrial inner membrane', 'mitochondrial outer membrane',
27
+ 'mitochondrial intermembrane space'],
28
+ 'ER': ['endoplasmic reticulum', 'sarcoplasmic reticulum',
29
+ 'rough endoplasmic reticulum', 'smooth endoplasmic reticulum',
30
+ 'er-golgi intermediate compartment', 'ergic'],
31
+ 'Golgi': ['golgi', 'trans-golgi', 'golgi apparatus', 'golgi membrane',
32
+ 'cis-golgi', 'trans-golgi network', 'tgn'],
33
+ 'Plasma Membrane': ['plasma membrane', 'cell membrane', 'cell surface',
34
+ 'tight junction', 'gap junction', 'adherens junction', 'desmosome',
35
+ 'focal adhesion', 'cell junction', 'synapse', 'postsynaptic',
36
+ 'presynaptic', 'neuromuscular junction'],
37
+ 'Lysosome': ['lysosome', 'lysosomal', 'vacuole', 'lysosomal membrane',
38
+ 'autophagosome', 'phagosome'],
39
+ 'Peroxisome': ['peroxisome', 'peroxisomal', 'glyoxysome'],
40
+ 'Ribosome': ['ribosome', 'ribosomal', 'ribosomal protein', 'polysome'],
41
+ 'Extracellular': ['extracellular', 'secreted', 'extracellular space',
42
+ 'extracellular matrix', 'basement membrane', 'basal lamina', 'collagen',
43
+ 'ecm'],
44
+ 'Vesicles': ['vesicle', 'endosome', 'transport vesicle', 'secretory vesicle',
45
+ 'early endosome', 'late endosome', 'recycling endosome',
46
+ 'clathrin-coated vesicle', 'coated vesicle'],
47
+ };
48
+
49
+ /** Locked hex palette (README §COLOR SCHEME) as ARGB ints — same 0xFF… form as
50
+ * volcano.ts:60-62. 11 categories + Unknown. */
51
+ export const LOCATION_COLORS: Record<string, number> = {
52
+ 'Nucleus': 0xFF000000 | parseInt('FF6B6B', 16),
53
+ 'Cytoplasm': 0xFF000000 | parseInt('ECDC44', 16),
54
+ 'Mitochondria': 0xFF000000 | parseInt('45B7D1', 16),
55
+ 'ER': 0xFF000000 | parseInt('96CEB4', 16),
56
+ 'Golgi': 0xFF000000 | parseInt('FAAFFE', 16),
57
+ 'Plasma Membrane': 0xFF000000 | parseInt('DDA0DD', 16),
58
+ 'Lysosome': 0xFF000000 | parseInt('F39C12', 16),
59
+ 'Peroxisome': 0xFF000000 | parseInt('E17055', 16),
60
+ 'Ribosome': 0xFF000000 | parseInt('A29BFE', 16),
61
+ 'Extracellular': 0xFF000000 | parseInt('FD79A8', 16),
62
+ 'Vesicles': 0xFF000000 | parseInt('FDCB6E', 16),
63
+ 'Unknown': 0xFF000000 | parseInt('CCCCCC', 16),
64
+ };
65
+
66
+ /** Escapes regex metacharacters (mirrors Python `re.escape` faithfully — the
67
+ * current keywords contain none, but stay defensive). */
68
+ function escapeRegex(s: string): string {
69
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ }
71
+
72
+ /** First location keyword (by earliest character position across ALL
73
+ * categories) in the lowercased RAW text. Ties keep the first-iterated
74
+ * category (strict `<`), so keyword-map insertion order is the tie-break.
75
+ * The raw string is NOT pre-stripped (Pitfall 2). */
76
+ function findFirstLocation(text: string): string | null {
77
+ if (!text) return null;
78
+ const lower = text.toLowerCase();
79
+ let earliestMatch: string | null = null;
80
+ let earliestPos = Infinity;
81
+ for (const category of Object.keys(LOCATION_KEYWORDS)) {
82
+ for (const keyword of LOCATION_KEYWORDS[category]) {
83
+ const m = new RegExp('\\b' + escapeRegex(keyword) + '\\b', 'i').exec(lower);
84
+ if (m && m.index < earliestPos) {
85
+ earliestPos = m.index;
86
+ earliestMatch = category;
87
+ }
88
+ }
89
+ }
90
+ return earliestMatch;
91
+ }
92
+
93
+ /** Verbatim port of CK-omics `parse_subcellular_location`: subcellular field is
94
+ * scanned fully first, GO cellular-component is fallback only, else 'Unknown'. */
95
+ export function parseSubcellularLocation(
96
+ subcell: string | null | undefined,
97
+ go: string | null | undefined,
98
+ ): string {
99
+ const subcellText = subcell == null ? '' : subcell;
100
+ const goText = go == null ? '' : go;
101
+ let location = findFirstLocation(subcellText);
102
+ if (location) return location;
103
+ location = findFirstLocation(goText);
104
+ if (location) return location;
105
+ return 'Unknown';
106
+ }
107
+
108
+ /** Result of parsing one UniProt stream TSV body. */
109
+ export interface StreamTsvResult {
110
+ locByAcc: Map<string, string>;
111
+ geneByAcc: Map<string, string>;
112
+ /** Whether the winning location for an accession came from a reviewed entry. */
113
+ reviewedByAcc: Map<string, boolean>;
114
+ }
115
+
116
+ /**
117
+ * Pure positional parse of a UniProt stream TSV (fields order:
118
+ * accession, cc_subcellular_location, go_c, reviewed, gene_primary). The first
119
+ * line is the display-name header and is skipped; columns are read by POSITION
120
+ * (`parts[0..4]`), never by header name (Pitfall 1). Within the text, the
121
+ * priority merge is: first occurrence sets; a reviewed non-Unknown overwrites;
122
+ * an existing Unknown is overwritten by any non-Unknown.
123
+ */
124
+ export function mergeStreamTsv(text: string): StreamTsvResult {
125
+ const locByAcc = new Map<string, string>();
126
+ const geneByAcc = new Map<string, string>();
127
+ const reviewedByAcc = new Map<string, boolean>();
128
+ const lines = text.trim().split('\n');
129
+ for (let li = 1; li < lines.length; li++) {
130
+ const line = lines[li];
131
+ if (!line) continue;
132
+ const p = line.split('\t');
133
+ const acc = (p[0] ?? '').trim();
134
+ if (!acc) continue;
135
+ const subcell = p[1] ?? '';
136
+ const goc = p[2] ?? '';
137
+ const reviewed = (p[3] ?? '').toLowerCase() === 'reviewed';
138
+ const gene = (p[4] ?? '').trim();
139
+ const cat = parseSubcellularLocation(subcell, goc);
140
+
141
+ if (!locByAcc.has(acc)) {
142
+ locByAcc.set(acc, cat);
143
+ reviewedByAcc.set(acc, reviewed);
144
+ } else if (reviewed && cat !== 'Unknown') {
145
+ locByAcc.set(acc, cat);
146
+ reviewedByAcc.set(acc, true);
147
+ } else if (locByAcc.get(acc) === 'Unknown' && cat !== 'Unknown') {
148
+ locByAcc.set(acc, cat);
149
+ reviewedByAcc.set(acc, reviewed);
150
+ }
151
+ if (gene && (!geneByAcc.has(acc) || reviewed))
152
+ geneByAcc.set(acc, gene);
153
+ }
154
+ return {locByAcc, geneByAcc, reviewedByAcc};
155
+ }
156
+
157
+ // --- Fetch + cache + D-03 fallback (Task 2) ---
158
+
159
+ /** Organism g:Profiler code → NCBI (species-level) taxonomy id. Covers all 9
160
+ * organisms in enrichment's `ORGANISM_LIST`. Species-level ids are used so the
161
+ * UniProt `taxonomy_id:` filter (hierarchical — matches the taxon and its
162
+ * descendants) stays inclusive of strains (e.g. E. coli K-12 under 562).
163
+ * Only the gene-fallback pass narrows by this; accession queries do not (A1:
164
+ * accessions are globally unique, so filtering them risks excluding valid hits). */
165
+ export const ORGANISM_TAXONOMY: Record<string, number> = {
166
+ hsapiens: 9606, mmusculus: 10090, rnorvegicus: 10116, scerevisiae: 4932,
167
+ ecoli: 562, drerio: 7955, dmelanogaster: 7227, athaliana: 3702, celegans: 6239,
168
+ };
169
+
170
+ /** userDataStorage key for the cross-session accession → category cache.
171
+ * Exported so the volcanoOptions OK handler can peek the cache to decide
172
+ * which pre-OK toast wording (cold vs warm) to show — see package.ts
173
+ * volcanoOptions. */
174
+ export const STORE = 'proteomics-subcell-loc';
175
+ /** Bump only if the locked keyword map ever changes (D-04: it is a contract,
176
+ * so this is effectively fixed). Mismatch → stale cache discarded. */
177
+ const SCHEMA_V = '13-04-1';
178
+ const SCHEMA_KEY = '__schema_v';
179
+ const ACC_CHUNK = 100;
180
+ const GENE_CHUNK = 20;
181
+
182
+ /** Progress callback shape used by getSubcellularLocations and forwarded by
183
+ * ensureLocationColumn / recomputeVolcano / volcanoOptions. The dialog's
184
+ * DG.TaskBarProgressIndicator.update is the call site that consumes this.
185
+ *
186
+ * - 'fetch-acc' — Pass 1 chunked accession-stream UniProt queries.
187
+ * - 'fetch-gene' — Pass 2 reviewed-by-gene fallback queries.
188
+ * - 'init-column' — bulk-init of the LOCATION_COL column on the DataFrame
189
+ * (in volcano.ts) AFTER the network work completes. */
190
+ export type ProgressCb = (done: number, total: number,
191
+ phase: 'fetch-acc' | 'fetch-gene' | 'init-column') => void;
192
+
193
+ /** Maximum in-flight UniProt stream chunks. 6 is a compromise: 4× the
194
+ * sequential baseline on a real ~80-chunk Spectronaut Candidates file while
195
+ * staying well under rest.uniprot.org's documented limits. Increase only
196
+ * with measured evidence; UniProt's stream endpoint is shared infrastructure. */
197
+ const FETCH_CONCURRENCY = 6;
198
+
199
+ /** Wall-clock interval between incremental cache flushes during a fetch.
200
+ * A single setInterval-driven flush from the orchestrator (not from inside
201
+ * workers) sidesteps the per-chunk-counter race that a previous draft hit
202
+ * when two workers reached the flush check between `await` points. */
203
+ const CACHE_FLUSH_INTERVAL_MS = 5000;
204
+
205
+ const VALID_CATEGORIES = new Set<string>([...Object.keys(LOCATION_KEYWORDS), 'Unknown']);
206
+
207
+ const STREAM_FIELDS = 'accession,cc_subcellular_location,go_c,reviewed,gene_primary';
208
+ const GENE_FIELDS = 'accession,gene_primary,cc_subcellular_location,go_c';
209
+
210
+ function chunk<T>(arr: T[], size: number): T[][] {
211
+ const out: T[][] = [];
212
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
213
+ return out;
214
+ }
215
+
216
+ /** Index-based worker pool. Maintains a shared `nextIndex` counter; launches
217
+ * `limit` async workers; each worker pulls indices and awaits `work(items[i], i)`,
218
+ * storing results at `results[i]`; resolves with the full results array when
219
+ * every worker drains. Used by getSubcellularLocations Pass 1/2 to cap
220
+ * concurrent UniProt fetchProxy calls at FETCH_CONCURRENCY.
221
+ *
222
+ * Exported only so the test suite can verify the concurrency cap directly
223
+ * with synthetic deferred-promise tasks (no network). */
224
+ export async function runWithConcurrency<T, R>(
225
+ items: T[], limit: number, work: (item: T, idx: number) => Promise<R>,
226
+ ): Promise<R[]> {
227
+ const results: R[] = new Array(items.length);
228
+ let nextIndex = 0;
229
+ const workerCount = Math.min(Math.max(1, limit), items.length || 1);
230
+ const workers: Promise<void>[] = [];
231
+ for (let w = 0; w < workerCount; w++) {
232
+ workers.push((async () => {
233
+ while (true) {
234
+ const i = nextIndex++;
235
+ if (i >= items.length) return;
236
+ results[i] = await work(items[i], i);
237
+ }
238
+ })());
239
+ }
240
+ await Promise.all(workers);
241
+ return results;
242
+ }
243
+
244
+ function taxonomyClause(organism?: string): string {
245
+ const tax = organism ? ORGANISM_TAXONOMY[organism] : undefined;
246
+ return tax ? ` AND (taxonomy_id:${tax})` : '';
247
+ }
248
+
249
+ async function loadCache(): Promise<Record<string, string>> {
250
+ try {
251
+ const raw = (await grok.dapi.userDataStorage.get(STORE)) ?? {};
252
+ if (raw[SCHEMA_KEY] !== SCHEMA_V)
253
+ return {[SCHEMA_KEY]: SCHEMA_V}; // stale → discard
254
+ return raw as Record<string, string>;
255
+ } catch {
256
+ return {[SCHEMA_KEY]: SCHEMA_V};
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Resolves accession → subcellular category for every requested accession.
262
+ * Cache-first (cross-session userDataStorage map, `__schema_v`-keyed), then a
263
+ * chunked UniProt stream fetch via grok.dapi.fetchProxy (no raw fetch — CORS),
264
+ * then the D-03 reviewed-by-gene fallback for Unknown+gene accessions. Never
265
+ * throws out of the fetch loop: a failed batch warns and continues.
266
+ */
267
+ export async function getSubcellularLocations(
268
+ accessions: string[],
269
+ organism?: string,
270
+ progress?: ProgressCb,
271
+ ): Promise<Map<string, string>> {
272
+ const unique = [...new Set(accessions.filter((a) => a && a.length > 0))];
273
+ const cache = await loadCache();
274
+
275
+ const resolved = new Map<string, string>();
276
+ const misses: string[] = [];
277
+ for (const acc of unique) {
278
+ const c = cache[acc];
279
+ if (c !== undefined && VALID_CATEGORIES.has(c)) resolved.set(acc, c);
280
+ else misses.push(acc);
281
+ }
282
+
283
+ const geneByAcc = new Map<string, string>();
284
+ const fetched: Record<string, string> = {};
285
+ // Applied ONLY to the Pass 2 gene fallback below — gene symbols are shared
286
+ // across species, so without this a rat gene resolves to the human entry.
287
+ const taxClause = taxonomyClause(organism);
288
+
289
+ // Single timer-driven incremental flush. Workers DO NOT touch the cache —
290
+ // they only mutate `resolved`/`fetched`/`geneByAcc`. The timer reads the
291
+ // current `fetched` map at its tick and writes through, so a session
292
+ // interrupted mid-fetch keeps every accession that finished. The previous
293
+ // draft folded the flush into the per-chunk worker body and hit a documented
294
+ // double-flush race when two workers reached the check between `await`
295
+ // points — see plan 13-08 BLOCKER FIX notes.
296
+ const flushCache = async (): Promise<void> => {
297
+ if (Object.keys(fetched).length === 0) return;
298
+ try {
299
+ await grok.dapi.userDataStorage.put(STORE,
300
+ {...cache, ...fetched, [SCHEMA_KEY]: SCHEMA_V});
301
+ } catch (e: any) {
302
+ console.warn(`Subcellular-location cache write failed: ${e?.message ?? e}`);
303
+ }
304
+ };
305
+ const flushTimer = setInterval(() => { flushCache().catch(() => {}); },
306
+ CACHE_FLUSH_INTERVAL_MS);
307
+
308
+ try {
309
+ // Pass 1: chunked accession stream queries, run with bounded concurrency.
310
+ const accChunks = chunk(misses, ACC_CHUNK);
311
+ const totalAcc = accChunks.length;
312
+ let doneAcc = 0;
313
+ await runWithConcurrency(accChunks, FETCH_CONCURRENCY, async (group) => {
314
+ // Pass 1 is deliberately NOT taxonomy-filtered: accessions are globally
315
+ // unique, so a wrong/mis-set organism must never exclude a valid protein.
316
+ const q = group.map((a) => `accession:${a}`).join(' OR ');
317
+ const url = 'https://rest.uniprot.org/uniprotkb/stream' +
318
+ `?query=(${encodeURIComponent(q)})` +
319
+ `&fields=${STREAM_FIELDS}&format=tsv`;
320
+ try {
321
+ const resp = await grok.dapi.fetchProxy(url);
322
+ if (!resp.ok) {
323
+ console.warn(`UniProt stream returned status ${resp.status}; continuing`);
324
+ return;
325
+ }
326
+ const {locByAcc, geneByAcc: g} = mergeStreamTsv(await resp.text());
327
+ for (const [acc, loc] of locByAcc) {
328
+ resolved.set(acc, loc);
329
+ fetched[acc] = loc;
330
+ }
331
+ for (const [acc, gene] of g) geneByAcc.set(acc, gene);
332
+ } catch (e: any) {
333
+ console.warn(`UniProt stream batch failed: ${e?.message ?? e}; continuing`);
334
+ }
335
+ // Increment AFTER the chunk completes — bounded concurrency means
336
+ // worker order can interleave, so the callback signal is non-decreasing
337
+ // (not strictly monotonic). Tests assert the non-decreasing invariant.
338
+ doneAcc++;
339
+ progress?.(doneAcc, totalAcc, 'fetch-acc');
340
+ });
341
+
342
+ // Pass 2 (D-03): reviewed-by-gene fallback for Unknown accessions with a gene.
343
+ const geneToUnknownAccs = new Map<string, string[]>();
344
+ for (const acc of misses) {
345
+ const loc = resolved.get(acc) ?? 'Unknown';
346
+ const gene = geneByAcc.get(acc);
347
+ if (loc === 'Unknown' && gene) {
348
+ if (!geneToUnknownAccs.has(gene)) geneToUnknownAccs.set(gene, []);
349
+ geneToUnknownAccs.get(gene)!.push(acc);
350
+ }
351
+ }
352
+ const genes = [...geneToUnknownAccs.keys()];
353
+ const geneChunks = chunk(genes, GENE_CHUNK);
354
+ const totalGene = geneChunks.length;
355
+ let doneGene = 0;
356
+ await runWithConcurrency(geneChunks, FETCH_CONCURRENCY, async (group) => {
357
+ const q = group.map((g) => `gene_exact:${g}`).join(' OR ');
358
+ const url = 'https://rest.uniprot.org/uniprotkb/stream' +
359
+ `?query=(${encodeURIComponent(q)}) AND (reviewed:true)${encodeURIComponent(taxClause)}` +
360
+ `&fields=${GENE_FIELDS}&format=tsv`;
361
+ try {
362
+ const resp = await grok.dapi.fetchProxy(url);
363
+ if (!resp.ok) {
364
+ console.warn(`UniProt gene fallback returned status ${resp.status}; continuing`);
365
+ return;
366
+ }
367
+ // Positional: accession, gene_primary, cc_subcellular_location, go_c.
368
+ const lines = (await resp.text()).trim().split('\n');
369
+ for (let li = 1; li < lines.length; li++) {
370
+ const parts = lines[li].split('\t');
371
+ const gene = (parts[1] ?? '').trim();
372
+ const loc = parseSubcellularLocation(parts[2] ?? '', parts[3] ?? '');
373
+ if (!gene || loc === 'Unknown') continue;
374
+ for (const acc of geneToUnknownAccs.get(gene) ?? []) {
375
+ resolved.set(acc, loc);
376
+ fetched[acc] = loc;
377
+ }
378
+ }
379
+ } catch (e: any) {
380
+ console.warn(`UniProt gene fallback batch failed: ${e?.message ?? e}; continuing`);
381
+ }
382
+ doneGene++;
383
+ progress?.(doneGene, totalGene, 'fetch-gene');
384
+ });
385
+
386
+ // Anything still unresolved → Unknown (explicit, also cached so we don't
387
+ // re-query it every session).
388
+ for (const acc of unique) {
389
+ if (!resolved.has(acc)) resolved.set(acc, 'Unknown');
390
+ if (fetched[acc] === undefined) fetched[acc] = resolved.get(acc)!;
391
+ }
392
+ } finally {
393
+ clearInterval(flushTimer);
394
+ // Single final write-through. Schema invariant preserved on every flush —
395
+ // tests assert this.
396
+ await flushCache();
397
+ }
398
+
399
+ const out = new Map<string, string>();
400
+ for (const acc of unique) out.set(acc, resolved.get(acc) ?? 'Unknown');
401
+ return out;
402
+ }
403
+
404
+ // --- Cached per-accession UniProt JSON (closes the folded cache-uniprot todo;
405
+ // uniprot-panel.ts delegates here so re-clicking a protein doesn't refetch). ---
406
+
407
+ const _entryCache = new Map<string, unknown>();
408
+
409
+ /** Cached single-accession UniProt JSON fetch. Mirrors the original panel
410
+ * discipline: grok.dapi.fetchProxy, warn + null on !resp.ok, warn + null on
411
+ * throw. Successful and definitive-miss (non-OK) results are cached for the
412
+ * session; transient throws are not. */
413
+ export async function fetchUniProtEntry(accession: string): Promise<unknown | null> {
414
+ if (_entryCache.has(accession)) return _entryCache.get(accession) ?? null;
415
+ const url = `https://rest.uniprot.org/uniprotkb/${encodeURIComponent(accession)}.json` +
416
+ '?fields=accession,protein_name,gene_names,organism_name,cc_function,go';
417
+ try {
418
+ const resp = await grok.dapi.fetchProxy(url);
419
+ if (!resp.ok) {
420
+ console.warn(`UniProt fetch for ${accession} returned status ${resp.status}`);
421
+ _entryCache.set(accession, null);
422
+ return null;
423
+ }
424
+ const json = await resp.json();
425
+ _entryCache.set(accession, json);
426
+ return json;
427
+ } catch (e: any) {
428
+ console.warn(`UniProt fetch for ${accession} failed:`, e?.message ?? e);
429
+ return null;
430
+ }
431
+ }
@@ -0,0 +1,94 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+
4
+ import {_package} from '../package';
5
+ import {setGroups} from '../analysis/experiment-setup';
6
+ import {runEnrichmentPipeline} from '../analysis/enrichment';
7
+ import {openEnrichmentVisualization} from '../viewers/enrichment-viewers';
8
+ import {createVolcanoPlot} from '../viewers/volcano';
9
+ import {focusProtein} from '../panels/protein-focus';
10
+ import {SEMTYPE, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
11
+
12
+ /**
13
+ * Single-organism pathway-enrichment demo. Loads a small engineered HUMAN
14
+ * differential-expression result (Treatment vs Control) whose regulated proteins
15
+ * form two coherent, textbook-enriched sets — cell-cycle / mitosis UP and
16
+ * oxidative phosphorylation DOWN — over a diverse non-significant background, so
17
+ * g:Profiler returns crisp, recognizable terms. It opens the volcano, runs
18
+ * enrichment (GO / KEGG / Reactome / WikiPathways), and docks the enrichment
19
+ * charts cross-linked to the volcano: selecting a term highlights its proteins.
20
+ *
21
+ * This is the counterpart to the main `Proteomics Demo`, which uses the
22
+ * three-species HYE benchmark — a mix that is deliberately unsuitable for
23
+ * enrichment (g:Profiler assumes a single organism). The dataset ships at
24
+ * `files/demo/enrichment-demo.csv` (see `files/demo/README.md`).
25
+ *
26
+ * Enrichment calls g:Profiler over the network; if the service is unreachable
27
+ * the demo still opens the DE table + volcano and explains how to retry.
28
+ */
29
+ export async function runEnrichmentDemo(): Promise<void> {
30
+ const pi = DG.TaskBarProgressIndicator.create('Loading enrichment demo…');
31
+ try {
32
+ pi.update(10, 'Loading human DE result…');
33
+ const text = await _package.files.readAsText('demo/enrichment-demo.csv');
34
+ const df = DG.DataFrame.fromCsv(text);
35
+ // Kept free of "Enrichment" so the derived enrichment view reads cleanly as
36
+ // "<source> — Enrichment" (mirrors the QC demo's "<source> — QC" naming).
37
+ df.name = 'Treatment vs Control (human)';
38
+
39
+ // Semantic-type the columns so the pipeline and panels find them by type,
40
+ // not by name — mirrors what the parsers do on a real import.
41
+ const setSem = (name: string, sem: string): void => {
42
+ const c = df.col(name);
43
+ if (c) c.semType = sem;
44
+ };
45
+ setSem('Protein ID', SEMTYPE.PROTEIN_ID);
46
+ setSem('Gene', SEMTYPE.GENE_SYMBOL);
47
+ setSem('log2FC', SEMTYPE.LOG2FC);
48
+ setSem('p-value', SEMTYPE.P_VALUE);
49
+ setSem('adj.p-value', SEMTYPE.P_VALUE);
50
+
51
+ // A pre-computed DE result (like a Spectronaut Candidates import): mark DE
52
+ // complete and record group NAMES so the volcano legend reads naturally.
53
+ // There are no per-sample columns, so the group column lists stay empty.
54
+ df.setTag('proteomics.source', 'generic');
55
+ df.setTag('proteomics.de_method', 'precomputed');
56
+ df.setTag('proteomics.de_complete', 'true');
57
+ setGroups(df, {group1: {name: 'Treatment', columns: []}, group2: {name: 'Control', columns: []}});
58
+
59
+ const tv = grok.shell.addTableView(df);
60
+ const volcano = createVolcanoPlot(df);
61
+ tv.dockManager.dock(volcano, DG.DOCK_TYPE.RIGHT, null, 'Volcano', 0.5);
62
+
63
+ // Headline protein (row 0 = a cell-cycle hit) selected for its UniProt panel.
64
+ focusProtein(df, 0);
65
+
66
+ pi.update(45, 'Running g:Profiler enrichment (GO / KEGG / Reactome)…');
67
+ try {
68
+ const {enrichmentDf, mapped, total} = await runEnrichmentPipeline(
69
+ df, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD, 'hsapiens',
70
+ ['GO:BP', 'GO:MF', 'GO:CC', 'KEGG', 'REAC', 'WP'], true);
71
+ pi.update(90, 'Opening enrichment charts…');
72
+ // openEnrichmentVisualization opens the enrichment view and makes it current.
73
+ // Name it "<source> — Enrichment" (mirrors the DE demo's "<source> — QC" tab)
74
+ // and land back on the main view, so the demo-app framework's leaf-rename puts
75
+ // "Enrichment Analysis" on the main view — matching where the DE demo lands.
76
+ const enrichTv = openEnrichmentVisualization(enrichmentDf, df);
77
+ enrichTv.name = `${df.name} — Enrichment`;
78
+ grok.shell.v = tv;
79
+ grok.shell.info(
80
+ `Enrichment demo: ${enrichmentDf.rowCount} over-represented terms across ${mapped}/${total} genes. ` +
81
+ 'Up-regulated proteins are enriched for cell-cycle / mitosis; down-regulated for oxidative ' +
82
+ 'phosphorylation. Select a term row to highlight its proteins on the volcano.');
83
+ } catch (e: any) {
84
+ grok.shell.warning(
85
+ `Enrichment demo: g:Profiler enrichment could not run (${e?.message ?? e}). ` +
86
+ 'The DE table and volcano are open — retry via Proteomics | Enrichment Analysis ' +
87
+ 'once the service is reachable.');
88
+ }
89
+
90
+ pi.update(100, 'Done');
91
+ } finally {
92
+ pi.close();
93
+ }
94
+ }
@@ -0,0 +1,123 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+
4
+ import {_package} from '../package';
5
+ import {parseSpectronautText} from '../parsers/spectronaut-parser';
6
+ import {getGroups} from '../analysis/experiment-setup';
7
+ import {imputeKnn} from '../analysis/imputation';
8
+ import {runDifferentialExpression} from '../analysis/differential-expression';
9
+ import {createVolcanoPlot} from '../viewers/volcano';
10
+ import {createExpressionHeatmap} from '../viewers/heatmap';
11
+ import {openQcDashboard} from '../viewers/qc-dashboard';
12
+ import {focusProtein} from '../panels/protein-focus';
13
+
14
+ /**
15
+ * The package's single "richest endpoint" demo. It takes the HYE benchmark (a
16
+ * Spectronaut DIA report where Human, Yeast, and E. coli proteomes are mixed at
17
+ * known, different ratios between two conditions — HYE mix A vs B, four
18
+ * replicates each) all the way through the analysis pipeline and lands on the
19
+ * fully-analyzed protein table with the Volcano and Expression Heatmap docked,
20
+ * a QC dashboard on a second tab, and the most significant protein pre-selected
21
+ * so its UniProt entry is already showing in the context panel.
22
+ *
23
+ * HYE is chosen deliberately: two whole species shift abundance between the
24
+ * conditions, so hundreds of proteins are genuinely differential and the
25
+ * volcano shows a strong, self-evident signal — the point of a "richest
26
+ * endpoint" demo. (A spike-in benchmark like CPTAC is real but deliberately
27
+ * subtle: at n=3 a plain t-test can't clear FDR, leaving an all-grey volcano.)
28
+ *
29
+ * Enrichment is intentionally NOT part of this demo: g:Profiler enrichment
30
+ * assumes a single organism, and HYE is a three-species mix, so GO/pathway
31
+ * enrichment would only map the human subset and mislead. Enrichment belongs in
32
+ * a single-organism demo.
33
+ *
34
+ * The Spectronaut parser auto-annotates the two conditions from R.Condition and
35
+ * tags the data preNormalized, so the demo skips straight to imputation + DE and
36
+ * does NOT re-normalize — re-normalizing an already-normalized export would
37
+ * distort the known species-ratio signal. Differential expression runs the
38
+ * client-side Welch t-test, so the demo needs no R environment. The only network
39
+ * touches are best-effort and cached: the parser's Ensembl gene-label lookup and
40
+ * the UniProt context panel, both of which degrade gracefully offline. The
41
+ * dataset ships at `files/demo/spectronaut-hye-demo.tsv` (see
42
+ * `files/demo/README.md`).
43
+ */
44
+ export async function runProteomicsDemo(): Promise<void> {
45
+ const pi = DG.TaskBarProgressIndicator.create('Loading proteomics demo…');
46
+ try {
47
+ pi.update(5, 'Importing HYE benchmark (Spectronaut DIA)…');
48
+ const text = await _package.files.readAsText('demo/spectronaut-hye-demo.tsv');
49
+ const df = await parseSpectronautText(text);
50
+ df.name = 'HYE benchmark';
51
+ const analysisTv = grok.shell.addTableView(df);
52
+
53
+ // Groups (HYE mix A / B) are auto-populated by the parser from R.Condition.
54
+ const groups = getGroups(df);
55
+ if (!groups) {
56
+ grok.shell.warning('Demo: expected two auto-annotated conditions — ' +
57
+ 'opened the table without running differential expression.');
58
+ return;
59
+ }
60
+ const allCols = [...groups.group1.columns, ...groups.group2.columns];
61
+
62
+ pi.update(40, 'Imputing missing values (KNN)…');
63
+ imputeKnn(df, allCols);
64
+ df.setTag('proteomics.imputed', 'true');
65
+
66
+ pi.update(60, 'Differential expression (t-test)…');
67
+ runDifferentialExpression(df, groups.group1.columns, groups.group2.columns,
68
+ groups.group1.name, groups.group2.name);
69
+ df.setTag('proteomics.de_method', 't-test');
70
+
71
+ pi.update(78, 'Building volcano and heatmap…');
72
+ const volcano = createVolcanoPlot(df);
73
+ analysisTv.dockManager.dock(volcano, DG.DOCK_TYPE.RIGHT, null, 'Volcano', 0.5);
74
+
75
+ const heatmap = await createExpressionHeatmap(df, {title: 'Heatmap: Top 50 DE Proteins'});
76
+ analysisTv.dockManager.dock(heatmap, DG.DOCK_TYPE.DOWN, null, 'Heatmap', 0.45);
77
+
78
+ // QC dashboard on a SECOND tab. openQcDashboard docks into grok.shell.tv, so
79
+ // open a second table view over the same DataFrame (it becomes current), build
80
+ // QC there, then land back on the analysis endpoint. Sharing one DataFrame
81
+ // across two views is fine — QC adds its own columns; the volcano keeps its own.
82
+ pi.update(90, 'Building QC dashboard tab…');
83
+ const qcTv = grok.shell.addTableView(df);
84
+ qcTv.name = 'HYE benchmark — QC';
85
+ openQcDashboard(df);
86
+ grok.shell.v = analysisTv;
87
+
88
+ // Pre-select the most significant protein so the UniProt context panel is
89
+ // already showing — a real accession from a spiked species, not the flat
90
+ // human background. Shared with the import handlers (which focus row 0).
91
+ focusProtein(df, topHitRow(df));
92
+
93
+ pi.update(100, 'Done');
94
+ grok.shell.info('Proteomics demo: HYE benchmark analyzed end to end ' +
95
+ '(import → impute → differential expression). Yeast and E. coli proteins ' +
96
+ 'shift abundance between the two conditions, so the volcano separates the ' +
97
+ 'genuinely differential proteins from the constant human background. ' +
98
+ 'QC charts are on the second tab; the top hit is selected for its UniProt panel.');
99
+ } finally {
100
+ pi.close();
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Row index of the most significant protein (smallest adj.p among the
106
+ * significant rows) to pre-select for the UniProt panel. Falls back to row 0
107
+ * when there is no significant protein or no adj.p column.
108
+ */
109
+ function topHitRow(df: DG.DataFrame): number {
110
+ const adj = df.col('adj.p-value');
111
+ const sig = df.col('significant');
112
+ if (!adj) return 0;
113
+
114
+ let best = -1;
115
+ let bestP = Infinity;
116
+ for (let i = 0; i < df.rowCount; i++) {
117
+ if (sig && !sig.get(i)) continue;
118
+ if (adj.isNone(i)) continue;
119
+ const p = adj.get(i) as number;
120
+ if (p < bestP) { bestP = p; best = i; }
121
+ }
122
+ return best < 0 ? 0 : best;
123
+ }
@@ -0,0 +1,15 @@
1
+ // Ambient module declarations for webpack `asset/source` raw-text imports.
2
+ // Used by src/tests/spectronaut-parser.ts to inline the committed
3
+ // tools/spectronaut-aggregate.{sql,sh} as strings (Plan 12-03 R5 drift guard);
4
+ // the tools/ dir is not deployed under files/, so it is unreachable via
5
+ // _package.files at runtime — webpack asset/source is the only way to pin the
6
+ // committed fallback content into the test bundle.
7
+ declare module '*.sql' {
8
+ const content: string;
9
+ export default content;
10
+ }
11
+
12
+ declare module '*.sh' {
13
+ const content: string;
14
+ export default content;
15
+ }