@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.
- package/CHANGELOG.md +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +87 -62
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2055 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- package/scripts/number_antibody_anarci.py +0 -200
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 16 SPC Dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Sibling-TableView host for the runs.csv-derived DataFrame (per
|
|
5
|
+
* ARCHITECTURE.md §"Cross-DataFrame Selection") — the dashboard NEVER docks
|
|
6
|
+
* onto the protein DataFrame's view. Composes Plan 02's math + Plan 03's
|
|
7
|
+
* AppData I/O into a live operator surface: instrument picker, 4 I-charts
|
|
8
|
+
* + 4 MR-charts with UCL/CL/LCL formula lines, baseline status sidebar with
|
|
9
|
+
* a per-metric Nelson rule toggle grid, Define / Rebuild baseline modal,
|
|
10
|
+
* and drill-down to the source Project on flagged-point click.
|
|
11
|
+
*
|
|
12
|
+
* Pareto chart (SPC-08, P2) is Plan 16-07's deliverable — `aggregateParetoCounts`
|
|
13
|
+
* lives there. The rules-tripped tag is set unconditionally so Plan 07 can
|
|
14
|
+
* land as a hotfix without unwinding this plan.
|
|
15
|
+
*/
|
|
16
|
+
import * as grok from 'datagrok-api/grok';
|
|
17
|
+
import * as ui from 'datagrok-api/ui';
|
|
18
|
+
import * as DG from 'datagrok-api/dg';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
WHEELER_D4,
|
|
22
|
+
evaluateNelsonRulesAllMetrics,
|
|
23
|
+
defaultRulesEnabledAllMetrics,
|
|
24
|
+
classifyStatus,
|
|
25
|
+
BaselineSnapshot, SpcMetrics, SpcRulesEnabled,
|
|
26
|
+
} from '../analysis/spc';
|
|
27
|
+
import {
|
|
28
|
+
LockedBaseline, SpcRunRow,
|
|
29
|
+
loadRuns, loadBaseline, saveBaseline,
|
|
30
|
+
iterativeOutlierRemoval, computeBaselineStats,
|
|
31
|
+
runsToDataFrame,
|
|
32
|
+
} from '../analysis/spc-storage';
|
|
33
|
+
|
|
34
|
+
type MetricKey = 'median_intensity' | 'missing_pct' | 'control_corr' | 'protein_count';
|
|
35
|
+
|
|
36
|
+
const METRIC_KEYS: MetricKey[] = [
|
|
37
|
+
'median_intensity', 'missing_pct', 'control_corr', 'protein_count',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const METRIC_LABELS: Record<MetricKey, string> = {
|
|
41
|
+
median_intensity: 'Median log2 intensity',
|
|
42
|
+
missing_pct: 'Missing %',
|
|
43
|
+
control_corr: 'Control replicate correlation',
|
|
44
|
+
protein_count: 'Protein count',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const FORMULA_LINE_COLORS = {
|
|
48
|
+
cl: '#888888',
|
|
49
|
+
bound: '#666666',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const LAST_INSTRUMENT_STORAGE_KEY = 'spc.last_picked_instrument';
|
|
53
|
+
|
|
54
|
+
// Five canonical false-alarm-rate disclosures per UI-SPEC §"False-alarm-rate".
|
|
55
|
+
const FALSE_ALARM_DISCLOSURE: Record<string, string> = {
|
|
56
|
+
'rule_1_only': 'Expected false-alarm rate at this rule set: ~0.27%. (Rule 1 only — a point beyond 3σ.)',
|
|
57
|
+
'rules_1_5': 'Expected false-alarm rate at this rule set: ~0.4%. (Rules 1 + 5 — a single point beyond 3σ, or 2 of 3 beyond 2σ.)',
|
|
58
|
+
'rules_1_5_2': 'Enabling rules 1+5+2 raises the expected false-alarm rate to ~1.1%.',
|
|
59
|
+
'rules_all_8': 'Expected false-alarm rate at this rule set: ~2.65%. Enabling all 8 rules will flag roughly 1 in 38 normal-operation runs.',
|
|
60
|
+
'rules_other': 'More rules enabled → higher false-alarm rate. The Nelson 1+5 default is calibrated for ~0.4%.',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function disclosureKey(enabled: SpcRulesEnabled): string {
|
|
64
|
+
const sample = enabled.median_intensity;
|
|
65
|
+
const onIds = Object.entries(sample).filter(([_, on]) => on).map(([id]) => id).sort();
|
|
66
|
+
if (onIds.length === 1 && onIds[0] === 'nelson_1') return 'rule_1_only';
|
|
67
|
+
if (onIds.length === 2 && onIds[0] === 'nelson_1' && onIds[1] === 'nelson_5') return 'rules_1_5';
|
|
68
|
+
if (onIds.length === 3 && onIds.includes('nelson_1') && onIds.includes('nelson_5') && onIds.includes('nelson_2'))
|
|
69
|
+
return 'rules_1_5_2';
|
|
70
|
+
if (onIds.length === 8) return 'rules_all_8';
|
|
71
|
+
return 'rules_other';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// =====================================================================================
|
|
75
|
+
// Pure helpers — exported because they're directly testable.
|
|
76
|
+
// =====================================================================================
|
|
77
|
+
|
|
78
|
+
export interface ControlLines {
|
|
79
|
+
ucl: number;
|
|
80
|
+
cl: number;
|
|
81
|
+
lcl: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** UCL = mean + 3sd, CL = mean, LCL = mean - 3sd. Used for the I-chart formula lines
|
|
85
|
+
* AND tested by SPC:formula_lines. Returns NaN-bearing lines when the baseline
|
|
86
|
+
* metric is unavailable; the caller skips formula-line addition in that case. */
|
|
87
|
+
export function computeControlLines(
|
|
88
|
+
baseline: {metrics: BaselineSnapshot} | null, metric: MetricKey,
|
|
89
|
+
): ControlLines {
|
|
90
|
+
if (!baseline) return {ucl: NaN, cl: NaN, lcl: NaN};
|
|
91
|
+
const m = baseline.metrics[metric];
|
|
92
|
+
if (!m || !Number.isFinite(m.mean) || !Number.isFinite(m.sd))
|
|
93
|
+
return {ucl: NaN, cl: NaN, lcl: NaN};
|
|
94
|
+
return {ucl: m.mean + 3 * m.sd, cl: m.mean, lcl: m.mean - 3 * m.sd};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface DrillDownInput {
|
|
98
|
+
run_id: string;
|
|
99
|
+
run_label: string;
|
|
100
|
+
source_project_id: string | null;
|
|
101
|
+
instrument_id?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type DrillDownResult =
|
|
105
|
+
| {kind: 'opened'; id: string}
|
|
106
|
+
| {kind: 'toast'; message: string};
|
|
107
|
+
|
|
108
|
+
/** Pure drill-down resolver. Caller injects a `projectsFind` that returns either
|
|
109
|
+
* a project (with `.open()`) or null. The handler classifies into:
|
|
110
|
+
* - 'opened' on success (project found + opened)
|
|
111
|
+
* - 'toast' for both the null-id case AND the find()-returns-null case
|
|
112
|
+
* Both toast messages are biologist-readable per UI-SPEC §"Drill-down". */
|
|
113
|
+
export async function resolveDrillDown(
|
|
114
|
+
row: DrillDownInput,
|
|
115
|
+
deps: {projectsFind: (id: string) => Promise<{open: () => any} | null>},
|
|
116
|
+
): Promise<DrillDownResult> {
|
|
117
|
+
if (row.source_project_id == null) {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'toast',
|
|
120
|
+
message: `Source for '${row.run_label}' is not currently available. ` +
|
|
121
|
+
`Open the run's analyzed file and re-run Compute SPC Status.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const project = await deps.projectsFind(row.source_project_id);
|
|
125
|
+
if (!project) {
|
|
126
|
+
return {
|
|
127
|
+
kind: 'toast',
|
|
128
|
+
message: `Source for '${row.run_label}' is not currently available. ` +
|
|
129
|
+
`Open the run's analyzed file and re-run Compute SPC Status.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
await project.open();
|
|
133
|
+
return {kind: 'opened', id: row.source_project_id};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// =====================================================================================
|
|
137
|
+
// createSpcChartPanel — pure factory returning {iChart, mrChart}. The MR column
|
|
138
|
+
// must already exist on runsDf (openSpcDashboard adds it before docking).
|
|
139
|
+
// =====================================================================================
|
|
140
|
+
|
|
141
|
+
export function createSpcChartPanel(
|
|
142
|
+
runsDf: DG.DataFrame,
|
|
143
|
+
baseline: {metrics: BaselineSnapshot} | null,
|
|
144
|
+
metric: MetricKey,
|
|
145
|
+
): {iChart: DG.Viewer; mrChart: DG.Viewer} {
|
|
146
|
+
const iChart: any = DG.Viewer.lineChart(runsDf, {
|
|
147
|
+
xColumnName: 'acquisition_datetime',
|
|
148
|
+
yColumnNames: [metric],
|
|
149
|
+
title: METRIC_LABELS[metric],
|
|
150
|
+
} as any);
|
|
151
|
+
|
|
152
|
+
if (baseline) {
|
|
153
|
+
const lines = computeControlLines(baseline, metric);
|
|
154
|
+
if (Number.isFinite(lines.cl)) {
|
|
155
|
+
const fl: any = iChart?.meta?.formulaLines;
|
|
156
|
+
if (fl && typeof fl.addLine === 'function') {
|
|
157
|
+
// Spike outcome (Plan 16-01) lands at runtime — both branches are
|
|
158
|
+
// documented in the spike test; using `style:'dashed'` is the
|
|
159
|
+
// expected PASS path. FAIL path is the constant-column overlay
|
|
160
|
+
// fallback described in 16-RESEARCH Pattern 4.
|
|
161
|
+
try {
|
|
162
|
+
fl.addLine({
|
|
163
|
+
formula: `\${${metric}} = ${lines.ucl}`,
|
|
164
|
+
color: FORMULA_LINE_COLORS.bound, width: 1, style: 'dashed',
|
|
165
|
+
visible: true, title: 'UCL',
|
|
166
|
+
});
|
|
167
|
+
fl.addLine({
|
|
168
|
+
formula: `\${${metric}} = ${lines.cl}`,
|
|
169
|
+
color: FORMULA_LINE_COLORS.cl, width: 1, style: 'solid',
|
|
170
|
+
visible: true, title: 'CL',
|
|
171
|
+
});
|
|
172
|
+
fl.addLine({
|
|
173
|
+
formula: `\${${metric}} = ${lines.lcl}`,
|
|
174
|
+
color: FORMULA_LINE_COLORS.bound, width: 1, style: 'dashed',
|
|
175
|
+
visible: true, title: 'LCL',
|
|
176
|
+
});
|
|
177
|
+
} catch (_e) {
|
|
178
|
+
// Spike FAIL path — formula-line API didn't honor the style. The
|
|
179
|
+
// dashboard still renders; the constant-overlay fallback is
|
|
180
|
+
// deferred to a follow-up if the spike returns FAIL in practice.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mrColName = `MR_${metric}`;
|
|
187
|
+
const mrChart: any = DG.Viewer.lineChart(runsDf, {
|
|
188
|
+
xColumnName: 'acquisition_datetime',
|
|
189
|
+
yColumnNames: [mrColName],
|
|
190
|
+
title: `MR ${METRIC_LABELS[metric]}`,
|
|
191
|
+
} as any);
|
|
192
|
+
|
|
193
|
+
if (baseline && runsDf.col(mrColName)) {
|
|
194
|
+
const mrCol = runsDf.col(mrColName)!;
|
|
195
|
+
let sum = 0;
|
|
196
|
+
let n = 0;
|
|
197
|
+
for (let i = 0; i < runsDf.rowCount; i++) {
|
|
198
|
+
if (mrCol.isNone(i)) continue;
|
|
199
|
+
const v = Number(mrCol.get(i));
|
|
200
|
+
if (Number.isFinite(v)) { sum += v; n++; }
|
|
201
|
+
}
|
|
202
|
+
if (n > 0) {
|
|
203
|
+
const mrMean = sum / n;
|
|
204
|
+
const mrUcl = WHEELER_D4 * mrMean;
|
|
205
|
+
const flMr: any = (mrChart as any)?.meta?.formulaLines;
|
|
206
|
+
if (flMr && typeof flMr.addLine === 'function') {
|
|
207
|
+
try {
|
|
208
|
+
flMr.addLine({
|
|
209
|
+
formula: `\${${mrColName}} = ${mrUcl}`,
|
|
210
|
+
color: FORMULA_LINE_COLORS.bound, width: 1, style: 'dashed',
|
|
211
|
+
visible: true, title: 'MR UCL',
|
|
212
|
+
});
|
|
213
|
+
flMr.addLine({
|
|
214
|
+
formula: `\${${mrColName}} = ${mrMean}`,
|
|
215
|
+
color: FORMULA_LINE_COLORS.cl, width: 1, style: 'solid',
|
|
216
|
+
visible: true, title: 'MR CL',
|
|
217
|
+
});
|
|
218
|
+
} catch (_e) { /* spike FAIL fallback — same as I-chart */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {iChart, mrChart};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// =====================================================================================
|
|
227
|
+
// Pareto aggregation (SPC-08, P2) — Plan 16-07.
|
|
228
|
+
// =====================================================================================
|
|
229
|
+
|
|
230
|
+
export interface ParetoEntry {
|
|
231
|
+
rule: string;
|
|
232
|
+
metric: string;
|
|
233
|
+
count: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Aggregates rules-tripped trip counts across a slice of runs and returns the
|
|
237
|
+
* entries sorted DESCENDING by count. Accepts an array shape (test-friendly:
|
|
238
|
+
* rules_tripped as a string[]) — the dashboard caller adapts from CSV strings
|
|
239
|
+
* via JSON.parse-with-try/catch before passing in. */
|
|
240
|
+
export function aggregateParetoCounts(
|
|
241
|
+
runs: Array<{rules_tripped: string[]}>,
|
|
242
|
+
): ParetoEntry[] {
|
|
243
|
+
const counts = new Map<string, number>();
|
|
244
|
+
for (const r of runs) {
|
|
245
|
+
const tripped = Array.isArray(r.rules_tripped) ? r.rules_tripped : [];
|
|
246
|
+
for (const t of tripped) {
|
|
247
|
+
const prev = counts.get(t) ?? 0;
|
|
248
|
+
counts.set(t, prev + 1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const entries: ParetoEntry[] = [];
|
|
252
|
+
for (const [key, count] of counts.entries()) {
|
|
253
|
+
const at = key.indexOf('@');
|
|
254
|
+
if (at < 0) entries.push({rule: key, metric: '', count});
|
|
255
|
+
else entries.push({rule: key.slice(0, at), metric: key.slice(at + 1), count});
|
|
256
|
+
}
|
|
257
|
+
entries.sort((a, b) => b.count - a.count);
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Returns a barChart viewer ordered DESC by trip count, or null when there
|
|
262
|
+
* are fewer than 4 runs (caller renders the empty-state body in that case). */
|
|
263
|
+
export function createParetoChart(
|
|
264
|
+
runsDf: DG.DataFrame, instrumentId: string,
|
|
265
|
+
): DG.Viewer | null {
|
|
266
|
+
if (runsDf.rowCount < 4) return null;
|
|
267
|
+
const rulesCol = runsDf.col('rules_tripped');
|
|
268
|
+
if (!rulesCol) return null;
|
|
269
|
+
const totalRuns = runsDf.rowCount;
|
|
270
|
+
const inputs: Array<{rules_tripped: string[]}> = [];
|
|
271
|
+
for (let i = 0; i < totalRuns; i++) {
|
|
272
|
+
const raw = String(rulesCol.get(i) ?? '');
|
|
273
|
+
let parsed: string[] = [];
|
|
274
|
+
if (raw.length > 0) {
|
|
275
|
+
try { parsed = JSON.parse(raw) as string[]; } catch { parsed = []; }
|
|
276
|
+
}
|
|
277
|
+
inputs.push({rules_tripped: parsed});
|
|
278
|
+
}
|
|
279
|
+
const entries = aggregateParetoCounts(inputs);
|
|
280
|
+
if (entries.length === 0) return null;
|
|
281
|
+
|
|
282
|
+
const ruleMetricCol = DG.Column.fromStrings('rule_metric',
|
|
283
|
+
entries.map((e) => e.metric ? `${e.rule}@${e.metric}` : e.rule));
|
|
284
|
+
const tripCountArr = new Int32Array(entries.map((e) => e.count));
|
|
285
|
+
const totalRunsArr = new Int32Array(entries.map(() => totalRuns));
|
|
286
|
+
const pctArr = new Float32Array(entries.map((e) => (e.count / totalRuns) * 100));
|
|
287
|
+
const aggDf = DG.DataFrame.fromColumns([
|
|
288
|
+
ruleMetricCol,
|
|
289
|
+
DG.Column.fromInt32Array('trip_count', tripCountArr),
|
|
290
|
+
DG.Column.fromInt32Array('total_runs', totalRunsArr),
|
|
291
|
+
DG.Column.fromFloat32Array('pct', pctArr),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
const chart: any = DG.Viewer.barChart(aggDf, {
|
|
295
|
+
splitColumnName: 'rule_metric',
|
|
296
|
+
valueColumnName: 'trip_count',
|
|
297
|
+
title: `Which rules trip most often — ${instrumentId}`,
|
|
298
|
+
} as any);
|
|
299
|
+
// Axis labels per UI-SPEC §"Pareto chart labels".
|
|
300
|
+
try {
|
|
301
|
+
chart.props.xLabel = '(rule, metric)';
|
|
302
|
+
chart.props.yLabel = 'Times tripped';
|
|
303
|
+
} catch (_e) { /* prop names may shift across platform versions — non-fatal */ }
|
|
304
|
+
return chart;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// =====================================================================================
|
|
308
|
+
// MR column derivation (4 sibling columns on the runs df, computed once at open).
|
|
309
|
+
// =====================================================================================
|
|
310
|
+
|
|
311
|
+
function addMrColumns(runsDf: DG.DataFrame): void {
|
|
312
|
+
for (const metric of METRIC_KEYS) {
|
|
313
|
+
const srcCol = runsDf.col(metric);
|
|
314
|
+
if (!srcCol) continue;
|
|
315
|
+
const name = `MR_${metric}`;
|
|
316
|
+
if (runsDf.columns.contains(name)) runsDf.columns.remove(name);
|
|
317
|
+
const mr = runsDf.columns.addNewFloat(name);
|
|
318
|
+
const rc = runsDf.rowCount;
|
|
319
|
+
let prev: number | null = null;
|
|
320
|
+
for (let i = 0; i < rc; i++) {
|
|
321
|
+
if (i === 0) { mr.set(i, null); prev = null; continue; }
|
|
322
|
+
if (srcCol.isNone(i) || srcCol.isNone(i - 1)) { mr.set(i, null); continue; }
|
|
323
|
+
const v = Number(srcCol.get(i));
|
|
324
|
+
const p = Number(srcCol.get(i - 1));
|
|
325
|
+
if (!Number.isFinite(v) || !Number.isFinite(p)) { mr.set(i, null); continue; }
|
|
326
|
+
mr.set(i, Math.abs(v - p));
|
|
327
|
+
prev = v; void prev;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// =====================================================================================
|
|
333
|
+
// Sidebar — instrument readout + baseline status + Nelson rule toggle grid
|
|
334
|
+
// =====================================================================================
|
|
335
|
+
|
|
336
|
+
interface SidebarRefs {
|
|
337
|
+
el: HTMLElement;
|
|
338
|
+
setDisclosure: (text: string) => void;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildSidebar(
|
|
342
|
+
instrumentId: string,
|
|
343
|
+
baseline: LockedBaseline | null,
|
|
344
|
+
onRebuildClick: () => void,
|
|
345
|
+
onRuleToggle: (metric: MetricKey, rule: keyof SpcRulesEnabled['median_intensity'], value: boolean) => Promise<void>,
|
|
346
|
+
): SidebarRefs {
|
|
347
|
+
const heading = ui.h2(`Instrument: ${instrumentId}`);
|
|
348
|
+
|
|
349
|
+
let statusBlock: HTMLElement;
|
|
350
|
+
if (baseline) {
|
|
351
|
+
statusBlock = ui.divV([
|
|
352
|
+
ui.divText(`Baseline locked: ${baseline.locked_at}`),
|
|
353
|
+
ui.divText(`${baseline.included_run_ids.length} runs included · ` +
|
|
354
|
+
`${baseline.excluded_run_ids.length} runs excluded (3σ outlier).`),
|
|
355
|
+
ui.button('Rebuild baseline...', onRebuildClick),
|
|
356
|
+
]);
|
|
357
|
+
} else {
|
|
358
|
+
statusBlock = ui.divV([
|
|
359
|
+
ui.divText('No baseline locked yet. The dashboard turns on once a baseline exists.'),
|
|
360
|
+
]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ruleIds: Array<keyof SpcRulesEnabled['median_intensity']> = [
|
|
364
|
+
'nelson_1', 'nelson_2', 'nelson_3', 'nelson_4',
|
|
365
|
+
'nelson_5', 'nelson_6', 'nelson_7', 'nelson_8',
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const enabled = baseline?.rules_enabled ?? defaultRulesEnabledAllMetrics();
|
|
369
|
+
const disclosure = ui.divText(FALSE_ALARM_DISCLOSURE[disclosureKey(enabled)]);
|
|
370
|
+
const setDisclosure = (text: string) => { disclosure.textContent = text; };
|
|
371
|
+
|
|
372
|
+
const gridRows: HTMLElement[] = [];
|
|
373
|
+
gridRows.push(ui.divH([
|
|
374
|
+
ui.divText(''),
|
|
375
|
+
...ruleIds.map((r) => ui.divText(r.replace('nelson_', 'R'))),
|
|
376
|
+
]));
|
|
377
|
+
for (const metric of METRIC_KEYS) {
|
|
378
|
+
const row: HTMLElement[] = [ui.divText(METRIC_LABELS[metric])];
|
|
379
|
+
for (const rid of ruleIds) {
|
|
380
|
+
const checkbox = ui.input.bool('', {
|
|
381
|
+
value: enabled[metric][rid],
|
|
382
|
+
tooltipText: ruleTooltip(rid),
|
|
383
|
+
});
|
|
384
|
+
checkbox.onChanged.subscribe(async (v: any) => {
|
|
385
|
+
await onRuleToggle(metric, rid, !!v);
|
|
386
|
+
});
|
|
387
|
+
row.push(checkbox.root);
|
|
388
|
+
}
|
|
389
|
+
gridRows.push(ui.divH(row));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const el = ui.divV([
|
|
393
|
+
heading,
|
|
394
|
+
statusBlock,
|
|
395
|
+
ui.h2('Nelson rules'),
|
|
396
|
+
ui.divV(gridRows),
|
|
397
|
+
disclosure,
|
|
398
|
+
]);
|
|
399
|
+
return {el, setDisclosure};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function ruleTooltip(rule: keyof SpcRulesEnabled['median_intensity']): string {
|
|
403
|
+
switch (rule) {
|
|
404
|
+
case 'nelson_1': return 'Nelson rule 1: a single point beyond 3σ from the centerline.';
|
|
405
|
+
case 'nelson_2': return 'Nelson rule 2: 9 consecutive points on the same side of the centerline.';
|
|
406
|
+
case 'nelson_3': return 'Nelson rule 3: 6 consecutive points all trending up, or all trending down.';
|
|
407
|
+
case 'nelson_4': return 'Nelson rule 4: 14 consecutive points alternating above and below the centerline.';
|
|
408
|
+
case 'nelson_5': return 'Nelson rule 5: 2 out of 3 consecutive points beyond 2σ on the same side.';
|
|
409
|
+
case 'nelson_6': return 'Nelson rule 6: 4 out of 5 consecutive points beyond 1σ on the same side.';
|
|
410
|
+
case 'nelson_7': return 'Nelson rule 7: 15 consecutive points within 1σ of the centerline (unusually stable).';
|
|
411
|
+
case 'nelson_8': return 'Nelson rule 8: 8 consecutive points more than 1σ from the centerline on either side.';
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// =====================================================================================
|
|
416
|
+
// Drill-down subscription — wires the resolveDrillDown helper to a live TableView.
|
|
417
|
+
// =====================================================================================
|
|
418
|
+
|
|
419
|
+
function wireDrillDown(tv: DG.TableView, df: DG.DataFrame): void {
|
|
420
|
+
tv.dataFrame.onCurrentRowChanged.subscribe(async () => {
|
|
421
|
+
const i = df.currentRowIdx;
|
|
422
|
+
if (i < 0) return;
|
|
423
|
+
const status = String(df.col('status')?.get(i) ?? '');
|
|
424
|
+
if (status !== 'flagged' && status !== 'out_of_control') return;
|
|
425
|
+
const sourceRaw = df.col('source_project_id')?.get(i);
|
|
426
|
+
const source = (sourceRaw == null || sourceRaw === '') ? null : String(sourceRaw);
|
|
427
|
+
const result = await resolveDrillDown(
|
|
428
|
+
{
|
|
429
|
+
run_id: String(df.col('run_id')?.get(i) ?? ''),
|
|
430
|
+
run_label: String(df.col('run_label')?.get(i) ?? ''),
|
|
431
|
+
source_project_id: source,
|
|
432
|
+
},
|
|
433
|
+
{projectsFind: async (id: string) => {
|
|
434
|
+
const p: any = await grok.dapi.projects.find(id);
|
|
435
|
+
return p ? {open: () => p.open()} : null;
|
|
436
|
+
}},
|
|
437
|
+
);
|
|
438
|
+
if (result.kind === 'toast') grok.shell.warning(result.message);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// =====================================================================================
|
|
443
|
+
// Status recomputation — invoked on every rule toggle to update point colors live.
|
|
444
|
+
// =====================================================================================
|
|
445
|
+
|
|
446
|
+
function recomputeStatusesInPlace(
|
|
447
|
+
runsDf: DG.DataFrame, baseline: LockedBaseline,
|
|
448
|
+
): void {
|
|
449
|
+
const rowCount = runsDf.rowCount;
|
|
450
|
+
if (rowCount === 0) return;
|
|
451
|
+
const cols = {
|
|
452
|
+
median_intensity: runsDf.col('median_intensity'),
|
|
453
|
+
missing_pct: runsDf.col('missing_pct'),
|
|
454
|
+
control_corr: runsDf.col('control_corr'),
|
|
455
|
+
protein_count: runsDf.col('protein_count'),
|
|
456
|
+
};
|
|
457
|
+
const statusCol = runsDf.col('status');
|
|
458
|
+
const rulesCol = runsDf.col('rules_tripped');
|
|
459
|
+
if (!statusCol || !rulesCol) return;
|
|
460
|
+
for (let i = 0; i < rowCount; i++) {
|
|
461
|
+
const metrics: SpcMetrics = {
|
|
462
|
+
median_intensity: Number(cols.median_intensity?.get(i)),
|
|
463
|
+
missing_pct: Number(cols.missing_pct?.get(i)),
|
|
464
|
+
control_corr: Number(cols.control_corr?.get(i)),
|
|
465
|
+
protein_count: Number(cols.protein_count?.get(i)),
|
|
466
|
+
sample_count: 0, computed_at: '',
|
|
467
|
+
};
|
|
468
|
+
const priorSeries = {
|
|
469
|
+
median_intensity: [] as number[],
|
|
470
|
+
missing_pct: [] as number[],
|
|
471
|
+
control_corr: [] as number[],
|
|
472
|
+
protein_count: [] as number[],
|
|
473
|
+
};
|
|
474
|
+
for (let j = 0; j < i; j++) {
|
|
475
|
+
priorSeries.median_intensity.push(Number(cols.median_intensity?.get(j)));
|
|
476
|
+
priorSeries.missing_pct.push(Number(cols.missing_pct?.get(j)));
|
|
477
|
+
priorSeries.control_corr.push(Number(cols.control_corr?.get(j)));
|
|
478
|
+
priorSeries.protein_count.push(Number(cols.protein_count?.get(j)));
|
|
479
|
+
}
|
|
480
|
+
const r = evaluateNelsonRulesAllMetrics(
|
|
481
|
+
metrics, priorSeries, baseline.metrics, baseline.rules_enabled,
|
|
482
|
+
);
|
|
483
|
+
statusCol.set(i, r.status);
|
|
484
|
+
rulesCol.set(i, JSON.stringify(r.rulesTripped));
|
|
485
|
+
}
|
|
486
|
+
void classifyStatus;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// =====================================================================================
|
|
490
|
+
// Define / Rebuild baseline modal
|
|
491
|
+
// =====================================================================================
|
|
492
|
+
|
|
493
|
+
export function showDefineBaselineDialog(
|
|
494
|
+
instrumentId: string,
|
|
495
|
+
runs: SpcRunRow[],
|
|
496
|
+
onLocked: (baseline: LockedBaseline) => void | Promise<void>,
|
|
497
|
+
existing?: LockedBaseline,
|
|
498
|
+
): void {
|
|
499
|
+
const isRebuild = !!existing;
|
|
500
|
+
const title = isRebuild
|
|
501
|
+
? `Rebuild baseline for ${instrumentId}`
|
|
502
|
+
: `Lock baseline for ${instrumentId}`;
|
|
503
|
+
|
|
504
|
+
const ordered = runs.slice().sort((a, b) =>
|
|
505
|
+
a.acquisition_datetime.localeCompare(b.acquisition_datetime));
|
|
506
|
+
|
|
507
|
+
// Default check selection per CONTEXT.md D-04.
|
|
508
|
+
const checkedSet = new Set<string>();
|
|
509
|
+
if (isRebuild) {
|
|
510
|
+
for (const id of existing!.included_run_ids) checkedSet.add(id);
|
|
511
|
+
} else {
|
|
512
|
+
const n = Math.min(7, ordered.length);
|
|
513
|
+
for (let i = 0; i < n; i++) checkedSet.add(ordered[i].run_id);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const runCheckboxes: Array<{run: SpcRunRow; input: DG.InputBase<boolean>}> = [];
|
|
517
|
+
const listEl = ui.divV([]);
|
|
518
|
+
listEl.style.maxHeight = '300px';
|
|
519
|
+
listEl.style.overflowY = 'auto';
|
|
520
|
+
for (const r of ordered) {
|
|
521
|
+
const cb = ui.input.bool('', {value: checkedSet.has(r.run_id)});
|
|
522
|
+
runCheckboxes.push({run: r, input: cb});
|
|
523
|
+
listEl.appendChild(ui.divH([
|
|
524
|
+
cb.root,
|
|
525
|
+
ui.divText(r.acquisition_datetime.slice(0, 10)),
|
|
526
|
+
ui.divText(r.run_label),
|
|
527
|
+
]));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const iterateToggle = ui.input.bool('Iterate 3σ outlier removal (recommended)', {
|
|
531
|
+
value: isRebuild ? (existing!.iteration_trace.length > 0) : true,
|
|
532
|
+
tooltipText: 'When on, runs more than 3 standard deviations from the baseline mean ' +
|
|
533
|
+
'are dropped from the baseline. Capped at 2 passes.',
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const initialEnabled: SpcRulesEnabled = existing?.rules_enabled ?? defaultRulesEnabledAllMetrics();
|
|
537
|
+
const ruleIds: Array<keyof SpcRulesEnabled['median_intensity']> = [
|
|
538
|
+
'nelson_1', 'nelson_2', 'nelson_3', 'nelson_4',
|
|
539
|
+
'nelson_5', 'nelson_6', 'nelson_7', 'nelson_8',
|
|
540
|
+
];
|
|
541
|
+
const ruleInputs: Record<MetricKey, Record<string, DG.InputBase<boolean>>> = {
|
|
542
|
+
median_intensity: {} as any, missing_pct: {} as any,
|
|
543
|
+
control_corr: {} as any, protein_count: {} as any,
|
|
544
|
+
};
|
|
545
|
+
const gridRows: HTMLElement[] = [
|
|
546
|
+
ui.divH([ui.divText(''), ...ruleIds.map((r) => ui.divText(r.replace('nelson_', 'R')))]),
|
|
547
|
+
];
|
|
548
|
+
const disclosureEl = ui.divText(FALSE_ALARM_DISCLOSURE[disclosureKey(initialEnabled)]);
|
|
549
|
+
for (const metric of METRIC_KEYS) {
|
|
550
|
+
const row: HTMLElement[] = [ui.divText(METRIC_LABELS[metric])];
|
|
551
|
+
for (const rid of ruleIds) {
|
|
552
|
+
const cb = ui.input.bool('', {
|
|
553
|
+
value: initialEnabled[metric][rid],
|
|
554
|
+
tooltipText: ruleTooltip(rid),
|
|
555
|
+
});
|
|
556
|
+
cb.onChanged.subscribe(() => {
|
|
557
|
+
const snapshot = currentEnabled();
|
|
558
|
+
disclosureEl.textContent = FALSE_ALARM_DISCLOSURE[disclosureKey(snapshot)];
|
|
559
|
+
});
|
|
560
|
+
ruleInputs[metric][rid] = cb;
|
|
561
|
+
row.push(cb.root);
|
|
562
|
+
}
|
|
563
|
+
gridRows.push(ui.divH(row));
|
|
564
|
+
}
|
|
565
|
+
function currentEnabled(): SpcRulesEnabled {
|
|
566
|
+
const out: any = {};
|
|
567
|
+
for (const m of METRIC_KEYS) {
|
|
568
|
+
out[m] = {} as any;
|
|
569
|
+
for (const rid of ruleIds) out[m][rid] = !!ruleInputs[m][rid].value;
|
|
570
|
+
}
|
|
571
|
+
return out as SpcRulesEnabled;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const bodyEls: HTMLElement[] = [];
|
|
575
|
+
if (isRebuild) {
|
|
576
|
+
bodyEls.push(ui.divText(
|
|
577
|
+
`This replaces the locked baseline from ${existing!.locked_at}. ` +
|
|
578
|
+
'The previous baseline is not kept.',
|
|
579
|
+
));
|
|
580
|
+
} else {
|
|
581
|
+
bodyEls.push(ui.divText('Pick the runs that represent normal operation:'));
|
|
582
|
+
}
|
|
583
|
+
bodyEls.push(listEl);
|
|
584
|
+
bodyEls.push(iterateToggle.root);
|
|
585
|
+
bodyEls.push(ui.h2('Nelson rules'));
|
|
586
|
+
bodyEls.push(ui.divV(gridRows));
|
|
587
|
+
bodyEls.push(disclosureEl);
|
|
588
|
+
|
|
589
|
+
const dialog = ui.dialog(title);
|
|
590
|
+
for (const el of bodyEls) dialog.add(el);
|
|
591
|
+
dialog.onOK(async () => {
|
|
592
|
+
const checked = runCheckboxes.filter((c) => c.input.value === true).map((c) => c.run);
|
|
593
|
+
if (checked.length < 3) {
|
|
594
|
+
grok.shell.warning('At least 3 runs must remain after outlier removal — ' +
|
|
595
|
+
'too few to compute a baseline.');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const exclusionUnion = new Set<string>();
|
|
599
|
+
const traceUnion: Array<{iter: number; dropped: string[]}> = [];
|
|
600
|
+
if (iterateToggle.value) {
|
|
601
|
+
for (const metric of METRIC_KEYS) {
|
|
602
|
+
const series = checked.map((r) => ({run_id: r.run_id, value: r[metric] as number}));
|
|
603
|
+
const res = iterativeOutlierRemoval(series, 2);
|
|
604
|
+
for (const id of res.excluded_run_ids) exclusionUnion.add(id);
|
|
605
|
+
for (const t of res.iteration_trace) traceUnion.push(t);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const retained = checked.filter((r) => !exclusionUnion.has(r.run_id));
|
|
609
|
+
if (retained.length < 3) {
|
|
610
|
+
grok.shell.warning('At least 3 runs must remain after outlier removal — ' +
|
|
611
|
+
'too few to compute a baseline.');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const metricsSnapshot = computeBaselineStats(retained);
|
|
615
|
+
const baseline: LockedBaseline = {
|
|
616
|
+
instrument_id: instrumentId,
|
|
617
|
+
locked_at: new Date().toISOString(),
|
|
618
|
+
included_run_ids: retained.map((r) => r.run_id),
|
|
619
|
+
excluded_run_ids: Array.from(exclusionUnion),
|
|
620
|
+
iteration_trace: traceUnion,
|
|
621
|
+
metrics: metricsSnapshot,
|
|
622
|
+
rules_enabled: currentEnabled(),
|
|
623
|
+
};
|
|
624
|
+
await onLocked(baseline);
|
|
625
|
+
if (isRebuild) {
|
|
626
|
+
grok.shell.info(`Baseline rebuilt for ${instrumentId}: ${retained.length} runs included, ` +
|
|
627
|
+
`${exclusionUnion.size} excluded. Previous baseline replaced.`);
|
|
628
|
+
} else {
|
|
629
|
+
grok.shell.info(`Baseline locked for ${instrumentId}: ${retained.length} runs included, ` +
|
|
630
|
+
`${exclusionUnion.size} excluded.`);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
dialog.show();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// =====================================================================================
|
|
637
|
+
// openSpcDashboard — orchestrator
|
|
638
|
+
// =====================================================================================
|
|
639
|
+
|
|
640
|
+
export async function openSpcDashboard(): Promise<void> {
|
|
641
|
+
const allRuns = await loadRuns();
|
|
642
|
+
if (allRuns.length === 0) {
|
|
643
|
+
grok.shell.warning('No SPC runs recorded yet. Open an analyzed file, then run ' +
|
|
644
|
+
'Analyze → Compute SPC Status. The dashboard turns on once at least 4 runs have been computed.');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const instruments = Array.from(new Set(allRuns.map((r) => r.instrument_id)));
|
|
648
|
+
// userDataStorage is soft-deprecated; matches v1.3 codebase (subcellular-location.ts:246).
|
|
649
|
+
// Migrate to grok.userSettings as cross-cutting cleanup in v1.5.
|
|
650
|
+
const lastPicked = await grok.dapi.userDataStorage.get(LAST_INSTRUMENT_STORAGE_KEY);
|
|
651
|
+
const last = (lastPicked && lastPicked['instrument_id']) || instruments[0];
|
|
652
|
+
const baselineByInstrument = new Map<string, LockedBaseline | null>();
|
|
653
|
+
for (const inst of instruments)
|
|
654
|
+
baselineByInstrument.set(inst, await loadBaseline(inst));
|
|
655
|
+
|
|
656
|
+
const items = instruments.map((i) => {
|
|
657
|
+
const b = baselineByInstrument.get(i);
|
|
658
|
+
return b ? `${i} (baseline locked)` : `${i} (no baseline)`;
|
|
659
|
+
});
|
|
660
|
+
const itemToId = new Map<string, string>();
|
|
661
|
+
for (let i = 0; i < instruments.length; i++) itemToId.set(items[i], instruments[i]);
|
|
662
|
+
const initialItem = items[instruments.indexOf(last)] ?? items[0];
|
|
663
|
+
|
|
664
|
+
const picker = ui.input.choice('Instrument', {
|
|
665
|
+
value: initialItem,
|
|
666
|
+
items,
|
|
667
|
+
nullable: false,
|
|
668
|
+
tooltipText: 'Switches the trend charts and baseline to a different instrument. ' +
|
|
669
|
+
'Only instruments with computed SPC runs are listed.',
|
|
670
|
+
} as any);
|
|
671
|
+
|
|
672
|
+
ui.dialog('SPC Dashboard').add(picker).onOK(async () => {
|
|
673
|
+
const picked = itemToId.get(String(picker.value)) ?? instruments[0];
|
|
674
|
+
// userDataStorage is soft-deprecated; matches v1.3 codebase.
|
|
675
|
+
await grok.dapi.userDataStorage.put(LAST_INSTRUMENT_STORAGE_KEY,
|
|
676
|
+
{instrument_id: picked});
|
|
677
|
+
await openDashboardForInstrument(picked);
|
|
678
|
+
}).show();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function openDashboardForInstrument(instrumentId: string): Promise<void> {
|
|
682
|
+
const slice = await loadRuns(instrumentId);
|
|
683
|
+
let runsDf: DG.DataFrame;
|
|
684
|
+
if (slice.length === 0)
|
|
685
|
+
runsDf = DG.DataFrame.create(0);
|
|
686
|
+
else
|
|
687
|
+
runsDf = runsToDataFrame(slice);
|
|
688
|
+
runsDf.name = `SPC — ${instrumentId}`;
|
|
689
|
+
addMrColumns(runsDf);
|
|
690
|
+
|
|
691
|
+
const tv = grok.shell.addTableView(runsDf);
|
|
692
|
+
let baseline = await loadBaseline(instrumentId);
|
|
693
|
+
|
|
694
|
+
const refreshCharts = (): void => {
|
|
695
|
+
for (const metric of METRIC_KEYS) {
|
|
696
|
+
const {iChart, mrChart} = createSpcChartPanel(runsDf, baseline, metric);
|
|
697
|
+
tv.dockManager.dock(iChart, DG.DOCK_TYPE.RIGHT, null, `I-chart ${METRIC_LABELS[metric]}`, 0.5);
|
|
698
|
+
tv.dockManager.dock(mrChart, DG.DOCK_TYPE.DOWN, null, `MR-chart ${METRIC_LABELS[metric]}`, 0.5);
|
|
699
|
+
}
|
|
700
|
+
// SPC-08 Pareto panel (Plan 16-07). Empty-state body when slice < 4.
|
|
701
|
+
const pareto = createParetoChart(runsDf, instrumentId);
|
|
702
|
+
if (pareto !== null) {
|
|
703
|
+
tv.dockManager.dock(pareto, DG.DOCK_TYPE.DOWN, null, 'Pareto', 0.3);
|
|
704
|
+
} else {
|
|
705
|
+
const empty = ui.divV([
|
|
706
|
+
ui.divText('Pareto chart appears once 4 or more runs have been recorded for this instrument.'),
|
|
707
|
+
]);
|
|
708
|
+
tv.dockManager.dock(empty, DG.DOCK_TYPE.DOWN, null, 'Pareto', 0.3);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const onRebuildClick = (): void => {
|
|
713
|
+
showDefineBaselineDialog(instrumentId, slice, async (newBaseline) => {
|
|
714
|
+
await saveBaseline(instrumentId, newBaseline);
|
|
715
|
+
baseline = newBaseline;
|
|
716
|
+
recomputeStatusesInPlace(runsDf, newBaseline);
|
|
717
|
+
refreshCharts();
|
|
718
|
+
}, baseline ?? undefined);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const onRuleToggle = async (
|
|
722
|
+
metric: MetricKey,
|
|
723
|
+
rule: keyof SpcRulesEnabled['median_intensity'],
|
|
724
|
+
value: boolean,
|
|
725
|
+
) => {
|
|
726
|
+
if (!baseline) return;
|
|
727
|
+
baseline.rules_enabled[metric][rule] = value;
|
|
728
|
+
await saveBaseline(instrumentId, baseline);
|
|
729
|
+
recomputeStatusesInPlace(runsDf, baseline);
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const sidebar = buildSidebar(instrumentId, baseline, onRebuildClick, onRuleToggle);
|
|
733
|
+
tv.dockManager.dock(sidebar.el, DG.DOCK_TYPE.LEFT, null, 'SPC Controls', 0.25);
|
|
734
|
+
|
|
735
|
+
if (baseline === null) {
|
|
736
|
+
const banner = ui.divV([
|
|
737
|
+
ui.h2(`No baseline locked for ${instrumentId}.`),
|
|
738
|
+
ui.divText('Pick the runs that represent normal operation, then click Lock baseline. ' +
|
|
739
|
+
'The dashboard turns on once a baseline exists.'),
|
|
740
|
+
ui.button('Define baseline...', () => {
|
|
741
|
+
showDefineBaselineDialog(instrumentId, slice, async (newBaseline) => {
|
|
742
|
+
await saveBaseline(instrumentId, newBaseline);
|
|
743
|
+
baseline = newBaseline;
|
|
744
|
+
recomputeStatusesInPlace(runsDf, newBaseline);
|
|
745
|
+
refreshCharts();
|
|
746
|
+
});
|
|
747
|
+
}),
|
|
748
|
+
]);
|
|
749
|
+
tv.dockManager.dock(banner, DG.DOCK_TYPE.RIGHT, null, 'Baseline', 0.75);
|
|
750
|
+
} else {
|
|
751
|
+
refreshCharts();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
wireDrillDown(tv, runsDf);
|
|
755
|
+
}
|