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.
Files changed (57) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/README.md +51 -19
  3. package/batch.mjs +5 -1
  4. package/benchmark/README.md +7 -7
  5. package/benchmark/changed.mjs +4 -3
  6. package/benchmark/fixtures.mjs +32 -4
  7. package/benchmark/profile.mjs +1 -1
  8. package/benchmark/run.mjs +1 -0
  9. package/benchmark/sequences.mjs +1 -0
  10. package/benchmark/tasks/02-primers.json +3 -3
  11. package/benchmark/tasks/06-analysis.json +111 -1
  12. package/benchmark/tasks/09-formats.json +44 -1
  13. package/benchmark/verifications.mjs +13 -0
  14. package/build/client-bundle.mjs +1 -1
  15. package/build/client-entry.mjs +80 -1
  16. package/build/panel-core.mjs +85 -0
  17. package/build/preset-patch.mjs +1 -1
  18. package/build/release-preflight.mjs +1 -1
  19. package/charts.mjs +35 -16
  20. package/contamination.mjs +229 -0
  21. package/cordis.patch.yml +10 -6
  22. package/design.mjs +111 -78
  23. package/docs/capability-gap-survey.md +5 -0
  24. package/docs/client-panel.md +10 -8
  25. package/docs/history.md +86 -0
  26. package/docs/maintainer.md +8 -5
  27. package/docs/roadmap.md +37 -15
  28. package/docs/rules.md +5 -2
  29. package/docs/store-evidence.md +30 -23
  30. package/docs/store-preset-exemption-issue.md +1 -1
  31. package/docs/store-preset-exemption.md +1 -1
  32. package/docs/v24-plan.md +178 -0
  33. package/docs/workflow.md +11 -10
  34. package/dotplot.mjs +257 -0
  35. package/fastq-qc.mjs +31 -16
  36. package/fastq-trim.mjs +238 -0
  37. package/host.mjs +2 -2
  38. package/index.mjs +521 -34
  39. package/lib/client.js +376 -109
  40. package/package.json +8 -3
  41. package/papers.mjs +10 -0
  42. package/plot.mjs +24 -8
  43. package/preset/install.mjs +8 -0
  44. package/preset/molbio-lab/agent.cordis.yml +19 -13
  45. package/preset/molbio-lab/preset.yml +1 -1
  46. package/protein-charge.mjs +158 -0
  47. package/protein-structure.mjs +10 -2
  48. package/protein.mjs +36 -14
  49. package/records.mjs +17 -2
  50. package/seqio.mjs +49 -14
  51. package/test/charts.mjs +11 -0
  52. package/test/client.mjs +12 -5
  53. package/test/contract.mjs +44 -12
  54. package/test/drift-probe.mjs +15 -1
  55. package/test/map-card.mjs +139 -1
  56. package/test/preset-health.mjs +39 -0
  57. 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.22.2",
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 hydropathy 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, read-level FASTQ QC, 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.",
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, ...vals);
114
- let max = Math.max(0, ...vals);
127
+ let min = Math.min(0, minValue(vals));
128
+ let max = Math.max(0, maxValue(vals));
115
129
  if (errs !== undefined) {
116
- min = Math.min(min, ...vals.map((v, i) => v - errs[i]));
117
- max = Math.max(max, ...vals.map((v, i) => v + errs[i]));
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 = Math.min(...xs);
162
- let xMax = Math.max(...xs);
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 = Math.min(...ys);
169
- let yMax = Math.max(...ys);
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);
@@ -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 60 tools load. The same bundle patch's
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.1-rc.3 (harness root
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.1-rc.3 ships the SAME row list as 0.2.1-rc.2 (verified by diffing the
57
- # file), so no row changed in this bump. The last upstream row change was
58
- # 0.2.1-rc.2: it added `time-context`, `tool-schedule` and `tool-plugin-market`;
59
- # dropped the two disabled optional subagent providers (`tool-subagent-codex`,
60
- # `tool-subagent-claude-code`) and `tool-ralph`; dropped the persona `suffix` and
61
- # both `backgroundMode: continuable` settings; and reworded the plan-mode
62
- # section. Absorb such a bump verbatim — see workflow.md §4.
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
- modelSelectionSettings: true
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 (60 tools across sequence
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: 分子生物学专属模式:标准编码能力 + 60 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。
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
+ }
@@ -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(Math.min(...points.map((point) => point.hydropathy))),
288
- maximum_hydropathy: round3(Math.max(...points.map((point) => point.hydropathy))),
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
- // Bjellqvist 1993 pK values (EMBOSS-style)
36
- const PK = {
37
- Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
38
- K: 10.8, R: 12.0, Y: 10.3,
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
- function chargeAt(seq, counts, pH) {
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(PK.Nterm);
58
- for (const [aa, pKa] of [['K', PK.K], ['R', PK.R], ['H', PK.H]]) {
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(PK.Cterm);
62
- for (const [aa, pKa] of [['D', PK.D], ['E', PK.E], ['C', PK.C], ['Y', PK.Y]]) {
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 = chargeAt(seq, counts, lo);
73
- let chargeHi = chargeAt(seq, counts, hi);
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 = chargeAt(seq, counts, mid);
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() !== '') protocol.name = 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
- const lengths = entries.map((entry) => entry.sequence.length);
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: lengths.reduce((sum, len) => sum + len, 0),
101
- min_length: Math.min(...lengths),
102
- max_length: Math.max(...lengths),
103
- mean_length: Math.round((lengths.reduce((sum, len) => sum + len, 0) / lengths.length) * 100) / 100,
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
- const allQ = entries.flatMap((entry) => [...entry.quality].map((ch) => ch.charCodeAt(0) - 33));
108
- if (allQ.length > 0) {
109
- const mean = allQ.reduce((sum, q) => sum + q, 0) / allQ.length;
110
- const low = allQ.filter((q) => q < 20).length / allQ.length;
111
- stats.quality_mean = Math.round(mean * 100) / 100;
112
- stats.quality_min = Math.min(...allQ);
113
- stats.quality_max = Math.max(...allQ);
114
- stats.low_quality_fraction = Math.round(low * 1000) / 1000;
115
- const maxLen = Math.min(150, Math.max(...entries.map((e) => e.sequence.length)));
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', () => {