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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-molbio-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and
|
|
3
|
+
"version": "0.24.1",
|
|
4
|
+
"description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel, hydropathy and charge-pH plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, k-mer dotplots, read-level FASTQ QC, vector/adapter contamination screening, FASTQ trimming/filtering, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
7
7
|
"engines": {
|
|
@@ -59,6 +59,10 @@
|
|
|
59
59
|
"sanger.mjs",
|
|
60
60
|
"protein.mjs",
|
|
61
61
|
"protein-structure.mjs",
|
|
62
|
+
"protein-charge.mjs",
|
|
63
|
+
"dotplot.mjs",
|
|
64
|
+
"contamination.mjs",
|
|
65
|
+
"fastq-trim.mjs",
|
|
62
66
|
"taqman.mjs",
|
|
63
67
|
"multiplex.mjs",
|
|
64
68
|
"methylation.mjs",
|
|
@@ -98,7 +102,8 @@
|
|
|
98
102
|
"0.2.1-rc.2": "compatible",
|
|
99
103
|
"0.2.1-rc.3": "compatible",
|
|
100
104
|
"0.2.1-rc.4": "unknown",
|
|
101
|
-
"0.2.1-rc.5": "compatible"
|
|
105
|
+
"0.2.1-rc.5": "compatible",
|
|
106
|
+
"0.2.2-rc.1": "compatible"
|
|
102
107
|
}
|
|
103
108
|
},
|
|
104
109
|
"bundle": {
|
package/papers.mjs
CHANGED
|
@@ -51,6 +51,12 @@ export async function loadLibrary(fs, path) {
|
|
|
51
51
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.papers)) {
|
|
52
52
|
throw new Error('expected an object with a "papers" array');
|
|
53
53
|
}
|
|
54
|
+
// A hand-edited library can contain a null or scalar entry. Without this
|
|
55
|
+
// check, paperKey()/render() would throw a raw TypeError; a clear input
|
|
56
|
+
// error names the file instead.
|
|
57
|
+
if (!parsed.papers.every((paper) => typeof paper === 'object' && paper !== null && !Array.isArray(paper))) {
|
|
58
|
+
throw new Error('every entry in "papers" must be an object');
|
|
59
|
+
}
|
|
54
60
|
return parsed;
|
|
55
61
|
} catch (error) {
|
|
56
62
|
throw new MolbioInputError(`the paper library at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -115,6 +121,10 @@ export function updatePaper(library, id, changes) {
|
|
|
115
121
|
if (Array.isArray(changes.tags)) {
|
|
116
122
|
paper.tags = changes.tags.filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter((tag) => tag !== '');
|
|
117
123
|
}
|
|
124
|
+
// The id is DERIVED from pmid, then url, then title+year. Recompute it after
|
|
125
|
+
// an update: leaving the old title-based id in place means adding the renamed
|
|
126
|
+
// paper later is not recognised as a duplicate.
|
|
127
|
+
paper.id = paperKey(paper);
|
|
118
128
|
return paper;
|
|
119
129
|
}
|
|
120
130
|
|
package/plot.mjs
CHANGED
|
@@ -70,6 +70,20 @@ function validateNumbers(values, label) {
|
|
|
70
70
|
return values;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Smallest datum, by loop: spreading a long series into Math.min overflows the argument limit. */
|
|
74
|
+
function minValue(values) {
|
|
75
|
+
let min = Infinity;
|
|
76
|
+
for (const value of values) if (value < min) min = value;
|
|
77
|
+
return min;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Largest datum, by loop (same reason as {@link minValue}). */
|
|
81
|
+
function maxValue(values) {
|
|
82
|
+
let max = -Infinity;
|
|
83
|
+
for (const value of values) if (value > max) max = value;
|
|
84
|
+
return max;
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
function frameParts(title, xLabel, yLabel) {
|
|
74
88
|
const parts = [];
|
|
75
89
|
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="${FIGURE_FONT}" role="img">`);
|
|
@@ -110,11 +124,13 @@ export function renderBarChart({ title, x_label, y_label, labels, values, errors
|
|
|
110
124
|
}
|
|
111
125
|
errs = errors;
|
|
112
126
|
}
|
|
113
|
-
let min = Math.min(0,
|
|
114
|
-
let max = Math.max(0,
|
|
127
|
+
let min = Math.min(0, minValue(vals));
|
|
128
|
+
let max = Math.max(0, maxValue(vals));
|
|
115
129
|
if (errs !== undefined) {
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
for (let index = 0; index < vals.length; index++) {
|
|
131
|
+
min = Math.min(min, vals[index] - errs[index]);
|
|
132
|
+
max = Math.max(max, vals[index] + errs[index]);
|
|
133
|
+
}
|
|
118
134
|
}
|
|
119
135
|
if (max === min) max = min + 1;
|
|
120
136
|
const parts = frameParts(title, x_label, y_label);
|
|
@@ -158,15 +174,15 @@ export function renderScatterChart({ title, x_label, y_label, x, y, fit }) {
|
|
|
158
174
|
}
|
|
159
175
|
fitLine = fit;
|
|
160
176
|
}
|
|
161
|
-
let xMin =
|
|
162
|
-
let xMax =
|
|
177
|
+
let xMin = minValue(xs);
|
|
178
|
+
let xMax = maxValue(xs);
|
|
163
179
|
if (xMax === xMin) {
|
|
164
180
|
const pad = Math.abs(xMax) * 0.1 + 1;
|
|
165
181
|
xMin -= pad;
|
|
166
182
|
xMax += pad;
|
|
167
183
|
}
|
|
168
|
-
let yMin =
|
|
169
|
-
let yMax =
|
|
184
|
+
let yMin = minValue(ys);
|
|
185
|
+
let yMax = maxValue(ys);
|
|
170
186
|
if (fitLine !== undefined) {
|
|
171
187
|
yMin = Math.min(yMin, fitLine.intercept + fitLine.slope * xMin, fitLine.intercept + fitLine.slope * xMax);
|
|
172
188
|
yMax = Math.max(yMax, fitLine.intercept + fitLine.slope * xMin, fitLine.intercept + fitLine.slope * xMax);
|
package/preset/install.mjs
CHANGED
|
@@ -55,6 +55,14 @@ function flag(name) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const profile = flag('profile') === undefined ? 'web' : String(flag('profile'));
|
|
58
|
+
// DSH profile names are directory names under <dshHome>/profiles. Reject
|
|
59
|
+
// separators/control characters before the value can reach a child process:
|
|
60
|
+
// on Windows this script runs through a shell, where an unquoted metacharacter
|
|
61
|
+
// (for example "web & calc") would be a command injection.
|
|
62
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile) || profile === '.' || profile === '..') {
|
|
63
|
+
console.error(`invalid --profile ${JSON.stringify(profile)}: start with a letter or digit, then letters, digits, ".", "_" or "-"`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
58
66
|
const checkOnly = flag('check') === true;
|
|
59
67
|
const dshHomeFlag = flag('dsh-home');
|
|
60
68
|
const dshHome = resolve(
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
#
|
|
18
18
|
# Edit THIS file, then run `node build/preset-patch.mjs`.
|
|
19
19
|
#
|
|
20
|
-
# NOTE: the mode is the ONLY place these
|
|
20
|
+
# NOTE: the mode is the ONLY place these 64 tools load. The same bundle patch's
|
|
21
21
|
# other insert (`molbio-client` → `./host.mjs`) deliberately registers nothing —
|
|
22
22
|
# registering the tool at the host level as well would put it in every session
|
|
23
23
|
# and is not what this package is for.
|
|
@@ -48,18 +48,20 @@
|
|
|
48
48
|
# list carries no comments of its own, so none appear below; provenance and the
|
|
49
49
|
# upgrade rules live in this header.
|
|
50
50
|
#
|
|
51
|
-
# Baseline: the `standard` preset of dsh 0.2.
|
|
51
|
+
# Baseline: the `standard` preset of dsh 0.2.2-rc.1 (harness root
|
|
52
52
|
# `%APPDATA%\npm\node_modules\@deepseek-ai\dsh`, file
|
|
53
53
|
# `node_modules/@deepseek-ai/dsh-web-app/presets/standard.patch.yml`, whose
|
|
54
54
|
# rows live under the `preset-standard` row's `config.plugins:`).
|
|
55
55
|
#
|
|
56
|
-
# 0.2.
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# `tool-subagent
|
|
61
|
-
#
|
|
62
|
-
#
|
|
56
|
+
# 0.2.2-rc.1 is the FIRST row change since 0.2.1-rc.2 dropped the optional
|
|
57
|
+
# subagent providers and `tool-ralph`. It folded `list_agents` into
|
|
58
|
+
# `@deepseek-ai/dsh-tool-subagent-control` (the `./list-agents` subpath export is
|
|
59
|
+
# gone, so the `tool-subagent-list-agents` row was dropped here), renamed the
|
|
60
|
+
# `tool-subagent` model-selection settings to `modelSelection` +
|
|
61
|
+
# `modelDiscovery`, and made `applyPatch: false` explicit on `tool-fs`. A row
|
|
62
|
+
# that names a deleted package subpath still PASSES a directory-existence check:
|
|
63
|
+
# `test/preset-health.mjs` now resolves the specifier the way the Loader does.
|
|
64
|
+
# Absorb such a bump verbatim — see workflow.md §4.
|
|
63
65
|
#
|
|
64
66
|
# Keeping the text verbatim is not cosmetic: a preset mounts DSH's OWN
|
|
65
67
|
# packages, so upstream drift here kills the whole preset at mount (the failure
|
|
@@ -100,6 +102,11 @@
|
|
|
100
102
|
disabled: !!js process.platform !== 'win32'
|
|
101
103
|
- id: tool-fs
|
|
102
104
|
name: '@deepseek-ai/dsh-tool-fs'
|
|
105
|
+
config:
|
|
106
|
+
# Set false to drop the apply_patch tool beside edit. (0.2.2-rc.1 makes this
|
|
107
|
+
# explicit in the shipped standard preset; the package default is false, and
|
|
108
|
+
# the row is kept verbatim so the two stay comparable.)
|
|
109
|
+
applyPatch: false
|
|
103
110
|
- id: tool-fs-search
|
|
104
111
|
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
105
112
|
config:
|
|
@@ -162,14 +169,13 @@
|
|
|
162
169
|
config:
|
|
163
170
|
- id: tool-subagent-control
|
|
164
171
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
165
|
-
- id: tool-subagent-list-agents
|
|
166
|
-
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
167
172
|
- id: tool-subagent
|
|
168
173
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
169
174
|
config:
|
|
170
175
|
provider: spawn
|
|
171
176
|
toolName: subagent
|
|
172
|
-
|
|
177
|
+
modelSelection: true
|
|
178
|
+
modelDiscovery: true
|
|
173
179
|
- id: tool-subagent-fork
|
|
174
180
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
175
181
|
config:
|
|
@@ -201,7 +207,7 @@
|
|
|
201
207
|
name: '@deepseek-ai/dsh-plugin-market/tools'
|
|
202
208
|
disabled: true
|
|
203
209
|
|
|
204
|
-
# dsh-molbio-tools: molecular-biology toolset (
|
|
210
|
+
# dsh-molbio-tools: molecular-biology toolset (64 tools across sequence
|
|
205
211
|
# analysis, primers (incl. TaqMan probes and multiplex compatibility),
|
|
206
212
|
# in-silico PCR, cloning (incl. Golden Gate), enzyme catalog, methylation-aware
|
|
207
213
|
# and double digests, plasmids, Sanger verification, proteins (incl. helical
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
name: Molecular Biology Lab
|
|
2
|
-
description: 分子生物学专属模式:标准编码能力 +
|
|
2
|
+
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 图片自动用系统默认应用打开,文献/协议/实验记录)。
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-molbio-tools/protein-charge.mjs
|
|
3
|
+
*
|
|
4
|
+
* Net-charge-vs-pH titration for a protein, with the pI found on the same
|
|
5
|
+
* curve. Pure calculation plus an SVG renderer: no external data, no services.
|
|
6
|
+
*
|
|
7
|
+
* The pK tables live in `protein.mjs` (PK_SETS) so `molbio_protein_props` and
|
|
8
|
+
* this tool can never disagree about the default set. `emboss` is offered as an
|
|
9
|
+
* explicitly named alternative; every result carries the set it used, because a
|
|
10
|
+
* pI is only meaningful together with its pK values.
|
|
11
|
+
*/
|
|
12
|
+
import { MolbioInputError } from './lib.mjs';
|
|
13
|
+
import { PK_SETS, countAminoAcids, isoelectricPoint, netCharge, normalizeProtein } from './protein.mjs';
|
|
14
|
+
import { axis, linearScale, niceTicks, panel, rect, series, svgDocument, textRun } from './svgio.mjs';
|
|
15
|
+
|
|
16
|
+
/** Accepted ranges; the schema checks types and the module checks values. */
|
|
17
|
+
export const CHARGE_LIMITS = {
|
|
18
|
+
maxLength: 20_000,
|
|
19
|
+
minPh: 0,
|
|
20
|
+
maxPh: 14,
|
|
21
|
+
minSteps: 2,
|
|
22
|
+
maxSteps: 141,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const round = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute the titration curve and the pI for one protein.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} raw protein sequence (one-letter codes).
|
|
31
|
+
* @param {{pkaSet?: string, phMin?: number, phMax?: number, steps?: number}} [options]
|
|
32
|
+
* @returns {{length: number, pka_set: string, pka_label: string, ph_min: number,
|
|
33
|
+
* ph_max: number, steps: number, pI: number, charge_at_7_4: number,
|
|
34
|
+
* charge_min: number, charge_max: number, points: Array<{ph: number, charge: number}>}}
|
|
35
|
+
*/
|
|
36
|
+
export function chargeCurve(raw, options = {}) {
|
|
37
|
+
const seq = normalizeProtein(raw);
|
|
38
|
+
if (seq.length > CHARGE_LIMITS.maxLength) {
|
|
39
|
+
throw new MolbioInputError('the protein is ' + seq.length + ' aa; the titration limit is ' + CHARGE_LIMITS.maxLength);
|
|
40
|
+
}
|
|
41
|
+
const pkaSet = options.pkaSet ?? 'bjellqvist';
|
|
42
|
+
const pk = PK_SETS[pkaSet];
|
|
43
|
+
if (pk === undefined) {
|
|
44
|
+
throw new MolbioInputError('unknown pka_set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
|
|
45
|
+
}
|
|
46
|
+
const phMin = options.phMin ?? 0;
|
|
47
|
+
const phMax = options.phMax ?? 14;
|
|
48
|
+
const steps = options.steps ?? 29;
|
|
49
|
+
if (!(typeof phMin === 'number' && Number.isFinite(phMin) && phMin >= CHARGE_LIMITS.minPh && phMin <= CHARGE_LIMITS.maxPh)) {
|
|
50
|
+
throw new MolbioInputError('ph_min must be a number between ' + CHARGE_LIMITS.minPh + ' and ' + CHARGE_LIMITS.maxPh);
|
|
51
|
+
}
|
|
52
|
+
if (!(typeof phMax === 'number' && Number.isFinite(phMax) && phMax >= CHARGE_LIMITS.minPh && phMax <= CHARGE_LIMITS.maxPh)) {
|
|
53
|
+
throw new MolbioInputError('ph_max must be a number between ' + CHARGE_LIMITS.minPh + ' and ' + CHARGE_LIMITS.maxPh);
|
|
54
|
+
}
|
|
55
|
+
if (!(phMax > phMin)) throw new MolbioInputError('ph_max must be greater than ph_min');
|
|
56
|
+
if (!Number.isInteger(steps) || steps < CHARGE_LIMITS.minSteps || steps > CHARGE_LIMITS.maxSteps) {
|
|
57
|
+
throw new MolbioInputError('steps must be an integer between ' + CHARGE_LIMITS.minSteps + ' and ' + CHARGE_LIMITS.maxSteps);
|
|
58
|
+
}
|
|
59
|
+
const counts = countAminoAcids(seq);
|
|
60
|
+
const points = [];
|
|
61
|
+
for (let index = 0; index < steps; index++) {
|
|
62
|
+
const ph = phMin + ((phMax - phMin) * index) / (steps - 1);
|
|
63
|
+
points.push({ ph: round(ph, 3), charge: round(netCharge(seq, counts, ph, pkaSet), 4) });
|
|
64
|
+
}
|
|
65
|
+
const charges = points.map((point) => point.charge);
|
|
66
|
+
return {
|
|
67
|
+
length: seq.length,
|
|
68
|
+
pka_set: pkaSet,
|
|
69
|
+
pka_label: pk.label,
|
|
70
|
+
ph_min: phMin,
|
|
71
|
+
ph_max: phMax,
|
|
72
|
+
steps,
|
|
73
|
+
pI: isoelectricPoint(seq, counts, pkaSet),
|
|
74
|
+
charge_at_7_4: round(netCharge(seq, counts, 7.4, pkaSet), 4),
|
|
75
|
+
charge_min: Math.min(...charges),
|
|
76
|
+
charge_max: Math.max(...charges),
|
|
77
|
+
points,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render the titration curve as an SVG document: net charge against pH, with
|
|
83
|
+
* the zero line, the pI marker and the charged ends labelled.
|
|
84
|
+
*
|
|
85
|
+
* @param {ReturnType<typeof chargeCurve>} curve
|
|
86
|
+
* @param {{title?: string}} [options]
|
|
87
|
+
* @returns {string} SVG text.
|
|
88
|
+
*/
|
|
89
|
+
export function renderChargeCurve(curve, options = {}) {
|
|
90
|
+
const width = 720;
|
|
91
|
+
const height = 420;
|
|
92
|
+
const frame = panel({
|
|
93
|
+
x: 16,
|
|
94
|
+
y: 16,
|
|
95
|
+
width: width - 32,
|
|
96
|
+
height: height - 32,
|
|
97
|
+
title: options.title ?? 'Net charge vs pH',
|
|
98
|
+
subtitle: curve.length + ' aa · ' + curve.pka_label + ' pK set · pI ' + curve.pI,
|
|
99
|
+
});
|
|
100
|
+
const plot = frame.plot;
|
|
101
|
+
const yMin = Math.min(0, curve.charge_min);
|
|
102
|
+
const yMax = Math.max(0, curve.charge_max);
|
|
103
|
+
const pad = Math.max(0.5, (yMax - yMin) * 0.08);
|
|
104
|
+
const domain = [yMin - pad, yMax + pad];
|
|
105
|
+
const x = linearScale([curve.ph_min, curve.ph_max], [plot.x, plot.x + plot.width]);
|
|
106
|
+
const y = linearScale(domain, [plot.y + plot.height, plot.y]);
|
|
107
|
+
const parts = [frame.markup];
|
|
108
|
+
// Zero-charge line: the sign of the charge is the point of the plot.
|
|
109
|
+
if (domain[0] < 0 && domain[1] > 0) {
|
|
110
|
+
const zero = y.of(0);
|
|
111
|
+
parts.push('<line x1="' + plot.x + '" y1="' + zero + '" x2="' + (plot.x + plot.width) + '" y2="' + zero + '" stroke="#8c959f" stroke-width="1" stroke-dasharray="4 3"/>');
|
|
112
|
+
}
|
|
113
|
+
parts.push(axis({
|
|
114
|
+
from: plot.x,
|
|
115
|
+
to: plot.x + plot.width,
|
|
116
|
+
at: plot.y + plot.height,
|
|
117
|
+
ticks: niceTicks([curve.ph_min, curve.ph_max], 7).map((tick) => x.of(tick)),
|
|
118
|
+
orientation: 'x',
|
|
119
|
+
label: 'pH',
|
|
120
|
+
tickLabel: (pixel) => String(Math.round(x.invert(pixel) * 10) / 10),
|
|
121
|
+
title: 'pH',
|
|
122
|
+
}));
|
|
123
|
+
parts.push(axis({
|
|
124
|
+
from: plot.y,
|
|
125
|
+
to: plot.y + plot.height,
|
|
126
|
+
at: plot.x,
|
|
127
|
+
ticks: niceTicks(domain, 6).map((tick) => y.of(tick)),
|
|
128
|
+
orientation: 'y',
|
|
129
|
+
label: 'net charge',
|
|
130
|
+
tickLabel: (pixel) => String(Math.round(y.invert(pixel) * 10) / 10),
|
|
131
|
+
title: 'net charge (e)',
|
|
132
|
+
}));
|
|
133
|
+
parts.push(series({
|
|
134
|
+
values: curve.points.map((point) => point.charge),
|
|
135
|
+
xOf: (index) => x.of(curve.points[index].ph),
|
|
136
|
+
yOf: (charge) => y.of(charge),
|
|
137
|
+
stroke: '#0969da',
|
|
138
|
+
width: 2,
|
|
139
|
+
}));
|
|
140
|
+
if (curve.pI >= curve.ph_min && curve.pI <= curve.ph_max) {
|
|
141
|
+
const marker = x.of(curve.pI);
|
|
142
|
+
parts.push('<line x1="' + marker + '" y1="' + plot.y + '" x2="' + marker + '" y2="' + (plot.y + plot.height) + '" stroke="#bf3989" stroke-width="1" stroke-dasharray="2 3"/>');
|
|
143
|
+
parts.push(textRun({ x: marker + 4, y: plot.y + 12, text: 'pI ' + curve.pI, size: 9, fill: '#bf3989' }));
|
|
144
|
+
}
|
|
145
|
+
// Label the two ends of the curve with the charges they represent.
|
|
146
|
+
const first = curve.points[0];
|
|
147
|
+
const last = curve.points[curve.points.length - 1];
|
|
148
|
+
parts.push(textRun({ x: x.of(first.ph) + 4, y: y.of(first.charge) - 6, text: 'pH ' + first.ph + ': ' + first.charge, size: 9, fill: '#57606a' }));
|
|
149
|
+
parts.push(textRun({ x: x.of(last.ph) - 4, y: y.of(last.charge) + 14, text: 'pH ' + last.ph + ': ' + last.charge, size: 9, fill: '#57606a', anchor: 'end' }));
|
|
150
|
+
parts.push(rect({ x: plot.x, y: plot.y, width: plot.width, height: plot.height, fill: 'none', stroke: '#d0d7de' }));
|
|
151
|
+
return svgDocument({
|
|
152
|
+
width,
|
|
153
|
+
height,
|
|
154
|
+
title: options.title ?? 'Net charge vs pH',
|
|
155
|
+
description: 'Titration curve for a ' + curve.length + ' aa protein using the ' + curve.pka_label + ' pK set; pI ' + curve.pI + '.',
|
|
156
|
+
body: parts.join('\n'),
|
|
157
|
+
});
|
|
158
|
+
}
|
package/protein-structure.mjs
CHANGED
|
@@ -278,14 +278,22 @@ export function hydropathyProfile(rawSequence, options = {}) {
|
|
|
278
278
|
}
|
|
279
279
|
if (current !== null) peaks.push(current);
|
|
280
280
|
const total = points.reduce((sum, point) => sum + point.hydropathy, 0);
|
|
281
|
+
// Loop rather than Math.min(...points): the protein length is not capped and
|
|
282
|
+
// a long input would overflow the argument limit with a RangeError.
|
|
283
|
+
let minimumHydropathy = Infinity;
|
|
284
|
+
let maximumHydropathy = -Infinity;
|
|
285
|
+
for (const point of points) {
|
|
286
|
+
if (point.hydropathy < minimumHydropathy) minimumHydropathy = point.hydropathy;
|
|
287
|
+
if (point.hydropathy > maximumHydropathy) maximumHydropathy = point.hydropathy;
|
|
288
|
+
}
|
|
281
289
|
return {
|
|
282
290
|
length: sequence.length,
|
|
283
291
|
window,
|
|
284
292
|
threshold,
|
|
285
293
|
gravy: sequence.length === 0 ? 0 : round2([...sequence].reduce((sum, aa) => sum + KYTE_DOOLITTLE[aa], 0) / sequence.length),
|
|
286
294
|
mean_profile: round3(total / points.length),
|
|
287
|
-
minimum_hydropathy: round3(
|
|
288
|
-
maximum_hydropathy: round3(
|
|
295
|
+
minimum_hydropathy: round3(minimumHydropathy),
|
|
296
|
+
maximum_hydropathy: round3(maximumHydropathy),
|
|
289
297
|
peaks: peaks.map((peak) => ({ ...peak, maximum: round3(peak.maximum), length: peak.end - peak.start + 1 })),
|
|
290
298
|
points,
|
|
291
299
|
};
|
package/protein.mjs
CHANGED
|
@@ -32,48 +32,70 @@ const RESIDUE_MASS = {
|
|
|
32
32
|
};
|
|
33
33
|
const WATER = 18.015;
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Selectable pK sets for the Henderson-Hasselbalch charge model. `bjellqvist`
|
|
37
|
+
* is the set `proteinProperties` has always used for its pI; `emboss` is the
|
|
38
|
+
* Epk.dat table EMBOSS ships with `iep`. They are NOT interchangeable — the
|
|
39
|
+
* reported set name travels with every charge/pI value.
|
|
40
|
+
*/
|
|
41
|
+
export const PK_SETS = {
|
|
42
|
+
bjellqvist: {
|
|
43
|
+
label: 'Bjellqvist 1993',
|
|
44
|
+
Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
|
|
45
|
+
K: 10.8, R: 12.0, Y: 10.3,
|
|
46
|
+
},
|
|
47
|
+
emboss: {
|
|
48
|
+
label: 'EMBOSS Epk.dat',
|
|
49
|
+
Nterm: 8.6, Cterm: 3.6, C: 8.5, D: 3.9, E: 4.1, H: 6.5,
|
|
50
|
+
K: 10.8, R: 12.5, Y: 10.1,
|
|
51
|
+
},
|
|
39
52
|
};
|
|
40
53
|
|
|
54
|
+
const PK = PK_SETS.bjellqvist;
|
|
55
|
+
|
|
41
56
|
const HYDROPATHY = {
|
|
42
57
|
A: 1.8, R: -4.5, N: -3.5, D: -3.5, C: 2.5, Q: -3.5, E: -3.5,
|
|
43
58
|
G: -0.4, H: -3.2, I: 4.5, L: 3.8, K: -3.9, M: 1.9, F: 2.8,
|
|
44
59
|
P: -1.6, S: -0.8, T: -0.7, W: -0.9, Y: -1.3, V: 4.2,
|
|
45
60
|
};
|
|
46
61
|
|
|
47
|
-
function countAminoAcids(seq) {
|
|
62
|
+
export function countAminoAcids(seq) {
|
|
48
63
|
const counts = {};
|
|
49
64
|
for (const aa of seq) counts[aa] = (counts[aa] ?? 0) + 1;
|
|
50
65
|
return counts;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Net charge at a given pH by Henderson-Hasselbalch summation. The terminal
|
|
70
|
+
* groups count once each; K/R/H are positive, D/E/C/Y negative. `pkaSet` is a
|
|
71
|
+
* key of PK_SETS (or a set object), so callers can name the table they used.
|
|
72
|
+
*/
|
|
73
|
+
export function netCharge(seq, counts, pH, pkaSet = 'bjellqvist') {
|
|
74
|
+
const pk = typeof pkaSet === 'string' ? PK_SETS[pkaSet] : pkaSet;
|
|
75
|
+
if (pk === undefined) throw new MolbioInputError('unknown pK set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
|
|
54
76
|
let charge = 0;
|
|
55
77
|
const pos = (pKa) => 1 / (1 + Math.pow(10, pH - pKa));
|
|
56
78
|
const neg = (pKa) => 1 / (1 + Math.pow(10, pKa - pH));
|
|
57
|
-
charge += pos(
|
|
58
|
-
for (const [aa, pKa] of [['K',
|
|
79
|
+
charge += pos(pk.Nterm);
|
|
80
|
+
for (const [aa, pKa] of [['K', pk.K], ['R', pk.R], ['H', pk.H]]) {
|
|
59
81
|
if (counts[aa] !== undefined) charge += counts[aa] * pos(pKa);
|
|
60
82
|
}
|
|
61
|
-
charge -= neg(
|
|
62
|
-
for (const [aa, pKa] of [['D',
|
|
83
|
+
charge -= neg(pk.Cterm);
|
|
84
|
+
for (const [aa, pKa] of [['D', pk.D], ['E', pk.E], ['C', pk.C], ['Y', pk.Y]]) {
|
|
63
85
|
if (counts[aa] !== undefined) charge -= counts[aa] * neg(pKa);
|
|
64
86
|
}
|
|
65
87
|
return charge;
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/** Isoelectric point by bisection over the net-charge function. */
|
|
69
|
-
export function isoelectricPoint(seq, counts) {
|
|
91
|
+
export function isoelectricPoint(seq, counts, pkaSet = 'bjellqvist') {
|
|
70
92
|
let lo = 0;
|
|
71
93
|
let hi = 14;
|
|
72
|
-
let chargeLo =
|
|
73
|
-
let chargeHi =
|
|
94
|
+
let chargeLo = netCharge(seq, counts, lo, pkaSet);
|
|
95
|
+
let chargeHi = netCharge(seq, counts, hi, pkaSet);
|
|
74
96
|
for (let i = 0; i < 60; i++) {
|
|
75
97
|
const mid = (lo + hi) / 2;
|
|
76
|
-
const chargeMid =
|
|
98
|
+
const chargeMid = netCharge(seq, counts, mid, pkaSet);
|
|
77
99
|
if (Math.abs(chargeMid) < 1e-4) return Math.round(mid * 100) / 100;
|
|
78
100
|
if (Math.sign(chargeMid) === Math.sign(chargeLo)) {
|
|
79
101
|
lo = mid;
|
package/records.mjs
CHANGED
|
@@ -27,6 +27,11 @@ export async function loadRecords(fs, path, key) {
|
|
|
27
27
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed[key])) {
|
|
28
28
|
throw new Error(`expected an object with a "${key}" array`);
|
|
29
29
|
}
|
|
30
|
+
// See papers.mjs: a null/scalar entry would otherwise reach
|
|
31
|
+
// record.name.trim() (protocols) as a raw TypeError.
|
|
32
|
+
if (!parsed[key].every((record) => typeof record === 'object' && record !== null && !Array.isArray(record))) {
|
|
33
|
+
throw new Error(`every entry in "${key}" must be an object`);
|
|
34
|
+
}
|
|
30
35
|
return parsed;
|
|
31
36
|
} catch (error) {
|
|
32
37
|
throw new MolbioInputError(`${path} is not a valid record file: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -54,7 +59,7 @@ export function addProtocol(records, entry) {
|
|
|
54
59
|
if (typeof entry !== 'object' || entry === null || typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
55
60
|
throw new MolbioInputError('each protocol needs a non-empty "name"');
|
|
56
61
|
}
|
|
57
|
-
if (records.some((record) => record.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) {
|
|
62
|
+
if (records.some((record) => typeof record?.name === 'string' && record.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) {
|
|
58
63
|
throw new MolbioInputError(`a protocol named ${JSON.stringify(entry.name)} already exists`);
|
|
59
64
|
}
|
|
60
65
|
const protocol = {
|
|
@@ -78,7 +83,17 @@ export function addProtocol(records, entry) {
|
|
|
78
83
|
export function updateProtocol(records, id, changes) {
|
|
79
84
|
const protocol = records.find((record) => record.id === id);
|
|
80
85
|
if (protocol === undefined) return undefined;
|
|
81
|
-
if (typeof changes.name === 'string' && changes.name.trim() !== '')
|
|
86
|
+
if (typeof changes.name === 'string' && changes.name.trim() !== '') {
|
|
87
|
+
const nextName = changes.name.trim();
|
|
88
|
+
// addProtocol enforces unique names; update must too, or a rename can
|
|
89
|
+
// create a duplicate the next add refuses to explain.
|
|
90
|
+
if (records.some((record) => record !== protocol
|
|
91
|
+
&& typeof record?.name === 'string'
|
|
92
|
+
&& record.name.trim().toLowerCase() === nextName.toLowerCase())) {
|
|
93
|
+
throw new MolbioInputError(`a protocol named ${JSON.stringify(nextName)} already exists`);
|
|
94
|
+
}
|
|
95
|
+
protocol.name = nextName;
|
|
96
|
+
}
|
|
82
97
|
if (typeof changes.category === 'string' && changes.category.trim() !== '') protocol.category = changes.category.trim();
|
|
83
98
|
if (Array.isArray(changes.steps)) {
|
|
84
99
|
protocol.steps = changes.steps.filter((step) => typeof step === 'string').map((step) => step.trim()).filter((step) => step !== '');
|
package/seqio.mjs
CHANGED
|
@@ -84,12 +84,35 @@ export function toFasta(entries) {
|
|
|
84
84
|
return entries.map((entry) => `>${entry.id}${entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}`).join('\n') + '\n';
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Render entries as four-line FASTQ text (quality is preserved verbatim). */
|
|
88
|
+
export function toFastq(entries) {
|
|
89
|
+
return entries.map((entry) => {
|
|
90
|
+
if (typeof entry.quality !== 'string' || entry.quality.length !== entry.sequence.length) {
|
|
91
|
+
throw new MolbioInputError(`entry ${entry.id}: sequence and quality lengths differ`);
|
|
92
|
+
}
|
|
93
|
+
return `@${entry.id}${entry.description !== undefined && entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}\n+\n${entry.quality}`;
|
|
94
|
+
}).join('\n') + '\n';
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** Summary statistics over parsed entries. */
|
|
88
98
|
export function entryStats(entries, qualityMode = false) {
|
|
89
|
-
|
|
99
|
+
// Count in a loop instead of spreading into Math.min/Math.max: a large FASTQ
|
|
100
|
+
// can hold hundreds of thousands of reads (and millions of quality
|
|
101
|
+
// characters), and spreading such an array overflows the engine's argument
|
|
102
|
+
// limit with a RangeError. The numbers are identical; only the failure mode
|
|
103
|
+
// changes.
|
|
104
|
+
let totalBases = 0;
|
|
105
|
+
let minLength = entries.length === 0 ? 0 : Infinity;
|
|
106
|
+
let maxLength = 0;
|
|
107
|
+
let longest = 0;
|
|
90
108
|
let gc = 0;
|
|
91
109
|
let at = 0;
|
|
92
110
|
for (const entry of entries) {
|
|
111
|
+
const length = entry.sequence.length;
|
|
112
|
+
totalBases += length;
|
|
113
|
+
if (length < minLength) minLength = length;
|
|
114
|
+
if (length > maxLength) maxLength = length;
|
|
115
|
+
if (length > longest) longest = length;
|
|
93
116
|
for (const base of entry.sequence) {
|
|
94
117
|
if (base === 'G' || base === 'C') gc++;
|
|
95
118
|
else if (base === 'A' || base === 'T') at++;
|
|
@@ -97,22 +120,34 @@ export function entryStats(entries, qualityMode = false) {
|
|
|
97
120
|
}
|
|
98
121
|
const stats = {
|
|
99
122
|
entries: entries.length,
|
|
100
|
-
total_bases:
|
|
101
|
-
min_length:
|
|
102
|
-
max_length:
|
|
103
|
-
mean_length:
|
|
123
|
+
total_bases: totalBases,
|
|
124
|
+
min_length: minLength,
|
|
125
|
+
max_length: maxLength,
|
|
126
|
+
mean_length: entries.length === 0 ? 0 : Math.round((totalBases / entries.length) * 100) / 100,
|
|
104
127
|
gc_percent: gc + at === 0 ? 0 : Math.round((gc / (gc + at)) * 1000) / 10,
|
|
105
128
|
};
|
|
106
129
|
if (qualityMode) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
let qualitySum = 0;
|
|
131
|
+
let qualityCount = 0;
|
|
132
|
+
let lowCount = 0;
|
|
133
|
+
let qualityMin = Infinity;
|
|
134
|
+
let qualityMax = -Infinity;
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
for (const ch of entry.quality) {
|
|
137
|
+
const q = ch.charCodeAt(0) - 33;
|
|
138
|
+
qualitySum += q;
|
|
139
|
+
qualityCount++;
|
|
140
|
+
if (q < 20) lowCount++;
|
|
141
|
+
if (q < qualityMin) qualityMin = q;
|
|
142
|
+
if (q > qualityMax) qualityMax = q;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (qualityCount > 0) {
|
|
146
|
+
stats.quality_mean = Math.round((qualitySum / qualityCount) * 100) / 100;
|
|
147
|
+
stats.quality_min = qualityMin;
|
|
148
|
+
stats.quality_max = qualityMax;
|
|
149
|
+
stats.low_quality_fraction = Math.round((lowCount / qualityCount) * 1000) / 1000;
|
|
150
|
+
const maxLen = Math.min(150, longest);
|
|
116
151
|
const perPosition = [];
|
|
117
152
|
for (let pos = 0; pos < maxLen; pos++) {
|
|
118
153
|
const values = entries.filter((e) => e.sequence.length > pos).map((e) => e.quality.charCodeAt(pos) - 33);
|
package/test/charts.mjs
CHANGED
|
@@ -284,6 +284,17 @@ test('histogram binning honours an explicit count and degenerates safely', () =>
|
|
|
284
284
|
assert.ok(tiny.bin_count >= 1 && Number.isFinite(tiny.bin_width), 'a two-value sample still gets a usable bin');
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
test('a table at the row cap does not overflow the argument limit', () => {
|
|
288
|
+
// 130k exceeds the engine's argument limit for Math.min(...values); the
|
|
289
|
+
// chart helpers must use a loop. 130000 is below MAX_ROWS (200000), so the
|
|
290
|
+
// input is one the tool advertises as supported.
|
|
291
|
+
const values = Array.from({ length: 130000 }, (_, index) => index % 1000);
|
|
292
|
+
const shape = histogramBins(values, 10);
|
|
293
|
+
assert.equal(shape.bins.reduce((sum, bin) => sum + bin.count, 0), values.length);
|
|
294
|
+
assert.equal(shape.bins[0].start, 0);
|
|
295
|
+
assert.equal(shape.bins.at(-1).end, 999);
|
|
296
|
+
});
|
|
297
|
+
|
|
287
298
|
// ── CSV / TSV reader ────────────────────────────────────────────────────────
|
|
288
299
|
|
|
289
300
|
test('the reader detects the delimiter and keeps quoted fields intact', () => {
|