@morscherlab/mint-sdk 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
- package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
- package/dist/components/SmartGroup.types.d.ts +22 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-vHfxV2PB.js → components-bbanLd1u.js} +499 -263
- package/dist/components-bbanLd1u.js.map +1 -0
- package/dist/composables/autoGroup/classify.d.ts +7 -1
- package/dist/composables/autoGroup/columns.d.ts +16 -1
- package/dist/composables/autoGroup/index.d.ts +5 -7
- package/dist/composables/autoGroup/tokenize.d.ts +36 -1
- package/dist/composables/index.js +2 -2
- package/dist/composables/useAutoGroup.d.ts +13 -2
- package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
- package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/permissions.js +2 -2
- package/dist/permissions.js.map +1 -1
- package/dist/styles.css +71 -82
- package/dist/types/auto-group.d.ts +1 -1
- package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
- package/dist/validation-CukfLjy-.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
- package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
- package/src/__tests__/components/SampleSelector.test.ts +14 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
- package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
- package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
- package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
- package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
- package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
- package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
- package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
- package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
- package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
- package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
- package/src/__tests__/utils/permissions.test.ts +15 -0
- package/src/components/AutoGroupModal.adapter.ts +80 -9
- package/src/components/AutoGroupModal.vue +41 -8
- package/src/components/SmartGroup.types.ts +23 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
- package/src/components/SmartGroupFieldRecipe.vue +289 -169
- package/src/composables/autoGroup/classify.ts +26 -7
- package/src/composables/autoGroup/columns.ts +144 -36
- package/src/composables/autoGroup/compose.ts +8 -10
- package/src/composables/autoGroup/fingerprint.ts +1 -1
- package/src/composables/autoGroup/index.ts +5 -12
- package/src/composables/autoGroup/tokenLength.ts +8 -0
- package/src/composables/autoGroup/tokenize.ts +148 -1
- package/src/composables/autoGroup/vocab.json +5 -11
- package/src/composables/useAutoGroup.ts +92 -79
- package/src/permissions.ts +3 -2
- package/src/styles/components/smart-group.css +71 -82
- package/src/types/auto-group.ts +1 -0
- package/dist/components-vHfxV2PB.js.map +0 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
- package/dist/validation-Dmp9Q5W0.js.map +0 -1
- package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
|
@@ -56,6 +56,22 @@ export interface DetectClassOptions {
|
|
|
56
56
|
vocab?: Partial<ClassVocab>
|
|
57
57
|
/** Per-sample explicit `sample_type` hint (e.g. from experiment design data). */
|
|
58
58
|
sampleTypeHints?: (string | undefined)[]
|
|
59
|
+
/** Number of leading tokens that all samples share (`TokenizedBatch.sharedPrefix`). */
|
|
60
|
+
sharedPrefix?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A token that names a QC-type class (blank, QC, standard, spike-in, stock). */
|
|
64
|
+
export function isClassMarker(token: string, vocab: ClassVocab = classVocab): boolean {
|
|
65
|
+
return (
|
|
66
|
+
vocab.blankPattern.test(token) || vocab.iqcPattern.test(token) || vocab.eqcPattern.test(token)
|
|
67
|
+
|| vocab.standardPattern.test(token) || vocab.standardAllow.has(token)
|
|
68
|
+
|| vocab.spikeInPattern.test(token) || vocab.stockPattern.test(token)
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Kinds that form experimental groups; all other kinds are QC-type overlay classes. */
|
|
73
|
+
export function isExperimentalKind(kind: SampleClassKind): boolean {
|
|
74
|
+
return kind === 'biological' || kind === 'unknown'
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
const DEFAULT_DISPOSITION: Record<SampleClassKind, ClassDisposition> = {
|
|
@@ -72,26 +88,29 @@ const DEFAULT_DISPOSITION: Record<SampleClassKind, ClassDisposition> = {
|
|
|
72
88
|
function classifyTokens(
|
|
73
89
|
tokens: string[],
|
|
74
90
|
vocab: ClassVocab,
|
|
91
|
+
qcFrom: number,
|
|
75
92
|
): { kind: SampleClassKind; subKind?: string; tagPositions: number[] } {
|
|
76
93
|
// First-match-wins ordering: Blank → IQC → EQC → Standard → SpikeIn → Stock → Biological → Unknown.
|
|
77
|
-
|
|
94
|
+
// QC kinds are read from `qcFrom`: a token that all names share (the
|
|
95
|
+
// experiment title) cannot mark one sample as QC.
|
|
96
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
78
97
|
if (vocab.blankPattern.test(tokens[i])) return { kind: 'blank', tagPositions: [i] }
|
|
79
98
|
}
|
|
80
|
-
for (let i =
|
|
99
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
81
100
|
if (vocab.iqcPattern.test(tokens[i])) return { kind: 'iqc', tagPositions: [i] }
|
|
82
101
|
}
|
|
83
|
-
for (let i =
|
|
102
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
84
103
|
if (vocab.eqcPattern.test(tokens[i])) return { kind: 'eqc', tagPositions: [i] }
|
|
85
104
|
}
|
|
86
|
-
for (let i =
|
|
105
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
87
106
|
if (vocab.standardPattern.test(tokens[i]) || vocab.standardAllow.has(tokens[i])) {
|
|
88
107
|
return { kind: 'standard', tagPositions: [i] }
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
|
-
for (let i =
|
|
110
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
92
111
|
if (vocab.spikeInPattern.test(tokens[i])) return { kind: 'spike-in', tagPositions: [i] }
|
|
93
112
|
}
|
|
94
|
-
for (let i =
|
|
113
|
+
for (let i = qcFrom; i < tokens.length; i++) {
|
|
95
114
|
if (vocab.stockPattern.test(tokens[i])) return { kind: 'stock', tagPositions: [i] }
|
|
96
115
|
}
|
|
97
116
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -168,7 +187,7 @@ export function detectClass(
|
|
|
168
187
|
kind = 'blank'
|
|
169
188
|
tagPositions = []
|
|
170
189
|
} else {
|
|
171
|
-
const r = classifyTokens(tokenizedSamples[i], vocab)
|
|
190
|
+
const r = classifyTokens(tokenizedSamples[i], vocab, options.sharedPrefix ?? 0)
|
|
172
191
|
kind = r.kind
|
|
173
192
|
subKind = r.subKind
|
|
174
193
|
tagPositions = r.tagPositions
|
|
@@ -5,15 +5,40 @@ import type {
|
|
|
5
5
|
NumericParse,
|
|
6
6
|
SampleClassKind,
|
|
7
7
|
} from '../../types/auto-group'
|
|
8
|
+
import vocabData from './vocab.json'
|
|
8
9
|
|
|
9
|
-
const NUMERIC_VALUE_UNIT = /^(\d+(?:dot\d+)?)([A-Za-z%/μ]+)$/
|
|
10
|
+
const NUMERIC_VALUE_UNIT = /^(\d+(?:(?:dot|\.)\d+)?)([A-Za-z%/μ]+)$/
|
|
10
11
|
const RATIO = /^(\d+)to(\d+)$/
|
|
11
12
|
const RUN_ORDER_PATTERN = /^\d{2,4}[A-Z]?$/
|
|
12
|
-
// Explicit replicate-token vocabulary: rep1 / Rep2 /
|
|
13
|
+
// Explicit replicate-token vocabulary: rep1 / Rep2 / replicate1 / R1 / T1.
|
|
13
14
|
// These tokens almost always identify a within-group replicate dimension that
|
|
14
15
|
// users do NOT want in the default group key — e.g. ctrl/rep1, ctrl/rep2
|
|
15
16
|
// should land in one Ctrl group with two replicates, not two singleton groups.
|
|
16
|
-
const REPLICATE_TOKEN_PATTERN =
|
|
17
|
+
const REPLICATE_TOKEN_PATTERN = new RegExp(vocabData.replicateToken, 'i')
|
|
18
|
+
/**
|
|
19
|
+
* A replicate counter is a dense run of small numbers or letters: `1, 2, 3`,
|
|
20
|
+
* `01, 02, 03` or `A, B, C`. A quarter of the run can be missing (a failed
|
|
21
|
+
* sample). `1, 5, 10` is a dose and `1, 2, 6, 7` an animal number: too sparse.
|
|
22
|
+
*/
|
|
23
|
+
function isCounterRun(values: string[]): boolean {
|
|
24
|
+
if (!values.every(value => /^(\d{1,2}|[A-Za-z])$/.test(value))) return false
|
|
25
|
+
// One batch can count some replicates 1, 2, 3 and others a, b, c.
|
|
26
|
+
const ranks = [...new Set(values.map(value => (/\d/.test(value) ? Number(value) : value.toUpperCase().charCodeAt(0) - 64)))]
|
|
27
|
+
const allowedGaps = Math.max(1, Math.floor(ranks.length / 4))
|
|
28
|
+
return Math.min(...ranks) >= 1 && Math.max(...ranks) <= ranks.length + allowedGaps
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A stem with a counter attached: C1, C2, H1.
|
|
32
|
+
const STEM_COUNTER_PATTERN = /^([A-Za-z]+)(\d{1,2})$/
|
|
33
|
+
// Share of a column that must agree before the column is split or read as
|
|
34
|
+
// one-value-for-each-sample. The rest allows for odd names and re-injections.
|
|
35
|
+
const COLUMN_AGREEMENT = 0.9
|
|
36
|
+
const ID_MIN_CLASS_SIZE = 4
|
|
37
|
+
|
|
38
|
+
/** Roles the Field Recipe offers as grouping fields. `replicate` and `id` are off by default. */
|
|
39
|
+
export const GROUPING_FIELD_ROLES: ReadonlySet<ColumnRole | undefined> = new Set<ColumnRole>([
|
|
40
|
+
'factor', 'numeric', 'replicate', 'id',
|
|
41
|
+
])
|
|
17
42
|
|
|
18
43
|
// Real-world LC-MS batches often have 30+ distinct conditions (subject IDs,
|
|
19
44
|
// calibration levels, treatment combinations) in a single class. The earlier
|
|
@@ -48,16 +73,27 @@ export interface InferRoleInput {
|
|
|
48
73
|
position: number
|
|
49
74
|
totalPositions: number
|
|
50
75
|
classSize: number
|
|
76
|
+
/** The column ends a name or a split token, where replicate counters sit. Defaults to the last position. */
|
|
77
|
+
tail?: boolean
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
export function inferRole(input: InferRoleInput): ColumnRole {
|
|
54
81
|
const { values, position, totalPositions, classSize } = input
|
|
82
|
+
const tail = input.tail ?? position === totalPositions - 1
|
|
55
83
|
const unique = new Set(values)
|
|
56
84
|
const cardinality = unique.size
|
|
57
85
|
|
|
58
86
|
if (cardinality === 1) return 'constant'
|
|
59
87
|
|
|
60
|
-
//
|
|
88
|
+
// replicate: explicit names (rep1, R1, T1), or a small counter that repeats
|
|
89
|
+
// at the end of a name (1, 2, 3, 1, 2, 3). Empty cells come from names that
|
|
90
|
+
// a column split did not match.
|
|
91
|
+
const present = values.filter(Boolean)
|
|
92
|
+
if (present.every(v => REPLICATE_TOKEN_PATTERN.test(v))) return 'replicate'
|
|
93
|
+
if (tail && cardinality * 2 <= values.length && isCounterRun(present)) return 'replicate'
|
|
94
|
+
|
|
95
|
+
// run-order: trailing position, injection-like values and high cardinality.
|
|
96
|
+
// Numbers repeat when one batch holds two experiments, so half is enough.
|
|
61
97
|
if (position === totalPositions - 1) {
|
|
62
98
|
const allRunLike = values.every(v => RUN_ORDER_PATTERN.test(v))
|
|
63
99
|
if (allRunLike && cardinality >= classSize * 0.5) return 'run-order'
|
|
@@ -67,31 +103,90 @@ export function inferRole(input: InferRoleInput): ColumnRole {
|
|
|
67
103
|
const allNumeric = values.every(v => parseNumeric(v) !== null)
|
|
68
104
|
if (allNumeric) {
|
|
69
105
|
// A column like 5min / 10min reads as a factor in practice — users want to
|
|
70
|
-
// group on the timepoint, not bin it.
|
|
71
|
-
// column popover for columns the user explicitly wants binned.
|
|
106
|
+
// group on the timepoint, not bin it.
|
|
72
107
|
if (cardinality <= LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX) return 'factor'
|
|
73
108
|
return 'numeric'
|
|
74
109
|
}
|
|
75
110
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (allReplicateNamed) return 'replicate'
|
|
111
|
+
// id: one value for (almost) each sample, (almost) all with a digit — an
|
|
112
|
+
// animal or sample number (212, M10, C01). Grouping by it gives groups of
|
|
113
|
+
// one, so it is off by default. Unique words (Ctrl, DrugA, DrugB) are
|
|
114
|
+
// conditions of a design with no replicates and stay a factor.
|
|
115
|
+
if (
|
|
116
|
+
values.length >= ID_MIN_CLASS_SIZE
|
|
117
|
+
&& cardinality >= values.length * COLUMN_AGREEMENT
|
|
118
|
+
&& values.filter(v => /\d/.test(v)).length >= values.length * COLUMN_AGREEMENT
|
|
119
|
+
) return 'id'
|
|
86
120
|
|
|
87
121
|
if (cardinality <= DEFAULT_FACTOR_CARDINALITY_MAX) return 'factor'
|
|
88
122
|
|
|
89
123
|
return 'ignore'
|
|
90
124
|
}
|
|
91
125
|
|
|
126
|
+
/**
|
|
127
|
+
* How a column splits when most of its values agree: on `-` into equal parts
|
|
128
|
+
* (`2-1` → `2`, `1`), or into stem + counter (`C1` → `C`, `1`) when two or more
|
|
129
|
+
* stems repeat with different counters. `D1`/`D3` (one stem) and `B13`/`S13`
|
|
130
|
+
* (one counter for each stem) stay whole.
|
|
131
|
+
*/
|
|
132
|
+
function columnSplitter(values: string[]): ((value: string) => string[]) | undefined {
|
|
133
|
+
const agree = (test: (value: string) => boolean): boolean =>
|
|
134
|
+
values.filter(test).length >= values.length * COLUMN_AGREEMENT
|
|
135
|
+
|
|
136
|
+
const partCounts = new Map<number, number>()
|
|
137
|
+
for (const value of values) {
|
|
138
|
+
const parts = value.split('-').length
|
|
139
|
+
partCounts.set(parts, (partCounts.get(parts) ?? 0) + 1)
|
|
140
|
+
}
|
|
141
|
+
const parts = [...partCounts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 1
|
|
142
|
+
if (parts > 1 && agree(value => value.split('-').length === parts)) {
|
|
143
|
+
return value => {
|
|
144
|
+
const pieces = value.split('-')
|
|
145
|
+
return pieces.length === parts ? pieces : [value, ...Array<string>(parts - 1).fill('')]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const countersByStem = new Map<string, Set<string>>()
|
|
150
|
+
for (const value of values) {
|
|
151
|
+
const match = STEM_COUNTER_PATTERN.exec(value)
|
|
152
|
+
if (match) countersByStem.set(match[1], (countersByStem.get(match[1]) ?? new Set()).add(match[2]))
|
|
153
|
+
}
|
|
154
|
+
const repeated = [...countersByStem.values()].filter(counters => counters.size > 1).length
|
|
155
|
+
if (countersByStem.size > 1 && repeated * 2 >= countersByStem.size && agree(value => STEM_COUNTER_PATTERN.test(value))) {
|
|
156
|
+
return value => STEM_COUNTER_PATTERN.exec(value)?.slice(1) ?? [value, '']
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ColumnSplit {
|
|
162
|
+
rows: string[][]
|
|
163
|
+
/** `origin[newIndex]` = index of the source column before the split. */
|
|
164
|
+
origin: number[]
|
|
165
|
+
/** New indices that end a split token. */
|
|
166
|
+
tail: number[]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Split the columns of one class (rows of equal length) that hold two fields in one token. */
|
|
170
|
+
export function splitColumns(rows: string[][]): ColumnSplit {
|
|
171
|
+
const width = rows[0]?.length ?? 0
|
|
172
|
+
const splitters = Array.from({ length: width }, (_, col) => columnSplitter(rows.map(row => row[col] ?? '')))
|
|
173
|
+
const origin: number[] = []
|
|
174
|
+
const tail: number[] = []
|
|
175
|
+
splitters.forEach((splitter, col) => {
|
|
176
|
+
const parts = splitter ? splitter(rows[0][col]).length : 1
|
|
177
|
+
for (let part = 0; part < parts; part++) origin.push(col)
|
|
178
|
+
if (parts > 1) tail.push(origin.length - 1)
|
|
179
|
+
})
|
|
180
|
+
return {
|
|
181
|
+
rows: rows.map(row => row.flatMap((token, col) => splitters[col]?.(token) ?? [token])),
|
|
182
|
+
origin,
|
|
183
|
+
tail,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
92
187
|
/**
|
|
93
188
|
* Auto-detect a friendlier column name from the column's values + role.
|
|
94
|
-
* Returns null when nothing matches, so callers can fall back to "
|
|
189
|
+
* Returns null when nothing matches, so callers can fall back to "Field N".
|
|
95
190
|
*
|
|
96
191
|
* Heuristics are content-derived and deterministic. They cover the common
|
|
97
192
|
* LC-MS / proteomics naming conventions; users can override anything via
|
|
@@ -149,12 +244,13 @@ function inferColumnName(
|
|
|
149
244
|
// Experiment / study identifiers (exp268, study42, expt7).
|
|
150
245
|
if (values.every(v => /^(exp|expt|study)\d+$/i.test(v))) return 'Experiment'
|
|
151
246
|
|
|
152
|
-
// Operator / Username
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
247
|
+
// Operator / Username: a constant column of 2-3 uppercase letters is almost
|
|
248
|
+
// always operator initials (LCK, JD, AB). A column with two or more values
|
|
249
|
+
// (WT / KO) is a factor and gets no name here.
|
|
250
|
+
if (uniqueValues.length === 1 && /^[A-Z]{2,3}$/.test(uniqueValues[0])) return 'Username'
|
|
251
|
+
|
|
252
|
+
if (role === 'replicate') return 'Replicate'
|
|
253
|
+
if (role === 'id') return 'Sample ID'
|
|
158
254
|
|
|
159
255
|
return null
|
|
160
256
|
}
|
|
@@ -164,14 +260,20 @@ export function buildClassSchema(
|
|
|
164
260
|
classKind: SampleClassKind,
|
|
165
261
|
subKind: string | undefined,
|
|
166
262
|
classTagPositions: number[],
|
|
263
|
+
/** Positions that end a split token (from `splitColumns`). */
|
|
264
|
+
tailPositions: number[] = [],
|
|
167
265
|
): ClassSchema {
|
|
168
266
|
const totalPositions = tokenizedMembers[0]?.length ?? 0
|
|
169
267
|
const classSize = tokenizedMembers.length
|
|
170
268
|
|
|
171
269
|
const columns: ColumnInfo[] = []
|
|
172
270
|
const tagSet = new Set(classTagPositions)
|
|
271
|
+
// Replicate counters sit at the end of a name: the last position, or the one
|
|
272
|
+
// before the injection number.
|
|
273
|
+
const tailSet = new Set([...tailPositions, totalPositions - 1])
|
|
173
274
|
|
|
174
|
-
|
|
275
|
+
// Right to left, so the injection column is known before its neighbour.
|
|
276
|
+
for (let pos = totalPositions - 1; pos >= 0; pos--) {
|
|
175
277
|
const values = tokenizedMembers.map(t => t[pos] ?? '')
|
|
176
278
|
const uniqueValues = [...new Set(values)]
|
|
177
279
|
|
|
@@ -182,8 +284,12 @@ export function buildClassSchema(
|
|
|
182
284
|
// used as factor columns for grouping.
|
|
183
285
|
role = 'class-tag'
|
|
184
286
|
} else {
|
|
185
|
-
role = inferRole({ values, position: pos, totalPositions, classSize })
|
|
287
|
+
role = inferRole({ values, position: pos, totalPositions, classSize, tail: tailSet.has(pos) })
|
|
186
288
|
}
|
|
289
|
+
// Injection numbers with no zero padding (7, 18, 20) read as an id column
|
|
290
|
+
// at the last position; a counter can sit before either form.
|
|
291
|
+
const lastPosition = pos === totalPositions - 1
|
|
292
|
+
if (role === 'run-order' || (lastPosition && role === 'id')) tailSet.add(pos - 1)
|
|
187
293
|
|
|
188
294
|
const numeric =
|
|
189
295
|
role === 'numeric'
|
|
@@ -197,9 +303,9 @@ export function buildClassSchema(
|
|
|
197
303
|
: undefined
|
|
198
304
|
|
|
199
305
|
const inferredName = inferColumnName(values, uniqueValues, role)
|
|
200
|
-
columns.
|
|
306
|
+
columns.unshift({
|
|
201
307
|
index: pos,
|
|
202
|
-
name: inferredName ?? `
|
|
308
|
+
name: inferredName ?? `Field ${pos + 1}`,
|
|
203
309
|
sourceIndices: [pos],
|
|
204
310
|
uniqueValues,
|
|
205
311
|
cardinality: uniqueValues.length,
|
|
@@ -210,17 +316,19 @@ export function buildClassSchema(
|
|
|
210
316
|
})
|
|
211
317
|
}
|
|
212
318
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
319
|
+
// The name is the field's identity across classes, so it must be unique in
|
|
320
|
+
// a schema: a second `Date` or `Replicate` becomes `Date 2`. Grouping fields
|
|
321
|
+
// take their names first, so a card keeps `Matrix` and the constant class
|
|
322
|
+
// tag beside it becomes `Matrix 2`.
|
|
323
|
+
const nameCounts = new Map<string, number>()
|
|
324
|
+
const isField = (c: ColumnInfo): boolean => GROUPING_FIELD_ROLES.has(c.role)
|
|
325
|
+
for (const c of [...columns.filter(isField), ...columns.filter(c => !isField(c))]) {
|
|
326
|
+
const count = (nameCounts.get(c.name) ?? 0) + 1
|
|
327
|
+
nameCounts.set(c.name, count)
|
|
328
|
+
if (count > 1) c.name = `${c.name} ${count}`
|
|
221
329
|
}
|
|
222
330
|
|
|
223
|
-
const groupBy = columns.filter(c => c.role === 'factor').map(c => c.index)
|
|
331
|
+
const groupBy = columns.filter(c => c.role === 'factor' || c.role === 'numeric').map(c => c.index)
|
|
224
332
|
|
|
225
333
|
return { classKind, subKind, columns, groupBy }
|
|
226
334
|
}
|
|
@@ -154,22 +154,20 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
154
154
|
// above) so group names from different classes don't collide when they
|
|
155
155
|
// share a factor value (e.g. two tissue classes both emitting "5min").
|
|
156
156
|
//
|
|
157
|
-
// Only fires for disposition === 'group' classes
|
|
158
|
-
//
|
|
159
|
-
// single key part, so prefixing again would produce "IQC / IQC".
|
|
160
|
-
//
|
|
157
|
+
// Only fires for disposition === 'group' classes (`classPrefix` holds no
|
|
158
|
+
// other class): overlay/exclude classes already use cls.label AS their
|
|
159
|
+
// single key part, so prefixing again would produce "IQC / IQC". A class
|
|
160
|
+
// with one condition has no factor keyParts and is named by the prefix
|
|
161
|
+
// alone ("Brain" next to "Liver / Ctrl"). The Unknown class opts out —
|
|
162
|
+
// its label ("Unknown") adds no signal.
|
|
161
163
|
const hasMissingGroupPart =
|
|
162
164
|
input.missingGroupName &&
|
|
163
165
|
cls.disposition === 'group' &&
|
|
164
166
|
keyParts.some(part => part.trim().length === 0)
|
|
165
|
-
const prefix = cls.kind !== 'unknown'
|
|
167
|
+
const prefix = cls.kind !== 'unknown' ? classPrefix.get(cls) : undefined
|
|
166
168
|
const groupKey = hasMissingGroupPart
|
|
167
169
|
? input.missingGroupName!
|
|
168
|
-
: prefix
|
|
169
|
-
? `${prefix} / ${keyParts.join(' / ')}`
|
|
170
|
-
: keyParts.length > 0
|
|
171
|
-
? keyParts.join(' / ')
|
|
172
|
-
: cls.label
|
|
170
|
+
: [...(prefix ? [prefix] : []), ...keyParts].join(' / ') || cls.label
|
|
173
171
|
|
|
174
172
|
const bucket = groupMap.get(groupKey)
|
|
175
173
|
if (bucket) {
|
|
@@ -34,7 +34,7 @@ export function restoreFingerprint(
|
|
|
34
34
|
c =>
|
|
35
35
|
c.classKind === snap.kind &&
|
|
36
36
|
c.subKind === snap.subKind &&
|
|
37
|
-
(snap.tokenLength == null || c.tokenLength === snap.tokenLength),
|
|
37
|
+
(snap.tokenLength == null || c.tokenLength == null || c.tokenLength === snap.tokenLength),
|
|
38
38
|
)
|
|
39
39
|
const target =
|
|
40
40
|
candidates.find(c => c.columns.length === snap.columns.length) ?? candidates[0]
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Internal engine for `useAutoGroup` / `AutoGroupModal`. Deprecated: scheduled
|
|
2
2
|
// for removal in MINT 1.4 together with `useAutoGroup`; no SDK replacement.
|
|
3
3
|
|
|
4
|
-
export { splitMulti, pickPrimaryDelimiter } from './tokenize'
|
|
5
|
-
export type { Delimiter } from './tokenize'
|
|
6
|
-
export { detectClass, classVocab } from './classify'
|
|
4
|
+
export { splitMulti, pickPrimaryDelimiter, tokenizeBatch } from './tokenize'
|
|
5
|
+
export type { Delimiter, ExperimentHeader, TokenizedBatch } from './tokenize'
|
|
6
|
+
export { detectClass, classVocab, isExperimentalKind } from './classify'
|
|
7
7
|
export type { ClassVocab, DetectClassOptions } from './classify'
|
|
8
|
-
export { inferRole, parseNumeric, buildClassSchema } from './columns'
|
|
9
|
-
export type { InferRoleInput } from './columns'
|
|
8
|
+
export { inferRole, parseNumeric, buildClassSchema, splitColumns, GROUPING_FIELD_ROLES } from './columns'
|
|
9
|
+
export type { InferRoleInput, ColumnSplit } from './columns'
|
|
10
10
|
export { findMerges } from './cooccurrence'
|
|
11
11
|
export { composeGroups } from './compose'
|
|
12
12
|
export type { ComposeInput } from './compose'
|
|
@@ -15,11 +15,4 @@ export { composeTemplate } from './template'
|
|
|
15
15
|
export type { TemplateOptions } from './template'
|
|
16
16
|
export { DEFAULT_COLORS, QC_OVERLAY_COLOR } from './colors'
|
|
17
17
|
export { classKey } from './classKey'
|
|
18
|
-
export {
|
|
19
|
-
stripReplicateTokens,
|
|
20
|
-
preGroupReplicates,
|
|
21
|
-
expandGroupsWithReplicates,
|
|
22
|
-
expandTreeWithReplicates,
|
|
23
|
-
} from './replicatePreGroup'
|
|
24
|
-
export type { ReplicatePreGrouping } from './replicatePreGroup'
|
|
25
18
|
export { splitByTokenLength } from './tokenLength'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SampleClass } from '../../types/auto-group'
|
|
2
|
+
import { isExperimentalKind } from './classify'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Token-length pre-grouping.
|
|
@@ -22,6 +23,13 @@ export function splitByTokenLength(
|
|
|
22
23
|
const out: SampleClass[] = []
|
|
23
24
|
|
|
24
25
|
for (const cls of classes) {
|
|
26
|
+
// QC-kind classes (QC, blank, standard) are one group named by the class
|
|
27
|
+
// label and never group by column, so they need no rectangular matrix.
|
|
28
|
+
if (!isExperimentalKind(cls.kind)) {
|
|
29
|
+
out.push(cls)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
// Bucket members by token count, preserving first-seen order of lengths.
|
|
26
34
|
const byLength = new Map<number, number[]>()
|
|
27
35
|
const lengthOrder: number[] = []
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { isClassMarker } from './classify'
|
|
2
|
+
|
|
3
|
+
const DELIMITER_CANDIDATES = ['_', '-', ' ', '.'] as const
|
|
2
4
|
export type Delimiter = (typeof DELIMITER_CANDIDATES)[number]
|
|
3
5
|
|
|
4
6
|
/** Pick the delimiter whose split produces the most consistent field count across `lines`. */
|
|
@@ -39,3 +41,148 @@ export function pickPrimaryDelimiter(lines: string[]): Delimiter {
|
|
|
39
41
|
export function splitMulti(name: string, delimiter: Delimiter): string[] {
|
|
40
42
|
return name.split(delimiter)
|
|
41
43
|
}
|
|
44
|
+
|
|
45
|
+
/** The `exp<NNN>_<date>_<operator>` fields at the start of a sample name. */
|
|
46
|
+
export interface ExperimentHeader {
|
|
47
|
+
code?: string
|
|
48
|
+
/** ISO date. Names carry it as YYMMDD, YYYYMMDD or DDMMYYYY. */
|
|
49
|
+
date?: string
|
|
50
|
+
operator?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TokenizedBatch {
|
|
54
|
+
delimiter: Delimiter
|
|
55
|
+
/** One row of tokens for each input name, in input order. */
|
|
56
|
+
rows: string[][]
|
|
57
|
+
/** Header fields with one value in the batch. They are not in `rows`. */
|
|
58
|
+
experiment: ExperimentHeader
|
|
59
|
+
/**
|
|
60
|
+
* Number of leading tokens that all rows share (normally the experiment
|
|
61
|
+
* title). They cannot tell a QC sample from a study sample.
|
|
62
|
+
*/
|
|
63
|
+
sharedPrefix: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const HEADER_FIELDS = ['code', 'date', 'operator'] as const
|
|
67
|
+
// Share of the names with a header that must carry the same operator before it
|
|
68
|
+
// leaves the rows. The rest allows for stray names such as `exp157_test_blank_1`.
|
|
69
|
+
const OPERATOR_AGREEMENT = 0.9
|
|
70
|
+
// Outside this window a 6-digit token is read day-first: `091123` is 2023-11-09, not 2009.
|
|
71
|
+
const FIRST_PLAUSIBLE_YEAR = 2015
|
|
72
|
+
|
|
73
|
+
function isoDate(year: number, month: number, day: number): string | undefined {
|
|
74
|
+
const lastYear = new Date().getFullYear() + 1
|
|
75
|
+
if (year < FIRST_PLAUSIBLE_YEAR || year > lastYear) return undefined
|
|
76
|
+
// `Date` rolls an impossible day over to the next month; reject that.
|
|
77
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
78
|
+
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return undefined
|
|
79
|
+
return date.toISOString().slice(0, 10)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read a 6- or 8-digit date token; year-first is tried before day-first. */
|
|
83
|
+
export function parseDateToken(token: string): string | undefined {
|
|
84
|
+
const n = (from: number, to: number): number => Number(token.slice(from, to))
|
|
85
|
+
if (/^\d{8}$/.test(token)) return isoDate(n(0, 4), n(4, 6), n(6, 8)) ?? isoDate(n(4, 8), n(2, 4), n(0, 2))
|
|
86
|
+
if (/^\d{6}$/.test(token)) return isoDate(2000 + n(0, 2), n(2, 4), n(4, 6)) ?? isoDate(2000 + n(4, 6), n(2, 4), n(0, 2))
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type HeaderField = (typeof HEADER_FIELDS)[number]
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the experiment code, date and operator from the left of a name, in any
|
|
94
|
+
* order. `position` holds the token index of each field. A name with no code
|
|
95
|
+
* and no date has no header, so `WT_Ctrl_1` keeps `WT` as a sample token.
|
|
96
|
+
*/
|
|
97
|
+
function readHeader(tokens: string[]): { fields: ExperimentHeader; position: Partial<Record<HeaderField, number>> } {
|
|
98
|
+
const fields: ExperimentHeader = {}
|
|
99
|
+
const position: Partial<Record<HeaderField, number>> = {}
|
|
100
|
+
let i = 0
|
|
101
|
+
if (tokens.length > 1 && /^exp\d+$/i.test(tokens[0])) {
|
|
102
|
+
fields.code = tokens[0].toLowerCase()
|
|
103
|
+
position.code = i++
|
|
104
|
+
}
|
|
105
|
+
for (; i < tokens.length - 1; i++) {
|
|
106
|
+
const date = fields.date ? undefined : parseDateToken(tokens[i])
|
|
107
|
+
if (date) {
|
|
108
|
+
fields.date = date
|
|
109
|
+
position.date = i
|
|
110
|
+
} else if (!fields.operator && /^[A-Z]{2,3}$/.test(tokens[i]) && !isClassMarker(tokens[i])) {
|
|
111
|
+
fields.operator = tokens[i]
|
|
112
|
+
position.operator = i
|
|
113
|
+
} else {
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!fields.code && !fields.date) return { fields: {}, position: {} }
|
|
118
|
+
return { fields, position }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const MS_FILE_EXTENSION = /\.(raw|mzml|mzxml|mzdata|wiff2?|d|mgf|csv|tsv|txt)$/i
|
|
122
|
+
|
|
123
|
+
/** Drop a mass-spec file extension from each name, and any other extension that all names share. */
|
|
124
|
+
function stripExtensions(names: string[]): string[] {
|
|
125
|
+
const bare = names.map(name => name.replace(MS_FILE_EXTENSION, ''))
|
|
126
|
+
const ext = /\.[A-Za-z][A-Za-z0-9]{0,4}$/.exec(bare[0] ?? '')?.[0]
|
|
127
|
+
return ext && bare.every(name => name.endsWith(ext)) ? bare.map(name => name.slice(0, -ext.length)) : bare
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sample names → token rows.
|
|
132
|
+
*
|
|
133
|
+
* 1. Trim; drop a file extension that all names share.
|
|
134
|
+
* 2. Split on runs of the primary delimiter.
|
|
135
|
+
* 3. Drop a trailing 14-digit acquisition stamp.
|
|
136
|
+
* 4. Read the experiment header. A field moves to `experiment` when the batch
|
|
137
|
+
* has one value for it. Any other field stays in the rows as written, so
|
|
138
|
+
* `QC` or `WT` after the date in some names is not taken for operator
|
|
139
|
+
* initials.
|
|
140
|
+
* 5. Count the tokens that all names share after the header (the experiment
|
|
141
|
+
* title), so a title word such as `std` cannot classify the batch as QC.
|
|
142
|
+
*/
|
|
143
|
+
export function tokenizeBatch(names: string[]): TokenizedBatch {
|
|
144
|
+
const cleaned = stripExtensions(names.map(name => name.trim()))
|
|
145
|
+
const delimiter = pickPrimaryDelimiter(cleaned)
|
|
146
|
+
const parsed = cleaned.map(name => {
|
|
147
|
+
const split = name.split(delimiter).filter(Boolean)
|
|
148
|
+
if (split.length > 1 && /^\d{14}$/.test(split[split.length - 1])) split.pop()
|
|
149
|
+
const tokens = split.length > 0 ? split : [name]
|
|
150
|
+
return { tokens, ...readHeader(tokens) }
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const experiment: ExperimentHeader = {}
|
|
154
|
+
for (const field of ['code', 'date'] as const) {
|
|
155
|
+
const values = new Set(parsed.map(({ fields }) => fields[field]).filter(Boolean))
|
|
156
|
+
if (values.size === 1) experiment[field] = [...values][0]
|
|
157
|
+
}
|
|
158
|
+
// `exp<NNN>` and a date are clear by their form. Operator initials are not:
|
|
159
|
+
// `QC`, `WT` or `KO` look the same. The operator is the caps token that
|
|
160
|
+
// (almost) all complete headers carry; any other caps token stays in its row.
|
|
161
|
+
const dated = parsed.filter(({ fields }) => fields.date)
|
|
162
|
+
const complete = dated.length > 0 ? dated : parsed.filter(({ fields }) => fields.code)
|
|
163
|
+
const operatorCounts = new Map<string, number>()
|
|
164
|
+
for (const { fields } of complete) {
|
|
165
|
+
if (fields.operator) operatorCounts.set(fields.operator, (operatorCounts.get(fields.operator) ?? 0) + 1)
|
|
166
|
+
}
|
|
167
|
+
const [operator, count] = [...operatorCounts].sort((a, b) => b[1] - a[1])[0] ?? []
|
|
168
|
+
if (operator && count >= complete.length * OPERATOR_AGREEMENT) experiment.operator = operator
|
|
169
|
+
|
|
170
|
+
const rows = parsed.map(({ tokens, fields, position }) => {
|
|
171
|
+
const moved = new Set(
|
|
172
|
+
HEADER_FIELDS.filter(field => experiment[field] && fields[field] === experiment[field]).map(field => position[field]),
|
|
173
|
+
)
|
|
174
|
+
return tokens.filter((_, index) => !moved.has(index))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// A title exists only after a header; without one a shared first token can
|
|
178
|
+
// be the QC marker of a QC-only paste.
|
|
179
|
+
let sharedPrefix = 0
|
|
180
|
+
if (rows.length > 1 && Object.keys(experiment).length > 0) {
|
|
181
|
+
while (rows.every(row => row.length > sharedPrefix + 1 && row[sharedPrefix] === rows[0][sharedPrefix])) sharedPrefix++
|
|
182
|
+
// When only numbers follow, the shared tokens are the sample description
|
|
183
|
+
// (a QC-only batch: `QC_1`, `QC_2`), not a title.
|
|
184
|
+
if (rows.every(row => row.slice(sharedPrefix).every(token => /^\d+[A-Za-z]?$/.test(token)))) sharedPrefix = 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { delimiter, experiment, rows, sharedPrefix }
|
|
188
|
+
}
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
"matrixVocab": "Lowercase token → canonical display name. Tokens are normalised to lowercase before lookup, so adding 'tissues' handles 'Tissues' / 'TISSUES' / 'tissues' uniformly. Multiple aliases map to one canonical (e.g. cell + cells → Cells).",
|
|
8
8
|
"ionizationModes": "Lowercase polarity tokens (pos / positive / neg / negative). Suffixed to the subKind so POS and NEG acquisitions get independent schemas.",
|
|
9
9
|
"tissueParentTokens": "Tokens that flag the immediately-following token as the organ subKind (so `tissues / kidney` becomes Biological/Tissues, tagging both positions).",
|
|
10
|
-
"
|
|
10
|
+
"replicateToken": "Regex source for a token that names a replicate: rep1, Rep2, replicate3, R1, T1 (technical replicate). A column whose values all match is a replicate column: it stays a visible field and is left out of the default group key. B<n> is not in the pattern: on the lab share B13 / S13 are label tokens."
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
"patterns": {
|
|
14
14
|
"blank": "^(blank|blk|solv?blank|matrix.?blank)$",
|
|
15
|
-
"iqc": "^iqc$",
|
|
15
|
+
"iqc": "^(iqc|qc|pqc|pooled.?qc)$",
|
|
16
16
|
"eqc": "^eqc$",
|
|
17
|
-
"standard": "^(
|
|
17
|
+
"standard": "^(standards?|stds?\\d*|istd|cal(?:ibrator)?\\d*|.+standard)$",
|
|
18
18
|
"spikeIn": "spike.?in",
|
|
19
19
|
"stock": "^stock$"
|
|
20
20
|
},
|
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"standardAllow": [
|
|
23
23
|
"ExtractionS",
|
|
24
24
|
"IS",
|
|
25
|
-
"internal.standard"
|
|
26
|
-
"PQC",
|
|
27
|
-
"pooledQC"
|
|
25
|
+
"internal.standard"
|
|
28
26
|
],
|
|
29
27
|
|
|
30
28
|
"matrixVocab": {
|
|
@@ -58,9 +56,5 @@
|
|
|
58
56
|
|
|
59
57
|
"tissueParentTokens": ["tissue", "tissues"],
|
|
60
58
|
|
|
61
|
-
"
|
|
62
|
-
"[_-]T\\d+(?=[_-]|$)",
|
|
63
|
-
"[_-]B\\d+(?=[_-]|$)",
|
|
64
|
-
"[_-](?:rep(?:licate)?)\\d+(?=[_-]|$)"
|
|
65
|
-
]
|
|
59
|
+
"replicateToken": "^(?:rep(?:licate)?|r|t)\\d+$"
|
|
66
60
|
}
|