@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.
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 +88 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2073 -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
Binary file
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Derives files/demo/spectronaut-hye-precursor-golden.json from the committed
4
+ * duckdb golden files/demo/spectronaut-hye-precursor-golden.tsv.
5
+ *
6
+ * This script does NO aggregation — it is a pure, lossless transcription of the
7
+ * already-committed duckdb output into a JSON map the in-browser `grok test`
8
+ * runner can read with certainty when committed-file reads are unavailable in
9
+ * the test runner. Because it never re-computes anything, the equivalence test
10
+ * stays pinned to REAL duckdb output (the D-04 oracle), not to a hand-derived
11
+ * approximation. Re-running it against an unchanged golden produces a
12
+ * byte-identical JSON.
13
+ *
14
+ * Regen chain (run in order, or the artifacts silently drift):
15
+ * 1. node tools/generate-spectronaut-precursor-fixture.mjs
16
+ * 2. tools/spectronaut-aggregate.sh files/demo/spectronaut-hye-precursor.tsv \
17
+ * files/demo/spectronaut-hye-precursor-golden.tsv
18
+ * 3. node tools/derive-precursor-golden-sidecar.mjs (this script)
19
+ *
20
+ * Run: node tools/derive-precursor-golden-sidecar.mjs
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import {fileURLToPath} from 'node:url';
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ const PKG_ROOT = path.resolve(__dirname, '..');
30
+ const GOLDEN = path.join(PKG_ROOT, 'files/demo/spectronaut-hye-precursor-golden.tsv');
31
+ const OUTPUT = path.join(PKG_ROOT, 'files/demo/spectronaut-hye-precursor-golden.json');
32
+
33
+ // Quantity column preference order — same as the in-package Spectronaut parser
34
+ // (src/parsers/spectronaut-parser.ts QUANTITY_COLUMNS).
35
+ const QUANTITY_COLUMNS = ['PG.IBAQ', 'PG.Quantity'];
36
+
37
+ const text = fs.readFileSync(GOLDEN, 'utf8');
38
+ const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
39
+ if (lines.length < 2)
40
+ throw new Error(`Golden ${GOLDEN} has no data rows — run the aggregate wrapper first.`);
41
+
42
+ const headers = lines[0].split('\t');
43
+ const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
44
+
45
+ const protIdx = idx['PG.ProteinGroups'];
46
+ const condIdx = idx['R.Condition'];
47
+ const replIdx = idx['R.Replicate'];
48
+ const qvalIdx = idx['EG.Qvalue'];
49
+ const quantCol = QUANTITY_COLUMNS.find((c) => idx[c] !== undefined);
50
+
51
+ if (protIdx === undefined || condIdx === undefined || replIdx === undefined ||
52
+ qvalIdx === undefined || quantCol === undefined) {
53
+ throw new Error(
54
+ `Golden ${GOLDEN} is missing an expected column. ` +
55
+ `Need PG.ProteinGroups / R.Condition / R.Replicate / EG.Qvalue / ` +
56
+ `${QUANTITY_COLUMNS.join('|')}. Got: ${headers.join(', ')}`);
57
+ }
58
+ const quantIdx = idx[quantCol];
59
+
60
+ const out = {};
61
+ for (let i = 1; i < lines.length; i++) {
62
+ const f = lines[i].split('\t');
63
+ const protein = f[protIdx];
64
+ const condition = f[condIdx];
65
+ const replicate = f[replIdx];
66
+ // Numbers exactly as duckdb wrote them. Empty q-value (TRY_CAST→NULL min,
67
+ // e.g. the non-numeric / empty-string-q-value proteins) → Number('') is 0;
68
+ // keep the verbatim transcription rather than inventing a sentinel.
69
+ const key = `${protein}${condition}_${replicate}`;
70
+ out[key] = {
71
+ quantity: Number(f[quantIdx]),
72
+ qvalue: Number(f[qvalIdx] ?? ''),
73
+ };
74
+ }
75
+
76
+ fs.writeFileSync(OUTPUT, JSON.stringify(out, null, 2) + '\n');
77
+ const keys = Object.keys(out);
78
+ console.log(`Wrote ${OUTPUT}`);
79
+ console.log(` ${keys.length} (protein × condition × replicate) entries ` +
80
+ `transcribed verbatim from ${path.basename(GOLDEN)} ` +
81
+ `(quantity column: ${quantCol}).`);
@@ -0,0 +1,160 @@
1
+ #!/bin/bash
2
+ # Engineered human Treatment-vs-Control DE result for the enrichment demo.
3
+ # UP = cell cycle / mitosis, DOWN = oxidative phosphorylation, plus a diverse
4
+ # non-significant background. Gene symbols are real HGNC; accessions real UniProt.
5
+ # Fold-changes and p-values are spread with a DETERMINISTIC sin-hash jitter so
6
+ # the volcano looks organic (no banding) yet regenerates identically anywhere.
7
+ out=files/demo/enrichment-demo.csv
8
+ echo "Protein ID,Gene,log2FC,p-value,adj.p-value,significant" > "$out"
9
+
10
+ UP="CDK1 P06493
11
+ CCNB1 P14635
12
+ CCNB2 O95067
13
+ CDC20 Q12834
14
+ BUB1 O43683
15
+ BUB1B O60566
16
+ AURKA O14965
17
+ AURKB Q96GD4
18
+ PLK1 P53350
19
+ CCNA2 P20248
20
+ CDK2 P24941
21
+ MAD2L1 Q13257
22
+ TOP2A P11388
23
+ KIF11 P52732
24
+ CENPA P49450
25
+ CENPE Q02224
26
+ NDC80 O14777
27
+ CDC6 Q99741
28
+ CDC45 O75419
29
+ MCM2 P49736
30
+ MCM3 P25205
31
+ MCM4 P33991
32
+ MCM5 P33992
33
+ MCM6 Q14566
34
+ MCM7 P33993
35
+ CDC25C P30307
36
+ CDK4 P11802
37
+ CCNE1 P24864
38
+ CHEK1 O14757
39
+ WEE1 P30291"
40
+
41
+ DOWN="NDUFA9 Q16795
42
+ NDUFS1 P28331
43
+ NDUFS2 O75306
44
+ NDUFB10 O96000
45
+ NDUFV1 P49821
46
+ NDUFA4 O00483
47
+ NDUFB8 O95169
48
+ SDHA P31040
49
+ SDHB P21912
50
+ UQCRC1 P31930
51
+ UQCRC2 P22695
52
+ UQCRFS1 P47985
53
+ CYC1 P08574
54
+ COX4I1 P13073
55
+ COX5A P20674
56
+ COX5B P10606
57
+ COX6C P09669
58
+ COX7A2 P14406
59
+ COX6B1 P14854
60
+ ATP5F1A P25705
61
+ ATP5F1B P06576
62
+ ATP5F1C P36542
63
+ ATP5PO P48047
64
+ ATP5PB P24539
65
+ ATP5MC1 P05496"
66
+
67
+ BG="GAPDH P04406
68
+ ACTB P60709
69
+ TUBB P07437
70
+ ALB P02768
71
+ ENO1 P06733
72
+ PKM P14618
73
+ LDHA P00338
74
+ PGK1 P00558
75
+ ALDOA P04075
76
+ TPI1 P60174
77
+ GPI P06744
78
+ PGAM1 P18669
79
+ HSPA8 P11142
80
+ HSP90AA1 P07900
81
+ HSPA5 P11021
82
+ HSPD1 P10809
83
+ CALR P27797
84
+ CANX P27824
85
+ PDIA3 P30101
86
+ P4HB P07237
87
+ VIM P08670
88
+ FLNA P21333
89
+ ACTN1 P12814
90
+ TLN1 Q9Y490
91
+ CFL1 P23528
92
+ PFN1 P07737
93
+ GSN P06396
94
+ CAP1 Q01518
95
+ ANXA1 P04083
96
+ ANXA2 P07355
97
+ ANXA5 P08758
98
+ S100A4 P26447
99
+ YWHAZ P63104
100
+ YWHAE P62258
101
+ YWHAB P31946
102
+ PPIA P62937
103
+ PPIB P23284
104
+ SOD1 P00441
105
+ SOD2 P04179
106
+ CAT P04040
107
+ PRDX1 Q06830
108
+ PRDX2 P32119
109
+ GSTP1 P09211
110
+ TXN P10599
111
+ FTL P02792
112
+ FTH1 P02794
113
+ TF P02787
114
+ CTSD P07339
115
+ CTSB P07858
116
+ LGALS1 P09382
117
+ LGALS3 P17931
118
+ CD44 P16070
119
+ ITGB1 P05556
120
+ EEF1A1 P68104
121
+ EEF2 P13639
122
+ RPS3 P23396
123
+ RPL4 P36578
124
+ RPLP0 P05388
125
+ NPM1 P06748
126
+ NCL P19338
127
+ HNRNPK P61978
128
+ SRSF1 Q07955
129
+ G3BP1 Q13283
130
+ DDX5 P17844"
131
+
132
+ # deterministic pseudo-random in [0,1) from a seed integer and a multiplier
133
+ emit() { # $1=list $2=sign(+1/-1) for sig sets, 0=background
134
+ local sign="$2"; local i=0
135
+ while read -r gene acc; do
136
+ [ -z "$gene" ] && continue
137
+ awk -v i="$i" -v gene="$gene" -v acc="$acc" -v sign="$sign" 'BEGIN{
138
+ h1=sin((i+1)*12.9898)*43758.5453; j1=h1-int(h1); if(j1<0)j1+=1
139
+ h2=sin((i+1)*78.2330)*24634.6345; j2=h2-int(h2); if(j2<0)j2+=1
140
+ if (sign!=0) {
141
+ fc=sign*(1.2 + j1*2.3) # |fc| 1.2..3.5
142
+ e=2.5 + j2*4.5 # -log10(p) 2.5..7.0
143
+ p=exp(-e*log(10)); adj=2*p
144
+ printf "%s,%s,%.3f,%.2e,%.2e,true\n", acc, gene, fc, p, adj
145
+ } else {
146
+ fc=-1.3 + j1*2.6 # spread -1.3..1.3
147
+ p=0.06 + j2*0.90 # 0.06..0.96 (never significant)
148
+ adj=p*1.05; if(adj>0.99)adj=0.99
149
+ printf "%s,%s,%.3f,%.3f,%.3f,false\n", acc, gene, fc, p, adj
150
+ }
151
+ }' >> "$out"
152
+ i=$((i+1))
153
+ done <<< "$1"
154
+ }
155
+
156
+ emit "$UP" 1
157
+ emit "$DOWN" -1
158
+ emit "$BG" 0
159
+
160
+ echo "rows: $(($(wc -l < "$out") - 1)) (sig=$(grep -c ',true$' "$out"), ns=$(grep -c ',false$' "$out"))"
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Generates files/demo/spectronaut-hye-candidates.tsv from the already-shipped
4
+ * files/demo/spectronaut-hye-mix.tsv (HYE three-species mix, SpectroPipeR origin).
5
+ *
6
+ * Source data is real Spectronaut long-format PG quantification. The Candidates
7
+ * report it ships alongside is DERIVED — we run our own Welch's t-test with BH
8
+ * FDR correction in this script and emit the result formatted as a Spectronaut
9
+ * Candidates report. This is not a verbatim Spectronaut export; it exists to
10
+ * exercise the Candidates import path with values that line up with the source
11
+ * PG file so the two demo fixtures tell the same story.
12
+ *
13
+ * Run: node tools/generate-spectronaut-candidates-fixture.mjs
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import {fileURLToPath} from 'node:url';
19
+ import jStat from 'jstat';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const PKG_ROOT = path.resolve(__dirname, '..');
24
+ const SOURCE = path.join(PKG_ROOT, 'files/demo/spectronaut-hye-mix.tsv');
25
+ const OUTPUT = path.join(PKG_ROOT, 'files/demo/spectronaut-hye-candidates.tsv');
26
+
27
+ // Numerator (group1) and denominator (group2) per Spectronaut convention.
28
+ // AVG Log2 Ratio = log2(numerator / denominator). The HYE mix is symmetric;
29
+ // labelling B as the numerator just keeps the convention legible.
30
+ const NUMERATOR = 'HYE mix B';
31
+ const DENOMINATOR = 'HYE mix A';
32
+
33
+ /** Read TSV file into header + rows. */
34
+ function readTsv(filePath) {
35
+ const text = fs.readFileSync(filePath, 'utf8');
36
+ const lines = text.split(/\r?\n/);
37
+ const headers = lines[0].split('\t');
38
+ const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
39
+ const rows = [];
40
+ for (let i = 1; i < lines.length; i++) {
41
+ if (!lines[i]) continue;
42
+ const fields = lines[i].split('\t');
43
+ rows.push(fields);
44
+ }
45
+ return {headers, idx, rows};
46
+ }
47
+
48
+ /** Pivot long PG report: protein -> {sampleKey -> IBAQ}. First-encountered
49
+ * non-empty IBAQ wins per protein × sample (constant per protein × sample in
50
+ * SpectroPipeR's report; mirrors the in-package Spectronaut parser). */
51
+ function pivotProteinIbaq(headers, idx, rows) {
52
+ const protein = {};
53
+ for (const r of rows) {
54
+ const pg = r[idx['PG.ProteinGroups']];
55
+ const cond = r[idx['R.Condition']];
56
+ const repl = r[idx['R.Replicate']];
57
+ const ibaqRaw = r[idx['PG.IBAQ']];
58
+ const org = r[idx['PG.Organisms']];
59
+ if (!pg || !cond || !repl) continue;
60
+ const ibaq = Number(ibaqRaw);
61
+ if (!Number.isFinite(ibaq) || ibaq <= 0) continue;
62
+ const sampleKey = `${cond}|${repl}`;
63
+ if (!protein[pg]) protein[pg] = {samples: {}, organism: org || ''};
64
+ if (protein[pg].samples[sampleKey] === undefined)
65
+ protein[pg].samples[sampleKey] = ibaq;
66
+ if (!protein[pg].organism && org) protein[pg].organism = org;
67
+ }
68
+ return protein;
69
+ }
70
+
71
+ /** Welch's two-sample t-test using jStat. Returns null when either sample has
72
+ * fewer than two finite values. */
73
+ function welchTTest(a, b) {
74
+ const af = a.filter(Number.isFinite);
75
+ const bf = b.filter(Number.isFinite);
76
+ if (af.length < 2 || bf.length < 2) return null;
77
+ const meanA = jStat.mean(af);
78
+ const meanB = jStat.mean(bf);
79
+ const varA = jStat.variance(af, true);
80
+ const varB = jStat.variance(bf, true);
81
+ const seA = varA / af.length;
82
+ const seB = varB / bf.length;
83
+ const se = Math.sqrt(seA + seB);
84
+ if (se === 0) return {t: 0, df: af.length + bf.length - 2, p: 1, meanA, meanB};
85
+ const t = (meanB - meanA) / se;
86
+ // Welch–Satterthwaite degrees of freedom
87
+ const df = Math.pow(seA + seB, 2) /
88
+ (Math.pow(seA, 2) / (af.length - 1) + Math.pow(seB, 2) / (bf.length - 1));
89
+ const pTwoSided = 2 * (1 - jStat.studentt.cdf(Math.abs(t), df));
90
+ return {t, df, p: pTwoSided, meanA, meanB};
91
+ }
92
+
93
+ /** Benjamini–Hochberg FDR correction. Returns array of adjusted p-values aligned
94
+ * to input order; entries left as null where the input is null. */
95
+ function bhAdjust(pvals) {
96
+ const indexed = pvals.map((p, i) => ({p, i})).filter((x) => x.p !== null);
97
+ indexed.sort((a, b) => a.p - b.p);
98
+ const n = indexed.length;
99
+ const adj = new Array(pvals.length).fill(null);
100
+ let cumMin = 1;
101
+ for (let rank = n; rank >= 1; rank--) {
102
+ const e = indexed[rank - 1];
103
+ const q = e.p * n / rank;
104
+ cumMin = Math.min(cumMin, q);
105
+ adj[e.i] = Math.min(cumMin, 1);
106
+ }
107
+ return adj;
108
+ }
109
+
110
+ function main() {
111
+ const {headers, idx, rows} = readTsv(SOURCE);
112
+ console.log(`Read ${rows.length} long-format rows, ${headers.length} columns.`);
113
+
114
+ const protein = pivotProteinIbaq(headers, idx, rows);
115
+ const proteins = Object.keys(protein).sort();
116
+ console.log(`Pivoted to ${proteins.length} protein groups.`);
117
+
118
+ // Sample keys per group
119
+ const sampleKey = (c, r) => `${c}|${r}`;
120
+ const groupSamples = (cond) => [1, 2, 3, 4].map((r) => sampleKey(cond, String(r)));
121
+ const numKeys = groupSamples(NUMERATOR);
122
+ const denKeys = groupSamples(DENOMINATOR);
123
+
124
+ // Per-protein log2FC + raw p-value
125
+ const proteinStats = proteins.map((pg) => {
126
+ const samples = protein[pg].samples;
127
+ const num = numKeys.map((k) => samples[k]).filter(Number.isFinite);
128
+ const den = denKeys.map((k) => samples[k]).filter(Number.isFinite);
129
+ const numLog2 = num.map((x) => Math.log2(x));
130
+ const denLog2 = den.map((x) => Math.log2(x));
131
+ const t = welchTTest(denLog2, numLog2); // a=denominator, b=numerator
132
+ if (!t) {
133
+ return {
134
+ pg,
135
+ organism: protein[pg].organism,
136
+ log2fc: null,
137
+ p: null,
138
+ meanNum: num.length > 0 ? jStat.mean(num) : null,
139
+ meanDen: den.length > 0 ? jStat.mean(den) : null,
140
+ nRatios: Math.min(num.length, den.length),
141
+ valid: false,
142
+ };
143
+ }
144
+ return {
145
+ pg,
146
+ organism: protein[pg].organism,
147
+ log2fc: t.meanB - t.meanA, // log2(num) - log2(den)
148
+ p: t.p,
149
+ meanNum: jStat.mean(num),
150
+ meanDen: jStat.mean(den),
151
+ nRatios: Math.min(num.length, den.length),
152
+ valid: true,
153
+ };
154
+ });
155
+
156
+ const adj = bhAdjust(proteinStats.map((s) => s.p));
157
+
158
+ // Write Candidates TSV
159
+ const outHeaders = [
160
+ 'Valid', 'Comparison (group1/group2)',
161
+ 'Condition Numerator', 'Condition Denominator',
162
+ '# of Ratios', 'Group',
163
+ 'AVG Group Quantity Numerator', 'AVG Group Quantity Denominator',
164
+ 'AVG Log2 Ratio', 'Absolute AVG Log2 Ratio',
165
+ '% Change', 'Ratio',
166
+ 'Pvalue', 'Qvalue',
167
+ 'ProteinGroups', 'Genes', 'UniProtIds', 'Organisms',
168
+ ];
169
+ const comparison = `${NUMERATOR} / ${DENOMINATOR}`;
170
+
171
+ const lines = [outHeaders.join('\t')];
172
+ let nSig = 0, nUp = 0, nDown = 0;
173
+ for (let i = 0; i < proteinStats.length; i++) {
174
+ const s = proteinStats[i];
175
+ const q = adj[i];
176
+ const ratio = (s.meanNum != null && s.meanDen != null && s.meanDen > 0)
177
+ ? (s.meanNum / s.meanDen) : null;
178
+ const pctChange = ratio != null ? (ratio - 1) * 100 : null;
179
+ const row = [
180
+ s.valid ? 'True' : 'False',
181
+ comparison,
182
+ NUMERATOR,
183
+ DENOMINATOR,
184
+ String(s.nRatios),
185
+ s.pg,
186
+ s.meanNum != null ? s.meanNum.toFixed(4) : '',
187
+ s.meanDen != null ? s.meanDen.toFixed(4) : '',
188
+ s.log2fc != null ? s.log2fc.toFixed(6) : '',
189
+ s.log2fc != null ? Math.abs(s.log2fc).toFixed(6) : '',
190
+ pctChange != null ? pctChange.toFixed(4) : '',
191
+ ratio != null ? ratio.toFixed(6) : '',
192
+ s.p != null ? s.p.toExponential(6) : '',
193
+ q != null ? q.toExponential(6) : '',
194
+ s.pg, // ProteinGroups (same as Group here)
195
+ '', // Genes (HYE PG report has no symbols)
196
+ s.pg, // UniProtIds (PG.ProteinGroups in this dataset are UniProt accs)
197
+ s.organism,
198
+ ];
199
+ lines.push(row.join('\t'));
200
+
201
+ if (s.log2fc != null && q != null && Math.abs(s.log2fc) >= 1 && q <= 0.05) {
202
+ nSig++;
203
+ if (s.log2fc > 0) nUp++; else nDown++;
204
+ }
205
+ }
206
+
207
+ fs.writeFileSync(OUTPUT, lines.join('\n') + '\n');
208
+ console.log(`Wrote ${OUTPUT}`);
209
+ console.log(` ${proteinStats.length} rows; ${nSig} significant (${nUp} up, ${nDown} down) at |log2FC|>=1, q<=0.05`);
210
+ }
211
+
212
+ main();
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Generates files/demo/spectronaut-hye-precursor.tsv — a small SYNTHETIC
4
+ * precursor-level Spectronaut long-format report used to exercise the streaming
5
+ * import path (Plan 02) and the duckdb-equivalence golden test (Plan 03).
6
+ *
7
+ * The fixture carries the D-01 precursor signature columns
8
+ * (EG.ModifiedPeptide / FG.Charge / PEP.StrippedSequence) so the header-sniff
9
+ * routes it to the streaming aggregator, uses CondA/CondB (so the committed
10
+ * tools/spectronaut-aggregate.sql DMD↔WT flip is a structural no-op), keeps the
11
+ * quantity value CONSTANT per (protein × condition × replicate) so duckdb max()
12
+ * equals the parser's first-encountered value, and carries NO
13
+ * PG.Genes/PG.ProteinAccessions columns (the real hye-mix demo lacks them and
14
+ * the committed SQL drops the matching any_value SELECT terms for this reason).
15
+ *
16
+ * It deliberately includes one CON__ row, one REV__ row, one protein whose
17
+ * precursors mix a >0.01 sub-threshold q-value with a passing one, one
18
+ * non-numeric-q-value protein, and one empty-q-value protein so every R2 filter
19
+ * branch is exercised by a single fixture.
20
+ *
21
+ * Pure standalone Node (no datagrok-api import); the row shape mirrors the
22
+ * extended makeLongFormatTsv in src/tests/spectronaut-parser.ts.
23
+ *
24
+ * Run: node tools/generate-spectronaut-precursor-fixture.mjs
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import {fileURLToPath} from 'node:url';
30
+
31
+ const __filename = fileURLToPath(import.meta.url);
32
+ const __dirname = path.dirname(__filename);
33
+ const PKG_ROOT = path.resolve(__dirname, '..');
34
+ const OUTPUT = path.join(PKG_ROOT, 'files/demo/spectronaut-hye-precursor.tsv');
35
+
36
+ const CONDITIONS = ['CondA', 'CondB'];
37
+ const REPLICATES = [1, 2, 3];
38
+
39
+ // Column order mirrors the extended makeLongFormatTsv header. NO
40
+ // PG.Genes/PG.ProteinAccessions — see doc block.
41
+ const HEADERS = [
42
+ 'R.FileName', 'R.Condition', 'R.Replicate', 'PG.ProteinGroups',
43
+ 'PG.Organisms', 'PG.Quantity', 'EG.Qvalue', 'PEP.StrippedSequence',
44
+ 'EG.ModifiedPeptide', 'FG.Charge', 'FG.Id',
45
+ ];
46
+
47
+ // Two distinct precursors per (protein × condition × replicate). Quantity is
48
+ // held constant within each group; only precursor identity varies, so duckdb
49
+ // max() collapses to the same value the parser's first-encountered-wins logic
50
+ // produces.
51
+ const PRECURSORS = [
52
+ {strip: 'PEPTIDER', mod: '_PEPTIDER_', charge: '2', fg: 'FG1'},
53
+ {strip: 'ANOTHERPEP', mod: '_ANOTHERPEP_', charge: '3', fg: 'FG2'},
54
+ ];
55
+
56
+ const ORGANISMS = ['Homo sapiens', 'Saccharomyces cerevisiae', 'Escherichia coli'];
57
+
58
+ const rows = [HEADERS.join('\t')];
59
+ let nDataRows = 0;
60
+ const proteinSet = new Set();
61
+ const sampleSet = new Set();
62
+
63
+ function emit(fileName, cond, rep, id, organism, quantity, qVal) {
64
+ for (const p of PRECURSORS) {
65
+ rows.push([fileName, cond, String(rep), id, organism,
66
+ String(quantity), String(qVal), p.strip, p.mod, p.charge, p.fg].join('\t'));
67
+ nDataRows++;
68
+ }
69
+ }
70
+
71
+ function emitProtein(id, organism, qVal, quantity) {
72
+ proteinSet.add(id);
73
+ for (const cond of CONDITIONS) {
74
+ for (const rep of REPLICATES) {
75
+ const fileName = `run_${cond}_${rep}`;
76
+ sampleSet.add(`${cond}_${rep}`);
77
+ emit(fileName, cond, rep, id, organism, quantity, qVal);
78
+ }
79
+ }
80
+ }
81
+
82
+ // ── Regular passing proteins: a few dozen, three-species mix, all q<=0.01 ──
83
+ const N_REGULAR = 36;
84
+ let quant = 1000;
85
+ for (let i = 0; i < N_REGULAR; i++) {
86
+ const id = `P${String(i).padStart(5, '0')}`;
87
+ const organism = ORGANISMS[i % ORGANISMS.length];
88
+ emitProtein(id, organism, 0.001, quant++);
89
+ }
90
+
91
+ // ── Decoy / contaminant rows (dropped by the WHERE CON__/REV__ filter) ──
92
+ emitProtein('CON__P99999', 'Homo sapiens', 0.001, 9000);
93
+ emitProtein('REV__Q88888', 'Homo sapiens', 0.001, 9001);
94
+
95
+ // ── Mixed sub-threshold protein: one passing precursor, one >0.01 dropped ──
96
+ // Emit the rows by hand so the two precursors carry DIFFERENT q-values.
97
+ {
98
+ const id = 'P70001';
99
+ proteinSet.add(id);
100
+ for (const cond of CONDITIONS) {
101
+ for (const rep of REPLICATES) {
102
+ const fileName = `run_${cond}_${rep}`;
103
+ sampleSet.add(`${cond}_${rep}`);
104
+ // passing precursor
105
+ rows.push([fileName, cond, String(rep), id, 'Homo sapiens',
106
+ '7000', '0.002', PRECURSORS[0].strip, PRECURSORS[0].mod,
107
+ PRECURSORS[0].charge, PRECURSORS[0].fg].join('\t'));
108
+ nDataRows++;
109
+ // sub-threshold precursor (q-value 0.05 > 0.01 → dropped by WHERE)
110
+ rows.push([fileName, cond, String(rep), id, 'Homo sapiens',
111
+ '7000', '0.05', PRECURSORS[1].strip, PRECURSORS[1].mod,
112
+ PRECURSORS[1].charge, PRECURSORS[1].fg].join('\t'));
113
+ nDataRows++;
114
+ }
115
+ }
116
+ }
117
+
118
+ // ── Non-numeric q-value protein ('Profiled' → TRY_CAST NULL → passes) ──
119
+ emitProtein('P70002', 'Saccharomyces cerevisiae', 'Profiled', 6500);
120
+
121
+ // ── Empty-string q-value protein (nullstr → NULL → passes) ──
122
+ emitProtein('P70003', 'Escherichia coli', '', 6600);
123
+
124
+ fs.writeFileSync(OUTPUT, rows.join('\n') + '\n');
125
+ console.log(`Wrote ${OUTPUT}`);
126
+ console.log(` ${nDataRows} data rows, ${proteinSet.size} protein groups ` +
127
+ `(incl. CON__/REV__/edge), ${sampleSet.size} samples ` +
128
+ `(${CONDITIONS.length} conditions × ${REPLICATES.length} replicates).`);
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # Collapse a long-form Spectronaut Report into the protein-group × sample shape
3
+ # the Proteomics package's parseSpectronautText parser consumes.
4
+ #
5
+ # This is the documented MANUAL FALLBACK for a Spectronaut report too large for
6
+ # the in-browser streaming importer: run it locally, then import the small
7
+ # aggregated .tsv it produces. It is also the D-04 equivalence oracle the Wave-0
8
+ # golden test pins to (see files/demo/README.md).
9
+ #
10
+ # Run: tools/spectronaut-aggregate.sh <input.tsv> [output.tsv]
11
+ #
12
+ # Requires the `duckdb` CLI on PATH (v1.3.0 used to derive the committed golden).
13
+
14
+ set -euo pipefail
15
+
16
+ if [[ $# -lt 1 ]]; then
17
+ echo "Usage: $0 <input.tsv> [output.tsv]" >&2
18
+ exit 1
19
+ fi
20
+
21
+ IN="$1"
22
+ OUT="${2:-${IN%.tsv}.aggregated.tsv}"
23
+ SQL="$(dirname "$0")/spectronaut-aggregate.sql"
24
+
25
+ if [[ ! -f "$IN" ]]; then
26
+ echo "Input not found: $IN" >&2
27
+ exit 1
28
+ fi
29
+
30
+ echo "Aggregating: $IN"
31
+ echo "Output: $OUT"
32
+
33
+ # Substitute paths into the SQL template. DuckDB's COPY ... TO doesn't accept
34
+ # variables, so we materialize the SQL first. SQL single-quote escaping: double up
35
+ # any embedded ' in the paths.
36
+ IN_ESC="${IN//\'/\'\'}"
37
+ OUT_ESC="${OUT//\'/\'\'}"
38
+ TMP_SQL="$(mktemp -t spectronaut-aggregate.XXXXXX.sql)"
39
+ trap 'rm -f "$TMP_SQL"' EXIT
40
+ sed -e "s|__IN_PATH__|${IN_ESC}|g" -e "s|__OUT_PATH__|${OUT_ESC}|g" "$SQL" > "$TMP_SQL"
41
+
42
+ duckdb < "$TMP_SQL"
43
+
44
+ echo
45
+ echo "Input size: $(du -h "$IN" | cut -f1)"
46
+ echo "Output size: $(du -h "$OUT" | cut -f1)"