@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,199 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+ import dayjs from 'dayjs';
5
+
6
+ import {SEMTYPE} from '../utils/proteomics-types';
7
+ import {RunMeta, setRunMeta, getRunMeta} from './spc';
8
+ import {ORGANISM_LIST, detectOrganismCode, organismDisplayForCode,
9
+ organismCodeForDisplay} from '../utils/organisms';
10
+
11
+ /** Describes which intensity columns belong to each experimental group. */
12
+ export interface GroupAssignment {
13
+ group1: {name: string; columns: string[]};
14
+ group2: {name: string; columns: string[]};
15
+ }
16
+
17
+ /** Store group assignments as a DataFrame tag. */
18
+ export function setGroups(df: DG.DataFrame, groups: GroupAssignment): void {
19
+ df.setTag('proteomics.groups', JSON.stringify(groups));
20
+ }
21
+
22
+ /** Retrieve group assignments from DataFrame tag. Returns null if not set. */
23
+ export function getGroups(df: DG.DataFrame): GroupAssignment | null {
24
+ const raw = df.getTag('proteomics.groups');
25
+ if (!raw) return null;
26
+ try {
27
+ return JSON.parse(raw) as GroupAssignment;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** Store the experiment's organism as a g:Profiler code (e.g. `rnorvegicus`) on
34
+ * the DataFrame. Single source of truth shared by enrichment and the subcellular-
35
+ * location fetch — set once (enrichment dialog OK), read by both. */
36
+ export function setOrganism(df: DG.DataFrame, code: string): void {
37
+ df.setTag('proteomics.organism', code);
38
+ }
39
+
40
+ /** Retrieve the experiment's organism code, or undefined if never chosen. The
41
+ * subcellular-location gene fallback narrows by this; undefined = unfiltered
42
+ * (safe — same behavior as before an organism was ever selected). */
43
+ export function getOrganism(df: DG.DataFrame): string | undefined {
44
+ return df.getTag('proteomics.organism') || undefined;
45
+ }
46
+
47
+ /** Seed values for the Annotate Experiment dialog. */
48
+ export interface AnnotationSeed {
49
+ group1Name: string;
50
+ group1Cols: DG.Column[];
51
+ group2Name: string;
52
+ group2Cols: DG.Column[];
53
+ /** Phase 16 D-01: persisted operator value, else parser-side seed, else undefined. */
54
+ instrumentId?: string;
55
+ /** Phase 16 D-01: ISO-8601 (date-only acceptable per RESEARCH gotcha #1). */
56
+ acquisitionDatetime?: string;
57
+ }
58
+
59
+ /**
60
+ * Seeds Annotate Experiment dialog inputs from existing `proteomics.groups` if present,
61
+ * else returns the legacy Control/Treatment defaults. Stale column refs (no longer in the
62
+ * DataFrame or in the `available` list) are silently dropped — protects parser-populated
63
+ * assignments (e.g. Spectronaut DMD/WT) from being blanked when the user OKs the dialog
64
+ * without re-selecting columns.
65
+ */
66
+ export function seedAnnotationDialogInputs(df: DG.DataFrame, available: string[]): AnnotationSeed {
67
+ const runMetaSeed = readRunMetaSeed(df);
68
+ const existing = getGroups(df);
69
+ if (!existing) {
70
+ return {
71
+ group1Name: 'Control', group1Cols: [],
72
+ group2Name: 'Treatment', group2Cols: [],
73
+ instrumentId: runMetaSeed?.instrument_id,
74
+ acquisitionDatetime: runMetaSeed?.acquisition_datetime,
75
+ };
76
+ }
77
+ const availableSet = new Set(available);
78
+ const resolve = (names: string[]): DG.Column[] => names
79
+ .filter((n) => availableSet.has(n))
80
+ .map((n) => df.col(n))
81
+ .filter((c): c is DG.Column => c !== null);
82
+ return {
83
+ group1Name: existing.group1.name || 'Control',
84
+ group1Cols: resolve(existing.group1.columns),
85
+ group2Name: existing.group2.name || 'Treatment',
86
+ group2Cols: resolve(existing.group2.columns),
87
+ instrumentId: runMetaSeed?.instrument_id,
88
+ acquisitionDatetime: runMetaSeed?.acquisition_datetime,
89
+ };
90
+ }
91
+
92
+ /** Reads the Phase 16 run-identity seed in priority order:
93
+ * 1. Persisted `proteomics.spc_run_meta` (operator value from a prior dialog OK)
94
+ * 2. Parser-side `proteomics.spc_run_meta_seed` (set by the Spectronaut PG parser)
95
+ * 3. undefined (operator types both fields manually)
96
+ */
97
+ function readRunMetaSeed(df: DG.DataFrame): RunMeta | undefined {
98
+ const persisted = getRunMeta(df);
99
+ if (persisted) return persisted;
100
+ const raw = df.getTag('proteomics.spc_run_meta_seed');
101
+ if (!raw) return undefined;
102
+ try {
103
+ return JSON.parse(raw) as RunMeta;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
108
+
109
+ /** Shows a dialog for assigning intensity columns to two experimental groups. */
110
+ export function showAnnotationDialog(df: DG.DataFrame): void {
111
+ const intensityColNames = df.columns.toList()
112
+ .filter((c) => c.semType === SEMTYPE.INTENSITY && c.name.startsWith('log2('))
113
+ .map((c) => c.name);
114
+
115
+ const seed = seedAnnotationDialogInputs(df, intensityColNames);
116
+
117
+ const group1Name = ui.input.string('Group 1 Name', {value: seed.group1Name});
118
+ const group1Cols = ui.input.columns('Group 1', {table: df, available: intensityColNames, value: seed.group1Cols});
119
+ const group2Name = ui.input.string('Group 2 Name', {value: seed.group2Name});
120
+ const group2Cols = ui.input.columns('Group 2', {table: df, available: intensityColNames, value: seed.group2Cols});
121
+ // Phase 16 D-01: two new optional run-identity inputs. Tooltips come VERBATIM from UI-SPEC.
122
+ const instrumentInput = ui.input.string('Instrument', {
123
+ value: seed.instrumentId ?? '',
124
+ tooltipText: 'Free-text identifier for the instrument that ran this analysis (e.g. QExactive-01). ' +
125
+ 'Used to group runs in the SPC dashboard.',
126
+ });
127
+ const datetimeInput = ui.input.date('Acquisition datetime', {
128
+ value: seed.acquisitionDatetime ? dayjs(seed.acquisitionDatetime) : null,
129
+ tooltipText: 'When the samples were acquired on the instrument. ' +
130
+ 'Used to order runs on the SPC trend chart — not the file-import time.',
131
+ } as any);
132
+ // Organism: persisted choice, else auto-detected from the data's organism
133
+ // column, else human. Sets proteomics.organism early so enrichment and the
134
+ // subcellular-location fetch narrow to the right species (not always human).
135
+ const organismInput = ui.input.choice('Organism', {
136
+ value: (organismDisplayForCode(getOrganism(df) ?? detectOrganismCode(df)) ??
137
+ 'Homo sapiens (Human)') as string,
138
+ items: ORGANISM_LIST.map((o) => o.display) as unknown as string[],
139
+ nullable: false,
140
+ tooltipText: 'Species for enrichment (g:Profiler) and UniProt subcellular-location ' +
141
+ 'lookup. Auto-detected from the data when available.',
142
+ });
143
+
144
+ ui.dialog('Annotate Experiment')
145
+ .add(group1Name)
146
+ .add(group1Cols)
147
+ .add(group2Name)
148
+ .add(group2Cols)
149
+ .add(organismInput)
150
+ .add(instrumentInput)
151
+ .add(datetimeInput)
152
+ .onOK(() => {
153
+ const g1: DG.Column[] = group1Cols.value ?? [];
154
+ const g2: DG.Column[] = group2Cols.value ?? [];
155
+
156
+ if (g1.length === 0 || g2.length === 0) {
157
+ grok.shell.warning('Select at least one column per group');
158
+ return;
159
+ }
160
+
161
+ const groups: GroupAssignment = {
162
+ group1: {name: group1Name.value, columns: g1.map((c) => c.name)},
163
+ group2: {name: group2Name.value, columns: g2.map((c) => c.name)},
164
+ };
165
+ const instrumentValue = (instrumentInput.value ?? '').trim();
166
+ const datetimeValue: any = datetimeInput.value;
167
+ const isoDatetime = datetimeValue && typeof datetimeValue.toISOString === 'function'
168
+ ? datetimeValue.toISOString()
169
+ : (datetimeValue instanceof Date ? datetimeValue.toISOString() : '');
170
+ applyAnnotation(df, {
171
+ group1: groups.group1,
172
+ group2: groups.group2,
173
+ organism: organismCodeForDisplay(organismInput.value ?? undefined),
174
+ runMeta: (instrumentValue.length > 0 || isoDatetime.length > 0)
175
+ ? {instrument_id: instrumentValue, acquisition_datetime: isoDatetime}
176
+ : undefined,
177
+ });
178
+ grok.shell.info(`Groups assigned: ${g1.length} + ${g2.length} samples`);
179
+ })
180
+ .show();
181
+ }
182
+
183
+ /** Programmatic Annotate Experiment OK-path. Used by Plan 16-01's
184
+ * `SPC:annotation_dialog_persists_run_meta` test and by the dialog above to
185
+ * share the persist-on-OK flow. setGroups + setRunMeta in lockstep so the
186
+ * test never has to instantiate a real dialog. */
187
+ export function applyAnnotation(
188
+ df: DG.DataFrame,
189
+ payload: {
190
+ group1: GroupAssignment['group1'];
191
+ group2: GroupAssignment['group2'];
192
+ organism?: string;
193
+ runMeta?: RunMeta;
194
+ },
195
+ ): void {
196
+ setGroups(df, {group1: payload.group1, group2: payload.group2});
197
+ if (payload.organism) setOrganism(df, payload.organism);
198
+ if (payload.runMeta) setRunMeta(df, payload.runMeta);
199
+ }
@@ -0,0 +1,407 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+
5
+ import {SEMTYPE} from '../utils/proteomics-types';
6
+ import {getGroups} from './experiment-setup';
7
+
8
+ /** Generates a random number from a normal distribution using Box-Muller transform. */
9
+ function randomNormal(mean: number, stdev: number): number {
10
+ const u1 = Math.random();
11
+ const u2 = Math.random();
12
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
13
+ return mean + stdev * z;
14
+ }
15
+
16
+ /** Imputes missing values using the MinProb method (Perseus-style).
17
+ * Draws random values from a downshifted normal distribution
18
+ * simulating low-abundance proteins below detection limit.
19
+ * Returns the total number of imputed values. */
20
+ export function imputeMinProb(
21
+ df: DG.DataFrame,
22
+ colNames: string[],
23
+ downshift: number = 1.8,
24
+ width: number = 0.3,
25
+ ): number {
26
+ let totalImputed = 0;
27
+
28
+ for (const name of colNames) {
29
+ const col = df.col(name);
30
+ if (!col) continue;
31
+ if (col.type !== DG.COLUMN_TYPE.FLOAT) continue;
32
+
33
+ const avg = col.stats.avg;
34
+ const stdev = col.stats.stdev;
35
+ if (isNaN(avg) || isNaN(stdev) || stdev === 0) continue;
36
+
37
+ const imputeMean = avg - downshift * stdev;
38
+ const imputeSd = width * stdev;
39
+
40
+ const raw = col.getRawData() as Float32Array | Float64Array;
41
+ for (let i = 0; i < df.rowCount; i++) {
42
+ if (raw[i] === DG.FLOAT_NULL) {
43
+ raw[i] = randomNormal(imputeMean, imputeSd);
44
+ totalImputed++;
45
+ }
46
+ }
47
+ }
48
+
49
+ df.fireValuesChanged();
50
+ df.setTag('proteomics.imputed', 'true');
51
+ return totalImputed;
52
+ }
53
+
54
+ /** Imputes missing values using k-nearest neighbor averaging.
55
+ * For each row with missing values, finds the k nearest rows by mean-squared
56
+ * distance (RMSE) over the columns where both rows have valid values, then
57
+ * averages those neighbors' values. Normalizing by sharedCount keeps distances
58
+ * comparable across rows with different numbers of shared observations.
59
+ * Falls back to the column mean when no neighbors have values for a column;
60
+ * if a column is entirely missing, leaves the cell null.
61
+ * Shows a progress indicator during computation.
62
+ * Returns the total number of imputed values. */
63
+ export function imputeKnn(
64
+ df: DG.DataFrame,
65
+ colNames: string[],
66
+ k: number = 10,
67
+ ): number {
68
+ const cols = colNames.map((n) => df.col(n)).filter((c) => c != null && c.type === DG.COLUMN_TYPE.FLOAT) as DG.Column[];
69
+ if (cols.length === 0) return 0;
70
+
71
+ // Cache the underlying typed arrays for direct in-place writes — avoids the
72
+ // JS→native bridge per cell in the inner imputation loops below.
73
+ const rawArrays = cols.map((c) => c.getRawData() as Float32Array | Float64Array);
74
+
75
+ const nRows = df.rowCount;
76
+ const nCols = cols.length;
77
+
78
+ // Build matrix and missing mask
79
+ const matrix: Float64Array[] = [];
80
+ const missing: boolean[][] = [];
81
+ for (let r = 0; r < nRows; r++) {
82
+ const row = new Float64Array(nCols);
83
+ const miss: boolean[] = [];
84
+ for (let c = 0; c < nCols; c++) {
85
+ const isNull = cols[c].isNone(r);
86
+ miss.push(isNull);
87
+ row[c] = isNull ? 0 : cols[c].get(r);
88
+ }
89
+ matrix.push(row);
90
+ missing.push(miss);
91
+ }
92
+
93
+ // Compute column means (from non-missing values) as fallback.
94
+ // Use NaN for all-missing columns so downstream consumers can detect "no signal"
95
+ // rather than silently filling missing cells with a literal 0 (a valid log2 intensity).
96
+ const colMeans = new Float64Array(nCols);
97
+ for (let c = 0; c < nCols; c++) {
98
+ let sum = 0;
99
+ let count = 0;
100
+ for (let r = 0; r < nRows; r++) {
101
+ if (!missing[r][c]) {
102
+ sum += matrix[r][c];
103
+ count++;
104
+ }
105
+ }
106
+ colMeans[c] = count > 0 ? sum / count : NaN;
107
+ }
108
+
109
+ // Find rows with any missing values (regardless of whether they also have valid ones —
110
+ // the all-missing case is handled inside the loop via column means or null).
111
+ const rowsWithMissing: number[] = [];
112
+ for (let r = 0; r < nRows; r++) {
113
+ if (missing[r].some((m) => m))
114
+ rowsWithMissing.push(r);
115
+ }
116
+
117
+ const pi = DG.TaskBarProgressIndicator.create('kNN imputation...');
118
+ let totalImputed = 0;
119
+
120
+ try {
121
+ for (let idx = 0; idx < rowsWithMissing.length; idx++) {
122
+ const r = rowsWithMissing[idx];
123
+ pi.update(Math.round((idx / rowsWithMissing.length) * 100), `Row ${idx + 1}/${rowsWithMissing.length}`);
124
+
125
+ // Check if this row has any valid values for distance computation
126
+ const hasAnyValid = missing[r].some((m) => !m);
127
+
128
+ if (!hasAnyValid) {
129
+ // All-missing row: use column means, but leave null if the column itself was all-missing
130
+ for (let c = 0; c < nCols; c++) {
131
+ if (missing[r][c] && isFinite(colMeans[c])) {
132
+ rawArrays[c][r] = colMeans[c];
133
+ totalImputed++;
134
+ }
135
+ }
136
+ continue;
137
+ }
138
+
139
+ // Compute distances to all other rows on shared non-missing columns
140
+ const distances: {row: number; dist: number}[] = [];
141
+ for (let other = 0; other < nRows; other++) {
142
+ if (other === r) continue;
143
+
144
+ let sumSq = 0;
145
+ let sharedCount = 0;
146
+ for (let c = 0; c < nCols; c++) {
147
+ if (!missing[r][c] && !missing[other][c]) {
148
+ const diff = matrix[r][c] - matrix[other][c];
149
+ sumSq += diff * diff;
150
+ sharedCount++;
151
+ }
152
+ }
153
+
154
+ if (sharedCount > 0)
155
+ distances.push({row: other, dist: Math.sqrt(sumSq / sharedCount)});
156
+ }
157
+
158
+ // Sort by distance, take top k
159
+ distances.sort((a, b) => a.dist - b.dist);
160
+ const neighbors = distances.slice(0, k);
161
+
162
+ // For each missing column in this row, average neighbors' values
163
+ for (let c = 0; c < nCols; c++) {
164
+ if (!missing[r][c]) continue;
165
+
166
+ let sum = 0;
167
+ let count = 0;
168
+ for (const n of neighbors) {
169
+ if (!missing[n.row][c]) {
170
+ sum += matrix[n.row][c];
171
+ count++;
172
+ }
173
+ }
174
+
175
+ const val = count > 0 ? sum / count : colMeans[c];
176
+ if (isFinite(val)) {
177
+ rawArrays[c][r] = val;
178
+ totalImputed++;
179
+ }
180
+ }
181
+ }
182
+ } finally {
183
+ pi.close();
184
+ }
185
+
186
+ df.fireValuesChanged();
187
+ df.setTag('proteomics.imputed', 'true');
188
+ return totalImputed;
189
+ }
190
+
191
+ /** Imputes missing values by replacing them with zero.
192
+ * Returns the total number of imputed values. */
193
+ export function imputeZero(df: DG.DataFrame, colNames: string[]): number {
194
+ let totalImputed = 0;
195
+ for (const name of colNames) {
196
+ const col = df.col(name);
197
+ if (!col || col.type !== DG.COLUMN_TYPE.FLOAT) continue;
198
+ const raw = col.getRawData() as Float32Array | Float64Array;
199
+ for (let i = 0; i < df.rowCount; i++) {
200
+ if (raw[i] === DG.FLOAT_NULL) {
201
+ raw[i] = 0;
202
+ totalImputed++;
203
+ }
204
+ }
205
+ }
206
+ df.fireValuesChanged();
207
+ df.setTag('proteomics.imputed', 'true');
208
+ return totalImputed;
209
+ }
210
+
211
+ /** Imputes missing values by replacing them with the column mean.
212
+ * Returns the total number of imputed values. */
213
+ export function imputeMean(df: DG.DataFrame, colNames: string[]): number {
214
+ let totalImputed = 0;
215
+ for (const name of colNames) {
216
+ const col = df.col(name);
217
+ if (!col || col.type !== DG.COLUMN_TYPE.FLOAT) continue;
218
+ const avg = col.stats.avg;
219
+ if (isNaN(avg)) continue;
220
+ const raw = col.getRawData() as Float32Array | Float64Array;
221
+ for (let i = 0; i < df.rowCount; i++) {
222
+ if (raw[i] === DG.FLOAT_NULL) {
223
+ raw[i] = avg;
224
+ totalImputed++;
225
+ }
226
+ }
227
+ }
228
+ df.fireValuesChanged();
229
+ df.setTag('proteomics.imputed', 'true');
230
+ return totalImputed;
231
+ }
232
+
233
+ /** Imputes missing values by replacing them with the column median.
234
+ * Returns the total number of imputed values. */
235
+ export function imputeMedian(df: DG.DataFrame, colNames: string[]): number {
236
+ let totalImputed = 0;
237
+ for (const name of colNames) {
238
+ const col = df.col(name);
239
+ if (!col || col.type !== DG.COLUMN_TYPE.FLOAT) continue;
240
+ const med = col.stats.med;
241
+ if (isNaN(med)) continue;
242
+ const raw = col.getRawData() as Float32Array | Float64Array;
243
+ for (let i = 0; i < df.rowCount; i++) {
244
+ if (raw[i] === DG.FLOAT_NULL) {
245
+ raw[i] = med;
246
+ totalImputed++;
247
+ }
248
+ }
249
+ }
250
+ df.fireValuesChanged();
251
+ df.setTag('proteomics.imputed', 'true');
252
+ return totalImputed;
253
+ }
254
+
255
+ /** Shows a dialog for imputing missing values with method selection,
256
+ * conditional parameters, and valid-values protein filter. */
257
+ export function showImputationDialog(df: DG.DataFrame): void {
258
+ if (df.getTag('proteomics.imputed') === 'true') {
259
+ grok.shell.warning('Missing values already imputed');
260
+ return;
261
+ }
262
+
263
+ const log2ColNames = df.columns.toList()
264
+ .filter((c) => c.semType === SEMTYPE.INTENSITY && c.name.startsWith('log2('))
265
+ .map((c) => c.name);
266
+
267
+ const log2Cols = log2ColNames.map((n) => df.col(n)!);
268
+
269
+ // Method selector
270
+ const methodInput = ui.input.choice('Method', {
271
+ value: 'MinProb',
272
+ items: ['MinProb', 'kNN', 'Zero', 'Mean', 'Median'],
273
+ nullable: false,
274
+ });
275
+
276
+ // Column picker
277
+ const colsInput = ui.input.columns('Intensity columns', {
278
+ table: df, available: log2ColNames, value: log2Cols,
279
+ });
280
+
281
+ // MinProb-specific inputs
282
+ const downshiftInput = ui.input.float('Downshift', {value: 1.8});
283
+ downshiftInput.setTooltip('Standard deviations to shift left from mean (Perseus default: 1.8)');
284
+ const widthInput = ui.input.float('Width', {value: 0.3});
285
+ widthInput.setTooltip('Fraction of standard deviation for imputation distribution (Perseus default: 0.3)');
286
+ const minProbContainer = ui.div([downshiftInput.root, widthInput.root]);
287
+
288
+ // kNN-specific input
289
+ const kInput = ui.input.int('k (neighbors)', {value: 10});
290
+ const knnContainer = ui.div([kInput.root]);
291
+ knnContainer.style.display = 'none';
292
+
293
+ // Toggle visibility based on method
294
+ methodInput.onChanged.subscribe(() => {
295
+ const method = methodInput.value;
296
+ minProbContainer.style.display = method === 'MinProb' ? '' : 'none';
297
+ knnContainer.style.display = method === 'kNN' ? '' : 'none';
298
+ });
299
+
300
+ // Valid-values filter
301
+ const minValidInput = ui.input.int('Min valid values per group', {value: 2});
302
+ const filterCountDiv = ui.divText('');
303
+ filterCountDiv.style.cssText = 'font-size:12px; color:#888; margin-bottom:8px;';
304
+
305
+ const updateFilterCount = () => {
306
+ const threshold = minValidInput.value ?? 0;
307
+ const groups = getGroups(df);
308
+ if (threshold === 0 || !groups) {
309
+ filterCountDiv.textContent = `Will keep all ${df.rowCount} proteins`;
310
+ return;
311
+ }
312
+
313
+ const g1Cols = groups.group1.columns.map((n) => df.col(n)).filter((c) => c != null) as DG.Column[];
314
+ const g2Cols = groups.group2.columns.map((n) => df.col(n)).filter((c) => c != null) as DG.Column[];
315
+ let kept = 0;
316
+
317
+ for (let r = 0; r < df.rowCount; r++) {
318
+ let g1Valid = 0;
319
+ for (const col of g1Cols) {
320
+ if (!col.isNone(r)) g1Valid++;
321
+ }
322
+ let g2Valid = 0;
323
+ for (const col of g2Cols) {
324
+ if (!col.isNone(r)) g2Valid++;
325
+ }
326
+ // Protein passes if ANY group has >= threshold valid values
327
+ if (g1Valid >= threshold || g2Valid >= threshold)
328
+ kept++;
329
+ }
330
+
331
+ const removed = df.rowCount - kept;
332
+ filterCountDiv.textContent = `Will keep ${kept}/${df.rowCount} proteins (${removed} removed)`;
333
+ };
334
+
335
+ minValidInput.onChanged.subscribe(updateFilterCount);
336
+ updateFilterCount();
337
+
338
+ ui.dialog('Impute Missing Values')
339
+ .add(methodInput)
340
+ .add(colsInput)
341
+ .add(minProbContainer)
342
+ .add(knnContainer)
343
+ .add(minValidInput)
344
+ .add(filterCountDiv)
345
+ .onOK(() => {
346
+ const selected = colsInput.value.map((c: DG.Column) => c.name);
347
+ const method = methodInput.value;
348
+
349
+ // Apply valid-values filter if threshold > 0 and groups exist
350
+ const threshold = minValidInput.value ?? 0;
351
+ const groups = getGroups(df);
352
+ if (threshold > 0 && groups) {
353
+ const g1Cols = groups.group1.columns.map((n) => df.col(n)).filter((c) => c != null) as DG.Column[];
354
+ const g2Cols = groups.group2.columns.map((n) => df.col(n)).filter((c) => c != null) as DG.Column[];
355
+ const removeSet = DG.BitSet.create(df.rowCount);
356
+
357
+ for (let r = 0; r < df.rowCount; r++) {
358
+ let g1Valid = 0;
359
+ for (const col of g1Cols) {
360
+ if (!col.isNone(r)) g1Valid++;
361
+ }
362
+ let g2Valid = 0;
363
+ for (const col of g2Cols) {
364
+ if (!col.isNone(r)) g2Valid++;
365
+ }
366
+ if (g1Valid < threshold && g2Valid < threshold)
367
+ removeSet.set(r, true);
368
+ }
369
+
370
+ const removed = removeSet.trueCount;
371
+ if (removed > 0) {
372
+ df.rows.removeWhere((row) => removeSet.get(row.idx));
373
+ grok.shell.info(`Filtered: removed ${removed} proteins below threshold`);
374
+ }
375
+ }
376
+
377
+ // Dispatch to selected imputation method. Imputation runs AFTER the valid-values
378
+ // filter above, which has already mutated df.rows — if imputation throws, the
379
+ // filter cannot be reverted, so surface the partial state clearly.
380
+ let count = 0;
381
+ try {
382
+ switch (method) {
383
+ case 'MinProb':
384
+ count = imputeMinProb(df, selected, downshiftInput.value!, widthInput.value!);
385
+ break;
386
+ case 'kNN':
387
+ count = imputeKnn(df, selected, kInput.value!);
388
+ break;
389
+ case 'Zero':
390
+ count = imputeZero(df, selected);
391
+ break;
392
+ case 'Mean':
393
+ count = imputeMean(df, selected);
394
+ break;
395
+ case 'Median':
396
+ count = imputeMedian(df, selected);
397
+ break;
398
+ }
399
+ } catch (e: any) {
400
+ grok.shell.error(`Imputation failed: ${e?.message ?? e}. Valid-values filter was already applied; data remains filtered but un-imputed.`);
401
+ return;
402
+ }
403
+
404
+ grok.shell.info(`Imputed ${count} missing values across ${selected.length} columns (${method})`);
405
+ })
406
+ .show();
407
+ }