@datagrok/proteomics 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -63
- 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,163 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
3
|
+
|
|
4
|
+
/** Creates log2-transformed copies of the specified columns.
|
|
5
|
+
* Original columns get SEMTYPE.INTENSITY. New log2 columns get SEMTYPE.INTENSITY
|
|
6
|
+
* and names prefixed with 'log2(...)'.
|
|
7
|
+
* Zero, negative, and null values produce DG.FLOAT_NULL in the log2 column. */
|
|
8
|
+
export function log2TransformColumns(df: DG.DataFrame, colNames: string[]): void {
|
|
9
|
+
let nonFiniteCount = 0;
|
|
10
|
+
for (const name of colNames) {
|
|
11
|
+
const col = df.col(name);
|
|
12
|
+
if (!col) continue;
|
|
13
|
+
col.semType = SEMTYPE.INTENSITY;
|
|
14
|
+
const log2Name = `log2(${name})`;
|
|
15
|
+
const log2Col = df.columns.addNewFloat(log2Name);
|
|
16
|
+
log2Col.init((i) => {
|
|
17
|
+
if (col.isNone(i)) return DG.FLOAT_NULL;
|
|
18
|
+
const val = Number(col.get(i));
|
|
19
|
+
if (isNaN(val)) { nonFiniteCount++; return DG.FLOAT_NULL; }
|
|
20
|
+
return val > 0 ? Math.log2(val) : DG.FLOAT_NULL;
|
|
21
|
+
});
|
|
22
|
+
log2Col.semType = SEMTYPE.INTENSITY;
|
|
23
|
+
}
|
|
24
|
+
if (nonFiniteCount > 0)
|
|
25
|
+
console.warn(`log2TransformColumns: ${nonFiniteCount} non-numeric value(s) dropped to null`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Copies intensity columns with log2() prefix for already-transformed data.
|
|
29
|
+
* No math transformation -- just copies values into log2-named columns
|
|
30
|
+
* for downstream compatibility. Sets both original and log2 column
|
|
31
|
+
* semType = SEMTYPE.INTENSITY. */
|
|
32
|
+
export function copyAsLog2Columns(df: DG.DataFrame, colNames: string[]): void {
|
|
33
|
+
for (const name of colNames) {
|
|
34
|
+
const col = df.col(name);
|
|
35
|
+
if (!col) continue;
|
|
36
|
+
col.semType = SEMTYPE.INTENSITY;
|
|
37
|
+
const log2Name = `log2(${name})`;
|
|
38
|
+
const log2Col = df.columns.addNewFloat(log2Name);
|
|
39
|
+
log2Col.init((i) => col.isNone(i) ? DG.FLOAT_NULL : Number(col.get(i)));
|
|
40
|
+
log2Col.semType = SEMTYPE.INTENSITY;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Creates a primary (first semicolon-delimited entry) column if semicolons
|
|
45
|
+
* are detected in source data. Skips creation entirely if no semicolons found. */
|
|
46
|
+
export function addPrimaryColumnIfNeeded(
|
|
47
|
+
df: DG.DataFrame, sourceColName: string, newName: string, semType: string,
|
|
48
|
+
): void {
|
|
49
|
+
const srcCol = df.col(sourceColName);
|
|
50
|
+
if (!srcCol) return;
|
|
51
|
+
|
|
52
|
+
// Scan for semicolons -- skip creation if none found
|
|
53
|
+
let hasSemicolon = false;
|
|
54
|
+
for (let i = 0; i < df.rowCount && !hasSemicolon; i++) {
|
|
55
|
+
const val = srcCol.get(i);
|
|
56
|
+
if (typeof val === 'string' && val.includes(';'))
|
|
57
|
+
hasSemicolon = true;
|
|
58
|
+
}
|
|
59
|
+
if (!hasSemicolon) return;
|
|
60
|
+
|
|
61
|
+
const newCol = df.columns.addNewString(newName);
|
|
62
|
+
newCol.init((i) => {
|
|
63
|
+
const val = srcCol.get(i);
|
|
64
|
+
if (typeof val !== 'string' || val.length === 0) return null;
|
|
65
|
+
const idx = val.indexOf(';');
|
|
66
|
+
return idx >= 0 ? val.substring(0, idx) : val;
|
|
67
|
+
});
|
|
68
|
+
newCol.semType = semType;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Detects whether intensity columns appear to be log2-transformed.
|
|
72
|
+
* Samples up to 200 non-null numeric values across the specified columns.
|
|
73
|
+
* Heuristic: values >= 1000 suggest raw intensities; values in [0, 30] suggest log2. */
|
|
74
|
+
export function detectLog2Status(
|
|
75
|
+
df: DG.DataFrame, colNames: string[],
|
|
76
|
+
): {isLog2: boolean; message: string} {
|
|
77
|
+
let inLog2Range = 0;
|
|
78
|
+
let inRawRange = 0;
|
|
79
|
+
let total = 0;
|
|
80
|
+
const maxSamples = 200;
|
|
81
|
+
|
|
82
|
+
// Only count values that fall in either bucket — ambiguous "middle" values
|
|
83
|
+
// (e.g. 31..999) carry no signal about scale, and counting them in `total`
|
|
84
|
+
// makes the ratios at L101-105 unreliable on heterogeneous datasets.
|
|
85
|
+
for (const name of colNames) {
|
|
86
|
+
const col = df.col(name);
|
|
87
|
+
if (!col) continue;
|
|
88
|
+
if (col.type !== DG.COLUMN_TYPE.FLOAT && col.type !== DG.COLUMN_TYPE.INT && col.type !== DG.COLUMN_TYPE.BIG_INT) continue;
|
|
89
|
+
for (let i = 0; i < df.rowCount && total < maxSamples; i++) {
|
|
90
|
+
if (col.isNone(i)) continue;
|
|
91
|
+
const val = Number(col.get(i));
|
|
92
|
+
if (val >= 0 && val <= 30) { inLog2Range++; total++; }
|
|
93
|
+
else if (val >= 1000) { inRawRange++; total++; }
|
|
94
|
+
}
|
|
95
|
+
if (total >= maxSamples) break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (total === 0)
|
|
99
|
+
return {isLog2: false, message: 'No intensity values found'};
|
|
100
|
+
if (inRawRange / total > 0.5)
|
|
101
|
+
return {isLog2: false, message: 'Data appears to be raw intensities'};
|
|
102
|
+
if (inLog2Range / total > 0.8)
|
|
103
|
+
return {isLog2: true, message: 'Data appears to be already log2-transformed'};
|
|
104
|
+
return {isLog2: false, message: 'Could not determine data scale'};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Detects delimiter by checking the first line for tab vs comma prevalence.
|
|
108
|
+
* Returns '\t' or ','. */
|
|
109
|
+
export function detectDelimiter(text: string): string {
|
|
110
|
+
const newlineIdx = text.indexOf('\n');
|
|
111
|
+
const firstLine = newlineIdx >= 0 ? text.substring(0, newlineIdx) : text;
|
|
112
|
+
const tabs = (firstLine.match(/\t/g) || []).length;
|
|
113
|
+
const commas = (firstLine.match(/,/g) || []).length;
|
|
114
|
+
return tabs > commas ? '\t' : ',';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Scans string columns for names containing protein-related keywords.
|
|
118
|
+
* Returns the first matching column, or null. */
|
|
119
|
+
export function autoSuggestProteinIdColumn(df: DG.DataFrame): DG.Column | null {
|
|
120
|
+
const keywords = ['protein', 'accession', 'uniprot'];
|
|
121
|
+
for (const col of df.columns.toList()) {
|
|
122
|
+
if (col.type !== DG.COLUMN_TYPE.STRING) continue;
|
|
123
|
+
const lower = col.name.toLowerCase();
|
|
124
|
+
for (const kw of keywords) {
|
|
125
|
+
if (lower.includes(kw))
|
|
126
|
+
return col;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Scans string columns for names containing gene-related keywords.
|
|
133
|
+
* Returns the first matching column, or null. */
|
|
134
|
+
export function autoSuggestGeneNameColumn(df: DG.DataFrame): DG.Column | null {
|
|
135
|
+
const keywords = ['gene', 'symbol', 'gene_name', 'genename'];
|
|
136
|
+
for (const col of df.columns.toList()) {
|
|
137
|
+
if (col.type !== DG.COLUMN_TYPE.STRING) continue;
|
|
138
|
+
const lower = col.name.toLowerCase();
|
|
139
|
+
for (const kw of keywords) {
|
|
140
|
+
if (lower.includes(kw))
|
|
141
|
+
return col;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Scans numeric column names for intensity-related keywords.
|
|
148
|
+
* Returns all matching column names. */
|
|
149
|
+
export function autoSuggestIntensityColumns(df: DG.DataFrame): string[] {
|
|
150
|
+
const keywords = ['intensity', 'lfq', 'ibaq', 'tmt', 'reporter', 'abundance'];
|
|
151
|
+
const result: string[] = [];
|
|
152
|
+
for (const col of df.columns.toList()) {
|
|
153
|
+
if (col.type !== DG.COLUMN_TYPE.FLOAT && col.type !== DG.COLUMN_TYPE.INT && col.type !== DG.COLUMN_TYPE.BIG_INT) continue;
|
|
154
|
+
const lower = col.name.toLowerCase();
|
|
155
|
+
for (const kw of keywords) {
|
|
156
|
+
if (lower.includes(kw)) {
|
|
157
|
+
result.push(col.name);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
import {SEMTYPE, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
|
|
3
|
+
import {addPrimaryColumnIfNeeded, detectDelimiter} from './shared-utils';
|
|
4
|
+
import {resolveGeneLabels} from '../utils/gene-label-resolver';
|
|
5
|
+
import {setGroups} from '../analysis/experiment-setup';
|
|
6
|
+
|
|
7
|
+
/** Column name variants for protein-level identifiers in Spectronaut Candidates output.
|
|
8
|
+
* Modern Spectronaut prefixes with `PG.`; older / re-exported reports may strip it. */
|
|
9
|
+
const PROTEIN_ID_COLUMNS = ['PG.ProteinGroups', 'ProteinGroups', 'Protein Groups'];
|
|
10
|
+
|
|
11
|
+
/** Column name variants for gene symbols. */
|
|
12
|
+
const GENE_NAME_COLUMNS = ['PG.Genes', 'Genes', 'Gene Names'];
|
|
13
|
+
|
|
14
|
+
/** Column name variants for log2 fold change. Spectronaut's canonical name is
|
|
15
|
+
* `AVG Log2 Ratio`; some export profiles abbreviate or rename it. */
|
|
16
|
+
const LOG2FC_COLUMNS = ['AVG Log2 Ratio', 'Log2 Ratio', 'AVG Log2 Fold Change'];
|
|
17
|
+
|
|
18
|
+
/** Column name variants for the FDR-adjusted significance metric. Spectronaut emits
|
|
19
|
+
* `Qvalue` from its built-in Storey FDR; some export profiles use `Q-Value` or
|
|
20
|
+
* `AdjustedPValue`. */
|
|
21
|
+
const ADJ_P_COLUMNS = ['Qvalue', 'Q-Value', 'Q.Value', 'AdjustedPValue', 'Adjusted P-Value'];
|
|
22
|
+
|
|
23
|
+
/** Column name variants for the raw p-value (optional in Candidates reports). */
|
|
24
|
+
const P_VALUE_COLUMNS = ['Pvalue', 'PValue', 'P-Value', 'P.Value'];
|
|
25
|
+
|
|
26
|
+
/** The declared contrast string, kept verbatim. Form: "group1 / group2".
|
|
27
|
+
* Exported so callers (e.g. the multi-contrast filter in package.ts) locate the
|
|
28
|
+
* column by the same name candidates instead of re-inlining the literals. */
|
|
29
|
+
export const COMPARISON_COLUMNS = ['Comparison (group1/group2)', 'Comparison'];
|
|
30
|
+
|
|
31
|
+
/** Per-group mean abundance, swapped when a row's sign is flipped (A6: only
|
|
32
|
+
* when BOTH are present). */
|
|
33
|
+
const AVG_GROUP_QTY_NUM_COLUMNS = ['AVG Group Quantity Numerator'];
|
|
34
|
+
const AVG_GROUP_QTY_DEN_COLUMNS = ['AVG Group Quantity Denominator'];
|
|
35
|
+
|
|
36
|
+
/** Declared numerator/denominator condition labels, relabeled on flip. */
|
|
37
|
+
const CONDITION_NUM_COLUMNS = ['Condition Numerator'];
|
|
38
|
+
const CONDITION_DEN_COLUMNS = ['Condition Denominator'];
|
|
39
|
+
|
|
40
|
+
/** Decoy / contaminant prefixes to filter at the row level. Mirrors the FragPipe
|
|
41
|
+
* parser — Spectronaut libraries may include MaxQuant-style CON__/REV__ entries
|
|
42
|
+
* when the FASTA was re-used across pipelines. */
|
|
43
|
+
const CONTAMINANT_PREFIXES = ['contam_', 'rev_', 'CON__', 'REV__'];
|
|
44
|
+
|
|
45
|
+
// Default significance thresholds (DEFAULT_FC_THRESHOLD / DEFAULT_P_THRESHOLD)
|
|
46
|
+
// are imported from proteomics-types — used when the input does not pre-compute
|
|
47
|
+
// a boolean `significant` column, matching runDifferentialExpression.
|
|
48
|
+
|
|
49
|
+
/** Finds the first existing column from a list of name candidates. */
|
|
50
|
+
function findCol(df: DG.DataFrame, names: readonly string[]): DG.Column | null {
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const col = df.col(name);
|
|
53
|
+
if (col)
|
|
54
|
+
return col;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Marks rows for exclusion when the protein-group identifier starts with any
|
|
60
|
+
* contaminant/decoy prefix. */
|
|
61
|
+
function filterContaminantRows(df: DG.DataFrame, idColName: string): void {
|
|
62
|
+
const col = df.col(idColName);
|
|
63
|
+
if (!col) return;
|
|
64
|
+
for (let i = 0; i < df.rowCount; i++) {
|
|
65
|
+
const val = col.get(i);
|
|
66
|
+
if (typeof val !== 'string') continue;
|
|
67
|
+
for (const prefix of CONTAMINANT_PREFIXES) {
|
|
68
|
+
if (val.startsWith(prefix)) {
|
|
69
|
+
df.filter.set(i, false, false);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Renames a column to the canonical downstream name. Skips the rename if the
|
|
77
|
+
* canonical name is already taken by a different column — defensive against
|
|
78
|
+
* malformed inputs that already contain both `Qvalue` and `adj.p-value`. */
|
|
79
|
+
function renameToCanonical(df: DG.DataFrame, src: DG.Column, canonical: string): void {
|
|
80
|
+
if (src.name === canonical) return;
|
|
81
|
+
const existing = df.col(canonical);
|
|
82
|
+
if (existing && existing !== src) return;
|
|
83
|
+
src.name = canonical;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalizes each row's sign so positive log2FC = enriched in the canonical
|
|
88
|
+
* group1. Ported from CK-omics `create_subset_data` (CKomics_tool2.py:1625-1662)
|
|
89
|
+
* and locked by 13-WAVE0-FINDINGS.md A2: the canonical orientation is the first
|
|
90
|
+
* parseable declared "g1 / g2"; ONLY rows whose declared comparison is the exact
|
|
91
|
+
* reverse ("g2 / g1") are flipped — never unconditional. A flip negates log2FC,
|
|
92
|
+
* swaps AVG Group Quantity Numerator/Denominator (only if BOTH present — A6),
|
|
93
|
+
* and relabels Comparison + Condition Numerator/Denominator to canonical.
|
|
94
|
+
* Pure (no shell/UI side effects). Single-orientation / unparseable input is
|
|
95
|
+
* left byte-identical (Pitfall 4 — never invert on ambiguity).
|
|
96
|
+
*/
|
|
97
|
+
function normalizeCandidatesSign(df: DG.DataFrame): void {
|
|
98
|
+
const cmpCol = findCol(df, COMPARISON_COLUMNS);
|
|
99
|
+
if (!cmpCol) return;
|
|
100
|
+
const n = df.rowCount;
|
|
101
|
+
|
|
102
|
+
// Canonical orientation = first parseable "g1 / g2".
|
|
103
|
+
let canonical: string | null = null;
|
|
104
|
+
let canonG1 = '';
|
|
105
|
+
let canonG2 = '';
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
const v = cmpCol.get(i);
|
|
108
|
+
if (typeof v === 'string' && v.includes(' / ')) {
|
|
109
|
+
const parts = v.split(' / ');
|
|
110
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
111
|
+
canonical = v;
|
|
112
|
+
canonG1 = parts[0];
|
|
113
|
+
canonG2 = parts[1];
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (canonical === null) return; // no parseable comparison → all canonical
|
|
119
|
+
const reversed = `${canonG2} / ${canonG1}`;
|
|
120
|
+
|
|
121
|
+
// Flip ONLY rows whose declared comparison is the exact reverse of canonical.
|
|
122
|
+
const flip = new Uint8Array(n);
|
|
123
|
+
let anyFlip = false;
|
|
124
|
+
for (let i = 0; i < n; i++) {
|
|
125
|
+
if (cmpCol.get(i) === reversed) {
|
|
126
|
+
flip[i] = 1;
|
|
127
|
+
anyFlip = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!anyFlip) return; // single-orientation / no reversed rows → unchanged
|
|
131
|
+
|
|
132
|
+
// log2FC sign flip. Bulk getRawData → number[] → init (memory
|
|
133
|
+
// feedback_dg_column_bulk_init). FLOAT_NULL is preserved by writing the
|
|
134
|
+
// sentinel number back, never returning JS null (memory
|
|
135
|
+
// feedback_dg_column_init_null_sentinel). number[] (not Float32Array) keeps
|
|
136
|
+
// full double precision for large AVG Group Quantity values.
|
|
137
|
+
const fcCol = df.col('log2FC')!;
|
|
138
|
+
const fcRaw = fcCol.getRawData() as Float32Array | Float64Array;
|
|
139
|
+
const newFc: number[] = new Array(n);
|
|
140
|
+
for (let i = 0; i < n; i++) {
|
|
141
|
+
const v = fcRaw[i];
|
|
142
|
+
newFc[i] = (v === DG.FLOAT_NULL) ? DG.FLOAT_NULL : (flip[i] ? -v : v);
|
|
143
|
+
}
|
|
144
|
+
fcCol.init((i) => newFc[i]);
|
|
145
|
+
|
|
146
|
+
// AVG Group Quantity swap — only when BOTH columns exist (A6 guard).
|
|
147
|
+
const numCol = findCol(df, AVG_GROUP_QTY_NUM_COLUMNS);
|
|
148
|
+
const denCol = findCol(df, AVG_GROUP_QTY_DEN_COLUMNS);
|
|
149
|
+
if (numCol && denCol) {
|
|
150
|
+
const numRaw = numCol.getRawData() as Float32Array | Float64Array;
|
|
151
|
+
const denRaw = denCol.getRawData() as Float32Array | Float64Array;
|
|
152
|
+
const newNum: number[] = new Array(n);
|
|
153
|
+
const newDen: number[] = new Array(n);
|
|
154
|
+
for (let i = 0; i < n; i++) {
|
|
155
|
+
const nv = numRaw[i];
|
|
156
|
+
const dv = denRaw[i];
|
|
157
|
+
newNum[i] = flip[i] ? dv : nv;
|
|
158
|
+
newDen[i] = flip[i] ? nv : dv;
|
|
159
|
+
}
|
|
160
|
+
numCol.init((i) => newNum[i]);
|
|
161
|
+
denCol.init((i) => newDen[i]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Relabel Comparison + Condition for flipped rows (string init, not per-row
|
|
165
|
+
// set — memory feedback_dg_column_bulk_init).
|
|
166
|
+
const cmpVals: string[] = new Array(n);
|
|
167
|
+
for (let i = 0; i < n; i++)
|
|
168
|
+
cmpVals[i] = flip[i] ? canonical! : (cmpCol.get(i) as string);
|
|
169
|
+
cmpCol.init((i) => cmpVals[i]);
|
|
170
|
+
|
|
171
|
+
const condNumCol = findCol(df, CONDITION_NUM_COLUMNS);
|
|
172
|
+
const condDenCol = findCol(df, CONDITION_DEN_COLUMNS);
|
|
173
|
+
if (condNumCol && condDenCol) {
|
|
174
|
+
const cnVals: string[] = new Array(n);
|
|
175
|
+
const cdVals: string[] = new Array(n);
|
|
176
|
+
for (let i = 0; i < n; i++) {
|
|
177
|
+
cnVals[i] = flip[i] ? canonG1 : (condNumCol.get(i) as string);
|
|
178
|
+
cdVals[i] = flip[i] ? canonG2 : (condDenCol.get(i) as string);
|
|
179
|
+
}
|
|
180
|
+
condNumCol.init((i) => cnVals[i]);
|
|
181
|
+
condDenCol.init((i) => cdVals[i]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Parses a Spectronaut Candidates report into a DataFrame shaped like the output
|
|
186
|
+
* of `runDifferentialExpression` — pre-computed `log2FC`, `p-value` (optional),
|
|
187
|
+
* `adj.p-value`, and `significant` columns. Sets `proteomics.de_complete` so the
|
|
188
|
+
* downstream volcano/heatmap/enrichment viewers light up without running DE.
|
|
189
|
+
*
|
|
190
|
+
* Required input columns (any spelling):
|
|
191
|
+
* - Protein group: `PG.ProteinGroups` / `ProteinGroups`
|
|
192
|
+
* - log2FC: `AVG Log2 Ratio` / `Log2 Ratio` / `AVG Log2 Fold Change`
|
|
193
|
+
* - Adj. p-value: `Qvalue` / `Q-Value` / `AdjustedPValue` (Spectronaut's
|
|
194
|
+
* Storey-FDR output is the canonical Qvalue)
|
|
195
|
+
*
|
|
196
|
+
* Optional:
|
|
197
|
+
* - Gene symbol: `PG.Genes` / `Genes` / `Gene Names`
|
|
198
|
+
* - Raw p-value: `Pvalue` / `PValue` / `P-Value`
|
|
199
|
+
* - `Comparison` / `Comparison (group1/group2)` — kept as-is for reference.
|
|
200
|
+
*
|
|
201
|
+
* Notes:
|
|
202
|
+
* - Multi-comparison files (one row per protein × per comparison) are accepted
|
|
203
|
+
* verbatim; users filter the resulting DataFrame by the `Comparison` column.
|
|
204
|
+
* - Significance is computed from |log2FC| ≥ 1.0 and adj.p-value ≤ 0.05 by
|
|
205
|
+
* default, matching `runDifferentialExpression`'s defaults. */
|
|
206
|
+
export async function parseSpectronautCandidatesText(text: string): Promise<DG.DataFrame> {
|
|
207
|
+
const delimiter = detectDelimiter(text);
|
|
208
|
+
const raw = DG.DataFrame.fromCsv(text, {delimiter});
|
|
209
|
+
|
|
210
|
+
const idCol = findCol(raw, PROTEIN_ID_COLUMNS);
|
|
211
|
+
if (!idCol)
|
|
212
|
+
throw new Error(`Spectronaut Candidates: missing protein-group column ` +
|
|
213
|
+
`(expected one of ${PROTEIN_ID_COLUMNS.join(', ')})`);
|
|
214
|
+
|
|
215
|
+
if (!findCol(raw, LOG2FC_COLUMNS))
|
|
216
|
+
throw new Error(`Spectronaut Candidates: missing log2 ratio column ` +
|
|
217
|
+
`(expected one of ${LOG2FC_COLUMNS.join(', ')})`);
|
|
218
|
+
|
|
219
|
+
if (!findCol(raw, ADJ_P_COLUMNS))
|
|
220
|
+
throw new Error(`Spectronaut Candidates: missing q-value column ` +
|
|
221
|
+
`(expected one of ${ADJ_P_COLUMNS.join(', ')})`);
|
|
222
|
+
|
|
223
|
+
raw.filter.init((_i) => true);
|
|
224
|
+
filterContaminantRows(raw, idCol.name);
|
|
225
|
+
raw.filter.fireChanged();
|
|
226
|
+
|
|
227
|
+
const df = raw.clone(raw.filter);
|
|
228
|
+
|
|
229
|
+
// Re-resolve columns on the cloned frame (clone preserves names but not refs)
|
|
230
|
+
const idCol2 = findCol(df, PROTEIN_ID_COLUMNS)!;
|
|
231
|
+
const log2fcCol = findCol(df, LOG2FC_COLUMNS)!;
|
|
232
|
+
const adjPCol = findCol(df, ADJ_P_COLUMNS)!;
|
|
233
|
+
const geneCol = findCol(df, GENE_NAME_COLUMNS);
|
|
234
|
+
const pValCol = findCol(df, P_VALUE_COLUMNS);
|
|
235
|
+
|
|
236
|
+
renameToCanonical(df, log2fcCol, 'log2FC');
|
|
237
|
+
renameToCanonical(df, adjPCol, 'adj.p-value');
|
|
238
|
+
if (pValCol)
|
|
239
|
+
renameToCanonical(df, pValCol, 'p-value');
|
|
240
|
+
|
|
241
|
+
idCol2.semType = SEMTYPE.PROTEIN_ID;
|
|
242
|
+
if (geneCol) geneCol.semType = SEMTYPE.GENE_SYMBOL;
|
|
243
|
+
df.col('log2FC')!.semType = SEMTYPE.LOG2FC;
|
|
244
|
+
df.col('adj.p-value')!.semType = SEMTYPE.P_VALUE;
|
|
245
|
+
if (df.col('p-value'))
|
|
246
|
+
df.col('p-value')!.semType = SEMTYPE.P_VALUE;
|
|
247
|
+
|
|
248
|
+
addPrimaryColumnIfNeeded(df, idCol2.name, 'Primary Protein ID', SEMTYPE.PROTEIN_ID);
|
|
249
|
+
if (geneCol)
|
|
250
|
+
addPrimaryColumnIfNeeded(df, geneCol.name, 'Primary Gene Name', SEMTYPE.GENE_SYMBOL);
|
|
251
|
+
|
|
252
|
+
// R3/D-08: per-row sign normalization BEFORE significance (|log2FC| is
|
|
253
|
+
// unchanged by a sign flip, so significance is orientation-invariant).
|
|
254
|
+
normalizeCandidatesSign(df);
|
|
255
|
+
|
|
256
|
+
const sigCol = df.columns.addNewBool('significant');
|
|
257
|
+
const fcRaw = df.col('log2FC')!.getRawData() as Float32Array | Float64Array;
|
|
258
|
+
const adjPRaw = df.col('adj.p-value')!.getRawData() as Float32Array | Float64Array;
|
|
259
|
+
sigCol.init((i) => {
|
|
260
|
+
const fc = fcRaw[i];
|
|
261
|
+
const adjP = adjPRaw[i];
|
|
262
|
+
if (fc === DG.FLOAT_NULL || adjP === DG.FLOAT_NULL) return false;
|
|
263
|
+
return Math.abs(fc) >= DEFAULT_FC_THRESHOLD && adjP <= DEFAULT_P_THRESHOLD;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
df.setTag('proteomics.source', 'spectronaut-candidates');
|
|
267
|
+
df.setTag('proteomics.de_complete', 'true');
|
|
268
|
+
df.setTag('proteomics.de_method', 'spectronaut');
|
|
269
|
+
|
|
270
|
+
// Name the contrast groups (e.g. DMT / WT) from the report's Condition
|
|
271
|
+
// Numerator / Denominator (or the "num / den" Comparison string) so the
|
|
272
|
+
// volcano legend + title show real names instead of generic group1/group2.
|
|
273
|
+
// Candidates carries no per-sample intensities, so the column lists stay
|
|
274
|
+
// empty — downstream Annotate/Normalize/Impute/DE are skipped via
|
|
275
|
+
// de_complete, and the volcano reads only the names. group1 = Numerator
|
|
276
|
+
// (positive log2FC, magenta), group2 = Denominator (cyan).
|
|
277
|
+
let g1Name = firstNonEmpty(findCol(df, CONDITION_NUM_COLUMNS));
|
|
278
|
+
let g2Name = firstNonEmpty(findCol(df, CONDITION_DEN_COLUMNS));
|
|
279
|
+
if (!g1Name || !g2Name) {
|
|
280
|
+
const cmp = firstNonEmpty(findCol(df, COMPARISON_COLUMNS));
|
|
281
|
+
if (cmp && cmp.includes('/')) {
|
|
282
|
+
const [a, b] = cmp.split('/').map((s) => s.trim());
|
|
283
|
+
g1Name = g1Name ?? (a || null);
|
|
284
|
+
g2Name = g2Name ?? (b || null);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (g1Name && g2Name) {
|
|
288
|
+
setGroups(df, {
|
|
289
|
+
group1: {name: g1Name, columns: []},
|
|
290
|
+
group2: {name: g2Name, columns: []},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await resolveGeneLabels(df);
|
|
295
|
+
|
|
296
|
+
return df;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** First non-empty trimmed string value in a column, or null. */
|
|
300
|
+
function firstNonEmpty(col: DG.Column | null): string | null {
|
|
301
|
+
if (!col) return null;
|
|
302
|
+
for (let i = 0; i < col.length; i++) {
|
|
303
|
+
const v = col.get(i);
|
|
304
|
+
if (v != null && String(v).trim().length > 0) return String(v).trim();
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|