@datagrok/proteomics 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Phase 16 SPC math + tag helpers.
3
+ *
4
+ * Pure TS layer — zero shell / UI side effects. AppData I/O lives in
5
+ * `src/analysis/spc-storage.ts` (Plan 16-03); viewers live in
6
+ * `src/viewers/spc-dashboard.ts` (Plan 16-06). Public-domain math: Nelson 1984
7
+ * for the eight rules and Wheeler 1992 for the n=2 MR-chart constants.
8
+ *
9
+ * Belt-and-braces (Phase 15 D-05): {@link setSpcStatus} persists a single-row
10
+ * string column `~spc_metrics_meta` carrying `JSON.stringify(metrics)`. One
11
+ * column keeps the published-project allowlist small and survives the
12
+ * serializer-strip pattern that loses df tags.
13
+ */
14
+ import * as DG from 'datagrok-api/dg';
15
+
16
+ import {GroupAssignment} from './experiment-setup';
17
+
18
+ /** Run-identity payload persisted in `proteomics.spc_run_meta` JSON tag. */
19
+ export interface RunMeta {
20
+ instrument_id: string;
21
+ acquisition_datetime: string;
22
+ }
23
+
24
+ /** Per-run rollup written to AppData runs.csv and surfaced on every analyzed df. */
25
+ export interface SpcMetrics {
26
+ median_intensity: number;
27
+ missing_pct: number;
28
+ control_corr: number;
29
+ protein_count: number;
30
+ sample_count: number;
31
+ computed_at: string;
32
+ }
33
+
34
+ export type SpcStatus = 'pass' | 'flagged' | 'out_of_control';
35
+
36
+ /** One run's evaluated rules, with status classification per D-04. */
37
+ export interface SpcRuleResult {
38
+ status: SpcStatus;
39
+ rulesTripped: string[];
40
+ }
41
+
42
+ export type NelsonRuleId = 'nelson_1' | 'nelson_2' | 'nelson_3' | 'nelson_4'
43
+ | 'nelson_5' | 'nelson_6' | 'nelson_7' | 'nelson_8';
44
+
45
+ /** Per-metric enabled flags (used by the simple form of evaluateNelsonRules). */
46
+ export type FlatRulesEnabled = Record<NelsonRuleId, boolean>;
47
+
48
+ /** Four-metric rules-enabled grid persisted in baseline-<instrument_id>.json. */
49
+ export interface SpcRulesEnabled {
50
+ median_intensity: FlatRulesEnabled;
51
+ missing_pct: FlatRulesEnabled;
52
+ control_corr: FlatRulesEnabled;
53
+ protein_count: FlatRulesEnabled;
54
+ }
55
+
56
+ /** Mean + standard deviation for one metric over a locked baseline window. */
57
+ export interface MetricBaseline {
58
+ mean: number;
59
+ sd: number;
60
+ }
61
+
62
+ /** Four-metric baseline snapshot stored on baseline-<instrument_id>.json. */
63
+ export interface BaselineSnapshot {
64
+ median_intensity: MetricBaseline;
65
+ missing_pct: MetricBaseline;
66
+ control_corr: MetricBaseline;
67
+ protein_count: MetricBaseline;
68
+ }
69
+
70
+ /** Result of iteratively pruning 3σ outliers from a baseline window. */
71
+ export interface IterativeBaselineResult {
72
+ mean: number;
73
+ sd: number;
74
+ includedCount: number;
75
+ excludedIndices: number[];
76
+ iterationTrace: Array<{iter: number; dropped: number[]}>;
77
+ }
78
+
79
+ /** Wheeler 1992 control-chart constants for n=2 (used by Plan 16-06 MR-chart). */
80
+ export const WHEELER_D2 = 1.128;
81
+ export const WHEELER_D4 = 3.267;
82
+
83
+ /** Phase 16 explicit ban (Pitfall 5) — Spectronaut Candidates carry pre-computed DE
84
+ * with no per-sample intensities, so SPC cannot be computed from them. */
85
+ const SPECTRONAUT_CANDIDATES_REFUSAL =
86
+ 'SPC requires per-sample intensities. Re-import this analysis from the Spectronaut PG ' +
87
+ 'report (not the Candidates report) to compute SPC.';
88
+
89
+ const BELT_AND_BRACES_COLUMN = '~spc_metrics_meta';
90
+
91
+ // =====================================================================================
92
+ // Run-meta tag helpers (mirror setGroups/getGroups in experiment-setup.ts).
93
+ // =====================================================================================
94
+
95
+ export function setRunMeta(df: DG.DataFrame, meta: RunMeta): void {
96
+ df.setTag('proteomics.spc_run_meta', JSON.stringify(meta));
97
+ }
98
+
99
+ export function getRunMeta(df: DG.DataFrame): RunMeta | null {
100
+ const raw = df.getTag('proteomics.spc_run_meta');
101
+ if (!raw) return null;
102
+ try {
103
+ return JSON.parse(raw) as RunMeta;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // =====================================================================================
110
+ // Source-gate (Pitfall 5 — Spectronaut Candidates hard refusal).
111
+ // =====================================================================================
112
+
113
+ export function assertSpcEligible(df: DG.DataFrame): void {
114
+ const source = df.getTag('proteomics.source');
115
+ if (source === 'spectronaut-candidates')
116
+ throw new Error(SPECTRONAUT_CANDIDATES_REFUSAL);
117
+ }
118
+
119
+ // =====================================================================================
120
+ // Metric math (NaN-tolerant; matches qc-computations.ts semantics).
121
+ // =====================================================================================
122
+
123
+ function isMissing(col: DG.Column, i: number): boolean {
124
+ if (col.isNone(i)) return true;
125
+ const v = col.get(i);
126
+ if (typeof v === 'number' && Number.isNaN(v)) return true;
127
+ return false;
128
+ }
129
+
130
+ function median(values: number[]): number {
131
+ if (values.length === 0) return NaN;
132
+ const sorted = values.slice().sort((a, b) => a - b);
133
+ const n = sorted.length;
134
+ const mid = Math.floor(n / 2);
135
+ return (n % 2 === 0) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
136
+ }
137
+
138
+ function pearson(xs: number[], ys: number[]): number {
139
+ const n = xs.length;
140
+ if (n < 3) return NaN;
141
+ let sumX = 0, sumY = 0;
142
+ for (let i = 0; i < n; i++) { sumX += xs[i]; sumY += ys[i]; }
143
+ const mx = sumX / n;
144
+ const my = sumY / n;
145
+ let num = 0, dx2 = 0, dy2 = 0;
146
+ for (let i = 0; i < n; i++) {
147
+ const dx = xs[i] - mx;
148
+ const dy = ys[i] - my;
149
+ num += dx * dy;
150
+ dx2 += dx * dx;
151
+ dy2 += dy * dy;
152
+ }
153
+ const denom = Math.sqrt(dx2 * dy2);
154
+ return denom === 0 ? NaN : num / denom;
155
+ }
156
+
157
+ function resolveCols(df: DG.DataFrame, names: string[]): DG.Column[] {
158
+ const out: DG.Column[] = [];
159
+ for (const name of names) {
160
+ const c = df.col(name);
161
+ if (c !== null) out.push(c);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ export function computeSpcMetrics(
167
+ df: DG.DataFrame, groups: GroupAssignment, runMeta: RunMeta,
168
+ ): SpcMetrics {
169
+ // Use runMeta so a future refactor can persist it alongside the metrics
170
+ // without breaking the public signature; intentionally not stored here.
171
+ void runMeta;
172
+
173
+ const g1Cols = resolveCols(df, groups.group1.columns);
174
+ const g2Cols = resolveCols(df, groups.group2.columns);
175
+ const intensityCols = g1Cols.concat(g2Cols);
176
+ const sampleCount = groups.group1.columns.length + groups.group2.columns.length;
177
+
178
+ // Median: flat array of all non-null intensity cells.
179
+ const allValues: number[] = [];
180
+ const rowCount = df.rowCount;
181
+ let missingCells = 0;
182
+ let totalCells = 0;
183
+ let rowsWithData = 0;
184
+ for (let r = 0; r < rowCount; r++) {
185
+ let rowHasData = false;
186
+ for (const col of intensityCols) {
187
+ totalCells++;
188
+ if (isMissing(col, r)) {
189
+ missingCells++;
190
+ } else {
191
+ allValues.push(col.get(r) as number);
192
+ rowHasData = true;
193
+ }
194
+ }
195
+ if (rowHasData) rowsWithData++;
196
+ }
197
+
198
+ const medianIntensity = median(allValues);
199
+ const missingPct = totalCells === 0 ? NaN : (missingCells / totalCells) * 100;
200
+
201
+ // Pairwise Pearson over Group 1 controls.
202
+ let controlCorr = NaN;
203
+ if (g1Cols.length >= 2) {
204
+ let sum = 0;
205
+ let pairCount = 0;
206
+ for (let i = 0; i < g1Cols.length; i++) {
207
+ for (let j = i + 1; j < g1Cols.length; j++) {
208
+ const ci = g1Cols[i];
209
+ const cj = g1Cols[j];
210
+ const xs: number[] = [];
211
+ const ys: number[] = [];
212
+ for (let r = 0; r < rowCount; r++) {
213
+ if (!isMissing(ci, r) && !isMissing(cj, r)) {
214
+ xs.push(ci.get(r) as number);
215
+ ys.push(cj.get(r) as number);
216
+ }
217
+ }
218
+ const r = pearson(xs, ys);
219
+ if (!Number.isNaN(r)) {
220
+ sum += r;
221
+ pairCount++;
222
+ }
223
+ }
224
+ }
225
+ controlCorr = pairCount === 0 ? NaN : sum / pairCount;
226
+ }
227
+
228
+ return {
229
+ median_intensity: medianIntensity,
230
+ missing_pct: missingPct,
231
+ control_corr: controlCorr,
232
+ protein_count: rowsWithData,
233
+ sample_count: sampleCount,
234
+ computed_at: new Date().toISOString(),
235
+ };
236
+ }
237
+
238
+ // =====================================================================================
239
+ // Nelson rules engine (1984 definitions, locked in CONTEXT.md canonical_refs).
240
+ // =====================================================================================
241
+
242
+ export function defaultRulesEnabled(): FlatRulesEnabled {
243
+ return {
244
+ nelson_1: true, nelson_2: false, nelson_3: false, nelson_4: false,
245
+ nelson_5: true, nelson_6: false, nelson_7: false, nelson_8: false,
246
+ };
247
+ }
248
+
249
+ export function defaultRulesEnabledAllMetrics(): SpcRulesEnabled {
250
+ const d = defaultRulesEnabled();
251
+ return {
252
+ median_intensity: {...d}, missing_pct: {...d},
253
+ control_corr: {...d}, protein_count: {...d},
254
+ };
255
+ }
256
+
257
+ interface RuleTripContext {
258
+ series: number[];
259
+ z: number[];
260
+ mean: number;
261
+ sd: number;
262
+ }
263
+
264
+ function rule1(ctx: RuleTripContext): boolean {
265
+ // Single point > 3σ. Evaluated at the tail.
266
+ const i = ctx.z.length - 1;
267
+ return Math.abs(ctx.z[i]) > 3;
268
+ }
269
+
270
+ function rule2(ctx: RuleTripContext): boolean {
271
+ // 9 consecutive points on the same side of CL.
272
+ if (ctx.series.length < 9) return false;
273
+ const tail = ctx.series.slice(-9);
274
+ const above = tail.every((v) => v > ctx.mean);
275
+ const below = tail.every((v) => v < ctx.mean);
276
+ return above || below;
277
+ }
278
+
279
+ function rule3(ctx: RuleTripContext): boolean {
280
+ // 6 consecutive points steadily increasing or decreasing.
281
+ if (ctx.series.length < 6) return false;
282
+ const t = ctx.series.slice(-6);
283
+ let inc = true, dec = true;
284
+ for (let i = 1; i < 6; i++) {
285
+ if (!(t[i] > t[i - 1])) inc = false;
286
+ if (!(t[i] < t[i - 1])) dec = false;
287
+ }
288
+ return inc || dec;
289
+ }
290
+
291
+ function rule4(ctx: RuleTripContext): boolean {
292
+ // 14 consecutive points alternating above/below CL.
293
+ if (ctx.series.length < 14) return false;
294
+ const t = ctx.series.slice(-14);
295
+ const sides = t.map((v) => v > ctx.mean ? 1 : (v < ctx.mean ? -1 : 0));
296
+ for (let i = 1; i < sides.length; i++) {
297
+ if (sides[i] === 0 || sides[i - 1] === 0) return false;
298
+ if (sides[i] === sides[i - 1]) return false;
299
+ }
300
+ return true;
301
+ }
302
+
303
+ function rule5(ctx: RuleTripContext): boolean {
304
+ // 2-of-3 consecutive points beyond 2σ on the same side. Evaluated at the tail.
305
+ if (ctx.z.length < 3) {
306
+ // With fewer than 3 points, count current + prior(s).
307
+ const z = ctx.z;
308
+ if (z.length < 2) return false;
309
+ const above2 = z.filter((v) => v > 2).length;
310
+ const below2 = z.filter((v) => v < -2).length;
311
+ return above2 >= 2 || below2 >= 2;
312
+ }
313
+ const last3 = ctx.z.slice(-3);
314
+ const above = last3.filter((v) => v > 2).length;
315
+ const below = last3.filter((v) => v < -2).length;
316
+ return above >= 2 || below >= 2;
317
+ }
318
+
319
+ function rule6(ctx: RuleTripContext): boolean {
320
+ // 4-of-5 consecutive points beyond 1σ on the same side.
321
+ if (ctx.z.length < 5) return false;
322
+ const last5 = ctx.z.slice(-5);
323
+ const above = last5.filter((v) => v > 1).length;
324
+ const below = last5.filter((v) => v < -1).length;
325
+ return above >= 4 || below >= 4;
326
+ }
327
+
328
+ function rule7(ctx: RuleTripContext): boolean {
329
+ // 15 consecutive points within 1σ of CL.
330
+ if (ctx.z.length < 15) return false;
331
+ return ctx.z.slice(-15).every((v) => Math.abs(v) < 1);
332
+ }
333
+
334
+ function rule8(ctx: RuleTripContext): boolean {
335
+ // 8 consecutive points outside 1σ on either side.
336
+ if (ctx.z.length < 8) return false;
337
+ return ctx.z.slice(-8).every((v) => Math.abs(v) > 1);
338
+ }
339
+
340
+ const RULE_FNS: Record<NelsonRuleId, (ctx: RuleTripContext) => boolean> = {
341
+ nelson_1: rule1, nelson_2: rule2, nelson_3: rule3, nelson_4: rule4,
342
+ nelson_5: rule5, nelson_6: rule6, nelson_7: rule7, nelson_8: rule8,
343
+ };
344
+
345
+ const RULE_IDS: NelsonRuleId[] = [
346
+ 'nelson_1', 'nelson_2', 'nelson_3', 'nelson_4',
347
+ 'nelson_5', 'nelson_6', 'nelson_7', 'nelson_8',
348
+ ];
349
+
350
+ /**
351
+ * Simple form — evaluates the enabled Nelson rules on a single metric's series.
352
+ * `enabled` is a flat record (no per-metric grid). For the aggregate form used by
353
+ * the dashboard layer, see {@link evaluateNelsonRulesAllMetrics}.
354
+ */
355
+ export function evaluateNelsonRules(
356
+ series: number[],
357
+ baselineMean: number,
358
+ baselineSd: number,
359
+ enabled: FlatRulesEnabled,
360
+ metricLabel?: string,
361
+ ): SpcRuleResult {
362
+ if (series.length === 0 || !Number.isFinite(baselineSd) || baselineSd === 0)
363
+ return {status: 'pass', rulesTripped: []};
364
+ const z = series.map((v) => (v - baselineMean) / baselineSd);
365
+ const ctx: RuleTripContext = {series, z, mean: baselineMean, sd: baselineSd};
366
+ const rulesTripped: string[] = [];
367
+ for (const id of RULE_IDS) {
368
+ if (!enabled[id]) continue;
369
+ if (RULE_FNS[id](ctx))
370
+ rulesTripped.push(metricLabel ? `${id}@${metricLabel}` : id);
371
+ }
372
+ return {status: classifyStatus(rulesTripped), rulesTripped};
373
+ }
374
+
375
+ /**
376
+ * Aggregate form — evaluates rules across all four metrics. Used by the dashboard
377
+ * and storage layers; the simple form above is the test-facing entry point.
378
+ */
379
+ export function evaluateNelsonRulesAllMetrics(
380
+ newMetrics: SpcMetrics,
381
+ priorSeries: {
382
+ median_intensity: number[]; missing_pct: number[];
383
+ control_corr: number[]; protein_count: number[];
384
+ },
385
+ baseline: BaselineSnapshot,
386
+ enabled: SpcRulesEnabled,
387
+ ): SpcRuleResult {
388
+ const tripped: string[] = [];
389
+ const metricNames: Array<keyof BaselineSnapshot> = [
390
+ 'median_intensity', 'missing_pct', 'control_corr', 'protein_count',
391
+ ];
392
+ for (const m of metricNames) {
393
+ const newValue = newMetrics[m];
394
+ if (!Number.isFinite(newValue)) continue; // D-03: NaN metric = unavailable, skip
395
+ const series = priorSeries[m].concat([newValue]);
396
+ const r = evaluateNelsonRules(series, baseline[m].mean, baseline[m].sd, enabled[m], m);
397
+ for (const trip of r.rulesTripped) tripped.push(trip);
398
+ }
399
+ return {status: classifyStatus(tripped), rulesTripped: tripped};
400
+ }
401
+
402
+ export function classifyStatus(rulesTripped: string[]): SpcStatus {
403
+ if (rulesTripped.length === 0) return 'pass';
404
+ for (const t of rulesTripped) {
405
+ if (t.startsWith('nelson_1@') || t === 'nelson_1') return 'out_of_control';
406
+ }
407
+ return 'flagged';
408
+ }
409
+
410
+ // =====================================================================================
411
+ // Iterative baseline (3σ outlier removal, capped at 2 iterations per Pitfall 6).
412
+ // =====================================================================================
413
+
414
+ function meanSd(values: number[]): {mean: number; sd: number} {
415
+ const n = values.length;
416
+ if (n === 0) return {mean: NaN, sd: NaN};
417
+ let sum = 0;
418
+ for (const v of values) sum += v;
419
+ const mean = sum / n;
420
+ let sse = 0;
421
+ for (const v of values) {
422
+ const d = v - mean;
423
+ sse += d * d;
424
+ }
425
+ // Sample standard deviation (n-1) when n>1; population (n) when n=1.
426
+ const sd = n > 1 ? Math.sqrt(sse / (n - 1)) : 0;
427
+ return {mean, sd};
428
+ }
429
+
430
+ const MAX_BASELINE_ITERATIONS = 2;
431
+
432
+ export function computeIterativeBaseline(
433
+ values: number[], iterateOutliers: boolean,
434
+ ): IterativeBaselineResult {
435
+ const includedIdx = new Set<number>();
436
+ for (let i = 0; i < values.length; i++) includedIdx.add(i);
437
+ const iterationTrace: Array<{iter: number; dropped: number[]}> = [];
438
+ let mean = NaN;
439
+ let sd = NaN;
440
+
441
+ if (!iterateOutliers) {
442
+ ({mean, sd} = meanSd(values));
443
+ return {
444
+ mean, sd, includedCount: values.length, excludedIndices: [], iterationTrace,
445
+ };
446
+ }
447
+
448
+ for (let iter = 1; iter <= MAX_BASELINE_ITERATIONS; iter++) {
449
+ const current = Array.from(includedIdx).map((i) => values[i]);
450
+ const stats = meanSd(current);
451
+ mean = stats.mean;
452
+ sd = stats.sd;
453
+ if (!Number.isFinite(sd) || sd === 0) break;
454
+ const dropped: number[] = [];
455
+ for (const i of includedIdx) {
456
+ const z = (values[i] - mean) / sd;
457
+ if (Math.abs(z) > 3) dropped.push(i);
458
+ }
459
+ if (dropped.length === 0) break;
460
+ for (const i of dropped) includedIdx.delete(i);
461
+ iterationTrace.push({iter, dropped});
462
+ // Recompute after dropping for the final returned mean/sd.
463
+ const updated = Array.from(includedIdx).map((i) => values[i]);
464
+ ({mean, sd} = meanSd(updated));
465
+ }
466
+
467
+ const excluded: number[] = [];
468
+ for (let i = 0; i < values.length; i++)
469
+ if (!includedIdx.has(i)) excluded.push(i);
470
+
471
+ return {
472
+ mean, sd,
473
+ includedCount: includedIdx.size,
474
+ excludedIndices: excluded,
475
+ iterationTrace,
476
+ };
477
+ }
478
+
479
+ // =====================================================================================
480
+ // Status persistence on the analyzed df (tags + belt-and-braces column).
481
+ // =====================================================================================
482
+
483
+ function ensureFreshStringColumn(df: DG.DataFrame, name: string): DG.Column {
484
+ if (df.columns.contains(name))
485
+ df.columns.remove(name);
486
+ return df.columns.addNewString(name);
487
+ }
488
+
489
+ export function setSpcStatus(
490
+ df: DG.DataFrame, metrics: SpcMetrics, ruleResult: SpcRuleResult,
491
+ ): void {
492
+ df.setTag('proteomics.spc_metrics', JSON.stringify(metrics));
493
+ df.setTag('proteomics.spc_status', ruleResult.status);
494
+ df.setTag('proteomics.spc_rules_tripped', JSON.stringify(ruleResult.rulesTripped));
495
+
496
+ // Belt-and-braces (Phase 15 D-05) — single column carries the JSON so a
497
+ // published Project's serializer-strip can be recovered from this column
498
+ // even if df tags don't survive.
499
+ const col = ensureFreshStringColumn(df, BELT_AND_BRACES_COLUMN);
500
+ const payload = JSON.stringify({
501
+ metrics, status: ruleResult.status, rulesTripped: ruleResult.rulesTripped,
502
+ });
503
+ if (df.rowCount === 0) {
504
+ // Empty df has no row to write — the column still pins the schema.
505
+ return;
506
+ }
507
+ col.set(0, payload);
508
+ }
509
+
510
+ export function getSpcStatus(df: DG.DataFrame): {
511
+ metrics: SpcMetrics; status: SpcStatus; rulesTripped: string[];
512
+ } | null {
513
+ const metricsRaw = df.getTag('proteomics.spc_metrics');
514
+ const statusRaw = df.getTag('proteomics.spc_status');
515
+ const rulesRaw = df.getTag('proteomics.spc_rules_tripped');
516
+ if (!metricsRaw || !statusRaw) return null;
517
+ try {
518
+ return {
519
+ metrics: JSON.parse(metricsRaw) as SpcMetrics,
520
+ status: statusRaw as SpcStatus,
521
+ rulesTripped: rulesRaw ? (JSON.parse(rulesRaw) as string[]) : [],
522
+ };
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Recovers metrics from the belt-and-braces column when tags are missing
530
+ * (Phase 15 D-05 fallback — protects against the serializer-strip pattern).
531
+ */
532
+ export function recoverSpcMetricsFromColumn(df: DG.DataFrame): SpcMetrics | null {
533
+ if (!df.columns.contains(BELT_AND_BRACES_COLUMN)) return null;
534
+ const col = df.col(BELT_AND_BRACES_COLUMN);
535
+ if (!col || df.rowCount === 0) return null;
536
+ const raw = col.get(0);
537
+ if (typeof raw !== 'string' || raw.length === 0) return null;
538
+ try {
539
+ const parsed = JSON.parse(raw);
540
+ return parsed && parsed.metrics ? (parsed.metrics as SpcMetrics) : null;
541
+ } catch {
542
+ return null;
543
+ }
544
+ }