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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-molbio-tools/contamination.mjs
|
|
3
|
+
*
|
|
4
|
+
* A small, EMBEDDED set of common vector-backbone/promoter/origin and
|
|
5
|
+
* Illumina/Nextera adapter signatures, plus a bounded both-strand screen.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately a short-signature screen, not a database search:
|
|
8
|
+
* a hit is evidence worth following up, and a miss is NOT evidence that a
|
|
9
|
+
* sample is clean. The table lives in code because the package has no external
|
|
10
|
+
* data files; every entry names the feature it represents so a report can be
|
|
11
|
+
* read without guessing which 20-mer matched.
|
|
12
|
+
*
|
|
13
|
+
* The adapters are shared with the FASTQ QC adapter-content panel. They are DNA
|
|
14
|
+
* here: the Small RNA 5' entry is publicly documented as RNA (GUUCAG...), but a
|
|
15
|
+
* FASTQ read is cDNA, so keeping the U in the table made the forward molecule
|
|
16
|
+
* unmatchable. Normalizing U→T at the source fixes the screen for both the
|
|
17
|
+
* standalone tool and fastq-qc's adapter panel.
|
|
18
|
+
*/
|
|
19
|
+
import { MolbioInputError, normalizeSequence, reverseComplement } from './lib.mjs';
|
|
20
|
+
|
|
21
|
+
/** One signature in the embedded table. */
|
|
22
|
+
export const ADAPTERS = [
|
|
23
|
+
{ name: 'Illumina Universal', kind: 'adapter', category: 'adapter', sequence: 'AGATCGGAAGAG' },
|
|
24
|
+
{ name: "Illumina Small RNA 3'", kind: 'adapter', category: 'adapter', sequence: 'TGGAATTCTCGG' },
|
|
25
|
+
{ name: "Illumina Small RNA 5'", kind: 'adapter', category: 'adapter', sequence: 'GTTCAGAGTTCTACAGTCCGACGATC' },
|
|
26
|
+
{ name: 'Nextera Transposase', kind: 'adapter', category: 'adapter', sequence: 'CTGTCTCTTATA' },
|
|
27
|
+
{ name: 'PolyA', kind: 'adapter', category: 'homopolymer', sequence: 'AAAAAAAAAAAA' },
|
|
28
|
+
{ name: 'PolyG', kind: 'adapter', category: 'homopolymer', sequence: 'GGGGGGGGGGGG' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Common vector signatures. These are SHORT, deliberately: a 20-35 bp signature
|
|
33
|
+
* is enough to flag a backbone/promoter/origin in an assembly or read set, while
|
|
34
|
+
* a full vector sequence would turn this table into a reference database (and
|
|
35
|
+
* raise licensing questions). Names say which element matched.
|
|
36
|
+
*/
|
|
37
|
+
export const VECTORS = [
|
|
38
|
+
{ name: 'pUC/pBR322 origin', kind: 'vector', category: 'origin', sequence: 'GTTTGCCGGATCAAGAGCTACCAACTCTTT' },
|
|
39
|
+
{ name: 'pUC19 lacZ/M13 region', kind: 'vector', category: 'backbone', sequence: 'GTTTTCCCAGTCACGACGTTGTAAAACGACGGCCAGT' },
|
|
40
|
+
{ name: 'pUC/pBR322 bla (AmpR) start', kind: 'vector', category: 'selection', sequence: 'ATGAGTATTCAACATTTCCGTGTCGCCCTT' },
|
|
41
|
+
{ name: 'pET T7 promoter', kind: 'vector', category: 'promoter', sequence: 'TAATACGACTCACTATAGGG' },
|
|
42
|
+
{ name: 'pET T7 terminator', kind: 'vector', category: 'terminator', sequence: 'GCTAGTTATTGCTCAGCGG' },
|
|
43
|
+
{ name: 'pcDNA CMV promoter', kind: 'vector', category: 'promoter', sequence: 'CGCAAATGGGCGGTAGGCGTG' },
|
|
44
|
+
{ name: 'pcDNA BGH polyA', kind: 'vector', category: 'terminator', sequence: 'CTAGAAGGCACAGTCGAGG' },
|
|
45
|
+
{ name: 'Gateway attB1', kind: 'vector', category: 'recombination', sequence: 'ACAAGTTTGTACAAAAAAGCAGGCT' },
|
|
46
|
+
{ name: 'Gateway attB2', kind: 'vector', category: 'recombination', sequence: 'ACCCAGCTTTCTTGTACAAAGTGGT' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Every embedded signature, adapters first (the table order is stable). */
|
|
50
|
+
export const SIGNATURES = [...ADAPTERS, ...VECTORS];
|
|
51
|
+
|
|
52
|
+
/** Defaults and hard bounds shared by the tool, tests and docs. */
|
|
53
|
+
export const CONTAMINATION_DEFAULTS = {
|
|
54
|
+
maxMismatches: 0,
|
|
55
|
+
maxMismatchesHard: 2,
|
|
56
|
+
maxReads: 0,
|
|
57
|
+
maxReadsHard: 200000,
|
|
58
|
+
maxHits: 100,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const round = (value, digits = 1) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
62
|
+
|
|
63
|
+
/** Hamming distance over equal-length strings (non-ACGT positions count as mismatches). */
|
|
64
|
+
function hamming(a, b) {
|
|
65
|
+
let distance = 0;
|
|
66
|
+
for (let index = 0; index < a.length; index++) {
|
|
67
|
+
if (a[index] !== b[index]) distance++;
|
|
68
|
+
}
|
|
69
|
+
return distance;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The seed chunks used by the mismatch path; one chunk when maxMismatches is 0. */
|
|
73
|
+
function seedChunks(signature, maxMismatches) {
|
|
74
|
+
const count = maxMismatches + 1;
|
|
75
|
+
const base = Math.floor(signature.length / count);
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (let index = 0; index < count; index++) {
|
|
79
|
+
const length = index === count - 1 ? signature.length - offset : base;
|
|
80
|
+
chunks.push({ needle: signature.slice(offset, offset + length), offset });
|
|
81
|
+
offset += length;
|
|
82
|
+
}
|
|
83
|
+
return chunks;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Best occurrence of one needle on one strand, or undefined.
|
|
88
|
+
*
|
|
89
|
+
* Exact mode is a plain indexOf. The mismatch path uses maxMismatches+1 seed
|
|
90
|
+
* chunks: at most maxMismatches chunks can be altered, so at least one chunk
|
|
91
|
+
* must occur verbatim; every seed occurrence is verified with a Hamming count.
|
|
92
|
+
* This keeps a 30-mer search linear in the number of seed hits instead of
|
|
93
|
+
* scanning every window of every read.
|
|
94
|
+
*/
|
|
95
|
+
function bestOnStrand(sequence, needle, maxMismatches, strand) {
|
|
96
|
+
let best;
|
|
97
|
+
const consider = (start, mismatches) => {
|
|
98
|
+
if (start < 0 || start + needle.length > sequence.length) return;
|
|
99
|
+
if (best === undefined
|
|
100
|
+
|| mismatches < best.mismatches
|
|
101
|
+
|| (mismatches === best.mismatches && start < best.start)) {
|
|
102
|
+
best = { start, end: start + needle.length, strand, mismatches };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
if (maxMismatches === 0) {
|
|
106
|
+
let from = 0;
|
|
107
|
+
while (from <= sequence.length - needle.length) {
|
|
108
|
+
const at = sequence.indexOf(needle, from);
|
|
109
|
+
if (at === -1) break;
|
|
110
|
+
consider(at, 0);
|
|
111
|
+
from = at + 1;
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
for (const chunk of seedChunks(needle, maxMismatches)) {
|
|
116
|
+
if (chunk.needle.length === 0) continue;
|
|
117
|
+
let from = 0;
|
|
118
|
+
while (from <= sequence.length - chunk.needle.length) {
|
|
119
|
+
const at = sequence.indexOf(chunk.needle, from);
|
|
120
|
+
if (at === -1) break;
|
|
121
|
+
const start = at - chunk.offset;
|
|
122
|
+
if (start >= 0 && start + needle.length <= sequence.length) {
|
|
123
|
+
const mismatches = hamming(sequence.slice(start, start + needle.length), needle);
|
|
124
|
+
if (mismatches <= maxMismatches) consider(start, mismatches);
|
|
125
|
+
}
|
|
126
|
+
from = at + 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Best hit of a signature against a sequence on either strand. */
|
|
133
|
+
export function findSignatureHit(sequence, signature, maxMismatches = 0) {
|
|
134
|
+
const forward = bestOnStrand(sequence, signature, maxMismatches, '+');
|
|
135
|
+
const reverse = bestOnStrand(sequence, reverseComplement(signature), maxMismatches, '-');
|
|
136
|
+
if (forward === undefined) return reverse;
|
|
137
|
+
if (reverse === undefined) return forward;
|
|
138
|
+
if (reverse.mismatches < forward.mismatches) return reverse;
|
|
139
|
+
if (reverse.mismatches === forward.mismatches && reverse.start < forward.start) return reverse;
|
|
140
|
+
return forward;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Screen one or more sequences against the embedded signatures.
|
|
145
|
+
*
|
|
146
|
+
* @param {Array<{id: string, sequence: string}>} entries
|
|
147
|
+
* @param {{maxMismatches?: number, kinds?: 'both'|'adapter'|'vector', maxReads?: number}} [options]
|
|
148
|
+
* @returns {object} aggregate hits plus the criteria that produced them.
|
|
149
|
+
*/
|
|
150
|
+
export function screenContamination(entries, options = {}) {
|
|
151
|
+
if (!Array.isArray(entries) || entries.length === 0) throw new MolbioInputError('contamination screening needs at least one sequence');
|
|
152
|
+
const maxMismatches = options.maxMismatches ?? CONTAMINATION_DEFAULTS.maxMismatches;
|
|
153
|
+
if (!Number.isInteger(maxMismatches) || maxMismatches < 0 || maxMismatches > CONTAMINATION_DEFAULTS.maxMismatchesHard) {
|
|
154
|
+
throw new MolbioInputError('max_mismatches must be an integer between 0 and ' + CONTAMINATION_DEFAULTS.maxMismatchesHard);
|
|
155
|
+
}
|
|
156
|
+
const kinds = options.kinds ?? 'both';
|
|
157
|
+
if (!['both', 'adapter', 'vector'].includes(kinds)) throw new MolbioInputError('kinds must be "both", "adapter" or "vector"');
|
|
158
|
+
const maxReads = options.maxReads ?? CONTAMINATION_DEFAULTS.maxReads;
|
|
159
|
+
if (!Number.isInteger(maxReads) || maxReads < 0 || maxReads > CONTAMINATION_DEFAULTS.maxReadsHard) {
|
|
160
|
+
throw new MolbioInputError('max_reads must be an integer between 0 and ' + CONTAMINATION_DEFAULTS.maxReadsHard);
|
|
161
|
+
}
|
|
162
|
+
const limit = maxReads === 0 ? CONTAMINATION_DEFAULTS.maxReadsHard : maxReads;
|
|
163
|
+
const normalized = entries.map((entry, index) => ({
|
|
164
|
+
id: typeof entry?.id === 'string' && entry.id !== '' ? entry.id : 'entry' + (index + 1),
|
|
165
|
+
sequence: normalizeSequence(entry?.sequence ?? '', 'sequence'),
|
|
166
|
+
}));
|
|
167
|
+
const scanned = normalized.slice(0, limit);
|
|
168
|
+
const truncated = normalized.length > scanned.length;
|
|
169
|
+
const signatures = SIGNATURES.filter((signature) => kinds === 'both' || signature.kind === kinds);
|
|
170
|
+
let ambiguousBases = 0;
|
|
171
|
+
let bases = 0;
|
|
172
|
+
for (const entry of scanned) {
|
|
173
|
+
bases += entry.sequence.length;
|
|
174
|
+
for (const base of entry.sequence) if (!'ACGT'.includes(base)) ambiguousBases++;
|
|
175
|
+
}
|
|
176
|
+
const hits = [];
|
|
177
|
+
const withHit = new Set();
|
|
178
|
+
for (const signature of signatures) {
|
|
179
|
+
let reads = 0;
|
|
180
|
+
let best;
|
|
181
|
+
for (let index = 0; index < scanned.length; index++) {
|
|
182
|
+
const entry = scanned[index];
|
|
183
|
+
const hit = findSignatureHit(entry.sequence, signature.sequence, maxMismatches);
|
|
184
|
+
if (hit === undefined) continue;
|
|
185
|
+
reads++;
|
|
186
|
+
withHit.add(index);
|
|
187
|
+
if (best === undefined
|
|
188
|
+
|| hit.mismatches < best.mismatches
|
|
189
|
+
|| (hit.mismatches === best.mismatches && hit.start < best.start)) {
|
|
190
|
+
best = { ...hit, id: entry.id };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (reads === 0) continue;
|
|
194
|
+
hits.push({
|
|
195
|
+
name: signature.name,
|
|
196
|
+
kind: signature.kind,
|
|
197
|
+
category: signature.category,
|
|
198
|
+
signature: signature.sequence,
|
|
199
|
+
reads,
|
|
200
|
+
percent: round((reads / scanned.length) * 100),
|
|
201
|
+
best_mismatches: best.mismatches,
|
|
202
|
+
example: {
|
|
203
|
+
id: best.id,
|
|
204
|
+
start: best.start + 1,
|
|
205
|
+
end: best.end,
|
|
206
|
+
strand: best.strand,
|
|
207
|
+
mismatches: best.mismatches,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
hits.sort((a, b) => b.reads - a.reads || a.name.localeCompare(b.name));
|
|
212
|
+
return {
|
|
213
|
+
total_entries: normalized.length,
|
|
214
|
+
scanned_entries: scanned.length,
|
|
215
|
+
bases,
|
|
216
|
+
ambiguous_bases: ambiguousBases,
|
|
217
|
+
hits: hits.slice(0, CONTAMINATION_DEFAULTS.maxHits),
|
|
218
|
+
hit_count: Math.min(hits.length, CONTAMINATION_DEFAULTS.maxHits),
|
|
219
|
+
reads_with_hit: withHit.size,
|
|
220
|
+
truncated,
|
|
221
|
+
criteria: {
|
|
222
|
+
max_mismatches: maxMismatches,
|
|
223
|
+
kinds,
|
|
224
|
+
signature_count: signatures.length,
|
|
225
|
+
max_reads: maxReads,
|
|
226
|
+
match_basis: 'embedded short signatures, both strands, substitutions only (no gaps); a negative result does not prove the sample is clean',
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
package/cordis.patch.yml
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
#
|
|
22
22
|
# Keep the tool in the PRESET and only the anchor here. An earlier version of
|
|
23
23
|
# this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),
|
|
24
|
-
# which both registered
|
|
24
|
+
# which both registered every tool globally and collided with the preset's own
|
|
25
25
|
# copy of that row:
|
|
26
26
|
#
|
|
27
27
|
# broken: "tool-molbio (...): never started"
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
id: molbio-lab
|
|
48
48
|
order: 5
|
|
49
49
|
name: Molecular Biology Lab
|
|
50
|
-
description: "分子生物学专属模式:标准编码能力 +
|
|
50
|
+
description: "分子生物学专属模式:标准编码能力 + 64 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/电荷-pH 曲线/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、序列 dotplot、载体/接头污染筛查、FASTQ 修剪/过滤、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。"
|
|
51
51
|
plugins:
|
|
52
52
|
- id: persona
|
|
53
53
|
name: '@deepseek-ai/dsh-persona'
|
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
disabled: !!js process.platform !== 'win32'
|
|
68
68
|
- id: tool-fs
|
|
69
69
|
name: '@deepseek-ai/dsh-tool-fs'
|
|
70
|
+
config:
|
|
71
|
+
# Set false to drop the apply_patch tool beside edit. (0.2.2-rc.1 makes this
|
|
72
|
+
# explicit in the shipped standard preset; the package default is false, and
|
|
73
|
+
# the row is kept verbatim so the two stay comparable.)
|
|
74
|
+
applyPatch: false
|
|
70
75
|
- id: tool-fs-search
|
|
71
76
|
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
72
77
|
config:
|
|
@@ -129,14 +134,13 @@
|
|
|
129
134
|
config:
|
|
130
135
|
- id: tool-subagent-control
|
|
131
136
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
132
|
-
- id: tool-subagent-list-agents
|
|
133
|
-
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
134
137
|
- id: tool-subagent
|
|
135
138
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
136
139
|
config:
|
|
137
140
|
provider: spawn
|
|
138
141
|
toolName: subagent
|
|
139
|
-
|
|
142
|
+
modelSelection: true
|
|
143
|
+
modelDiscovery: true
|
|
140
144
|
- id: tool-subagent-fork
|
|
141
145
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
142
146
|
config:
|
|
@@ -168,7 +172,7 @@
|
|
|
168
172
|
name: '@deepseek-ai/dsh-plugin-market/tools'
|
|
169
173
|
disabled: true
|
|
170
174
|
|
|
171
|
-
# dsh-molbio-tools: molecular-biology toolset (
|
|
175
|
+
# dsh-molbio-tools: molecular-biology toolset (64 tools across sequence
|
|
172
176
|
# analysis, primers (incl. TaqMan probes and multiplex compatibility),
|
|
173
177
|
# in-silico PCR, cloning (incl. Golden Gate), enzyme catalog, methylation-aware
|
|
174
178
|
# and double digests, plasmids, Sanger verification, proteins (incl. helical
|
package/design.mjs
CHANGED
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
* from `anchor` and not from the RC-scan `start`. It is the REVERSE primer,
|
|
37
37
|
* and the amplicon ends at `anchor + length - 1`.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
39
|
+
* `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
|
|
40
|
+
* follows the SAME convention since 0.23.0. The junction is carried by the
|
|
41
|
+
* REVERSE primer (the assay sits 5' of the junction), so the reverse candidate
|
|
42
|
+
* is scanned on the reverse complement of the spliced transcript: every 3'-end
|
|
43
|
+
* constraint is then evaluated on the molecule that is ordered, and the hit is
|
|
44
|
+
* mapped back to sense coordinates through `anchor` (its 3' end, the LEFT edge
|
|
45
|
+
* of its site). `forward` is upstream of `reverse`, the spliced amplicon runs
|
|
46
|
+
* from the forward 5' end to the reverse 5' end, and the reported reverse
|
|
47
|
+
* sequence is what goes on the order form. Before 0.23.0 the tool reported both
|
|
48
|
+
* primers as sense substrings, so `forward` sat downstream of `reverse` and the
|
|
49
|
+
* reported reverse was the reverse complement of the molecule to order.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
import {
|
|
@@ -738,37 +738,60 @@ export function designPrimers(rawTemplate, rawOptions) {
|
|
|
738
738
|
// ── cross-intron primer design ──────────────────────────────────────────────
|
|
739
739
|
|
|
740
740
|
/**
|
|
741
|
-
* Mismatch report for cross-intron candidates.
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
* both
|
|
741
|
+
* Mismatch report for cross-intron candidates. The forward primer is the
|
|
742
|
+
* sense-strand substring at its binding site; the reverse primer is the REVERSE
|
|
743
|
+
* COMPLEMENT of the sense-strand substring at its site (the molecule that is
|
|
744
|
+
* ordered). `anchorStart` is the 0-based sense-strand position of the primer's
|
|
745
|
+
* 3' end — for the reverse primer that is the LEFT edge of its site — and
|
|
746
|
+
* `isReverse` flips both the perfect-match base and how a primer offset maps
|
|
747
|
+
* onto the transcript.
|
|
747
748
|
*/
|
|
748
|
-
function intronMismatchReport(candidate, spliced, splicedToGenomic) {
|
|
749
|
+
function intronMismatchReport(candidate, spliced, splicedToGenomic, anchorStart, isReverse) {
|
|
749
750
|
const len = candidate.length;
|
|
750
|
-
return candidate.mismatches.map(({ p, base }) =>
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
751
|
+
return candidate.mismatches.map(({ p, base }) => {
|
|
752
|
+
const offset = isReverse ? len - 1 - p : p;
|
|
753
|
+
const index = anchorStart + offset;
|
|
754
|
+
return {
|
|
755
|
+
position: p + 1,
|
|
756
|
+
template_base: isReverse ? complement(spliced[index]) : spliced[index],
|
|
757
|
+
primer_base: base,
|
|
758
|
+
spliced_position: index + 1,
|
|
759
|
+
genomic_position: splicedToGenomic[index] + 1,
|
|
760
|
+
distance_from_3prime: len - 1 - p,
|
|
761
|
+
};
|
|
762
|
+
});
|
|
758
763
|
}
|
|
759
764
|
|
|
760
765
|
/**
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
766
|
+
* The same weights as `mismatchPenalty`, over the snake_case report fields.
|
|
767
|
+
* The old intron path handed snake_case reports to the camelCase helper, so the
|
|
768
|
+
* 3'-zone surcharge never applied there; this keeps the two conventions apart on
|
|
769
|
+
* purpose instead of loosening the shared helper.
|
|
770
|
+
*/
|
|
771
|
+
function intronMismatchPenalty(report, zone) {
|
|
772
|
+
return report.reduce((sum, mismatch) => sum + 8 + (mismatch.distance_from_3prime <= zone ? 4 : 0), 0);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Design qPCR primer pairs across an exon-exon junction so genomic DNA cannot
|
|
777
|
+
* be amplified: the REVERSE primer straddles the junction (>= minJunctionBases
|
|
778
|
+
* on each side), the FORWARD primer sits upstream inside a single exon, and the
|
|
779
|
+
* spliced amplicon crosses the junction inside the reverse primer's site.
|
|
780
|
+
*
|
|
781
|
+
* Coordinates are reported both on the spliced transcript and on the genomic
|
|
782
|
+
* sequence. Orientation follows `designPrimerPairs` (see the file header):
|
|
783
|
+
* `forward.sequence` is the sense-strand substring at its site and
|
|
784
|
+
* `reverse.sequence` is the reverse complement of the sense-strand substring at
|
|
785
|
+
* its site. The reverse candidate is scanned on the reverse complement of the
|
|
786
|
+
* spliced transcript, so every 3'-end constraint (GC clamp, end stability, end
|
|
787
|
+
* GC, self-end, hairpin) applies to the molecule that is ordered.
|
|
765
788
|
*/
|
|
766
789
|
export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
767
790
|
if (!Array.isArray(exons) || exons.length < 2) throw new MolbioInputError('exons must be an array of at least two {start, end} spans');
|
|
768
791
|
const sorted = [...exons].sort((a, b) => a.start - b.start);
|
|
769
792
|
for (const exon of sorted) {
|
|
770
793
|
if (!Number.isInteger(exon.start) || !Number.isInteger(exon.end) || exon.start < 1 || exon.end > genomic.length || exon.start > exon.end) {
|
|
771
|
-
throw new MolbioInputError(
|
|
794
|
+
throw new MolbioInputError('exon ' + JSON.stringify(exon) + ' is outside the genomic sequence (length ' + genomic.length + ')');
|
|
772
795
|
}
|
|
773
796
|
}
|
|
774
797
|
for (let i = 1; i < sorted.length; i++) {
|
|
@@ -791,33 +814,39 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
791
814
|
}
|
|
792
815
|
});
|
|
793
816
|
if (spliced.length < 24) throw new MolbioInputError('the spliced transcript is too short for primer design');
|
|
817
|
+
const splicedLength = spliced.length;
|
|
818
|
+
const rcSpliced = reverseComplement(spliced);
|
|
794
819
|
|
|
795
820
|
// v13 target position preference (coordinates on the spliced transcript).
|
|
796
821
|
const targetPosition = opts.targetPosition;
|
|
797
|
-
if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition >
|
|
798
|
-
throw new MolbioInputError(
|
|
822
|
+
if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > splicedLength)) {
|
|
823
|
+
throw new MolbioInputError('target_position ' + targetPosition + ' is outside the spliced transcript (length ' + splicedLength + ')');
|
|
799
824
|
}
|
|
800
825
|
const targetWeight = opts.targetPenalty ?? DESIGN_DEFAULTS.targetPenalty;
|
|
801
826
|
|
|
802
|
-
// Forward candidates
|
|
803
|
-
const fwd =
|
|
804
|
-
|
|
827
|
+
// Forward candidates: entirely inside one exon on the sense strand.
|
|
828
|
+
const fwd = scanCandidates(spliced, 0, splicedLength - opts.lenMin, opts)
|
|
829
|
+
.filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
|
|
830
|
+
.map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
|
|
831
|
+
.sort((a, b) => a.start - b.start);
|
|
832
|
+
|
|
833
|
+
// Reverse candidates: scanned on the reverse complement (the ordered
|
|
834
|
+
// molecule), then mapped back to the sense-strand 3' end `anchor`.
|
|
835
|
+
const rev = [];
|
|
836
|
+
for (const candidate of scanCandidates(rcSpliced, 0, splicedLength - opts.lenMin, opts)) {
|
|
837
|
+
const anchor = splicedLength - (candidate.start + candidate.length);
|
|
838
|
+
const topEnd = anchor + candidate.length - 1;
|
|
805
839
|
for (const junction of junctionPositions) {
|
|
806
|
-
const left = junction.splicedPos -
|
|
807
|
-
const right =
|
|
840
|
+
const left = junction.splicedPos - anchor;
|
|
841
|
+
const right = topEnd + 1 - junction.splicedPos;
|
|
808
842
|
if (left >= minSide && right >= minSide) {
|
|
809
|
-
|
|
843
|
+
rev.push({ ...candidate, anchor, junction, junction_left: left, junction_right: right });
|
|
810
844
|
break;
|
|
811
845
|
}
|
|
812
846
|
}
|
|
813
847
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const rev = scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)
|
|
817
|
-
.filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
|
|
818
|
-
.map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
|
|
819
|
-
.sort((a, b) => a.start - b.start);
|
|
820
|
-
const revStarts = rev.map((candidate) => candidate.start);
|
|
848
|
+
rev.sort((a, b) => a.anchor - b.anchor);
|
|
849
|
+
const revAnchors = rev.map((candidate) => candidate.anchor);
|
|
821
850
|
|
|
822
851
|
const tmCenter = (opts.tmMin + opts.tmMax) / 2;
|
|
823
852
|
const misprimingIndex = opts.checkMispriming
|
|
@@ -826,48 +855,52 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
826
855
|
const maxSites = opts.misprimingMaxSites ?? DESIGN_DEFAULTS.misprimingMaxSites;
|
|
827
856
|
const pairs = [];
|
|
828
857
|
for (const f of fwd) {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
858
|
+
// The reverse site must start after the forward 3' end, and the amplicon
|
|
859
|
+
// (forward 5' -> reverse 5') must fit the window. The bounds are widened by
|
|
860
|
+
// the primer-length range and the exact length is checked below, because a
|
|
861
|
+
// long reverse primer can start earlier and still close inside the window.
|
|
862
|
+
const aMin = Math.max(f.start + f.length, f.start + opts.ampliconMin - opts.lenMax);
|
|
863
|
+
const aMax = f.start + opts.ampliconMax - opts.lenMin;
|
|
864
|
+
if (aMax < aMin) continue;
|
|
832
865
|
let lo = 0;
|
|
833
|
-
let hi =
|
|
866
|
+
let hi = revAnchors.length;
|
|
834
867
|
while (lo < hi) {
|
|
835
868
|
const mid = (lo + hi) >> 1;
|
|
836
|
-
if (
|
|
869
|
+
if (revAnchors[mid] < aMin) lo = mid + 1;
|
|
837
870
|
else hi = mid;
|
|
838
871
|
}
|
|
839
872
|
for (let i = lo; i < rev.length; i++) {
|
|
840
873
|
const r = rev[i];
|
|
841
|
-
if (r.
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
const
|
|
874
|
+
if (r.anchor > aMax) break;
|
|
875
|
+
const ampliconStart = f.start;
|
|
876
|
+
const ampliconEnd = r.anchor + r.length - 1;
|
|
877
|
+
const ampliconLength = ampliconEnd - ampliconStart + 1;
|
|
878
|
+
if (ampliconLength < opts.ampliconMin || ampliconLength > opts.ampliconMax) continue;
|
|
879
|
+
const gStart = splicedToGenomic[ampliconStart];
|
|
880
|
+
const gEnd = splicedToGenomic[ampliconEnd];
|
|
845
881
|
if (gEnd - gStart + 1 < minGenomicSpan) continue;
|
|
846
882
|
if (Math.abs(f.tm - r.tm) > opts.maxTmDelta) continue;
|
|
847
883
|
const dimer = dimerThermo(f.sequence, r.sequence, opts.ctMolar ?? DEFAULT_CT_MOLAR);
|
|
848
884
|
if (dimer.any_tm > opts.maxDimerTm || dimer.end_tm > opts.maxDimerEndTm) continue;
|
|
885
|
+
const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
|
|
849
886
|
let fMispriming = { count: 0, sites: [] };
|
|
850
887
|
let rMispriming = { count: 0, sites: [] };
|
|
851
888
|
if (misprimingIndex !== undefined) {
|
|
852
|
-
|
|
853
|
-
fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start);
|
|
889
|
+
// Exclude each primer's own 3'-tail window (not its 5' end).
|
|
890
|
+
fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start + f.length - misK);
|
|
854
891
|
if (fMispriming.count > maxSites) continue;
|
|
855
|
-
|
|
856
|
-
// the real oligo is its reverse complement, so its 3' tail is the RC
|
|
857
|
-
// of the FIRST k bases and the intended site is r.start
|
|
858
|
-
const rTail = reverseComplement(r.sequence.slice(0, misK));
|
|
859
|
-
rMispriming = misprimingForCandidate(r, rTail, misprimingIndex, opts, r.start);
|
|
892
|
+
rMispriming = misprimingForCandidate(r, r.sequence.slice(-misK), misprimingIndex, opts, r.anchor);
|
|
860
893
|
if (rMispriming.count > maxSites) continue;
|
|
861
894
|
}
|
|
862
|
-
const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic);
|
|
863
|
-
const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic);
|
|
895
|
+
const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic, f.start, false);
|
|
896
|
+
const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic, r.anchor, true);
|
|
864
897
|
const zone = opts.mismatch3PrimeZone ?? DESIGN_DEFAULTS.mismatch3PrimeZone;
|
|
865
898
|
let fTargetDistance;
|
|
866
899
|
let rTargetDistance;
|
|
867
900
|
let targetDistance;
|
|
868
901
|
if (targetPosition !== undefined) {
|
|
869
|
-
fTargetDistance = Math.abs(f.start + f.length - targetPosition); //
|
|
870
|
-
rTargetDistance = Math.abs(r.
|
|
902
|
+
fTargetDistance = Math.abs(f.start + f.length - targetPosition); // forward 3' end (1-based)
|
|
903
|
+
rTargetDistance = Math.abs(r.anchor + 1 - targetPosition); // reverse 3' end (1-based)
|
|
871
904
|
targetDistance = Math.min(fTargetDistance, rTargetDistance);
|
|
872
905
|
}
|
|
873
906
|
const penalty = 0.6 * Math.abs(f.tm - r.tm) + Math.abs(f.tm - tmCenter) + Math.abs(r.tm - tmCenter)
|
|
@@ -875,8 +908,8 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
875
908
|
+ 1.0 * Math.max(0, f.self_end - 1) + 1.0 * Math.max(0, r.self_end - 1)
|
|
876
909
|
+ 0.2 * Math.max(0, f.hairpin_tm - 40) + 0.2 * Math.max(0, r.hairpin_tm - 40)
|
|
877
910
|
+ 0.2 * Math.max(0, dimer.any_tm - 40) + 0.2 * Math.max(0, dimer.end_tm - 40)
|
|
878
|
-
+
|
|
879
|
-
+
|
|
911
|
+
+ intronMismatchPenalty(fMismatches, zone)
|
|
912
|
+
+ intronMismatchPenalty(rMismatches, zone)
|
|
880
913
|
+ 8 * fMispriming.count + 8 * rMispriming.count
|
|
881
914
|
+ (targetDistance !== undefined ? targetWeight * targetDistance : 0);
|
|
882
915
|
pairs.push({
|
|
@@ -894,9 +927,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
894
927
|
spliced_end: f.start + f.length,
|
|
895
928
|
genomic_start: splicedToGenomic[f.start] + 1,
|
|
896
929
|
genomic_end: splicedToGenomic[f.start + f.length - 1] + 1,
|
|
897
|
-
|
|
898
|
-
junction_left: f.junction_left,
|
|
899
|
-
junction_right: f.junction_right,
|
|
930
|
+
exon: f.exon + 1,
|
|
900
931
|
mismatch_count: fMismatches.length,
|
|
901
932
|
mismatches: fMismatches,
|
|
902
933
|
mispriming_count: fMispriming.count,
|
|
@@ -918,11 +949,13 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
918
949
|
hairpin_tm: r.hairpin_tm,
|
|
919
950
|
end_stability_kcal: r.end_stability_kcal,
|
|
920
951
|
end_gc_count: r.end_gc_count,
|
|
921
|
-
spliced_start: r.
|
|
922
|
-
spliced_end: r.
|
|
923
|
-
genomic_start: splicedToGenomic[r.
|
|
924
|
-
genomic_end: splicedToGenomic[r.
|
|
925
|
-
|
|
952
|
+
spliced_start: r.anchor + 1,
|
|
953
|
+
spliced_end: r.anchor + r.length,
|
|
954
|
+
genomic_start: splicedToGenomic[r.anchor] + 1,
|
|
955
|
+
genomic_end: splicedToGenomic[r.anchor + r.length - 1] + 1,
|
|
956
|
+
exons: [String(r.junction.up + 1), String(r.junction.down + 1)],
|
|
957
|
+
junction_left: r.junction_left,
|
|
958
|
+
junction_right: r.junction_right,
|
|
926
959
|
mismatch_count: rMismatches.length,
|
|
927
960
|
mismatches: rMismatches,
|
|
928
961
|
mispriming_count: rMispriming.count,
|
|
@@ -934,7 +967,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
934
967
|
})),
|
|
935
968
|
...(rTargetDistance !== undefined ? { target_distance: rTargetDistance } : {}),
|
|
936
969
|
},
|
|
937
|
-
spliced_amplicon: { start:
|
|
970
|
+
spliced_amplicon: { start: ampliconStart + 1, end: ampliconEnd + 1, length: ampliconLength },
|
|
938
971
|
genomic_amplicon_length: gEnd - gStart + 1,
|
|
939
972
|
...(targetDistance !== undefined ? { target_distance: targetDistance } : {}),
|
|
940
973
|
penalty: Math.round(penalty * 100) / 100,
|
|
@@ -945,7 +978,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
|
|
|
945
978
|
const seen = new Set();
|
|
946
979
|
const unique = [];
|
|
947
980
|
for (const pair of pairs) {
|
|
948
|
-
const key =
|
|
981
|
+
const key = pair.spliced_amplicon.start + ':' + pair.spliced_amplicon.end;
|
|
949
982
|
if (seen.has(key)) continue;
|
|
950
983
|
seen.add(key);
|
|
951
984
|
unique.push(pair);
|
|
@@ -6,6 +6,11 @@ mainstream bioinformatics landscape offer that this toolset does **not**, and wh
|
|
|
6
6
|
deterministic algorithms in a few hundred lines of dependency-free JS with **no external data files,
|
|
7
7
|
binaries, network, or reference databases**?
|
|
8
8
|
|
|
9
|
+
> **Status note (0.24.0).** The baseline below is the 52-tool package this survey was written
|
|
10
|
+
> against. Since then rows 1–7, 11, 12, 23, 30, 34, 40 and the Cas-family half of row 20 have shipped
|
|
11
|
+
> (see [history.md](history.md) and the roadmap); the remaining rows are still candidates unless
|
|
12
|
+
> §3 names a blocker. The rankings are left as written so the original judgement can be audited.
|
|
13
|
+
|
|
9
14
|
**Method.** Surveyed nine source families (commercial plasmid/cloning suites; bench web calculators;
|
|
10
15
|
Biopython/BioPerl/SeqKit/EMBOSS command-line toolkits; standard sequence/statistics analyses;
|
|
11
16
|
microbiome; population genetics; protein prediction; alignment post-processing; sequencing QC),
|