@jbrowse/modifications-utils 5.0.0-beta.1 → 5.0.0-beta.10
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/README.md +21 -1
- package/esm/forEachModRefPos.d.ts +2 -1
- package/esm/forEachModRefPos.js +4 -3
- package/esm/getMethBins.d.ts +4 -1
- package/esm/getMethBins.js +11 -9
- package/esm/getModPositions.d.ts +10 -1
- package/esm/getModPositions.js +225 -121
- package/esm/getModTag.d.ts +9 -0
- package/esm/getModTag.js +22 -0
- package/esm/index.d.ts +1 -0
- package/esm/index.js +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -25,9 +25,15 @@ cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
|
|
|
25
25
|
|
|
26
26
|
Parse MM tag to extract modification positions on the read sequence.
|
|
27
27
|
|
|
28
|
+
Only the calls placed inside `[readStart, readEnd)` are kept, and each entry's
|
|
29
|
+
`probStart` moves past the calls dropped ahead of them, so ML indexing is
|
|
30
|
+
unchanged. The deltas count from the read's 5' end, which is the END of `fseq`
|
|
31
|
+
on a reverse read: the bases between that end and the window are still counted,
|
|
32
|
+
but only as a tally, and nothing past the far side of the window is walked.
|
|
33
|
+
|
|
28
34
|
```js
|
|
29
35
|
// type signature
|
|
30
|
-
(mm: string, fseq: string, fstrand: number) => ModWithPositions[]
|
|
36
|
+
(mm: string, fseq: string, fstrand: number, readStart?: number, readEnd?: number) => ModWithPositions[]
|
|
31
37
|
```
|
|
32
38
|
|
|
33
39
|
[Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModPositions.ts)
|
|
@@ -63,6 +69,20 @@ survive. A caller that needs the numbers themselves still wants
|
|
|
63
69
|
|
|
64
70
|
[Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModProbabilities.ts)
|
|
65
71
|
|
|
72
|
+
### getModTag
|
|
73
|
+
|
|
74
|
+
The read's MM tag, or undefined when its MN tag says MM was computed on a
|
|
75
|
+
sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip or
|
|
76
|
+
trim that shifted the bases MM's deltas count, so on a mismatch every position
|
|
77
|
+
MM would place is wrong.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
// type signature
|
|
81
|
+
(feature: Feature) => string | undefined
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
[Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModTag.ts)
|
|
85
|
+
|
|
66
86
|
### getModTypes
|
|
67
87
|
|
|
68
88
|
The modification types an MM tag declares, from its headers alone.
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { ModWithPositions } from './getModPositions.ts';
|
|
2
|
-
|
|
2
|
+
import type { CigarCursor } from '@jbrowse/cigar-utils';
|
|
3
|
+
export declare function forEachModRefPos(modifications: readonly ModWithPositions[], probabilities: number[] | undefined, cigarOps: ArrayLike<number>, isReverse: boolean, cb: (mod: ModWithPositions, ref: number, idx: number, prob: number) => void, from?: CigarCursor): void;
|
package/esm/forEachModRefPos.js
CHANGED
|
@@ -3,13 +3,14 @@ import { modProbAt } from './getModProbabilities.js';
|
|
|
3
3
|
// Walk every modification's read-sequence positions, mapping each to its
|
|
4
4
|
// reference position via the CIGAR and recovering its ML probability. The
|
|
5
5
|
// ML-array offset (probStart/probStride) is precomputed per modification by
|
|
6
|
-
// getModPositions, so this no longer tracks a running index by hand.
|
|
7
|
-
|
|
6
|
+
// getModPositions, so this no longer tracks a running index by hand. `from`
|
|
7
|
+
// starts every walk at a `refWindowToRead` cursor, for positions cut to it.
|
|
8
|
+
export function forEachModRefPos(modifications, probabilities, cigarOps, isReverse, cb, from) {
|
|
8
9
|
for (const mod of modifications) {
|
|
9
10
|
const { positions, probStart, probStride } = mod;
|
|
10
11
|
getNextRefPos(cigarOps, positions, (ref, idx) => {
|
|
11
12
|
const prob = modProbAt(probabilities, probStart, probStride, isReverse, idx, positions.length);
|
|
12
13
|
cb(mod, ref, idx, prob);
|
|
13
|
-
});
|
|
14
|
+
}, from);
|
|
14
15
|
}
|
|
15
16
|
}
|
package/esm/getMethBins.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CytosineContext } from './cytosineContext.ts';
|
|
2
2
|
import type { ModWithPositions } from './getModPositions.ts';
|
|
3
|
+
import type { ReadWindow } from '@jbrowse/cigar-utils';
|
|
3
4
|
export interface ParsedModData {
|
|
4
5
|
modifications: ModWithPositions[];
|
|
5
6
|
probabilities: number[] | undefined;
|
|
@@ -7,6 +8,8 @@ export interface ParsedModData {
|
|
|
7
8
|
seq: string;
|
|
8
9
|
fstrand: -1 | 0 | 1;
|
|
9
10
|
flen: number;
|
|
11
|
+
/** the window `modifications` was cut to, and the only one binned */
|
|
12
|
+
window?: ReadWindow;
|
|
10
13
|
}
|
|
11
14
|
/**
|
|
12
15
|
* #api
|
|
@@ -26,7 +29,7 @@ export declare function isMethylationFillType(type: string): type is "h" | "m";
|
|
|
26
29
|
* positions, returning typed arrays for methylated/unmethylated calls. Only
|
|
27
30
|
* cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
|
|
28
31
|
*/
|
|
29
|
-
export declare function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }: ParsedModData, context?: CytosineContext): {
|
|
32
|
+
export declare function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen, window, }: ParsedModData, context?: CytosineContext): {
|
|
30
33
|
methBins: number[];
|
|
31
34
|
hydroxyMethBins: number[];
|
|
32
35
|
methProbs: number[];
|
package/esm/getMethBins.js
CHANGED
|
@@ -21,7 +21,7 @@ export function isMethylationFillType(type) {
|
|
|
21
21
|
* positions, returning typed arrays for methylated/unmethylated calls. Only
|
|
22
22
|
* cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
|
|
23
23
|
*/
|
|
24
|
-
export function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }, context = 'CG') {
|
|
24
|
+
export function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen, window, }, context = 'CG') {
|
|
25
25
|
const isReverse = fstrand === -1;
|
|
26
26
|
const methBins = [];
|
|
27
27
|
const hydroxyMethBins = [];
|
|
@@ -42,7 +42,7 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
|
|
|
42
42
|
hydroxyMethProbs[ref] = prob;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
});
|
|
45
|
+
}, window);
|
|
46
46
|
// Only fill undetected sites as unmethylated when the read actually carries a
|
|
47
47
|
// 5mC ('m') call. A read with only e.g. 6mA modifications never assayed
|
|
48
48
|
// methylation, so inventing unmethylated cytosines for it would be wrong.
|
|
@@ -55,12 +55,14 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
|
|
|
55
55
|
// both-strand modBAM ('C+m;G-m') has its uncalled sites in both directions.
|
|
56
56
|
const fillForward = methMods.some(m => isReverse === (m.strand === '-'));
|
|
57
57
|
const fillReverse = methMods.some(m => isReverse !== (m.strand === '-'));
|
|
58
|
-
// Scan the
|
|
59
|
-
// already detected from the MM tag as unmethylated (prob=0).
|
|
58
|
+
// Scan the window's read sequence for every cytosine in `context` and mark any
|
|
59
|
+
// not already detected from the MM tag as unmethylated (prob=0).
|
|
60
60
|
if (fillUnmethylated) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const lo = Math.max(0, window?.refStart ?? 0);
|
|
62
|
+
const hi = Math.min(flen, window?.refEnd ?? flen);
|
|
63
|
+
let readPos = window?.opRead ?? 0;
|
|
64
|
+
let refPos = window?.opRef ?? 0;
|
|
65
|
+
for (let i = window?.op ?? 0, l = cigarOps.length; i < l && refPos < hi; i++) {
|
|
64
66
|
const packed = cigarOps[i];
|
|
65
67
|
const len = packed >>> 4;
|
|
66
68
|
const op = packed & 0xf;
|
|
@@ -74,8 +76,8 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
|
|
|
74
76
|
for (let j = 0; j < len; j++) {
|
|
75
77
|
const rp = readPos + j;
|
|
76
78
|
const rf = refPos + j;
|
|
77
|
-
if (rf >=
|
|
78
|
-
rf <
|
|
79
|
+
if (rf >= lo &&
|
|
80
|
+
rf < hi &&
|
|
79
81
|
!methBins[rf] &&
|
|
80
82
|
((fillForward && matchesCytosineContext(seq, rp, false, context)) ||
|
|
81
83
|
(fillReverse && matchesCytosineContext(seq, rp, true, context)))) {
|
package/esm/getModPositions.d.ts
CHANGED
|
@@ -11,9 +11,18 @@ export interface ModWithPositions {
|
|
|
11
11
|
* #api
|
|
12
12
|
* Parse MM tag to extract modification positions on the read sequence.
|
|
13
13
|
*
|
|
14
|
+
* Only the calls placed inside `[readStart, readEnd)` are kept, and each
|
|
15
|
+
* entry's `probStart` moves past the calls dropped ahead of them, so ML
|
|
16
|
+
* indexing is unchanged. The deltas count from the read's 5' end, which is the
|
|
17
|
+
* END of `fseq` on a reverse read: the bases between that end and the window
|
|
18
|
+
* are still counted, but only as a tally, and nothing past the far side of the
|
|
19
|
+
* window is walked.
|
|
20
|
+
*
|
|
14
21
|
* @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
|
|
15
22
|
* @param fseq - Read sequence
|
|
16
23
|
* @param fstrand - Read strand (-1, 0, or 1)
|
|
24
|
+
* @param readStart - First read offset to keep
|
|
25
|
+
* @param readEnd - Read offset past the last one to keep
|
|
17
26
|
* @returns Array of modification objects with positions
|
|
18
27
|
*/
|
|
19
|
-
export declare function getModPositions(mm: string, fseq: string, fstrand: number): ModWithPositions[];
|
|
28
|
+
export declare function getModPositions(mm: string, fseq: string, fstrand: number, readStart?: number, readEnd?: number): ModWithPositions[];
|
package/esm/getModPositions.js
CHANGED
|
@@ -7,19 +7,209 @@ const COMPLEMENT_CODE = {
|
|
|
7
7
|
71: 67, // G->C
|
|
8
8
|
78: 78, // N->N
|
|
9
9
|
};
|
|
10
|
+
const COMMA = 44;
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const scratch = new Uint8Array(1 << 16);
|
|
13
|
+
const scratchWords = new Uint32Array(scratch.buffer, scratch.byteOffset, scratch.byteLength / 4);
|
|
14
|
+
// Occurrences of `code` in text[start, end), four characters per step over the
|
|
15
|
+
// text's bytes: a byte of `word ^ pattern` is zero exactly where the character
|
|
16
|
+
// matches, and the SWAR zero-byte test finds all four at once.
|
|
17
|
+
function countCode(text, code, start, end) {
|
|
18
|
+
const pattern = Math.imul(code, 0x01010101);
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (let from = start; from < end; from += scratch.length) {
|
|
21
|
+
const to = Math.min(end, from + scratch.length);
|
|
22
|
+
const len = to - from;
|
|
23
|
+
const { read, written } = encoder.encodeInto(text.substring(from, to), scratch);
|
|
24
|
+
if (read !== len || written !== len) {
|
|
25
|
+
for (let i = from; i < to; i++) {
|
|
26
|
+
n += +(text.charCodeAt(i) === code);
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const nWords = len >> 2;
|
|
31
|
+
for (let i = 0; i < nWords; i++) {
|
|
32
|
+
const x = scratchWords[i] ^ pattern;
|
|
33
|
+
const zero = ~(((x & 0x7f7f7f7f) + 0x7f7f7f7f) | x | 0x7f7f7f7f);
|
|
34
|
+
n += Math.imul(zero >>> 7, 0x01010101) >>> 24;
|
|
35
|
+
}
|
|
36
|
+
for (let i = nWords << 2; i < len; i++) {
|
|
37
|
+
n += +(scratch[i] === code);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
// One group's calls inside [windowStart, windowEnd), ascending, and how many
|
|
43
|
+
// calls came before them in MM order. `deltas` is the group after its header,
|
|
44
|
+
// `,2,2,1`.
|
|
45
|
+
//
|
|
46
|
+
// An MM tag may declare more calls of a base than the read has left. When that
|
|
47
|
+
// happens the walk has nowhere to put them, and every emitted value still has
|
|
48
|
+
// to be a valid index into the read — `getMethBins` indexes the sequence with
|
|
49
|
+
// these, and the CIGAR walk requires them ascending, so a position outside the
|
|
50
|
+
// read is read as a real one somewhere wrong rather than dropped. So the
|
|
51
|
+
// exhausted case clamps to the nearest valid index and stays there, which on an
|
|
52
|
+
// empty read is 0 for every call.
|
|
53
|
+
function walkGroup(deltas, nPositions, base, fseq, isRev, windowStart, windowEnd) {
|
|
54
|
+
const seqLength = fseq.length;
|
|
55
|
+
const positions = [];
|
|
56
|
+
if (seqLength === 0) {
|
|
57
|
+
for (let i = 0; i < nPositions; i++) {
|
|
58
|
+
positions.push(0);
|
|
59
|
+
}
|
|
60
|
+
return { positions, skipped: 0 };
|
|
61
|
+
}
|
|
62
|
+
if (windowStart >= windowEnd || nPositions === 0) {
|
|
63
|
+
return { positions, skipped: 0 };
|
|
64
|
+
}
|
|
65
|
+
const isN = base === 'N';
|
|
66
|
+
const baseCode = base.charCodeAt(0);
|
|
67
|
+
// Reverse reads are walked from the back of fseq, matching the complement,
|
|
68
|
+
// rather than reverse-complementing the read.
|
|
69
|
+
const code = isRev ? (COMPLEMENT_CODE[baseCode] ?? baseCode) : baseCode;
|
|
70
|
+
// `+field` for each delta in turn, read in place rather than split out: the
|
|
71
|
+
// digits are summed as they are passed, and a field holding anything else
|
|
72
|
+
// (a sign, a space, a fraction) goes to `+` itself, so a malformed tag reads
|
|
73
|
+
// as it always has. Against `split(',')` on this same windowed walk it
|
|
74
|
+
// measured 1.24x at a 1 kb view and 1.03x over whole reads
|
|
75
|
+
// (`benches/modWindow.bench.ts`); `mmParseShape.bench.ts` had declined it
|
|
76
|
+
// at 1.06x when every read was walked end to end.
|
|
77
|
+
const deltasLength = deltas.length;
|
|
78
|
+
let cursor = 0;
|
|
79
|
+
const next = () => {
|
|
80
|
+
const from = cursor + 1;
|
|
81
|
+
let to = from;
|
|
82
|
+
let value = 0;
|
|
83
|
+
let digits = true;
|
|
84
|
+
for (; to < deltasLength; to++) {
|
|
85
|
+
const d = deltas.charCodeAt(to) - 48;
|
|
86
|
+
if (d === COMMA - 48) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (d < 0 || d > 9) {
|
|
90
|
+
digits = false;
|
|
91
|
+
}
|
|
92
|
+
value = value * 10 + d;
|
|
93
|
+
}
|
|
94
|
+
cursor = to;
|
|
95
|
+
return digits && to - from < 16 ? value : +deltas.slice(from, to);
|
|
96
|
+
};
|
|
97
|
+
// The calls on the 5' side of the window are passed over by counting their
|
|
98
|
+
// base there once and subtracting whole deltas from the count. `carry` is
|
|
99
|
+
// what is left: occurrences the first kept call has already used up. A NaN
|
|
100
|
+
// delta fails the test and is handed to the walk, which places it as it
|
|
101
|
+
// always has.
|
|
102
|
+
const tallyStart = isRev ? windowEnd : 0;
|
|
103
|
+
const tallyEnd = isRev ? seqLength : windowStart;
|
|
104
|
+
let carry = isN
|
|
105
|
+
? tallyEnd - tallyStart
|
|
106
|
+
: countCode(fseq, code, tallyStart, tallyEnd);
|
|
107
|
+
let first = 1;
|
|
108
|
+
let delta = next();
|
|
109
|
+
while (delta + 1 <= carry && first < nPositions) {
|
|
110
|
+
carry -= delta + 1;
|
|
111
|
+
first++;
|
|
112
|
+
delta = next();
|
|
113
|
+
}
|
|
114
|
+
if (delta + 1 <= carry) {
|
|
115
|
+
return { positions, skipped: nPositions };
|
|
116
|
+
}
|
|
117
|
+
if (isRev) {
|
|
118
|
+
let currPos = seqLength - windowEnd;
|
|
119
|
+
for (let i = first; i <= nPositions; i++) {
|
|
120
|
+
if (i > first) {
|
|
121
|
+
delta = next();
|
|
122
|
+
}
|
|
123
|
+
let at = 0;
|
|
124
|
+
if (currPos < seqLength) {
|
|
125
|
+
let remaining = delta - carry;
|
|
126
|
+
carry = 0;
|
|
127
|
+
do {
|
|
128
|
+
if (isN || fseq.charCodeAt(seqLength - 1 - currPos) === code) {
|
|
129
|
+
remaining--;
|
|
130
|
+
}
|
|
131
|
+
currPos++;
|
|
132
|
+
} while (remaining >= 0 && currPos < seqLength);
|
|
133
|
+
at = seqLength - currPos;
|
|
134
|
+
}
|
|
135
|
+
if (at < windowStart) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
positions.push(at);
|
|
139
|
+
}
|
|
140
|
+
positions.reverse();
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// **Forward jumps, reverse steps, and the asymmetry is measured rather
|
|
144
|
+
// than assumed.** `indexOf` for a single character is a native scan, so
|
|
145
|
+
// finding the (delta+1)-th occurrence is delta+1 searches instead of one
|
|
146
|
+
// step per base — 1.560x on the sparse fixture and 1.247x on the dense
|
|
147
|
+
// one, parse phase, in `benches/mmDeltaJump.bench.ts`. `lastIndexOf` is
|
|
148
|
+
// NOT the mirror image: the same change on reverse reads measures
|
|
149
|
+
// **0.786x**.
|
|
150
|
+
const endClamp = seqLength - 1;
|
|
151
|
+
let currPos = windowStart;
|
|
152
|
+
for (let i = first; i <= nPositions; i++) {
|
|
153
|
+
if (i > first) {
|
|
154
|
+
delta = next();
|
|
155
|
+
}
|
|
156
|
+
const remaining = delta - carry;
|
|
157
|
+
carry = 0;
|
|
158
|
+
let at = -1;
|
|
159
|
+
if (isN) {
|
|
160
|
+
// 'N' matches every base, so the (delta+1)-th is delta ahead and
|
|
161
|
+
// there is nothing to search for.
|
|
162
|
+
at = currPos + remaining;
|
|
163
|
+
if (at >= seqLength) {
|
|
164
|
+
at = -1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
for (let k = 0; k <= remaining; k++) {
|
|
169
|
+
at = fseq.indexOf(base, currPos);
|
|
170
|
+
if (at < 0) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
currPos = at + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (at < 0) {
|
|
177
|
+
currPos = seqLength;
|
|
178
|
+
at = endClamp;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
currPos = at + 1;
|
|
182
|
+
}
|
|
183
|
+
if (at >= windowEnd) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
positions.push(at);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { positions, skipped: first - 1 };
|
|
190
|
+
}
|
|
10
191
|
/**
|
|
11
192
|
* #api
|
|
12
193
|
* Parse MM tag to extract modification positions on the read sequence.
|
|
13
194
|
*
|
|
195
|
+
* Only the calls placed inside `[readStart, readEnd)` are kept, and each
|
|
196
|
+
* entry's `probStart` moves past the calls dropped ahead of them, so ML
|
|
197
|
+
* indexing is unchanged. The deltas count from the read's 5' end, which is the
|
|
198
|
+
* END of `fseq` on a reverse read: the bases between that end and the window
|
|
199
|
+
* are still counted, but only as a tally, and nothing past the far side of the
|
|
200
|
+
* window is walked.
|
|
201
|
+
*
|
|
14
202
|
* @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
|
|
15
203
|
* @param fseq - Read sequence
|
|
16
204
|
* @param fstrand - Read strand (-1, 0, or 1)
|
|
205
|
+
* @param readStart - First read offset to keep
|
|
206
|
+
* @param readEnd - Read offset past the last one to keep
|
|
17
207
|
* @returns Array of modification objects with positions
|
|
18
208
|
*/
|
|
19
|
-
export function getModPositions(mm, fseq, fstrand) {
|
|
20
|
-
const seqLength = fseq.length;
|
|
209
|
+
export function getModPositions(mm, fseq, fstrand, readStart = 0, readEnd = fseq.length) {
|
|
21
210
|
const isRev = fstrand === -1;
|
|
22
|
-
const
|
|
211
|
+
const windowStart = Math.max(0, readStart);
|
|
212
|
+
const windowEnd = Math.min(fseq.length, readEnd);
|
|
23
213
|
const result = [];
|
|
24
214
|
// Running offset into the flat ML probabilities array. Each group consumes
|
|
25
215
|
// (numPositions * numTypes) values, interleaved per position.
|
|
@@ -29,27 +219,26 @@ export function getModPositions(mm, fseq, fstrand) {
|
|
|
29
219
|
// model emits `C+h?;C+m?` — two groups, one canonical base, identical delta
|
|
30
220
|
// lists — so on real ONT output this drops one of every three sequence walks.
|
|
31
221
|
//
|
|
32
|
-
// The key is what the walk
|
|
33
|
-
// (`fseq
|
|
34
|
-
// strand, which the walk does not currently read —
|
|
35
|
-
// needed, so the test cannot go quietly wrong if
|
|
36
|
-
// strand-aware.
|
|
222
|
+
// The key is what the walk READS: the canonical base and the delta list
|
|
223
|
+
// (`fseq`, `fstrand` and the window are per read, not per group). It also
|
|
224
|
+
// carries the MM strand, which the walk does not currently read —
|
|
225
|
+
// deliberately stronger than needed, so the test cannot go quietly wrong if
|
|
226
|
+
// the walk ever becomes strand-aware.
|
|
37
227
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
228
|
+
// A list rather than a Map: a read carries one to four groups, so a linear
|
|
229
|
+
// scan of char compares beats hashing a multi-kilobyte delta string, and it
|
|
230
|
+
// is allocated on the first walk so a single-group read builds none.
|
|
41
231
|
// `benches/sameBaseMerge.bench.ts` prices the whole test at inside the control
|
|
42
232
|
// on every fixture where it cannot fire, including one where every compare is
|
|
43
|
-
// forced to run to its final byte and then fail.
|
|
44
|
-
|
|
45
|
-
let
|
|
46
|
-
|
|
47
|
-
for (const mod of mods) {
|
|
233
|
+
// forced to run to its final byte and then fail. A group that matches is not
|
|
234
|
+
// even split.
|
|
235
|
+
let seen;
|
|
236
|
+
for (const mod of mm.split(';')) {
|
|
48
237
|
if (mod === '') {
|
|
49
238
|
continue;
|
|
50
239
|
}
|
|
51
|
-
const
|
|
52
|
-
const basemod =
|
|
240
|
+
const comma = mod.indexOf(',');
|
|
241
|
+
const basemod = comma < 0 ? mod : mod.slice(0, comma);
|
|
53
242
|
const { base, strand, typestr, mod: skipFlag, } = parseModHeader(basemod, mod);
|
|
54
243
|
const unknownSkip = skipFlag === '?';
|
|
55
244
|
// typestr can be multi-char lowercase e.g. 'mh' (5mC + 5hmC at same
|
|
@@ -59,7 +248,7 @@ export function getModPositions(mm, fseq, fstrand) {
|
|
|
59
248
|
const isSingleType = isSingleModType(typestr);
|
|
60
249
|
const nTypes = isSingleType ? 1 : typestr.length;
|
|
61
250
|
// ONE walk per GROUP, not per type. Every type in a combined code is called
|
|
62
|
-
// at the same positions — only probStart differs — so the walk
|
|
251
|
+
// at the same positions — only probStart differs — so the walk and the
|
|
63
252
|
// array it fills are shared, and the entries pushed after it point at the
|
|
64
253
|
// same array. `C+mh` used to walk the read sequence twice and allocate two
|
|
65
254
|
// identical arrays; `benches/modCombinedCode.bench.ts` (in the alignments
|
|
@@ -67,115 +256,30 @@ export function getModPositions(mm, fseq, fstrand) {
|
|
|
67
256
|
// `C+mh`, and at **1.16x** even on a single-type tag, where nothing is
|
|
68
257
|
// deduplicated and the win is the per-group closure this loop replaced.
|
|
69
258
|
//
|
|
70
|
-
// A combined code is the RARER of the two shapes this deduplicates, though.
|
|
71
|
-
// The reuse test below is the common one — see `seenKeys`.
|
|
72
|
-
//
|
|
73
259
|
// this logic based on parse_mm.pl from hts-specs
|
|
74
|
-
|
|
75
|
-
const nPositions = splitLength - 1;
|
|
260
|
+
//
|
|
76
261
|
// Everything after the header — `,2,2,1`. V8 slices a long string in O(1),
|
|
77
262
|
// and the compare rejects on length before it reads a byte.
|
|
78
263
|
const deltas = mod.slice(basemod.length);
|
|
79
264
|
const key = base + strand;
|
|
80
|
-
let
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let currPos = 0;
|
|
92
|
-
// An MM tag may declare more calls of a base than the read has left. When
|
|
93
|
-
// that happens the walk has nowhere to put them, and every emitted value
|
|
94
|
-
// still has to be a valid index into the read — `getMethBins` indexes the
|
|
95
|
-
// sequence with these, and the CIGAR walk requires them ascending, so a
|
|
96
|
-
// position outside the read is read as a real one somewhere wrong rather
|
|
97
|
-
// than dropped. So the exhausted case clamps to the nearest valid index
|
|
98
|
-
// and stays there.
|
|
99
|
-
const endClamp = isRev ? 0 : Math.max(0, seqLength - 1);
|
|
100
|
-
// Pre-allocate and fill backwards on reverse strand to avoid a final
|
|
101
|
-
// reverse(). Forward stays a growing literal on purpose: filling
|
|
102
|
-
// `new Array(n)` leaves holey elements, and this array is read in the
|
|
103
|
-
// CIGAR walk's inner loop.
|
|
104
|
-
positions = isRev ? new Array(nPositions) : [];
|
|
105
|
-
let writeIndex = isRev ? nPositions - 1 : 0;
|
|
106
|
-
if (isRev) {
|
|
107
|
-
// Avoid revcom(fseq) by reading fseq from the back and complementing the
|
|
108
|
-
// expected char-code.
|
|
109
|
-
const baseCode = base.charCodeAt(0);
|
|
110
|
-
const targetCode = COMPLEMENT_CODE[baseCode] ?? baseCode;
|
|
111
|
-
for (let i = 1; i < splitLength; i++) {
|
|
112
|
-
if (currPos >= seqLength) {
|
|
113
|
-
positions[writeIndex--] = endClamp;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
let delta = +split[i];
|
|
117
|
-
do {
|
|
118
|
-
if (isN ||
|
|
119
|
-
fseq.charCodeAt(seqLength - 1 - currPos) === targetCode) {
|
|
120
|
-
delta--;
|
|
121
|
-
}
|
|
122
|
-
currPos++;
|
|
123
|
-
} while (delta >= 0 && currPos < seqLength);
|
|
124
|
-
positions[writeIndex--] = seqLength - currPos;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
// **Forward jumps, reverse steps, and the asymmetry is measured rather
|
|
129
|
-
// than assumed.** `indexOf` for a single character is a native scan, so
|
|
130
|
-
// finding the (delta+1)-th occurrence is delta+1 searches instead of one
|
|
131
|
-
// step per base — 1.560x on the sparse fixture and 1.247x on the dense
|
|
132
|
-
// one, parse phase, in `benches/mmDeltaJump.bench.ts`.
|
|
133
|
-
//
|
|
134
|
-
// `lastIndexOf` is NOT the mirror image: the same change on reverse
|
|
135
|
-
// reads measures **0.786x**, i.e. materially slower than stepping, so
|
|
136
|
-
// reverse keeps the loop above. Applying it to both, which is the
|
|
137
|
-
// obvious version, nets 1.054x where branching on strand nets 1.263x.
|
|
138
|
-
for (let i = 1; i < splitLength; i++) {
|
|
139
|
-
const delta = +split[i];
|
|
140
|
-
let at = -1;
|
|
141
|
-
if (isN) {
|
|
142
|
-
// 'N' matches every base, so the (delta+1)-th is delta ahead and
|
|
143
|
-
// there is nothing to search for.
|
|
144
|
-
at = currPos + delta;
|
|
145
|
-
if (at >= seqLength) {
|
|
146
|
-
at = -1;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
for (let k = 0; k <= delta; k++) {
|
|
151
|
-
at = fseq.indexOf(base, currPos);
|
|
152
|
-
if (at < 0) {
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
currPos = at + 1;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (at < 0) {
|
|
159
|
-
currPos = seqLength;
|
|
160
|
-
positions[writeIndex++] = endClamp;
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
currPos = at + 1;
|
|
164
|
-
positions[writeIndex++] = at;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (seenKeys === undefined) {
|
|
169
|
-
seenKeys = [key];
|
|
170
|
-
seenDeltas = [deltas];
|
|
171
|
-
seenPositions = [positions];
|
|
265
|
+
let walked = seen?.find(w => w.key === key && w.deltas === deltas);
|
|
266
|
+
if (walked === undefined) {
|
|
267
|
+
const nPositions = countCode(deltas, COMMA, 0, deltas.length);
|
|
268
|
+
walked = {
|
|
269
|
+
key,
|
|
270
|
+
deltas,
|
|
271
|
+
nPositions,
|
|
272
|
+
...walkGroup(deltas, nPositions, base, fseq, isRev, windowStart, windowEnd),
|
|
273
|
+
};
|
|
274
|
+
if (seen === undefined) {
|
|
275
|
+
seen = [walked];
|
|
172
276
|
}
|
|
173
277
|
else {
|
|
174
|
-
|
|
175
|
-
seenDeltas.push(deltas);
|
|
176
|
-
seenPositions.push(positions);
|
|
278
|
+
seen.push(walked);
|
|
177
279
|
}
|
|
178
280
|
}
|
|
281
|
+
const { positions, skipped, nPositions } = walked;
|
|
282
|
+
const probStart = mlBase + skipped * nTypes;
|
|
179
283
|
if (isSingleType) {
|
|
180
284
|
result.push({
|
|
181
285
|
type: typestr,
|
|
@@ -183,7 +287,7 @@ export function getModPositions(mm, fseq, fstrand) {
|
|
|
183
287
|
strand,
|
|
184
288
|
unknownSkip,
|
|
185
289
|
positions,
|
|
186
|
-
probStart
|
|
290
|
+
probStart,
|
|
187
291
|
probStride: 1,
|
|
188
292
|
});
|
|
189
293
|
}
|
|
@@ -196,7 +300,7 @@ export function getModPositions(mm, fseq, fstrand) {
|
|
|
196
300
|
strand,
|
|
197
301
|
unknownSkip,
|
|
198
302
|
positions,
|
|
199
|
-
probStart:
|
|
303
|
+
probStart: probStart + j,
|
|
200
304
|
probStride: nTypes,
|
|
201
305
|
});
|
|
202
306
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Feature } from '@jbrowse/core/util';
|
|
2
|
+
/**
|
|
3
|
+
* #api
|
|
4
|
+
* The read's MM tag, or undefined when its MN tag says MM was computed on a
|
|
5
|
+
* sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip
|
|
6
|
+
* or trim that shifted the bases MM's deltas count, so on a mismatch every
|
|
7
|
+
* position MM would place is wrong.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getModTag(feature: Feature): string | undefined;
|
package/esm/getModTag.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getTag, getTagAlt } from './getTagAlt.js';
|
|
2
|
+
function seqLength(feature) {
|
|
3
|
+
const len = feature.get('seq_length');
|
|
4
|
+
return typeof len === 'number'
|
|
5
|
+
? len
|
|
6
|
+
: feature.get('seq')?.length;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* #api
|
|
10
|
+
* The read's MM tag, or undefined when its MN tag says MM was computed on a
|
|
11
|
+
* sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip
|
|
12
|
+
* or trim that shifted the bases MM's deltas count, so on a mismatch every
|
|
13
|
+
* position MM would place is wrong.
|
|
14
|
+
*/
|
|
15
|
+
export function getModTag(feature) {
|
|
16
|
+
const mm = getTagAlt(feature, 'MM', 'Mm');
|
|
17
|
+
if (!mm) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const mn = getTag(feature, 'MN');
|
|
21
|
+
return mn === undefined || Number(mn) === seqLength(feature) ? mm : undefined;
|
|
22
|
+
}
|
package/esm/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { forEachModRefPos } from './forEachModRefPos.ts';
|
|
|
6
6
|
export { getMethBins, isMethylationFillType } from './getMethBins.ts';
|
|
7
7
|
export type { ParsedModData } from './getMethBins.ts';
|
|
8
8
|
export { getModPositions } from './getModPositions.ts';
|
|
9
|
+
export { getModTag } from './getModTag.ts';
|
|
9
10
|
export type { ModWithPositions } from './getModPositions.ts';
|
|
10
11
|
export { getModTypes, isSingleModType } from './getModTypes.ts';
|
|
11
12
|
export type { ModTypeHeader } from './getModTypes.ts';
|
package/esm/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { detectSimplexModifications } from './detectSimplexModifications.js';
|
|
|
4
4
|
export { forEachModRefPos } from './forEachModRefPos.js';
|
|
5
5
|
export { getMethBins, isMethylationFillType } from './getMethBins.js';
|
|
6
6
|
export { getModPositions } from './getModPositions.js';
|
|
7
|
+
export { getModTag } from './getModTag.js';
|
|
7
8
|
export { getModTypes, isSingleModType } from './getModTypes.js';
|
|
8
9
|
export { getModProbabilities, getModProbabilityBytes, modProbAt, } from './getModProbabilities.js';
|
|
9
10
|
export { getTag, getTagAlt } from './getTagAlt.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbrowse/modifications-utils",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pure MM/ML base-modification tag parsers (methylation, etc.)",
|
|
6
6
|
"keywords": [
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"esm"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@jbrowse/cigar-utils": "^5.0.0-beta.
|
|
29
|
-
"@jbrowse/core": "^5.0.0-beta.
|
|
28
|
+
"@jbrowse/cigar-utils": "^5.0.0-beta.10",
|
|
29
|
+
"@jbrowse/core": "^5.0.0-beta.10"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|