@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
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Phase 16 SPC AppData I/O layer.
3
+ *
4
+ * Sole owner of `System:AppData/Proteomics/spc/runs.csv`,
5
+ * `runs-meta.json`, and `baseline-<slug>.json`. The math layer in
6
+ * `./spc.ts` stays AppData-free; the dashboard in `viewers/spc-dashboard.ts`
7
+ * stays I/O-free. This module is the only file in the package that touches
8
+ * the SPC AppData paths.
9
+ *
10
+ * ## Phase 17 additivity contract (CONTEXT.md D-02)
11
+ *
12
+ * Phase 17 reads runs.csv with the SAME 13-column shape and appends a
13
+ * `campaign_id` column when saving a run into a campaign. Existing rows
14
+ * back-fill with NULL — no migration script. The Phase 16 dashboard ignores
15
+ * `campaign_id` if present so a v1.4 reader stays compatible with v1.5
16
+ * writers.
17
+ *
18
+ * ## Concurrency
19
+ *
20
+ * Single-writer assumption per CONTEXT.md D-02. No file lock. A second
21
+ * concurrent appendRun would silently overwrite — the expected single-user
22
+ * usage pattern (one Datagrok user per machine) makes this acceptable for v1.4.
23
+ *
24
+ * ## Test root override
25
+ *
26
+ * Every public function accepts an optional `{root}` parameter pointing at
27
+ * an alternate `System:AppData/Proteomics/spc-test/<uuid>` sandbox for tests
28
+ * — production code never passes `root`, so the default `RUNS_DIR_DEFAULT`
29
+ * is used.
30
+ */
31
+ import * as grok from 'datagrok-api/grok';
32
+ import * as DG from 'datagrok-api/dg';
33
+
34
+ import {BaselineSnapshot, SpcRulesEnabled, SpcMetrics, MetricBaseline} from './spc';
35
+
36
+ export type {BaselineSnapshot, SpcRulesEnabled, SpcMetrics, MetricBaseline};
37
+
38
+ export const RUNS_DIR_DEFAULT = 'System:AppData/Proteomics/spc';
39
+ export const RUNS_CSV_PATH = 'System:AppData/Proteomics/spc/runs.csv';
40
+ export const RUNS_META_PATH = 'System:AppData/Proteomics/spc/runs-meta.json';
41
+ export const RUNS_CSV_SCHEMA_VERSION = '1';
42
+
43
+ export const RUNS_CSV_COLUMNS: readonly string[] = [
44
+ 'run_id',
45
+ 'instrument_id',
46
+ 'acquisition_datetime',
47
+ 'run_label',
48
+ 'median_intensity',
49
+ 'missing_pct',
50
+ 'control_corr',
51
+ 'protein_count',
52
+ 'status',
53
+ 'rules_tripped',
54
+ 'source_project_id',
55
+ 'source_df_name',
56
+ 'computed_at',
57
+ ];
58
+
59
+ export interface SpcRunRow {
60
+ run_id: string;
61
+ instrument_id: string;
62
+ acquisition_datetime: string;
63
+ run_label: string;
64
+ median_intensity: number;
65
+ missing_pct: number;
66
+ control_corr: number;
67
+ protein_count: number;
68
+ status: 'pass' | 'flagged' | 'out_of_control';
69
+ rules_tripped: string[];
70
+ source_project_id: string | null;
71
+ source_df_name: string;
72
+ computed_at: string;
73
+ }
74
+
75
+ export interface IterationTraceEntry {
76
+ iter: number;
77
+ dropped: string[];
78
+ }
79
+
80
+ export interface LockedBaseline {
81
+ instrument_id: string;
82
+ locked_at: string;
83
+ included_run_ids: string[];
84
+ excluded_run_ids: string[];
85
+ iteration_trace: IterationTraceEntry[];
86
+ metrics: BaselineSnapshot;
87
+ rules_enabled: SpcRulesEnabled;
88
+ }
89
+
90
+ export interface StorageOpts {
91
+ /** Override the AppData root (used by tests; production callers omit). */
92
+ root?: string;
93
+ }
94
+
95
+ const SCHEMA_VERSION_KEY = 'spc.runs.schema_version';
96
+
97
+ function root(opts?: StorageOpts): string {
98
+ return (opts && opts.root) ? opts.root : RUNS_DIR_DEFAULT;
99
+ }
100
+
101
+ function runsCsvPath(opts?: StorageOpts): string {
102
+ return `${root(opts)}/runs.csv`;
103
+ }
104
+
105
+ function runsMetaPath(opts?: StorageOpts): string {
106
+ return `${root(opts)}/runs-meta.json`;
107
+ }
108
+
109
+ function baselinePath(instrumentId: string, opts?: StorageOpts): string {
110
+ return `${root(opts)}/baseline-${slugifyInstrumentId(instrumentId)}.json`;
111
+ }
112
+
113
+ // =====================================================================================
114
+ // Slugify — T-16-01 mitigation. Mirrors publishing/publish-state.ts:slugifyTarget but
115
+ // PRESERVES case so instrument names like 'QExactive-01' round-trip exactly.
116
+ // =====================================================================================
117
+
118
+ export function slugifyInstrumentId(raw: string): string {
119
+ if (raw == null) return 'unnamed';
120
+ let s = String(raw)
121
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
122
+ .replace(/-{2,}/g, '-')
123
+ .replace(/^[-.]+|[-.]+$/g, '')
124
+ .slice(0, 64)
125
+ .replace(/[-.]+$/g, '');
126
+ if (s.length === 0) s = 'unnamed';
127
+ return s;
128
+ }
129
+
130
+ // =====================================================================================
131
+ // CSV helpers — small and explicit so the upsert-by-key pattern stays simple.
132
+ // DG.DataFrame.fromObjects + toCsv is avoided because it muddies number↔string
133
+ // type inference on the round-trip.
134
+ // =====================================================================================
135
+
136
+ function csvEscape(value: string): string {
137
+ if (value.indexOf(',') < 0 && value.indexOf('"') < 0 && value.indexOf('\n') < 0)
138
+ return value;
139
+ return '"' + value.replace(/"/g, '""') + '"';
140
+ }
141
+
142
+ function parseCsvRow(line: string): string[] {
143
+ const out: string[] = [];
144
+ let i = 0;
145
+ while (i < line.length) {
146
+ if (line[i] === '"') {
147
+ i++;
148
+ let cell = '';
149
+ while (i < line.length) {
150
+ if (line[i] === '"') {
151
+ if (line[i + 1] === '"') { cell += '"'; i += 2; }
152
+ else { i++; break; }
153
+ } else { cell += line[i]; i++; }
154
+ }
155
+ out.push(cell);
156
+ if (line[i] === ',') i++;
157
+ } else {
158
+ let cell = '';
159
+ while (i < line.length && line[i] !== ',') { cell += line[i]; i++; }
160
+ out.push(cell);
161
+ if (line[i] === ',') i++;
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function parseCsv(text: string): string[][] {
168
+ const rows: string[][] = [];
169
+ // Strip trailing newline; rows separated by \n. CSV cells may include \n if
170
+ // they're quoted, but the SPC schema never carries embedded newlines.
171
+ const lines = text.split('\n').filter((l) => l.length > 0);
172
+ for (const line of lines) rows.push(parseCsvRow(line));
173
+ return rows;
174
+ }
175
+
176
+ function serializeRow(row: SpcRunRow): string {
177
+ const ordered = [
178
+ row.run_id,
179
+ row.instrument_id,
180
+ row.acquisition_datetime,
181
+ row.run_label,
182
+ String(row.median_intensity),
183
+ String(row.missing_pct),
184
+ String(row.control_corr),
185
+ String(row.protein_count),
186
+ row.status,
187
+ JSON.stringify(row.rules_tripped),
188
+ row.source_project_id == null ? '' : row.source_project_id,
189
+ row.source_df_name,
190
+ row.computed_at,
191
+ ];
192
+ return ordered.map(csvEscape).join(',');
193
+ }
194
+
195
+ function deserializeRow(cells: string[]): SpcRunRow {
196
+ const idx = (n: string) => RUNS_CSV_COLUMNS.indexOf(n);
197
+ const get = (n: string) => cells[idx(n)] ?? '';
198
+ let rulesTripped: string[] = [];
199
+ try {
200
+ const raw = get('rules_tripped');
201
+ if (raw) rulesTripped = JSON.parse(raw) as string[];
202
+ } catch { /* malformed → empty */ }
203
+ const sourceProjectId = get('source_project_id');
204
+ return {
205
+ run_id: get('run_id'),
206
+ instrument_id: get('instrument_id'),
207
+ acquisition_datetime: get('acquisition_datetime'),
208
+ run_label: get('run_label'),
209
+ median_intensity: Number(get('median_intensity')),
210
+ missing_pct: Number(get('missing_pct')),
211
+ control_corr: Number(get('control_corr')),
212
+ protein_count: Number(get('protein_count')),
213
+ status: get('status') as SpcRunRow['status'],
214
+ rules_tripped: rulesTripped,
215
+ source_project_id: sourceProjectId.length === 0 ? null : sourceProjectId,
216
+ source_df_name: get('source_df_name'),
217
+ computed_at: get('computed_at'),
218
+ };
219
+ }
220
+
221
+ function emptyCsv(): string {
222
+ return RUNS_CSV_COLUMNS.map(csvEscape).join(',') + '\n';
223
+ }
224
+
225
+ function rowsToCsv(rows: SpcRunRow[]): string {
226
+ const lines = [RUNS_CSV_COLUMNS.map(csvEscape).join(',')];
227
+ for (const r of rows) lines.push(serializeRow(r));
228
+ return lines.join('\n') + '\n';
229
+ }
230
+
231
+ async function readRowsFromCsv(opts?: StorageOpts): Promise<SpcRunRow[]> {
232
+ const path = runsCsvPath(opts);
233
+ if (!await grok.dapi.files.exists(path)) return [];
234
+ const text = await grok.dapi.files.readAsText(path);
235
+ const parsed = parseCsv(text);
236
+ if (parsed.length <= 1) return [];
237
+ // First row is the header. Skip it.
238
+ return parsed.slice(1).map(deserializeRow);
239
+ }
240
+
241
+ // =====================================================================================
242
+ // Schema versioning
243
+ // =====================================================================================
244
+
245
+ export async function readRunsMeta(opts?: StorageOpts): Promise<Record<string, string>> {
246
+ const path = runsMetaPath(opts);
247
+ if (!await grok.dapi.files.exists(path)) return {};
248
+ try {
249
+ return JSON.parse(await grok.dapi.files.readAsText(path)) as Record<string, string>;
250
+ } catch (err) {
251
+ // Storage layer is shell-free; user-facing warnings live in the viewer.
252
+ // eslint-disable-next-line no-console
253
+ console.warn(`[spc-storage] failed to parse ${path}: ${err}`);
254
+ return {};
255
+ }
256
+ }
257
+
258
+ export async function writeRunsMeta(
259
+ meta: Record<string, string>, opts?: StorageOpts,
260
+ ): Promise<void> {
261
+ await grok.dapi.files.writeAsText(runsMetaPath(opts), JSON.stringify(meta, null, 2));
262
+ }
263
+
264
+ export async function readSchemaVersion(opts?: StorageOpts): Promise<string | null> {
265
+ const meta = await readRunsMeta(opts);
266
+ return meta[SCHEMA_VERSION_KEY] ?? null;
267
+ }
268
+
269
+ export async function writeSchemaVersion(version: string, opts?: StorageOpts): Promise<void> {
270
+ const meta = await readRunsMeta(opts);
271
+ meta[SCHEMA_VERSION_KEY] = version;
272
+ await writeRunsMeta(meta, opts);
273
+ }
274
+
275
+ // =====================================================================================
276
+ // runs.csv upsert (primary write path)
277
+ // =====================================================================================
278
+
279
+ function genRunId(): string {
280
+ if (typeof crypto !== 'undefined' && (crypto as any).randomUUID)
281
+ return (crypto as any).randomUUID();
282
+ // Fallback for environments without crypto.randomUUID.
283
+ return 'r-' + Math.random().toString(16).slice(2) + '-' + Date.now().toString(16);
284
+ }
285
+
286
+ export interface UpsertInput {
287
+ run_id?: string;
288
+ instrument_id: string;
289
+ acquisition_datetime: string;
290
+ run_label: string;
291
+ median_intensity: number;
292
+ missing_pct: number;
293
+ control_corr: number;
294
+ protein_count: number;
295
+ status: SpcRunRow['status'];
296
+ rules_tripped: string[];
297
+ source_project_id: string | null;
298
+ source_df_name: string;
299
+ computed_at: string;
300
+ }
301
+
302
+ /**
303
+ * Upserts on the composite key (instrument_id, acquisition_datetime). Second
304
+ * invocation with the same key OVERWRITES the prior row (preserving its
305
+ * run_id). First-ever invocation creates runs.csv and pins the schema
306
+ * version in runs-meta.json.
307
+ */
308
+ export async function upsertRun(
309
+ input: UpsertInput, opts?: StorageOpts,
310
+ ): Promise<SpcRunRow> {
311
+ const rows = await readRowsFromCsv(opts);
312
+ const idx = rows.findIndex(
313
+ (r) => r.instrument_id === input.instrument_id &&
314
+ r.acquisition_datetime === input.acquisition_datetime,
315
+ );
316
+ let persisted: SpcRunRow;
317
+ if (idx >= 0) {
318
+ // Preserve the existing run_id on overwrite.
319
+ persisted = {
320
+ ...rows[idx],
321
+ ...input,
322
+ run_id: rows[idx].run_id,
323
+ rules_tripped: input.rules_tripped,
324
+ source_project_id: input.source_project_id,
325
+ } as SpcRunRow;
326
+ rows[idx] = persisted;
327
+ } else {
328
+ persisted = {
329
+ run_id: input.run_id ?? genRunId(),
330
+ instrument_id: input.instrument_id,
331
+ acquisition_datetime: input.acquisition_datetime,
332
+ run_label: input.run_label,
333
+ median_intensity: input.median_intensity,
334
+ missing_pct: input.missing_pct,
335
+ control_corr: input.control_corr,
336
+ protein_count: input.protein_count,
337
+ status: input.status,
338
+ rules_tripped: input.rules_tripped,
339
+ source_project_id: input.source_project_id,
340
+ source_df_name: input.source_df_name,
341
+ computed_at: input.computed_at,
342
+ };
343
+ rows.push(persisted);
344
+ }
345
+ await grok.dapi.files.writeAsText(runsCsvPath(opts), rowsToCsv(rows));
346
+ // First-ever write: pin the schema version. Idempotent on subsequent calls
347
+ // because writeSchemaVersion merges into existing runs-meta.json.
348
+ const version = await readSchemaVersion(opts);
349
+ if (version !== RUNS_CSV_SCHEMA_VERSION)
350
+ await writeSchemaVersion(RUNS_CSV_SCHEMA_VERSION, opts);
351
+ return persisted;
352
+ }
353
+
354
+ /** Alias for Plan 16-05's wireup, which calls `appendRun` per the plan contract. */
355
+ export const appendRun = upsertRun;
356
+
357
+ // =====================================================================================
358
+ // runs.csv read (with optional instrument filter; always sorted by acquisition_datetime ASC)
359
+ // =====================================================================================
360
+
361
+ export async function loadRuns(
362
+ instrumentId?: string, opts?: StorageOpts,
363
+ ): Promise<SpcRunRow[]> {
364
+ const rows = await readRowsFromCsv(opts);
365
+ const filtered = instrumentId
366
+ ? rows.filter((r) => r.instrument_id === instrumentId)
367
+ : rows;
368
+ // Pitfall 7 lever: ALWAYS sort by acquisition_datetime, never by computed_at
369
+ // or insertion order. ISO-8601 lex-sorts correctly.
370
+ filtered.sort((a, b) => a.acquisition_datetime.localeCompare(b.acquisition_datetime));
371
+ return filtered;
372
+ }
373
+
374
+ // =====================================================================================
375
+ // Per-instrument baseline JSON
376
+ // =====================================================================================
377
+
378
+ export async function loadBaseline(
379
+ instrumentId: string, opts?: StorageOpts,
380
+ ): Promise<LockedBaseline | null> {
381
+ const path = baselinePath(instrumentId, opts);
382
+ if (!await grok.dapi.files.exists(path)) return null;
383
+ try {
384
+ return JSON.parse(await grok.dapi.files.readAsText(path)) as LockedBaseline;
385
+ } catch (err) {
386
+ // eslint-disable-next-line no-console
387
+ console.warn(`[spc-storage] failed to parse baseline at ${path}: ${err}`);
388
+ return null;
389
+ }
390
+ }
391
+
392
+ export async function saveBaseline(
393
+ instrumentId: string, baseline: LockedBaseline, opts?: StorageOpts,
394
+ ): Promise<void> {
395
+ await grok.dapi.files.writeAsText(
396
+ baselinePath(instrumentId, opts),
397
+ JSON.stringify(baseline, null, 2),
398
+ );
399
+ }
400
+
401
+ // =====================================================================================
402
+ // Iterative outlier removal (Pitfall 6 lever, capped at 2 iterations).
403
+ // =====================================================================================
404
+
405
+ const MAX_BASELINE_ITERATIONS = 2;
406
+
407
+ function meanSdValues(values: number[]): {mean: number; sd: number} {
408
+ const n = values.length;
409
+ if (n === 0) return {mean: NaN, sd: NaN};
410
+ let sum = 0;
411
+ for (const v of values) sum += v;
412
+ const mean = sum / n;
413
+ let sse = 0;
414
+ for (const v of values) {
415
+ const d = v - mean;
416
+ sse += d * d;
417
+ }
418
+ // Sample SD (n-1 denominator) for Shewhart 3σ consistency.
419
+ const sd = n > 1 ? Math.sqrt(sse / (n - 1)) : 0;
420
+ return {mean, sd};
421
+ }
422
+
423
+ export function iterativeOutlierRemoval(
424
+ series: Array<{run_id: string; value: number}>,
425
+ maxIter: number = MAX_BASELINE_ITERATIONS,
426
+ ): {
427
+ retained_run_ids: string[];
428
+ excluded_run_ids: string[];
429
+ iteration_trace: IterationTraceEntry[];
430
+ } {
431
+ const retained = series.slice();
432
+ const excluded: Array<{run_id: string; value: number}> = [];
433
+ const trace: IterationTraceEntry[] = [];
434
+
435
+ if (retained.length < 3) {
436
+ return {
437
+ retained_run_ids: retained.map((s) => s.run_id),
438
+ excluded_run_ids: [],
439
+ iteration_trace: [],
440
+ };
441
+ }
442
+
443
+ for (let iter = 1; iter <= maxIter; iter++) {
444
+ const vals = retained.map((s) => s.value).filter((v) => Number.isFinite(v));
445
+ const {mean, sd} = meanSdValues(vals);
446
+ if (!Number.isFinite(sd) || sd === 0) break;
447
+ const dropped: typeof retained = [];
448
+ for (let i = retained.length - 1; i >= 0; i--) {
449
+ const v = retained[i].value;
450
+ if (!Number.isFinite(v)) continue;
451
+ const z = (v - mean) / sd;
452
+ if (Math.abs(z) > 3) {
453
+ dropped.push(retained[i]);
454
+ excluded.push(retained[i]);
455
+ retained.splice(i, 1);
456
+ }
457
+ }
458
+ if (dropped.length === 0) break;
459
+ trace.push({iter, dropped: dropped.map((d) => d.run_id)});
460
+ }
461
+
462
+ return {
463
+ retained_run_ids: retained.map((s) => s.run_id),
464
+ excluded_run_ids: excluded.map((s) => s.run_id),
465
+ iteration_trace: trace,
466
+ };
467
+ }
468
+
469
+ // =====================================================================================
470
+ // Baseline statistics (mean+sd per metric over retained rows)
471
+ // =====================================================================================
472
+
473
+ const METRIC_KEYS: Array<keyof BaselineSnapshot> = [
474
+ 'median_intensity', 'missing_pct', 'control_corr', 'protein_count',
475
+ ];
476
+
477
+ function metricBaseline(values: number[]): MetricBaseline {
478
+ const clean = values.filter((v) => Number.isFinite(v));
479
+ if (clean.length < 2) return {mean: NaN, sd: NaN};
480
+ const {mean, sd} = meanSdValues(clean);
481
+ return {mean, sd};
482
+ }
483
+
484
+ export function computeBaselineStats(retainedRows: SpcRunRow[]): BaselineSnapshot {
485
+ const snapshot = {} as BaselineSnapshot;
486
+ for (const m of METRIC_KEYS)
487
+ snapshot[m] = metricBaseline(retainedRows.map((r) => r[m] as number));
488
+ return snapshot;
489
+ }
490
+
491
+ // =====================================================================================
492
+ // Reuse a DG.DataFrame helper for downstream viewers — small but keeps the dashboard
493
+ // from re-implementing the CSV walk. Plan 16-06 calls this in createSpcChartPanel.
494
+ // =====================================================================================
495
+
496
+ export function runsToDataFrame(rows: SpcRunRow[]): DG.DataFrame {
497
+ const get = <T>(fn: (r: SpcRunRow) => T) => rows.map(fn);
498
+ const cols = [
499
+ DG.Column.fromStrings('run_id', get((r) => r.run_id)),
500
+ DG.Column.fromStrings('instrument_id', get((r) => r.instrument_id)),
501
+ DG.Column.fromList(DG.COLUMN_TYPE.DATE_TIME, 'acquisition_datetime',
502
+ get((r) => Date.parse(r.acquisition_datetime))),
503
+ DG.Column.fromStrings('run_label', get((r) => r.run_label)),
504
+ DG.Column.fromFloat32Array('median_intensity', new Float32Array(get((r) => r.median_intensity))),
505
+ DG.Column.fromFloat32Array('missing_pct', new Float32Array(get((r) => r.missing_pct))),
506
+ DG.Column.fromFloat32Array('control_corr', new Float32Array(get((r) => r.control_corr))),
507
+ DG.Column.fromFloat32Array('protein_count', new Float32Array(get((r) => r.protein_count))),
508
+ DG.Column.fromStrings('status', get((r) => r.status)),
509
+ DG.Column.fromStrings('rules_tripped', get((r) => JSON.stringify(r.rules_tripped))),
510
+ DG.Column.fromStrings('source_project_id', get((r) => r.source_project_id ?? '')),
511
+ DG.Column.fromStrings('source_df_name', get((r) => r.source_df_name)),
512
+ DG.Column.fromStrings('computed_at', get((r) => r.computed_at)),
513
+ ];
514
+ return DG.DataFrame.fromColumns(cols);
515
+ }