dsh-molbio-tools 0.22.2 → 0.24.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/CHANGELOG.md +221 -0
- package/README.md +51 -19
- package/batch.mjs +5 -1
- package/benchmark/README.md +7 -7
- package/benchmark/changed.mjs +4 -3
- package/benchmark/fixtures.mjs +32 -4
- package/benchmark/profile.mjs +1 -1
- package/benchmark/run.mjs +1 -0
- package/benchmark/sequences.mjs +1 -0
- package/benchmark/tasks/02-primers.json +3 -3
- package/benchmark/tasks/06-analysis.json +111 -1
- package/benchmark/tasks/09-formats.json +44 -1
- package/benchmark/verifications.mjs +13 -0
- package/build/client-bundle.mjs +1 -1
- package/build/client-entry.mjs +80 -1
- package/build/panel-core.mjs +85 -0
- package/build/preset-patch.mjs +1 -1
- package/build/release-preflight.mjs +1 -1
- package/charts.mjs +35 -16
- package/contamination.mjs +229 -0
- package/cordis.patch.yml +10 -6
- package/design.mjs +111 -78
- package/docs/capability-gap-survey.md +5 -0
- package/docs/client-panel.md +10 -8
- package/docs/history.md +86 -0
- package/docs/maintainer.md +8 -5
- package/docs/roadmap.md +37 -15
- package/docs/rules.md +5 -2
- package/docs/store-evidence.md +30 -23
- package/docs/store-preset-exemption-issue.md +1 -1
- package/docs/store-preset-exemption.md +1 -1
- package/docs/v24-plan.md +178 -0
- package/docs/workflow.md +11 -10
- package/dotplot.mjs +257 -0
- package/fastq-qc.mjs +31 -16
- package/fastq-trim.mjs +238 -0
- package/host.mjs +2 -2
- package/index.mjs +521 -34
- package/lib/client.js +376 -109
- package/package.json +8 -3
- package/papers.mjs +10 -0
- package/plot.mjs +24 -8
- package/preset/install.mjs +8 -0
- package/preset/molbio-lab/agent.cordis.yml +19 -13
- package/preset/molbio-lab/preset.yml +1 -1
- package/protein-charge.mjs +158 -0
- package/protein-structure.mjs +10 -2
- package/protein.mjs +36 -14
- package/records.mjs +17 -2
- package/seqio.mjs +49 -14
- package/test/charts.mjs +11 -0
- package/test/client.mjs +12 -5
- package/test/contract.mjs +44 -12
- package/test/drift-probe.mjs +15 -1
- package/test/map-card.mjs +139 -1
- package/test/preset-health.mjs +39 -0
- package/test/smoke.mjs +378 -11
package/fastq-trim.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-molbio-tools/fastq-trim.mjs
|
|
3
|
+
*
|
|
4
|
+
* Pure FASTQ trimming/filtering: 5' primer removal, 3' adapter removal, 3'
|
|
5
|
+
* sliding-window quality trimming, poly-G/poly-A tail trimming, and length/N
|
|
6
|
+
* filters. No file I/O and no quality re-encoding — the tool layer reads and
|
|
7
|
+
* writes through ctx.fs, while this module only transforms entry objects.
|
|
8
|
+
*
|
|
9
|
+
* The deliberately narrow scope is stated in the tool description: this is not
|
|
10
|
+
* a paired-end merger, not cutadapt's semiglobal aligner, and not a UMI
|
|
11
|
+
* pipeline. The algorithms here are the ones a bench user can reason about.
|
|
12
|
+
*/
|
|
13
|
+
import { MolbioInputError, normalizeSequence } from './lib.mjs';
|
|
14
|
+
|
|
15
|
+
/** Defaults and hard bounds shared by the tool, tests and docs. */
|
|
16
|
+
export const FASTQ_TRIM_DEFAULTS = {
|
|
17
|
+
minQuality: 20,
|
|
18
|
+
qualityWindow: 4,
|
|
19
|
+
polyMin: 8,
|
|
20
|
+
minLength: 0,
|
|
21
|
+
maxNPercent: 100,
|
|
22
|
+
phred: 33,
|
|
23
|
+
maxReads: 0,
|
|
24
|
+
maxReadsHard: 200000,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PHRED_OFFSETS = [33, 64];
|
|
28
|
+
|
|
29
|
+
function integerIn(value, name, min, max) {
|
|
30
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
31
|
+
throw new MolbioInputError(name + ' must be an integer between ' + min + ' and ' + max);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function numberIn(value, name, min, max) {
|
|
37
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
|
|
38
|
+
throw new MolbioInputError(name + ' must be a number between ' + min + ' and ' + max);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validate and normalize the options once per call. */
|
|
44
|
+
export function resolveTrimOptions(options = {}) {
|
|
45
|
+
const minQuality = integerIn(options.minQuality ?? FASTQ_TRIM_DEFAULTS.minQuality, 'min_quality', 0, 60);
|
|
46
|
+
const qualityWindow = integerIn(options.qualityWindow ?? FASTQ_TRIM_DEFAULTS.qualityWindow, 'quality_window', 1, 100);
|
|
47
|
+
const polyMin = integerIn(options.polyMin ?? FASTQ_TRIM_DEFAULTS.polyMin, 'poly_min', 2, 100);
|
|
48
|
+
const minLength = integerIn(options.minLength ?? FASTQ_TRIM_DEFAULTS.minLength, 'min_length', 0, 1_000_000);
|
|
49
|
+
const maxNPercent = numberIn(options.maxNPercent ?? FASTQ_TRIM_DEFAULTS.maxNPercent, 'max_n_percent', 0, 100);
|
|
50
|
+
const phred = options.phred ?? FASTQ_TRIM_DEFAULTS.phred;
|
|
51
|
+
if (!PHRED_OFFSETS.includes(phred)) throw new MolbioInputError('phred must be 33 or 64');
|
|
52
|
+
const maxReads = integerIn(options.maxReads ?? FASTQ_TRIM_DEFAULTS.maxReads, 'max_reads', 0, FASTQ_TRIM_DEFAULTS.maxReadsHard);
|
|
53
|
+
const adapter = options.adapter === undefined || options.adapter === ''
|
|
54
|
+
? ''
|
|
55
|
+
: normalizeSequence(options.adapter, 'adapter');
|
|
56
|
+
const primer = options.primer === undefined || options.primer === ''
|
|
57
|
+
? ''
|
|
58
|
+
: normalizeSequence(options.primer, 'primer');
|
|
59
|
+
return {
|
|
60
|
+
minQuality,
|
|
61
|
+
qualityWindow,
|
|
62
|
+
polyMin,
|
|
63
|
+
minLength,
|
|
64
|
+
maxNPercent,
|
|
65
|
+
phred,
|
|
66
|
+
maxReads,
|
|
67
|
+
adapter,
|
|
68
|
+
primer,
|
|
69
|
+
trimPolyG: options.polyG === true,
|
|
70
|
+
trimPolyA: options.polyA === true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 0-based exclusive end after 3' sliding-window quality trimming. */
|
|
75
|
+
function qualityTrimEnd(sequence, quality, options) {
|
|
76
|
+
const length = sequence.length;
|
|
77
|
+
if (length === 0) return 0;
|
|
78
|
+
const window = Math.min(options.qualityWindow, length);
|
|
79
|
+
let end = length;
|
|
80
|
+
while (end >= window) {
|
|
81
|
+
let sum = 0;
|
|
82
|
+
for (let index = end - window; index < end; index++) {
|
|
83
|
+
const q = quality.charCodeAt(index) - options.phred;
|
|
84
|
+
if (q < 0 || q > 93) throw new MolbioInputError('FASTQ quality character outside Phred+' + options.phred + ' range at read position ' + (index + 1));
|
|
85
|
+
sum += q;
|
|
86
|
+
}
|
|
87
|
+
if (sum / window >= options.minQuality) break;
|
|
88
|
+
end--;
|
|
89
|
+
}
|
|
90
|
+
return end < window ? 0 : end;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Longest trailing run of one base, or 0. */
|
|
94
|
+
function trailingRun(sequence, base) {
|
|
95
|
+
let length = 0;
|
|
96
|
+
for (let index = sequence.length - 1; index >= 0 && sequence[index] === base; index--) length++;
|
|
97
|
+
return length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Count Ns (and any other non-ACGT base) in a sequence. */
|
|
101
|
+
function ambiguousCount(sequence) {
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const base of sequence) if (!'ACGT'.includes(base)) count++;
|
|
104
|
+
return count;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Trim/filter one parsed FASTQ entry.
|
|
109
|
+
*
|
|
110
|
+
* @param {{id: string, description: string, sequence: string, quality: string}} entry
|
|
111
|
+
* @param {ReturnType<typeof resolveTrimOptions>} options
|
|
112
|
+
* @returns {{entry?: object, dropped?: 'length'|'n', trims: {quality:boolean, adapter:boolean, poly_g:boolean, poly_a:boolean, primer:boolean}}}
|
|
113
|
+
*/
|
|
114
|
+
export function trimRead(entry, options) {
|
|
115
|
+
if (typeof entry?.sequence !== 'string' || typeof entry?.quality !== 'string') throw new MolbioInputError('each read needs a sequence and a quality string');
|
|
116
|
+
if (entry.sequence.length !== entry.quality.length) throw new MolbioInputError('read ' + entry.id + ': sequence and quality lengths differ');
|
|
117
|
+
let sequence = entry.sequence;
|
|
118
|
+
let quality = entry.quality;
|
|
119
|
+
const trims = { quality: false, adapter: false, poly_g: false, poly_a: false, primer: false };
|
|
120
|
+
|
|
121
|
+
if (options.primer !== '' && sequence.startsWith(options.primer)) {
|
|
122
|
+
sequence = sequence.slice(options.primer.length);
|
|
123
|
+
quality = quality.slice(options.primer.length);
|
|
124
|
+
trims.primer = true;
|
|
125
|
+
}
|
|
126
|
+
if (options.adapter !== '') {
|
|
127
|
+
const at = sequence.indexOf(options.adapter);
|
|
128
|
+
if (at !== -1) {
|
|
129
|
+
sequence = sequence.slice(0, at);
|
|
130
|
+
quality = quality.slice(0, at);
|
|
131
|
+
trims.adapter = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const qualityEnd = qualityTrimEnd(sequence, quality, options);
|
|
136
|
+
if (qualityEnd < sequence.length) {
|
|
137
|
+
sequence = sequence.slice(0, qualityEnd);
|
|
138
|
+
quality = quality.slice(0, qualityEnd);
|
|
139
|
+
trims.quality = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (options.trimPolyG) {
|
|
143
|
+
const run = trailingRun(sequence, 'G');
|
|
144
|
+
if (run >= options.polyMin) {
|
|
145
|
+
sequence = sequence.slice(0, sequence.length - run);
|
|
146
|
+
quality = quality.slice(0, quality.length - run);
|
|
147
|
+
trims.poly_g = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (options.trimPolyA) {
|
|
151
|
+
const run = trailingRun(sequence, 'A');
|
|
152
|
+
if (run >= options.polyMin) {
|
|
153
|
+
sequence = sequence.slice(0, sequence.length - run);
|
|
154
|
+
quality = quality.slice(0, quality.length - run);
|
|
155
|
+
trims.poly_a = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (sequence.length === 0) return { dropped: 'length', trims };
|
|
160
|
+
if (sequence.length < options.minLength) return { dropped: 'length', trims };
|
|
161
|
+
const nPercent = (ambiguousCount(sequence) / sequence.length) * 100;
|
|
162
|
+
if (nPercent > options.maxNPercent) return { dropped: 'n', trims };
|
|
163
|
+
return {
|
|
164
|
+
entry: {
|
|
165
|
+
id: entry.id,
|
|
166
|
+
description: entry.description ?? '',
|
|
167
|
+
sequence,
|
|
168
|
+
quality,
|
|
169
|
+
},
|
|
170
|
+
trims,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Trim/filter a set of parsed FASTQ entries.
|
|
176
|
+
*
|
|
177
|
+
* @param {Array<object>} entries
|
|
178
|
+
* @param {object} [options]
|
|
179
|
+
* @returns {{entries: Array<object>, stats: object}}
|
|
180
|
+
*/
|
|
181
|
+
export function trimFastq(entries, options = {}) {
|
|
182
|
+
if (!Array.isArray(entries) || entries.length === 0) throw new MolbioInputError('FASTQ trimming needs at least one read');
|
|
183
|
+
const resolved = resolveTrimOptions(options);
|
|
184
|
+
const limit = resolved.maxReads === 0 ? FASTQ_TRIM_DEFAULTS.maxReadsHard : resolved.maxReads;
|
|
185
|
+
const scanned = entries.slice(0, limit);
|
|
186
|
+
const kept = [];
|
|
187
|
+
const trimCounts = { quality: 0, adapter: 0, poly_g: 0, poly_a: 0, primer: 0 };
|
|
188
|
+
const dropCounts = { length: 0, n: 0 };
|
|
189
|
+
let basesIn = 0;
|
|
190
|
+
let basesOut = 0;
|
|
191
|
+
let trimmedReads = 0;
|
|
192
|
+
for (const entry of scanned) {
|
|
193
|
+
basesIn += entry.sequence.length;
|
|
194
|
+
const result = trimRead(entry, resolved);
|
|
195
|
+
if (result.entry === undefined) {
|
|
196
|
+
dropCounts[result.dropped]++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
kept.push(result.entry);
|
|
200
|
+
basesOut += result.entry.sequence.length;
|
|
201
|
+
let trimmed = false;
|
|
202
|
+
for (const [key, value] of Object.entries(result.trims)) {
|
|
203
|
+
if (value) {
|
|
204
|
+
trimCounts[key]++;
|
|
205
|
+
trimmed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (trimmed) trimmedReads++;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
entries: kept,
|
|
212
|
+
stats: {
|
|
213
|
+
total_reads: entries.length,
|
|
214
|
+
reads_in: scanned.length,
|
|
215
|
+
reads_out: kept.length,
|
|
216
|
+
bases_in: basesIn,
|
|
217
|
+
bases_out: basesOut,
|
|
218
|
+
trimmed_reads: trimmedReads,
|
|
219
|
+
dropped_reads: scanned.length - kept.length,
|
|
220
|
+
trim_counts: trimCounts,
|
|
221
|
+
drop_counts: dropCounts,
|
|
222
|
+
truncated: entries.length > scanned.length,
|
|
223
|
+
criteria: {
|
|
224
|
+
min_quality: resolved.minQuality,
|
|
225
|
+
quality_window: resolved.qualityWindow,
|
|
226
|
+
poly_min: resolved.polyMin,
|
|
227
|
+
min_length: resolved.minLength,
|
|
228
|
+
max_n_percent: resolved.maxNPercent,
|
|
229
|
+
phred: resolved.phred,
|
|
230
|
+
max_reads: resolved.maxReads,
|
|
231
|
+
adapter: resolved.adapter,
|
|
232
|
+
primer: resolved.primer,
|
|
233
|
+
poly_g: resolved.trimPolyG,
|
|
234
|
+
poly_a: resolved.trimPolyA,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/host.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ── WHY NOT JUST ANCHOR ON index.mjs ───────────────────────────────────────
|
|
29
29
|
*
|
|
30
|
-
* `index.mjs` is the TOOL plugin: importing it must register
|
|
31
|
-
* prompt section. A host row naming it would put every tool back into every
|
|
30
|
+
* `index.mjs` is the TOOL plugin: importing it must register all 64 tools and
|
|
31
|
+
* a prompt section. A host row naming it would put every tool back into every
|
|
32
32
|
* session, which is the thing the preset split exists to avoid. This anchor is
|
|
33
33
|
* deliberately inert.
|
|
34
34
|
*
|