dsh-molbio-panel 0.1.2 → 0.1.4
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/lib/client.js +665 -86
- package/package.json +12 -1
package/lib/client.js
CHANGED
|
@@ -1813,13 +1813,29 @@ window.__ModuleLoader__.load({
|
|
|
1813
1813
|
const { MolbioInputError } = __molbio_require("lib.mjs");
|
|
1814
1814
|
// ── minimal XML scanner ─────────────────────────────────────────────────────
|
|
1815
1815
|
|
|
1816
|
+
/**
|
|
1817
|
+
* Whether a numeric entity names a character this decoder may emit.
|
|
1818
|
+
*
|
|
1819
|
+
* `String.fromCodePoint` THROWS a RangeError above U+10FFFF, and a RangeError
|
|
1820
|
+
* raised from inside a `replace` callback escapes the whole parser as an error
|
|
1821
|
+
* type no caller handles — a crafted `�` in a SnapGene feature
|
|
1822
|
+
* qualifier (or in a PubMed response) turned `molbio_parse_snapgene` into a
|
|
1823
|
+
* crash instead of a MolbioInputError. Surrogates are rejected too: they are not
|
|
1824
|
+
* characters, and emitting a lone one would corrupt the value rather than
|
|
1825
|
+
* reject the reference. An unreadable entity is left as literal text, which is
|
|
1826
|
+
* what the named-entity branch already does.
|
|
1827
|
+
*/
|
|
1828
|
+
function isXmlCodePoint(code) {
|
|
1829
|
+
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1816
1832
|
function decodeEntities(value) {
|
|
1817
1833
|
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
|
|
1818
1834
|
if (entity[0] === '#') {
|
|
1819
1835
|
const code = entity[1] === 'x' || entity[1] === 'X'
|
|
1820
1836
|
? parseInt(entity.slice(2), 16)
|
|
1821
1837
|
: parseInt(entity.slice(1), 10);
|
|
1822
|
-
return
|
|
1838
|
+
return isXmlCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
1823
1839
|
}
|
|
1824
1840
|
const named = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' };
|
|
1825
1841
|
return named[entity] ?? match;
|
|
@@ -3985,6 +4001,138 @@ window.__ModuleLoader__.load({
|
|
|
3985
4001
|
/** Default SpCas9 PAM. */
|
|
3986
4002
|
const DEFAULT_PAM = 'NGG';
|
|
3987
4003
|
|
|
4004
|
+
/**
|
|
4005
|
+
* Cas-family geometry, in ONE table.
|
|
4006
|
+
*
|
|
4007
|
+
* The original design hard-coded SpCas9's shape everywhere: a 3' PAM, a 20 nt
|
|
4008
|
+
* protospacer, and a seed at the guide's 3' end. That is correct for SpCas9 and
|
|
4009
|
+
* SaCas9, but it is wrong for Cas12a (5' TTTV PAM, 23 nt crRNA, PAM-proximal 5'
|
|
4010
|
+
* seed) and meaningless for Cas13 (an RNA target with a PFS preference, not a
|
|
4011
|
+
* PAM). The table below is the fact each family is scored against; the geometry
|
|
4012
|
+
* and scoring code read it instead of assuming SpCas9.
|
|
4013
|
+
*
|
|
4014
|
+
* Sources: SpCas9 NGG / 20 nt; SaCas9 NNGRRT / 21 nt; Cas12a (LbCpf1, AsCpf1)
|
|
4015
|
+
* 5'-TTTV / 23 nt; LwaCas13a 3' H PFS / 28 nt; PspCas13b 5' D + 3' NAN PFS /
|
|
4016
|
+
* 30 nt; RfxCas13d no PFS / 22 nt. PFS preferences are NOT hard PAMs: a guide
|
|
4017
|
+
* without the preferred PFS can still work, so it is penalised and reported
|
|
4018
|
+
* rather than silently discarded unless the caller sets require_pfs.
|
|
4019
|
+
*/
|
|
4020
|
+
const CAS_FAMILIES = {
|
|
4021
|
+
spcas9: {
|
|
4022
|
+
id: 'spcas9',
|
|
4023
|
+
label: 'SpCas9',
|
|
4024
|
+
pam: 'NGG',
|
|
4025
|
+
pamSide: '3prime',
|
|
4026
|
+
guideLength: 20,
|
|
4027
|
+
guideLengthMin: 15,
|
|
4028
|
+
guideLengthMax: 30,
|
|
4029
|
+
seedSide: '3prime',
|
|
4030
|
+
seedLength: 12,
|
|
4031
|
+
seedMismatchWindow: 2,
|
|
4032
|
+
target: 'DNA',
|
|
4033
|
+
tmModel: 'dna',
|
|
4034
|
+
positionBonus: { index: 20, base: 'G', points: 4 },
|
|
4035
|
+
gcClampNote: true,
|
|
4036
|
+
pfs: null,
|
|
4037
|
+
},
|
|
4038
|
+
sacas9: {
|
|
4039
|
+
id: 'sacas9',
|
|
4040
|
+
label: 'SaCas9',
|
|
4041
|
+
pam: 'NNGRRT',
|
|
4042
|
+
pamSide: '3prime',
|
|
4043
|
+
guideLength: 21,
|
|
4044
|
+
guideLengthMin: 15,
|
|
4045
|
+
guideLengthMax: 30,
|
|
4046
|
+
seedSide: '3prime',
|
|
4047
|
+
seedLength: 12,
|
|
4048
|
+
seedMismatchWindow: 2,
|
|
4049
|
+
target: 'DNA',
|
|
4050
|
+
tmModel: 'dna',
|
|
4051
|
+
positionBonus: null,
|
|
4052
|
+
gcClampNote: true,
|
|
4053
|
+
pfs: null,
|
|
4054
|
+
},
|
|
4055
|
+
cas12a: {
|
|
4056
|
+
id: 'cas12a',
|
|
4057
|
+
label: 'Cas12a (Cpf1)',
|
|
4058
|
+
pam: 'TTTV',
|
|
4059
|
+
pamSide: '5prime',
|
|
4060
|
+
guideLength: 23,
|
|
4061
|
+
guideLengthMin: 15,
|
|
4062
|
+
guideLengthMax: 30,
|
|
4063
|
+
seedSide: '5prime',
|
|
4064
|
+
seedLength: 8,
|
|
4065
|
+
seedMismatchWindow: 2,
|
|
4066
|
+
target: 'DNA',
|
|
4067
|
+
tmModel: 'dna',
|
|
4068
|
+
positionBonus: null,
|
|
4069
|
+
gcClampNote: false,
|
|
4070
|
+
pfs: null,
|
|
4071
|
+
},
|
|
4072
|
+
cas13a: {
|
|
4073
|
+
id: 'cas13a',
|
|
4074
|
+
label: 'Cas13a (LwaCas13a)',
|
|
4075
|
+
pam: null,
|
|
4076
|
+
pamSide: 'none',
|
|
4077
|
+
guideLength: 28,
|
|
4078
|
+
guideLengthMin: 15,
|
|
4079
|
+
guideLengthMax: 30,
|
|
4080
|
+
seedSide: null,
|
|
4081
|
+
seedLength: 0,
|
|
4082
|
+
seedMismatchWindow: 0,
|
|
4083
|
+
target: 'RNA',
|
|
4084
|
+
tmModel: 'none',
|
|
4085
|
+
positionBonus: null,
|
|
4086
|
+
gcClampNote: false,
|
|
4087
|
+
pfs: { fivePrime: null, threePrime: 'H', label: "3' H (A/C/U) — a preference, not a PAM" },
|
|
4088
|
+
},
|
|
4089
|
+
cas13b: {
|
|
4090
|
+
id: 'cas13b',
|
|
4091
|
+
label: 'Cas13b (PspCas13b)',
|
|
4092
|
+
pam: null,
|
|
4093
|
+
pamSide: 'none',
|
|
4094
|
+
guideLength: 30,
|
|
4095
|
+
guideLengthMin: 15,
|
|
4096
|
+
guideLengthMax: 30,
|
|
4097
|
+
seedSide: null,
|
|
4098
|
+
seedLength: 0,
|
|
4099
|
+
seedMismatchWindow: 0,
|
|
4100
|
+
target: 'RNA',
|
|
4101
|
+
tmModel: 'none',
|
|
4102
|
+
positionBonus: null,
|
|
4103
|
+
gcClampNote: false,
|
|
4104
|
+
pfs: { fivePrime: 'D', threePrime: 'NAN', label: "5' D (A/G/U) and 3' NAN — preferences, not a PAM" },
|
|
4105
|
+
},
|
|
4106
|
+
cas13d: {
|
|
4107
|
+
id: 'cas13d',
|
|
4108
|
+
label: 'Cas13d (RfxCas13d)',
|
|
4109
|
+
pam: null,
|
|
4110
|
+
pamSide: 'none',
|
|
4111
|
+
guideLength: 22,
|
|
4112
|
+
guideLengthMin: 15,
|
|
4113
|
+
guideLengthMax: 30,
|
|
4114
|
+
seedSide: null,
|
|
4115
|
+
seedLength: 0,
|
|
4116
|
+
seedMismatchWindow: 0,
|
|
4117
|
+
target: 'RNA',
|
|
4118
|
+
tmModel: 'none',
|
|
4119
|
+
positionBonus: null,
|
|
4120
|
+
gcClampNote: false,
|
|
4121
|
+
pfs: null,
|
|
4122
|
+
},
|
|
4123
|
+
};
|
|
4124
|
+
|
|
4125
|
+
const CAS_FAMILY_NAMES = Object.keys(CAS_FAMILIES);
|
|
4126
|
+
|
|
4127
|
+
/** Look up a family, with a useful error instead of a silent SpCas9 fallback. */
|
|
4128
|
+
function resolveCasFamily(raw = 'spcas9') {
|
|
4129
|
+
const family = CAS_FAMILIES[raw];
|
|
4130
|
+
if (family === undefined) {
|
|
4131
|
+
throw new MolbioInputError(`unknown cas_family ${JSON.stringify(raw)}; known: ${CAS_FAMILY_NAMES.join(', ')}`);
|
|
4132
|
+
}
|
|
4133
|
+
return family;
|
|
4134
|
+
}
|
|
4135
|
+
|
|
3988
4136
|
/** Hard caps, so one call cannot enumerate an unbounded search space. */
|
|
3989
4137
|
const CRISPR_LIMITS = {
|
|
3990
4138
|
maxSequenceLength: 200_000,
|
|
@@ -3992,37 +4140,116 @@ window.__ModuleLoader__.load({
|
|
|
3992
4140
|
maxMismatches: 4,
|
|
3993
4141
|
maxVariantsPerGuide: 400_000,
|
|
3994
4142
|
maxReportedSites: 20,
|
|
4143
|
+
// A Cas13 scan has no PAM to thin the candidate set: every window of the
|
|
4144
|
+
// guide length is a candidate. 200 kb would be ~200,000 windows and each
|
|
4145
|
+
// evaluation computes two folding scores, so cap it and ask for a region.
|
|
4146
|
+
maxCas13Candidates: 50_000,
|
|
3995
4147
|
};
|
|
3996
4148
|
|
|
4149
|
+
/** Whether one IUPAC symbol stands for more than one base. */
|
|
4150
|
+
function isDegenerateSymbol(symbol) {
|
|
4151
|
+
const mask = IUPAC_MASKS[symbol];
|
|
4152
|
+
if (mask === undefined) return false;
|
|
4153
|
+
let count = 0;
|
|
4154
|
+
for (let bits = mask; bits !== 0; bits &= bits - 1) count++;
|
|
4155
|
+
return count > 1;
|
|
4156
|
+
}
|
|
4157
|
+
|
|
3997
4158
|
/**
|
|
3998
|
-
* Validate a PAM pattern: IUPAC DNA, 2-6 nt, with the
|
|
3999
|
-
*
|
|
4000
|
-
*
|
|
4159
|
+
* Validate a PAM pattern: IUPAC DNA, 2-6 nt, with the PAM position ADJACENT to
|
|
4160
|
+
* the protospacer degenerate.
|
|
4161
|
+
*
|
|
4162
|
+
* For a 3' PAM (SpCas9 NGG, SaCas9 NNGRRT) that is the 5'-most position, as
|
|
4163
|
+
* before. For a 5' PAM (Cas12a TTTV) it is the 3'-most position: the V is the
|
|
4164
|
+
* base next to the protospacer. Requiring "N first" for Cas12a would reject the
|
|
4165
|
+
* real PAM, so the side is an explicit argument rather than an assumption.
|
|
4001
4166
|
*/
|
|
4002
|
-
function assertPamPattern(raw) {
|
|
4167
|
+
function assertPamPattern(raw, side = '3prime') {
|
|
4003
4168
|
if (typeof raw !== 'string') throw new MolbioInputError('pam must be a string');
|
|
4004
4169
|
const pam = normalizeSequence(raw, 'pam');
|
|
4005
4170
|
if (pam.length < 2 || pam.length > 6) {
|
|
4006
4171
|
throw new MolbioInputError(`pam must be 2-6 nt (got ${pam.length})`);
|
|
4007
4172
|
}
|
|
4008
|
-
if (
|
|
4173
|
+
if (side === '5prime') {
|
|
4174
|
+
const anchor = pam[pam.length - 1];
|
|
4175
|
+
if (!isDegenerateSymbol(anchor)) {
|
|
4176
|
+
throw new MolbioInputError(`a 5' PAM must end with a degenerate position (got ${JSON.stringify(pam)}) — the base next to the protospacer cannot be fixed`);
|
|
4177
|
+
}
|
|
4178
|
+
return pam;
|
|
4179
|
+
}
|
|
4180
|
+
if (!isDegenerateSymbol(pam[0])) {
|
|
4009
4181
|
throw new MolbioInputError(`pam must start with the degenerate position N (got ${JSON.stringify(pam)}) — a fixed first position cannot anchor a protospacer`);
|
|
4010
4182
|
}
|
|
4011
4183
|
return pam;
|
|
4012
4184
|
}
|
|
4013
4185
|
|
|
4014
4186
|
/**
|
|
4015
|
-
*
|
|
4187
|
+
* The PFS observed around one top-strand protospacer window, as read on the
|
|
4188
|
+
* guide's own strand.
|
|
4189
|
+
*
|
|
4190
|
+
* Cas13 PFS elements are preferences, not PAMs, and PspCas13b has one on each
|
|
4191
|
+
* side. The flanks are therefore part of the candidate object (reported and
|
|
4192
|
+
* optionally filtered), not a reason to drop a window before the caller sees it.
|
|
4193
|
+
*/
|
|
4194
|
+
function observedPfs(seq, start, guideLength, strand, pfs) {
|
|
4195
|
+
if (pfs === null) return { five: '', three: '', sequence: '', ok: true };
|
|
4196
|
+
// For the reverse strand the guide is the reverse complement of the top
|
|
4197
|
+
// strand, so its 5' flank is the top-strand base AFTER the protospacer and its
|
|
4198
|
+
// 3' flank is the top-strand base BEFORE it. Reading both through the same
|
|
4199
|
+
// reverse-complement helper is what keeps the two ends from being swapped.
|
|
4200
|
+
const fiveTop = strand === 'forward' ? start - 1 : start + guideLength;
|
|
4201
|
+
const threeTop = strand === 'forward' ? start + guideLength : start - 1;
|
|
4202
|
+
const read = (from, length) => {
|
|
4203
|
+
if (from < 0 || from + length > seq.length) return undefined;
|
|
4204
|
+
const top = seq.slice(from, from + length);
|
|
4205
|
+
return strand === 'forward' ? top : reverseComplement(top);
|
|
4206
|
+
};
|
|
4207
|
+
const five = pfs.fivePrime === null ? '' : read(fiveTop, pfs.fivePrime.length) ?? '';
|
|
4208
|
+
const three = pfs.threePrime === null ? '' : read(threeTop, pfs.threePrime.length) ?? '';
|
|
4209
|
+
const okFive = pfs.fivePrime === null || (five !== '' && pamMatches(five, pfs.fivePrime));
|
|
4210
|
+
const okThree = pfs.threePrime === null || (three !== '' && pamMatches(three, pfs.threePrime));
|
|
4211
|
+
return { five, three, sequence: [five, three].filter((value) => value !== '').join('/'), ok: okFive && okThree };
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
/**
|
|
4215
|
+
* Every PAM-anchored (or, for Cas13, PFS-flanked) protospacer in `seq`.
|
|
4216
|
+
*
|
|
4016
4217
|
* @param {string} seq normalized (ACGT-only) sequence.
|
|
4017
|
-
* @param {{pam?: string, guideLength?: number
|
|
4218
|
+
* @param {{pam?: string, pamSide?: '3prime'|'5prime'|'none', guideLength?: number,
|
|
4219
|
+
* family?: object, targetStrand?: 'forward'|'reverse'|'both'}} [options]
|
|
4018
4220
|
* @returns {Array<{strand: 'forward'|'reverse', start: number, end: number,
|
|
4019
|
-
* sequence: string, pam: string, pam_start: number}>}
|
|
4020
|
-
* 1-based inclusive top-strand positions; `pam_start` is
|
|
4021
|
-
* Ordered by top-strand position, forward before reverse at a tie.
|
|
4221
|
+
* sequence: string, pam: string, pam_start: number, pfs?: string, pfs_ok?: boolean}>}
|
|
4222
|
+
* `start`/`end` are 1-based inclusive top-strand positions; `pam_start` is
|
|
4223
|
+
* 1-based too. Ordered by top-strand position, forward before reverse at a tie.
|
|
4022
4224
|
*/
|
|
4023
4225
|
function findProtospacers(seq, options = {}) {
|
|
4024
|
-
const
|
|
4025
|
-
const guideLength = options.guideLength ?? 20;
|
|
4226
|
+
const pamSide = options.pamSide ?? '3prime';
|
|
4227
|
+
const guideLength = options.guideLength ?? options.family?.guideLength ?? 20;
|
|
4228
|
+
if (pamSide === 'none') {
|
|
4229
|
+
const family = options.family ?? CAS_FAMILIES.cas13a;
|
|
4230
|
+
const targetStrand = options.targetStrand ?? 'forward';
|
|
4231
|
+
const strands = targetStrand === 'both' ? ['forward', 'reverse'] : [targetStrand];
|
|
4232
|
+
const hits = [];
|
|
4233
|
+
for (const strand of strands) {
|
|
4234
|
+
for (let start = 0; start + guideLength <= seq.length; start++) {
|
|
4235
|
+
const pfs = observedPfs(seq, start, guideLength, strand, family.pfs);
|
|
4236
|
+
hits.push({
|
|
4237
|
+
strand,
|
|
4238
|
+
start: start + 1,
|
|
4239
|
+
end: start + guideLength,
|
|
4240
|
+
sequence: strand === 'forward' ? seq.slice(start, start + guideLength) : reverseComplement(seq.slice(start, start + guideLength)),
|
|
4241
|
+
pam: '',
|
|
4242
|
+
pam_start: 0,
|
|
4243
|
+
pfs: pfs.sequence,
|
|
4244
|
+
pfs_ok: pfs.ok,
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
hits.sort((a, b) => a.start - b.start || a.end - b.end || a.strand.localeCompare(b.strand));
|
|
4249
|
+
return hits;
|
|
4250
|
+
}
|
|
4251
|
+
const pam = assertPamPattern(options.pam ?? options.family?.pam ?? DEFAULT_PAM, pamSide);
|
|
4252
|
+
const pamLength = pam.length;
|
|
4026
4253
|
const rcPam = reverseComplement(pam);
|
|
4027
4254
|
const hits = [];
|
|
4028
4255
|
const scan = (pattern, strand) => {
|
|
@@ -4038,39 +4265,51 @@ window.__ModuleLoader__.load({
|
|
|
4038
4265
|
}
|
|
4039
4266
|
}
|
|
4040
4267
|
if (!matched) continue;
|
|
4268
|
+
const pamStart = start; // 0-based top-strand PAM start, both strands
|
|
4269
|
+
let protospacerStart;
|
|
4270
|
+
let protospacerEnd; // 0-based inclusive
|
|
4271
|
+
let pamTop;
|
|
4041
4272
|
if (strand === 'forward') {
|
|
4042
|
-
|
|
4043
|
-
if (
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
}
|
|
4273
|
+
pamTop = seq.slice(pamStart, pamStart + pamLength);
|
|
4274
|
+
if (pamSide === '3prime') {
|
|
4275
|
+
// PAM is downstream of the protospacer on the top strand.
|
|
4276
|
+
protospacerStart = pamStart - guideLength;
|
|
4277
|
+
protospacerEnd = pamStart - 1;
|
|
4278
|
+
} else {
|
|
4279
|
+
// Cas12a: PAM is upstream of the protospacer on the top strand.
|
|
4280
|
+
protospacerStart = pamStart + pamLength;
|
|
4281
|
+
protospacerEnd = protospacerStart + guideLength - 1;
|
|
4282
|
+
}
|
|
4052
4283
|
} else {
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
end: protospacerEnd,
|
|
4065
|
-
sequence: reverseComplement(seq.slice(pamEnd, protospacerEnd)),
|
|
4066
|
-
pam: reverseComplement(seq.slice(start, pamEnd)),
|
|
4067
|
-
pam_start: start + 1,
|
|
4068
|
-
});
|
|
4284
|
+
pamTop = reverseComplement(seq.slice(pamStart, pamStart + pamLength));
|
|
4285
|
+
if (pamSide === '3prime') {
|
|
4286
|
+
// On the reverse strand the top-strand PAM is UPSTREAM of the
|
|
4287
|
+
// top-strand protospacer.
|
|
4288
|
+
protospacerStart = pamStart + pamLength;
|
|
4289
|
+
protospacerEnd = protospacerStart + guideLength - 1;
|
|
4290
|
+
} else {
|
|
4291
|
+
// Cas12a on the reverse strand: the top-strand PAM is DOWNSTREAM.
|
|
4292
|
+
protospacerEnd = pamStart - 1;
|
|
4293
|
+
protospacerStart = protospacerEnd - guideLength + 1;
|
|
4294
|
+
}
|
|
4069
4295
|
}
|
|
4296
|
+
if (protospacerStart < 0 || protospacerEnd >= seq.length) continue;
|
|
4297
|
+
const topProto = seq.slice(protospacerStart, protospacerEnd + 1);
|
|
4298
|
+
hits.push({
|
|
4299
|
+
strand,
|
|
4300
|
+
start: protospacerStart + 1,
|
|
4301
|
+
end: protospacerEnd + 1,
|
|
4302
|
+
sequence: strand === 'forward' ? topProto : reverseComplement(topProto),
|
|
4303
|
+
pam: pamTop,
|
|
4304
|
+
pam_start: pamStart + 1,
|
|
4305
|
+
});
|
|
4070
4306
|
}
|
|
4071
4307
|
};
|
|
4072
4308
|
scan(pam, 'forward');
|
|
4073
|
-
|
|
4309
|
+
// Palindromic PAM patterns are legitimate (e.g. an NNNN test pattern), and
|
|
4310
|
+
// the reverse-strand geometry is a different site even when the text matches,
|
|
4311
|
+
// so both scans run unconditionally.
|
|
4312
|
+
scan(rcPam, 'reverse');
|
|
4074
4313
|
hits.sort((a, b) => a.start - b.start || a.end - b.end || a.strand.localeCompare(b.strand));
|
|
4075
4314
|
return hits;
|
|
4076
4315
|
}
|
|
@@ -4104,25 +4343,32 @@ window.__ModuleLoader__.load({
|
|
|
4104
4343
|
* reject_reasons: string[]}}
|
|
4105
4344
|
*/
|
|
4106
4345
|
function evaluateGuide(guide, options = {}) {
|
|
4346
|
+
const family = options.family ?? CAS_FAMILIES.spcas9;
|
|
4107
4347
|
const notes = [];
|
|
4108
4348
|
const rejectReasons = [];
|
|
4109
4349
|
const gc = gcFraction(guide);
|
|
4110
4350
|
const gcPercent = Math.round(gc * 1000) / 10;
|
|
4111
4351
|
let tm = null;
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4352
|
+
if (family.tmModel === 'dna') {
|
|
4353
|
+
try {
|
|
4354
|
+
tm = primerTm(guide, {
|
|
4355
|
+
naMm: options.naMm ?? 50,
|
|
4356
|
+
mgMm: options.mgMm ?? 1.5,
|
|
4357
|
+
dntpMm: options.dntpMm ?? 0.8,
|
|
4358
|
+
primerNm: options.primerNm ?? 200,
|
|
4359
|
+
}).tm_celsius;
|
|
4360
|
+
} catch {
|
|
4361
|
+
tm = null; // outside the physical regime of the NN model
|
|
4362
|
+
notes.push('Tm could not be estimated with the NN model for this sequence');
|
|
4363
|
+
}
|
|
4364
|
+
} else {
|
|
4365
|
+
notes.push('Cas13 targets RNA; the DNA nearest-neighbour Tm is not reported for this guide (it would be a different molecule)');
|
|
4122
4366
|
}
|
|
4123
4367
|
const selfAny = round2(selfAnyScore(guide));
|
|
4124
4368
|
const selfEnd = round2(selfEndScoreSafe(guide));
|
|
4125
|
-
const seedAny =
|
|
4369
|
+
const seedAny = family.seedSide === null
|
|
4370
|
+
? 0
|
|
4371
|
+
: round2(selfAnyScore(family.seedSide === '5prime' ? guide.slice(0, family.seedLength) : guide.slice(-family.seedLength)));
|
|
4126
4372
|
let hairpinTm = null;
|
|
4127
4373
|
try {
|
|
4128
4374
|
const hairpins = hairpinThermo(guide);
|
|
@@ -4144,11 +4390,17 @@ window.__ModuleLoader__.load({
|
|
|
4144
4390
|
if (gcPercent < gcMin) rejectReasons.push(`GC ${gcPercent}% below the minimum ${gcMin}%`);
|
|
4145
4391
|
if (gcPercent > gcMax) rejectReasons.push(`GC ${gcPercent}% above the maximum ${gcMax}%`);
|
|
4146
4392
|
if (tRun > (options.maxTRun ?? 4)) rejectReasons.push(`poly-T run of ${tRun} (limit ${options.maxTRun ?? 4}) — a U6 terminator signal`);
|
|
4147
|
-
if (options.avoidGcClamp !== false && (guide[guide.length - 1] === 'G' || guide[guide.length - 1] === 'C') && (guide[guide.length - 2] === 'G' || guide[guide.length - 2] === 'C')) {
|
|
4393
|
+
if (family.gcClampNote && options.avoidGcClamp !== false && (guide[guide.length - 1] === 'G' || guide[guide.length - 1] === 'C') && (guide[guide.length - 2] === 'G' || guide[guide.length - 2] === 'C')) {
|
|
4148
4394
|
notes.push('3\' end is a GG/GC/CC/CG clamp, which can favour seed-independent extension');
|
|
4149
4395
|
}
|
|
4150
4396
|
if (selfEnd >= (options.maxSelfEnd ?? 3)) rejectReasons.push(`3' self-complementarity score ${selfEnd} at or above ${options.maxSelfEnd ?? 3}`);
|
|
4151
4397
|
if (options.avoidGcRun !== false && /G{4,}|C{4,}/.test(guide)) rejectReasons.push('homopolymer run of 4+ G/C');
|
|
4398
|
+
const pfsOk = options.pfsOk ?? true;
|
|
4399
|
+
if (family.pfs !== null && options.requirePfs === true && !pfsOk) {
|
|
4400
|
+
rejectReasons.push(`the preferred ${family.pfs.label} PFS is absent; a PFS is a preference, so set require_pfs=false to keep this guide`);
|
|
4401
|
+
} else if (family.pfs !== null && options.requirePfs !== true && !pfsOk) {
|
|
4402
|
+
notes.push(`the preferred ${family.pfs.label} PFS is absent (a preference, not a PAM; it costs ranking points)`);
|
|
4403
|
+
}
|
|
4152
4404
|
|
|
4153
4405
|
const scored = scoreGuide({
|
|
4154
4406
|
gcPercent,
|
|
@@ -4157,8 +4409,10 @@ window.__ModuleLoader__.load({
|
|
|
4157
4409
|
longestHomopolymer,
|
|
4158
4410
|
selfAny,
|
|
4159
4411
|
selfEnd,
|
|
4412
|
+
seedAny,
|
|
4160
4413
|
hairpinTm,
|
|
4161
4414
|
guide,
|
|
4415
|
+
pfsOk,
|
|
4162
4416
|
offTargetCount: options.offTargetCount ?? 0,
|
|
4163
4417
|
}, options);
|
|
4164
4418
|
return {
|
|
@@ -4206,28 +4460,37 @@ window.__ModuleLoader__.load({
|
|
|
4206
4460
|
* NOT fitted parameters.
|
|
4207
4461
|
*/
|
|
4208
4462
|
function scoreGuide(metrics, options) {
|
|
4463
|
+
const family = options.family ?? CAS_FAMILIES.spcas9;
|
|
4209
4464
|
const cap = (value, max) => Math.min(value, max);
|
|
4210
|
-
const penalties = { gc: 0, tm: 0, poly_t: 0, homopolymer: 0, self: 0, seed: 0, hairpin: 0, off_target: 0 };
|
|
4465
|
+
const penalties = { gc: 0, tm: 0, poly_t: 0, homopolymer: 0, self: 0, seed: 0, hairpin: 0, pfs: 0, off_target: 0 };
|
|
4211
4466
|
const gcMin = options.gcMin ?? 40;
|
|
4212
4467
|
const gcMax = options.gcMax ?? 70;
|
|
4213
4468
|
const tmMin = options.tmMin ?? 50;
|
|
4214
4469
|
const tmMax = options.tmMax ?? 70;
|
|
4215
4470
|
if (metrics.gcPercent < gcMin) penalties.gc = cap((gcMin - metrics.gcPercent) * 1.0, 20);
|
|
4216
4471
|
if (metrics.gcPercent > gcMax) penalties.gc = cap((metrics.gcPercent - gcMax) * 1.0, 20);
|
|
4217
|
-
if (
|
|
4218
|
-
|
|
4219
|
-
|
|
4472
|
+
if (family.tmModel === 'dna') {
|
|
4473
|
+
if (metrics.tm === null) penalties.tm = 12;
|
|
4474
|
+
else if (metrics.tm < tmMin) penalties.tm = cap((tmMin - metrics.tm) * 0.5, 20);
|
|
4475
|
+
else if (metrics.tm > tmMax) penalties.tm = cap((metrics.tm - tmMax) * 0.5, 20);
|
|
4476
|
+
}
|
|
4220
4477
|
if (metrics.tRun > 3) penalties.poly_t = cap((metrics.tRun - 3) * 4, 12);
|
|
4221
4478
|
if (metrics.longestHomopolymer > 4) penalties.homopolymer = cap((metrics.longestHomopolymer - 4) * 4, 12);
|
|
4222
4479
|
penalties.self = cap(2 * Math.max(0, metrics.selfAny - 8) + 4 * metrics.selfEnd, 16);
|
|
4223
|
-
const seedAny =
|
|
4224
|
-
if (seedAny >= 6) penalties.seed = cap(4 + 0.5 * (seedAny - 6), 12);
|
|
4480
|
+
const seedAny = metrics.seedAny ?? 0;
|
|
4481
|
+
if (family.seedSide !== null && seedAny >= 6) penalties.seed = cap(4 + 0.5 * (seedAny - 6), 12);
|
|
4225
4482
|
if (metrics.hairpinTm !== null && metrics.hairpinTm >= (options.maxHairpinTm ?? 47)) {
|
|
4226
4483
|
penalties.hairpin = cap(2 + (metrics.hairpinTm - (options.maxHairpinTm ?? 47)) * 0.5, 12);
|
|
4227
4484
|
}
|
|
4485
|
+
// A missing preferred PFS is a ranking penalty, never a hard rejection unless
|
|
4486
|
+
// require_pfs was set. 10 points puts it near the other strong sequence
|
|
4487
|
+
// signals but below an actual off-target site.
|
|
4488
|
+
if (family.pfs !== null && metrics.pfsOk === false) penalties.pfs = 10;
|
|
4228
4489
|
penalties.off_target = 8 * (metrics.offTargetCount ?? 0);
|
|
4229
4490
|
const total = Object.values(penalties).reduce((sum, value) => sum + value, 0);
|
|
4230
|
-
const bonus = metrics.guide[
|
|
4491
|
+
const bonus = family.positionBonus !== null && metrics.guide[family.positionBonus.index - 1] === family.positionBonus.base
|
|
4492
|
+
? family.positionBonus.points
|
|
4493
|
+
: 0;
|
|
4231
4494
|
const score = Math.round(Math.max(0, Math.min(100, 100 - total + bonus)) * 10) / 10;
|
|
4232
4495
|
return { score, penalties };
|
|
4233
4496
|
}
|
|
@@ -4284,12 +4547,21 @@ window.__ModuleLoader__.load({
|
|
|
4284
4547
|
*/
|
|
4285
4548
|
function offTargetSites(guide, index, options = {}) {
|
|
4286
4549
|
const maxMismatches = options.maxMismatches ?? 2;
|
|
4287
|
-
const
|
|
4550
|
+
const pamSide = options.pamSide ?? '3prime';
|
|
4551
|
+
const requirePam = pamSide !== 'none' && options.requirePam !== false;
|
|
4552
|
+
const seedSide = options.seedSide ?? (pamSide === 'none' ? null : '3prime');
|
|
4553
|
+
const seedWindow = options.seedMismatchWindow ?? (pamSide === 'none' ? 0 : 2);
|
|
4288
4554
|
const kmers = mismatchVariants(guide, maxMismatches);
|
|
4289
4555
|
const seen = new Map();
|
|
4290
4556
|
for (const [key, distance] of kmers) {
|
|
4291
|
-
//
|
|
4292
|
-
|
|
4557
|
+
// The seed is the PAM-proximal end. SpCas9/SaCas9 protect the last two
|
|
4558
|
+
// bases; Cas12a protects the first two; Cas13 has no PAM and therefore no
|
|
4559
|
+
// hard seed window in this model.
|
|
4560
|
+
if (seedWindow > 0 && seedSide !== null) {
|
|
4561
|
+
const seed = seedSide === '5prime' ? guide.slice(0, seedWindow) : guide.slice(-seedWindow);
|
|
4562
|
+
const candidate = seedSide === '5prime' ? key.slice(0, seedWindow) : key.slice(-seedWindow);
|
|
4563
|
+
if (candidate !== seed) continue;
|
|
4564
|
+
}
|
|
4293
4565
|
for (const [table, strand] of [[index.forward, 'forward'], [index.reverse, 'reverse']]) {
|
|
4294
4566
|
const positions = table.get(key);
|
|
4295
4567
|
if (positions === undefined) continue;
|
|
@@ -4306,22 +4578,43 @@ window.__ModuleLoader__.load({
|
|
|
4306
4578
|
function finalizeSites(entries, options, requirePam, guideSequence) {
|
|
4307
4579
|
const reference = options.reference;
|
|
4308
4580
|
const guideLength = options.guideLength ?? 20;
|
|
4581
|
+
const pamSide = options.pamSide ?? '3prime';
|
|
4582
|
+
const pam = options.pam ?? DEFAULT_PAM;
|
|
4583
|
+
const pamLength = pamSide === 'none' ? 0 : pam.length;
|
|
4309
4584
|
const ownStart = options.ownStart;
|
|
4310
4585
|
const ownStrand = options.ownStrand;
|
|
4311
4586
|
const sites = [];
|
|
4312
4587
|
let droppedPam = 0;
|
|
4313
4588
|
let intended = 0;
|
|
4314
4589
|
for (const entry of entries) {
|
|
4315
|
-
//
|
|
4316
|
-
|
|
4590
|
+
// The index stores every k-mer at its TOP-STRAND start, for both the
|
|
4591
|
+
// forward and the reverse table. The old reverse formula subtracted
|
|
4592
|
+
// guideLength-1 here, which shifted every reverse-strand off-target by
|
|
4593
|
+
// 19 nt and dropped most of them as out of bounds — a silent
|
|
4594
|
+
// under-count that the forward-only smoke fixture never exposed.
|
|
4595
|
+
const start = entry.position;
|
|
4317
4596
|
if (start < 0 || start + guideLength > reference.length) continue;
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4597
|
+
let pamTop = '';
|
|
4598
|
+
let pamIntact = true;
|
|
4599
|
+
if (pamSide !== 'none') {
|
|
4600
|
+
// The PAM as read on the GUIDE's strand, and its top-strand window.
|
|
4601
|
+
// 3' PAM: forward -> downstream; reverse -> upstream.
|
|
4602
|
+
// 5' PAM (Cas12a): forward -> upstream; reverse -> downstream.
|
|
4603
|
+
const topPamStart = entry.strand === 'forward'
|
|
4604
|
+
? (pamSide === '3prime' ? start + guideLength : start - pamLength)
|
|
4605
|
+
: (pamSide === '3prime' ? start - pamLength : start + guideLength);
|
|
4606
|
+
const topPamEnd = topPamStart + pamLength;
|
|
4607
|
+
if (topPamStart < 0 || topPamEnd > reference.length) {
|
|
4608
|
+
droppedPam++;
|
|
4609
|
+
continue;
|
|
4610
|
+
}
|
|
4611
|
+
const topPam = reference.slice(topPamStart, topPamEnd);
|
|
4612
|
+
pamTop = entry.strand === 'forward' ? topPam : reverseComplement(topPam);
|
|
4613
|
+
pamIntact = pamMatches(pamTop, pam);
|
|
4614
|
+
if (requirePam && !pamIntact) {
|
|
4615
|
+
droppedPam++;
|
|
4616
|
+
continue;
|
|
4617
|
+
}
|
|
4325
4618
|
}
|
|
4326
4619
|
// The guide's own intended site: same top-strand window and strand, and an
|
|
4327
4620
|
// exact match. Counted (not listed) so the caller can subtract it.
|
|
@@ -4330,7 +4623,7 @@ window.__ModuleLoader__.load({
|
|
|
4330
4623
|
intended++;
|
|
4331
4624
|
continue;
|
|
4332
4625
|
}
|
|
4333
|
-
|
|
4626
|
+
const site = {
|
|
4334
4627
|
strand: entry.strand,
|
|
4335
4628
|
start: start + 1,
|
|
4336
4629
|
end: start + guideLength,
|
|
@@ -4341,7 +4634,13 @@ window.__ModuleLoader__.load({
|
|
|
4341
4634
|
entry.strand === 'forward' ? reference.slice(start, start + guideLength) : reverseComplement(reference.slice(start, start + guideLength)),
|
|
4342
4635
|
guideSequence ?? '',
|
|
4343
4636
|
),
|
|
4344
|
-
}
|
|
4637
|
+
};
|
|
4638
|
+
if (options.pfsSpec !== undefined && options.pfsSpec !== null) {
|
|
4639
|
+
const pfs = observedPfs(reference, start, guideLength, entry.strand, options.pfsSpec);
|
|
4640
|
+
site.pfs = pfs.sequence;
|
|
4641
|
+
site.pfs_ok = pfs.ok;
|
|
4642
|
+
}
|
|
4643
|
+
sites.push(site);
|
|
4345
4644
|
}
|
|
4346
4645
|
sites.sort((a, b) => a.mismatches - b.mismatches || a.start - b.start || a.strand.localeCompare(b.strand));
|
|
4347
4646
|
return {
|
|
@@ -4372,20 +4671,37 @@ window.__ModuleLoader__.load({
|
|
|
4372
4671
|
* off-target penalty is already part of every scanned guide's score.
|
|
4373
4672
|
*/
|
|
4374
4673
|
function designGrnas(sequence, options = {}) {
|
|
4375
|
-
const
|
|
4376
|
-
const
|
|
4377
|
-
const
|
|
4674
|
+
const family = resolveCasFamily(options.casFamily ?? 'spcas9');
|
|
4675
|
+
const pam = family.pam === null ? '' : assertPamPattern(options.pam ?? family.pam, family.pamSide);
|
|
4676
|
+
const guideLength = options.guideLength ?? family.guideLength;
|
|
4677
|
+
if (guideLength < family.guideLengthMin || guideLength > family.guideLengthMax) {
|
|
4678
|
+
throw new MolbioInputError(`guide_length for ${family.id} must be between ${family.guideLengthMin} and ${family.guideLengthMax} nt (got ${guideLength})`);
|
|
4679
|
+
}
|
|
4680
|
+
const targetStrand = options.targetStrand ?? (family.target === 'RNA' ? 'forward' : 'both');
|
|
4681
|
+
const candidates = findProtospacers(sequence, {
|
|
4682
|
+
...family.pam === null ? {} : { pam },
|
|
4683
|
+
pamSide: family.pamSide,
|
|
4684
|
+
guideLength,
|
|
4685
|
+
family,
|
|
4686
|
+
targetStrand,
|
|
4687
|
+
});
|
|
4378
4688
|
const regionStart = options.regionStart ?? 1;
|
|
4379
4689
|
const regionEnd = options.regionEnd ?? sequence.length;
|
|
4380
4690
|
const inRegion = candidates.filter((hit) => hit.start >= regionStart && hit.end <= regionEnd);
|
|
4691
|
+
if (family.pamSide === 'none' && inRegion.length > CRISPR_LIMITS.maxCas13Candidates) {
|
|
4692
|
+
throw new MolbioInputError(`the Cas13 scan found ${inRegion.length} candidate windows in the requested region (limit ${CRISPR_LIMITS.maxCas13Candidates}); tighten region_start/region_end`);
|
|
4693
|
+
}
|
|
4694
|
+
const requirePfs = options.requirePfs === true;
|
|
4381
4695
|
const evaluated = inRegion.map((hit) => {
|
|
4382
|
-
const metrics = evaluateGuide(hit.sequence, options);
|
|
4696
|
+
const metrics = evaluateGuide(hit.sequence, { ...options, family, pfsOk: hit.pfs_ok ?? true, requirePfs });
|
|
4383
4697
|
return {
|
|
4384
4698
|
sequence: hit.sequence,
|
|
4385
4699
|
pam: hit.pam,
|
|
4386
4700
|
strand: hit.strand,
|
|
4387
4701
|
start: hit.start,
|
|
4388
4702
|
end: hit.end,
|
|
4703
|
+
...family.target === 'RNA' ? { rna_sequence: hit.sequence.replaceAll('T', 'U') } : {},
|
|
4704
|
+
...hit.pfs === undefined || hit.pfs === '' ? {} : { pfs: hit.pfs.replaceAll('T', 'U'), pfs_ok: hit.pfs_ok ?? true },
|
|
4389
4705
|
gc_percent: metrics.gc_percent,
|
|
4390
4706
|
tm_celsius: metrics.tm_celsius,
|
|
4391
4707
|
self_any: metrics.self_any,
|
|
@@ -4418,6 +4734,10 @@ window.__ModuleLoader__.load({
|
|
|
4418
4734
|
reference: sequence,
|
|
4419
4735
|
guideLength,
|
|
4420
4736
|
pam,
|
|
4737
|
+
pamSide: family.pamSide,
|
|
4738
|
+
seedSide: family.seedSide,
|
|
4739
|
+
seedMismatchWindow: family.seedMismatchWindow,
|
|
4740
|
+
pfsSpec: family.pfs,
|
|
4421
4741
|
guideSequence: guide.sequence,
|
|
4422
4742
|
ownStart: guide.start,
|
|
4423
4743
|
ownStrand: guide.strand,
|
|
@@ -4429,7 +4749,8 @@ window.__ModuleLoader__.load({
|
|
|
4429
4749
|
guide.off_target_sites = result.sites;
|
|
4430
4750
|
guide.pam_disrupted_sites = result.dropped_pam_disrupted;
|
|
4431
4751
|
if (guide.off_target_count > 0) {
|
|
4432
|
-
|
|
4752
|
+
const requirement = family.pamSide === 'none' ? 'with no PAM requirement (Cas13)' : 'and an intact PAM';
|
|
4753
|
+
guide.notes.push(`${guide.off_target_count} off-target site(s) with <= ${options.maxMismatches ?? 2} mismatch(es) ${requirement} in the supplied reference`);
|
|
4433
4754
|
}
|
|
4434
4755
|
// Re-score with the off-target penalty included (the only penalty that
|
|
4435
4756
|
// depends on the search, and the one weighted above all the others).
|
|
@@ -4440,10 +4761,12 @@ window.__ModuleLoader__.load({
|
|
|
4440
4761
|
longestHomopolymer: longestRun(guide.sequence),
|
|
4441
4762
|
selfAny: guide.self_any,
|
|
4442
4763
|
selfEnd: guide.self_end,
|
|
4764
|
+
seedAny: guide.seed_self_any,
|
|
4443
4765
|
hairpinTm: guide.hairpin_tm,
|
|
4444
4766
|
guide: guide.sequence,
|
|
4767
|
+
pfsOk: guide.pfs_ok ?? true,
|
|
4445
4768
|
offTargetCount: guide.off_target_count,
|
|
4446
|
-
}, options);
|
|
4769
|
+
}, { ...options, family });
|
|
4447
4770
|
guide.score = rescored.score;
|
|
4448
4771
|
guide.penalties = rescored.penalties;
|
|
4449
4772
|
}
|
|
@@ -4458,7 +4781,14 @@ window.__ModuleLoader__.load({
|
|
|
4458
4781
|
rejected_count: evaluated.length - accepted.length,
|
|
4459
4782
|
accepted_count: accepted.length,
|
|
4460
4783
|
off_target_scanned: scanned,
|
|
4784
|
+
cas_family: family.id,
|
|
4785
|
+
cas_label: family.label,
|
|
4461
4786
|
pam,
|
|
4787
|
+
pam_side: family.pamSide,
|
|
4788
|
+
pfs: family.pfs === null ? '' : family.pfs.label,
|
|
4789
|
+
target: family.target,
|
|
4790
|
+
target_strand: targetStrand,
|
|
4791
|
+
require_pfs: requirePfs,
|
|
4462
4792
|
guide_length: guideLength,
|
|
4463
4793
|
};
|
|
4464
4794
|
}
|
|
@@ -4531,6 +4861,9 @@ window.__ModuleLoader__.load({
|
|
|
4531
4861
|
}
|
|
4532
4862
|
|
|
4533
4863
|
exports.DEFAULT_PAM = DEFAULT_PAM;
|
|
4864
|
+
exports.CAS_FAMILIES = CAS_FAMILIES;
|
|
4865
|
+
exports.CAS_FAMILY_NAMES = CAS_FAMILY_NAMES;
|
|
4866
|
+
exports.resolveCasFamily = resolveCasFamily;
|
|
4534
4867
|
exports.CRISPR_LIMITS = CRISPR_LIMITS;
|
|
4535
4868
|
exports.assertPamPattern = assertPamPattern;
|
|
4536
4869
|
exports.findProtospacers = findProtospacers;
|
|
@@ -5686,6 +6019,40 @@ window.__ModuleLoader__.load({
|
|
|
5686
6019
|
maxAmplicons: 5,
|
|
5687
6020
|
};
|
|
5688
6021
|
|
|
6022
|
+
/**
|
|
6023
|
+
* Probe chemistries the designer knows about.
|
|
6024
|
+
*
|
|
6025
|
+
* A standard TaqMan probe is dual-labelled (5' reporter, 3' quencher) and is
|
|
6026
|
+
* designed at 18-27 nt with a Tm 5 °C above the primers. An MGB probe carries a
|
|
6027
|
+
* minor-groove binder that raises the Tm substantially, so the SAME unmodified
|
|
6028
|
+
* NN estimate must be compared with a lower window and the shorter length
|
|
6029
|
+
* window. The modification's Tm contribution is NOT modelled: the reported Tm
|
|
6030
|
+
* is the unmodified DNA estimate, and the result says so. Claiming a modified
|
|
6031
|
+
* Tm from the NN model would be a fabricated number.
|
|
6032
|
+
*/
|
|
6033
|
+
const PROBE_CHEMISTRY = {
|
|
6034
|
+
taqman: {
|
|
6035
|
+
label: 'TaqMan (standard dual-labelled)',
|
|
6036
|
+
probeLenMin: 18,
|
|
6037
|
+
probeLenMax: 27,
|
|
6038
|
+
probeTmMin: 58,
|
|
6039
|
+
probeTmMax: 72,
|
|
6040
|
+
minTmDelta: 5,
|
|
6041
|
+
defaultQuencher: 'BHQ1',
|
|
6042
|
+
tmModel: 'unmodified DNA nearest-neighbour estimate',
|
|
6043
|
+
},
|
|
6044
|
+
mgb: {
|
|
6045
|
+
label: 'TaqMan MGB (minor-groove binder)',
|
|
6046
|
+
probeLenMin: 13,
|
|
6047
|
+
probeLenMax: 20,
|
|
6048
|
+
probeTmMin: 48,
|
|
6049
|
+
probeTmMax: 60,
|
|
6050
|
+
minTmDelta: 0,
|
|
6051
|
+
defaultQuencher: 'NFQ-MGB',
|
|
6052
|
+
tmModel: 'unmodified DNA nearest-neighbour estimate; the MGB modification raises Tm by an amount this model does not predict — verify with the supplier',
|
|
6053
|
+
},
|
|
6054
|
+
};
|
|
6055
|
+
|
|
5689
6056
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
5690
6057
|
|
|
5691
6058
|
function pairTm(pair) {
|
|
@@ -5739,7 +6106,8 @@ window.__ModuleLoader__.load({
|
|
|
5739
6106
|
* Evaluate one probe candidate: thermodynamics, structure, and the heuristic
|
|
5740
6107
|
* flags a designer must know before ordering it.
|
|
5741
6108
|
*/
|
|
5742
|
-
function evaluateProbe(seq, { tmOpts, ctMolar, primerTmMax, maxRun, maxSelfAny }) {
|
|
6109
|
+
function evaluateProbe(seq, { tmOpts, ctMolar, primerTmMax, maxRun, maxSelfAny, chemistry = 'taqman' }) {
|
|
6110
|
+
const chemistrySpec = PROBE_CHEMISTRY[chemistry] ?? PROBE_CHEMISTRY.taqman;
|
|
5743
6111
|
const lower = seq.toUpperCase();
|
|
5744
6112
|
const tm = primerTm(lower, tmOpts).tm_celsius;
|
|
5745
6113
|
const gc = gcPercent(lower);
|
|
@@ -5749,6 +6117,7 @@ window.__ModuleLoader__.load({
|
|
|
5749
6117
|
const selfAny = safeSelfAny(lower);
|
|
5750
6118
|
const selfEnd = safeSelfEnd(lower);
|
|
5751
6119
|
const notes = [];
|
|
6120
|
+
if (chemistry === 'mgb') notes.push(`MGB probe: Tm ${round2(tm)} °C is the unmodified DNA NN estimate; the MGB modification raises it, so verify the modified Tm with the supplier`);
|
|
5752
6121
|
if (lower[0] === 'G') notes.push('5\' terminal G: the guanine quenches the reporter even before cleavage — move the probe or pick the reverse strand');
|
|
5753
6122
|
if (lower[lower.length - 1] === 'G') notes.push('3\' terminal G: a short probe is extended by the polymerase without cleavage — prefer a different 3\' base');
|
|
5754
6123
|
if (runs.length > 0) notes.push(`mononucleotide run >= ${maxRun + 1} bases (${runs.map((run) => `${run.base}${run.count}`).join(', ')})`);
|
|
@@ -5770,9 +6139,12 @@ window.__ModuleLoader__.load({
|
|
|
5770
6139
|
return {
|
|
5771
6140
|
sequence: lower,
|
|
5772
6141
|
length: lower.length,
|
|
6142
|
+
chemistry,
|
|
6143
|
+
chemistry_label: chemistrySpec.label,
|
|
6144
|
+
tm_model: chemistrySpec.tmModel,
|
|
5773
6145
|
tm: round2(tm),
|
|
5774
6146
|
gc_percent: gc,
|
|
5775
|
-
tm_delta_vs_primer: tmDelta,
|
|
6147
|
+
...tmDelta === undefined ? {} : { tm_delta_vs_primer: tmDelta },
|
|
5776
6148
|
hairpin_tm: round2(hairpin),
|
|
5777
6149
|
self_any: selfAny,
|
|
5778
6150
|
self_end: selfEnd,
|
|
@@ -5830,6 +6202,11 @@ window.__ModuleLoader__.load({
|
|
|
5830
6202
|
// outward from, and the 3' end the reported distance is measured against.
|
|
5831
6203
|
const stem = forwardUpstream ? forwardInterval : reverseInterval;
|
|
5832
6204
|
const work = reverse ? reverseComplement(template) : template;
|
|
6205
|
+
// An MGB probe's unmodified Tm is lower than its primers' by design; the
|
|
6206
|
+
// minor-groove binder supplies the missing stability. Comparing the raw NN Tm
|
|
6207
|
+
// with the primer Tm would penalise every MGB candidate, so the margin is not
|
|
6208
|
+
// applied to that chemistry (the MGB Tm window is the constraint instead).
|
|
6209
|
+
const tmReference = opts.chemistry === 'mgb' ? undefined : primerTmMax;
|
|
5833
6210
|
// On `work`, a top-strand interval [s, e] corresponds to [n-1-e, n-1-s].
|
|
5834
6211
|
const windowStart = reverse ? n - 1 - gap[1] : gap[0] + minGap;
|
|
5835
6212
|
const windowEnd = reverse ? n - 1 - gap[0] - minGap : gap[1] - minGap;
|
|
@@ -5840,9 +6217,10 @@ window.__ModuleLoader__.load({
|
|
|
5840
6217
|
const evaluated = evaluateProbe(sequence, {
|
|
5841
6218
|
tmOpts: opts.tmOpts,
|
|
5842
6219
|
ctMolar: opts.ctMolar,
|
|
5843
|
-
primerTmMax,
|
|
6220
|
+
primerTmMax: tmReference,
|
|
5844
6221
|
maxRun: opts.maxRun,
|
|
5845
6222
|
maxSelfAny: opts.maxSelfAny,
|
|
6223
|
+
chemistry: opts.chemistry,
|
|
5846
6224
|
});
|
|
5847
6225
|
if (evaluated.gc_percent < opts.probeGcMin || evaluated.gc_percent > opts.probeGcMax) continue;
|
|
5848
6226
|
if (evaluated.tm < opts.probeTmMin || evaluated.tm > opts.probeTmMax) continue;
|
|
@@ -5892,9 +6270,22 @@ window.__ModuleLoader__.load({
|
|
|
5892
6270
|
*/
|
|
5893
6271
|
function designTaqmanProbes(rawTemplate, rawOptions = {}) {
|
|
5894
6272
|
const template = normalizeSequence(rawTemplate);
|
|
5895
|
-
const
|
|
6273
|
+
const chemistry = rawOptions.probeChemistry ?? 'taqman';
|
|
6274
|
+
const chemistrySpec = PROBE_CHEMISTRY[chemistry];
|
|
6275
|
+
if (chemistrySpec === undefined) {
|
|
6276
|
+
throw new MolbioInputError(`unknown probe_chemistry ${JSON.stringify(chemistry)}; known: ${Object.keys(PROBE_CHEMISTRY).join(', ')}`);
|
|
6277
|
+
}
|
|
6278
|
+
const opts = {
|
|
6279
|
+
...PROBE_DEFAULTS,
|
|
6280
|
+
probeLenMin: chemistrySpec.probeLenMin,
|
|
6281
|
+
probeLenMax: chemistrySpec.probeLenMax,
|
|
6282
|
+
probeTmMin: chemistrySpec.probeTmMin,
|
|
6283
|
+
probeTmMax: chemistrySpec.probeTmMax,
|
|
6284
|
+
minTmDelta: chemistrySpec.minTmDelta,
|
|
6285
|
+
chemistry,
|
|
6286
|
+
};
|
|
5896
6287
|
for (const [key, value] of Object.entries(rawOptions)) {
|
|
5897
|
-
if (value !== undefined && value !== null) opts[key] = value;
|
|
6288
|
+
if (value !== undefined && value !== null && key !== 'probeChemistry') opts[key] = value;
|
|
5898
6289
|
}
|
|
5899
6290
|
// A hydrolysis-probe assay is deliberately short (the amplicon is the stretch
|
|
5900
6291
|
// the polymerase must traverse before it reaches and cleaves the probe), so
|
|
@@ -6000,7 +6391,11 @@ window.__ModuleLoader__.load({
|
|
|
6000
6391
|
tm: [primerOpts.tmMin, primerOpts.tmMax],
|
|
6001
6392
|
gc: [primerOpts.gcMin, primerOpts.gcMax],
|
|
6002
6393
|
},
|
|
6394
|
+
probe_chemistry: chemistry,
|
|
6003
6395
|
probe_options: {
|
|
6396
|
+
chemistry,
|
|
6397
|
+
chemistry_label: chemistrySpec.label,
|
|
6398
|
+
tm_model: chemistrySpec.tmModel,
|
|
6004
6399
|
length: [opts.probeLenMin, opts.probeLenMax],
|
|
6005
6400
|
tm: [opts.probeTmMin, opts.probeTmMax],
|
|
6006
6401
|
gc: [opts.probeGcMin, opts.probeGcMax],
|
|
@@ -6075,9 +6470,22 @@ window.__ModuleLoader__.load({
|
|
|
6075
6470
|
* that will run them.
|
|
6076
6471
|
*/
|
|
6077
6472
|
function resolveProbeOptions(raw = {}) {
|
|
6078
|
-
const
|
|
6473
|
+
const chemistry = raw.probeChemistry ?? 'taqman';
|
|
6474
|
+
const chemistrySpec = PROBE_CHEMISTRY[chemistry];
|
|
6475
|
+
if (chemistrySpec === undefined) {
|
|
6476
|
+
throw new MolbioInputError(`unknown probe_chemistry ${JSON.stringify(chemistry)}; known: ${Object.keys(PROBE_CHEMISTRY).join(', ')}`);
|
|
6477
|
+
}
|
|
6478
|
+
const opts = {
|
|
6479
|
+
...PROBE_DEFAULTS,
|
|
6480
|
+
probeLenMin: chemistrySpec.probeLenMin,
|
|
6481
|
+
probeLenMax: chemistrySpec.probeLenMax,
|
|
6482
|
+
probeTmMin: chemistrySpec.probeTmMin,
|
|
6483
|
+
probeTmMax: chemistrySpec.probeTmMax,
|
|
6484
|
+
minTmDelta: chemistrySpec.minTmDelta,
|
|
6485
|
+
chemistry,
|
|
6486
|
+
};
|
|
6079
6487
|
for (const [key, value] of Object.entries(raw)) {
|
|
6080
|
-
if (value !== undefined && value !== null && key !== 'primer_options') opts[key] = value;
|
|
6488
|
+
if (value !== undefined && value !== null && key !== 'primer_options' && key !== 'probeChemistry') opts[key] = value;
|
|
6081
6489
|
}
|
|
6082
6490
|
if (!Number.isInteger(opts.probeLenMin) || opts.probeLenMin < 12) throw new MolbioInputError('probe_len_min must be an integer >= 12');
|
|
6083
6491
|
if (!Number.isInteger(opts.probeLenMax) || opts.probeLenMax > 40 || opts.probeLenMax < opts.probeLenMin) throw new MolbioInputError('probe_len_max must be an integer between probe_len_min and 40');
|
|
@@ -6094,12 +6502,96 @@ window.__ModuleLoader__.load({
|
|
|
6094
6502
|
return opts;
|
|
6095
6503
|
}
|
|
6096
6504
|
|
|
6505
|
+
/** Quote one CSV field; ordering sheets contain commas in probe notes. */
|
|
6506
|
+
function csvField(value) {
|
|
6507
|
+
const text = value === undefined || value === null ? '' : String(value);
|
|
6508
|
+
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
6509
|
+
}
|
|
6510
|
+
|
|
6511
|
+
/**
|
|
6512
|
+
* Build the oligo rows a synthesis order needs: one row per primer and probe.
|
|
6513
|
+
*
|
|
6514
|
+
* The modification column is deliberately explicit rather than implied by the
|
|
6515
|
+
* chemistry name: a lab ordering sheet has separate 5' and 3' modification
|
|
6516
|
+
* fields, and an MGB probe without an NFQ-MGB quencher is a different molecule.
|
|
6517
|
+
*/
|
|
6518
|
+
function taqmanOrderRows(assays, options = {}) {
|
|
6519
|
+
const chemistry = options.chemistry ?? 'taqman';
|
|
6520
|
+
const spec = PROBE_CHEMISTRY[chemistry] ?? PROBE_CHEMISTRY.taqman;
|
|
6521
|
+
const reporter = options.reporter ?? 'FAM';
|
|
6522
|
+
const quencher = options.quencher ?? spec.defaultQuencher;
|
|
6523
|
+
const primerScale = options.primerScale ?? '25nm';
|
|
6524
|
+
const probeScale = options.probeScale ?? '25nm';
|
|
6525
|
+
const primerPurification = options.primerPurification ?? 'standard';
|
|
6526
|
+
const probePurification = options.probePurification ?? 'hplc';
|
|
6527
|
+
const rows = [];
|
|
6528
|
+
for (const [index, assay] of assays.entries()) {
|
|
6529
|
+
const id = `assay${index + 1}`;
|
|
6530
|
+
for (const [role, primer] of [['forward', assay.forward], ['reverse', assay.reverse]]) {
|
|
6531
|
+
rows.push({
|
|
6532
|
+
assay: index + 1,
|
|
6533
|
+
role,
|
|
6534
|
+
name: `${id}_${role === 'forward' ? 'F' : 'R'}`,
|
|
6535
|
+
sequence: primer.sequence,
|
|
6536
|
+
length: primer.length,
|
|
6537
|
+
tm_celsius: primer.tm,
|
|
6538
|
+
gc_percent: primer.gc_percent,
|
|
6539
|
+
modification: '',
|
|
6540
|
+
scale: primerScale,
|
|
6541
|
+
purification: primerPurification,
|
|
6542
|
+
notes: '',
|
|
6543
|
+
});
|
|
6544
|
+
}
|
|
6545
|
+
const probe = assay.probe;
|
|
6546
|
+
const noteParts = [spec.label, probe.tm_model, ...(probe.notes ?? [])];
|
|
6547
|
+
if (chemistry === 'mgb' && quencher !== 'NFQ-MGB') {
|
|
6548
|
+
noteParts.push('an MGB probe is normally ordered with an NFQ-MGB quencher; a different quencher is a different molecule');
|
|
6549
|
+
}
|
|
6550
|
+
rows.push({
|
|
6551
|
+
assay: index + 1,
|
|
6552
|
+
role: 'probe',
|
|
6553
|
+
name: `${id}_P`,
|
|
6554
|
+
sequence: probe.sequence,
|
|
6555
|
+
length: probe.length,
|
|
6556
|
+
tm_celsius: probe.tm,
|
|
6557
|
+
gc_percent: probe.gc_percent,
|
|
6558
|
+
modification: `5' ${reporter} / 3' ${quencher}`,
|
|
6559
|
+
scale: probeScale,
|
|
6560
|
+
purification: probePurification,
|
|
6561
|
+
notes: noteParts.filter((part) => part !== undefined && part !== '').join('; '),
|
|
6562
|
+
});
|
|
6563
|
+
}
|
|
6564
|
+
return rows;
|
|
6565
|
+
}
|
|
6566
|
+
|
|
6567
|
+
/** Render the ordering rows as CSV text. */
|
|
6568
|
+
function taqmanOrderCsv(rows) {
|
|
6569
|
+
const header = 'assay,role,name,sequence,length,tm_celsius,gc_percent,modification,scale,purification,notes';
|
|
6570
|
+
const lines = rows.map((row) => [
|
|
6571
|
+
row.assay,
|
|
6572
|
+
row.role,
|
|
6573
|
+
row.name,
|
|
6574
|
+
row.sequence,
|
|
6575
|
+
row.length,
|
|
6576
|
+
row.tm_celsius,
|
|
6577
|
+
row.gc_percent,
|
|
6578
|
+
row.modification,
|
|
6579
|
+
row.scale,
|
|
6580
|
+
row.purification,
|
|
6581
|
+
row.notes,
|
|
6582
|
+
].map(csvField).join(','));
|
|
6583
|
+
return `${[header, ...lines].join('\n')}\n`;
|
|
6584
|
+
}
|
|
6585
|
+
|
|
6097
6586
|
exports.PROBE_DEFAULTS = PROBE_DEFAULTS;
|
|
6587
|
+
exports.PROBE_CHEMISTRY = PROBE_CHEMISTRY;
|
|
6098
6588
|
exports.evaluateProbe = evaluateProbe;
|
|
6099
6589
|
exports.probeCandidates = probeCandidates;
|
|
6100
6590
|
exports.designTaqmanProbes = designTaqmanProbes;
|
|
6101
6591
|
exports.primerEngineOptions = primerEngineOptions;
|
|
6102
6592
|
exports.resolveProbeOptions = resolveProbeOptions;
|
|
6593
|
+
exports.taqmanOrderRows = taqmanOrderRows;
|
|
6594
|
+
exports.taqmanOrderCsv = taqmanOrderCsv;
|
|
6103
6595
|
return exports;
|
|
6104
6596
|
};
|
|
6105
6597
|
__molbio_modules["multiplex.mjs"] = () => {
|
|
@@ -6139,6 +6631,14 @@ window.__ModuleLoader__.load({
|
|
|
6139
6631
|
maxSitesReported: 6,
|
|
6140
6632
|
/** Concentration used for folding Tm (nM primer -> molar). */
|
|
6141
6633
|
primerNm: 200,
|
|
6634
|
+
/** Annealing-temperature recommendation: lowest primer Tm minus this offset. */
|
|
6635
|
+
annealingOffsetC: 3,
|
|
6636
|
+
/** Half-width of the recommended annealing window, °C. */
|
|
6637
|
+
annealingWindowC: 1,
|
|
6638
|
+
/** A primer-Tm spread above this makes one annealing temperature a compromise. */
|
|
6639
|
+
tmSpreadWarnC: 5,
|
|
6640
|
+
/** Starting primer concentration for every target, nM. */
|
|
6641
|
+
primerConcentrationNm: 200,
|
|
6142
6642
|
};
|
|
6143
6643
|
|
|
6144
6644
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
@@ -6271,6 +6771,18 @@ window.__ModuleLoader__.load({
|
|
|
6271
6771
|
if (rawTargets.length > opts.maxTargets) throw new MolbioInputError(`targets has ${rawTargets.length} entries; the limit is ${opts.maxTargets} per call`);
|
|
6272
6772
|
if (!(opts.dimerTmThreshold > 0)) throw new MolbioInputError('dimer_tm_threshold must be a positive temperature in °C');
|
|
6273
6773
|
if (!Number.isInteger(opts.minSizeSeparationBp) || opts.minSizeSeparationBp < 0) throw new MolbioInputError('min_size_separation_bp must be a non-negative integer');
|
|
6774
|
+
if (!(typeof opts.annealingOffsetC === 'number' && Number.isFinite(opts.annealingOffsetC) && opts.annealingOffsetC >= 0 && opts.annealingOffsetC <= 15)) {
|
|
6775
|
+
throw new MolbioInputError('annealing_offset_c must be a number between 0 and 15 (°C below the lowest primer Tm)');
|
|
6776
|
+
}
|
|
6777
|
+
if (!(typeof opts.annealingWindowC === 'number' && Number.isFinite(opts.annealingWindowC) && opts.annealingWindowC >= 0 && opts.annealingWindowC <= 5)) {
|
|
6778
|
+
throw new MolbioInputError('annealing_window_c must be a number between 0 and 5 (°C on each side of the recommendation)');
|
|
6779
|
+
}
|
|
6780
|
+
if (!(typeof opts.tmSpreadWarnC === 'number' && Number.isFinite(opts.tmSpreadWarnC) && opts.tmSpreadWarnC >= 0 && opts.tmSpreadWarnC <= 20)) {
|
|
6781
|
+
throw new MolbioInputError('tm_spread_warn_c must be a number between 0 and 20');
|
|
6782
|
+
}
|
|
6783
|
+
if (!(typeof opts.primerConcentrationNm === 'number' && Number.isFinite(opts.primerConcentrationNm) && opts.primerConcentrationNm >= 20 && opts.primerConcentrationNm <= 1000)) {
|
|
6784
|
+
throw new MolbioInputError('primer_concentration_nm must be a number between 20 and 1000');
|
|
6785
|
+
}
|
|
6274
6786
|
|
|
6275
6787
|
const ctMolar = opts.primerNm * 1e-9;
|
|
6276
6788
|
const targets = rawTargets.map((target, index) => {
|
|
@@ -6318,6 +6830,31 @@ window.__ModuleLoader__.load({
|
|
|
6318
6830
|
}
|
|
6319
6831
|
}
|
|
6320
6832
|
|
|
6833
|
+
// Annealing temperature: one tube, one temperature. The defensible starting
|
|
6834
|
+
// point is the LOWEST primer Tm minus a small offset, because a primer that
|
|
6835
|
+
// melts below the set temperature is the one that drops out first. This is a
|
|
6836
|
+
// rule of thumb, not a thermal-cycling optimisation — the note says so.
|
|
6837
|
+
const annealing = primers.length === 0 ? null : (() => {
|
|
6838
|
+
const tms = primers.map((primer) => primer.tm);
|
|
6839
|
+
const minTm = Math.min(...tms);
|
|
6840
|
+
const maxTm = Math.max(...tms);
|
|
6841
|
+
const spread = round2(maxTm - minTm);
|
|
6842
|
+
const limiting = primers.reduce((best, primer) => (primer.tm < best.tm ? primer : best));
|
|
6843
|
+
const recommended = round2(minTm - opts.annealingOffsetC);
|
|
6844
|
+
return {
|
|
6845
|
+
min_primer_tm: round2(minTm),
|
|
6846
|
+
max_primer_tm: round2(maxTm),
|
|
6847
|
+
spread_c: spread,
|
|
6848
|
+
recommended_c: recommended,
|
|
6849
|
+
range_c: [round2(recommended - opts.annealingWindowC), round2(recommended + opts.annealingWindowC)],
|
|
6850
|
+
limiting_primer: `${limiting.target}/${limiting.role}`,
|
|
6851
|
+
offset_c: opts.annealingOffsetC,
|
|
6852
|
+
window_c: opts.annealingWindowC,
|
|
6853
|
+
tm_spread_ok: spread <= opts.tmSpreadWarnC,
|
|
6854
|
+
note: 'Starting point: the lowest primer Tm minus the configured offset; optimise on the bench with a temperature gradient.',
|
|
6855
|
+
};
|
|
6856
|
+
})();
|
|
6857
|
+
|
|
6321
6858
|
// Cross interactions: every primer pair that is not the same primer. Within a
|
|
6322
6859
|
// target, forward+reverse is the intended pair (its dimer was already checked
|
|
6323
6860
|
// by the designer), so the conflicts are the CROSS-target ones plus each
|
|
@@ -6435,6 +6972,36 @@ window.__ModuleLoader__.load({
|
|
|
6435
6972
|
}
|
|
6436
6973
|
sizeConflicts.sort((x, y) => x.difference_bp - y.difference_bp);
|
|
6437
6974
|
|
|
6975
|
+
// Primer concentrations: a multiplex is a competition, and a much shorter
|
|
6976
|
+
// amplicon can out-compete a longer one. The plan is deliberately conservative
|
|
6977
|
+
// — it starts every pair at the same concentration and only shifts the
|
|
6978
|
+
// extremes when the size ratio is at least 2x. It is a starting point, not a
|
|
6979
|
+
// measured optimum; the rationale field says which case each target is in.
|
|
6980
|
+
const knownSizes = sizes.filter((entry) => entry.length !== undefined).map((entry) => entry.length);
|
|
6981
|
+
const minSize = knownSizes.length === 0 ? undefined : Math.min(...knownSizes);
|
|
6982
|
+
const maxSize = knownSizes.length === 0 ? undefined : Math.max(...knownSizes);
|
|
6983
|
+
const sizeRatio = minSize === undefined || minSize === 0 || maxSize === undefined ? 1 : maxSize / minSize;
|
|
6984
|
+
const primerConcentrations = targets.map((target) => {
|
|
6985
|
+
const size = sizes.find((entry) => entry.name === target.name)?.length;
|
|
6986
|
+
let recommended = opts.primerConcentrationNm;
|
|
6987
|
+
let rationale = 'panel default; no size-based adjustment applies';
|
|
6988
|
+
if (knownSizes.length >= 2 && sizeRatio >= 2 && size !== undefined) {
|
|
6989
|
+
if (size === minSize) {
|
|
6990
|
+
recommended = Math.round(opts.primerConcentrationNm * 0.75);
|
|
6991
|
+
rationale = 'shortest amplicon in the panel; a lower share limits its competitive advantage';
|
|
6992
|
+
} else if (size === maxSize) {
|
|
6993
|
+
recommended = Math.round(opts.primerConcentrationNm * 1.5);
|
|
6994
|
+
rationale = 'longest amplicon in the panel; a higher share compensates for lower amplification efficiency';
|
|
6995
|
+
} else {
|
|
6996
|
+
rationale = 'panel default; between the shortest and longest amplicon';
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
return { target: target.name, recommended_nm: recommended, rationale };
|
|
7000
|
+
});
|
|
7001
|
+
const concentrationNote = knownSizes.length >= 2 && sizeRatio >= 2
|
|
7002
|
+
? `amplicon sizes span ${minSize}-${maxSize} bp (${round2(sizeRatio)}x); the plan lowers the shortest and raises the longest pair — a starting point, not a measured optimum`
|
|
7003
|
+
: '';
|
|
7004
|
+
|
|
6438
7005
|
const blockingInteractions = interactions.filter((entry) => entry.conflict && entry.cross_target);
|
|
6439
7006
|
const blockingMispriming = mispriming.filter((entry) => entry.scope === 'other_templates');
|
|
6440
7007
|
const blockingSizes = sizeConflicts.filter((entry) => entry.severity === 'indistinguishable');
|
|
@@ -6450,6 +7017,13 @@ window.__ModuleLoader__.load({
|
|
|
6450
7017
|
for (const entry of blockingSizes.slice(0, 5)) {
|
|
6451
7018
|
advice.push(`${entry.a} (${entry.a_length} bp) and ${entry.b} (${entry.b_length} bp) differ by only ${entry.difference_bp} bp — not resolvable on a standard gel`);
|
|
6452
7019
|
}
|
|
7020
|
+
if (annealing !== null) {
|
|
7021
|
+
advice.push(`set the annealing temperature around ${annealing.recommended_c} °C (range ${annealing.range_c[0]}-${annealing.range_c[1]} °C): the lowest-Tm primer is ${annealing.limiting_primer} at ${annealing.min_primer_tm} °C`);
|
|
7022
|
+
if (!annealing.tm_spread_ok) {
|
|
7023
|
+
advice.push(`primer Tm spread is ${annealing.spread_c} °C (${annealing.min_primer_tm}-${annealing.max_primer_tm} °C) — one annealing temperature cannot be optimal for all; redesign the outlier primer(s) or use a two-step protocol`);
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
if (concentrationNote !== '') advice.push(concentrationNote);
|
|
6453
7027
|
|
|
6454
7028
|
return {
|
|
6455
7029
|
compatible: blockingInteractions.length === 0 && blockingMispriming.length === 0 && blockingSizes.length === 0,
|
|
@@ -6466,10 +7040,15 @@ window.__ModuleLoader__.load({
|
|
|
6466
7040
|
cross_template_mispriming: blockingMispriming.length,
|
|
6467
7041
|
amplicons: sizes,
|
|
6468
7042
|
size_conflicts: sizeConflicts,
|
|
7043
|
+
annealing,
|
|
7044
|
+
primer_concentrations: primerConcentrations,
|
|
7045
|
+
concentration_note: concentrationNote,
|
|
6469
7046
|
advice,
|
|
6470
7047
|
notes: [
|
|
6471
7048
|
'Dimer/hairpin Tms are the same SantaLucia-1998 nearest-neighbour estimates the primer designer uses; they rank risk, they do not predict a multiplex outcome.',
|
|
6472
7049
|
'Cross-template annealing is searched with a perfect 3\' tail only; own-template mispriming allows the configured mismatch tolerance.',
|
|
7050
|
+
'The annealing temperature is a rule of thumb (lowest primer Tm minus the configured offset), not a thermal-cycling optimisation; run a gradient.',
|
|
7051
|
+
'Primer concentrations are starting points chosen from amplicon-size balance, not measured optima; rebalance empirically.',
|
|
6473
7052
|
'A panel that passes this check can still fail on the bench: template competition, unequal primer efficiencies and amplicon secondary structure are not modelled here.',
|
|
6474
7053
|
],
|
|
6475
7054
|
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-molbio-panel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Browser panel for the DeepSeek Harness Web GUI: a Molbio tab in the right sidebar that draws plasmid maps and sequence logos from workspace sequence files, using the same computation modules as the dsh-molbio-tools plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=26",
|
|
9
|
+
"dsh": ">=0.2.1-rc.2 <0.3.0-0"
|
|
10
|
+
},
|
|
7
11
|
"exports": {
|
|
8
12
|
".": "./index.mjs",
|
|
9
13
|
"./client": {
|
|
@@ -17,6 +21,13 @@
|
|
|
17
21
|
"README.md"
|
|
18
22
|
],
|
|
19
23
|
"dsh": {
|
|
24
|
+
"compatibility": {
|
|
25
|
+
"dshReleases": {
|
|
26
|
+
"0.2.1-rc.1": "unknown",
|
|
27
|
+
"0.2.1-rc.2": "compatible",
|
|
28
|
+
"0.2.1-rc.3": "unknown"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
20
31
|
"bundle": {
|
|
21
32
|
"patch": "./cordis.patch.yml"
|
|
22
33
|
},
|