@gmod/hic 1.0.0
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/LICENSE +25 -0
- package/README.md +91 -0
- package/dist/binWindow.d.ts +24 -0
- package/dist/binWindow.js +32 -0
- package/dist/binWindow.js.map +1 -0
- package/dist/binary.d.ts +15 -0
- package/dist/binary.js +67 -0
- package/dist/binary.js.map +1 -0
- package/dist/bufferedFile.d.ts +13 -0
- package/dist/bufferedFile.js +78 -0
- package/dist/bufferedFile.js.map +1 -0
- package/dist/contactRecords.d.ts +25 -0
- package/dist/contactRecords.js +3 -0
- package/dist/contactRecords.js.map +1 -0
- package/dist/hicFile.d.ts +94 -0
- package/dist/hicFile.js +769 -0
- package/dist/hicFile.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lru.d.ts +29 -0
- package/dist/lru.js +55 -0
- package/dist/lru.js.map +1 -0
- package/dist/matrix.d.ts +9 -0
- package/dist/matrix.js +41 -0
- package/dist/matrix.js.map +1 -0
- package/dist/matrixZoomData.d.ts +14 -0
- package/dist/matrixZoomData.js +96 -0
- package/dist/matrixZoomData.js.map +1 -0
- package/dist/normalizationVector.d.ts +10 -0
- package/dist/normalizationVector.js +51 -0
- package/dist/normalizationVector.js.map +1 -0
- package/dist/package.json +1 -0
- package/dist/reader.d.ts +3 -0
- package/dist/reader.js +12 -0
- package/dist/reader.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/esm/binWindow.d.ts +24 -0
- package/esm/binWindow.js +29 -0
- package/esm/binWindow.js.map +1 -0
- package/esm/binary.d.ts +15 -0
- package/esm/binary.js +64 -0
- package/esm/binary.js.map +1 -0
- package/esm/bufferedFile.d.ts +13 -0
- package/esm/bufferedFile.js +75 -0
- package/esm/bufferedFile.js.map +1 -0
- package/esm/contactRecords.d.ts +25 -0
- package/esm/contactRecords.js +2 -0
- package/esm/contactRecords.js.map +1 -0
- package/esm/hicFile.d.ts +94 -0
- package/esm/hicFile.js +762 -0
- package/esm/hicFile.js.map +1 -0
- package/esm/index.d.ts +5 -0
- package/esm/index.js +3 -0
- package/esm/index.js.map +1 -0
- package/esm/lru.d.ts +29 -0
- package/esm/lru.js +52 -0
- package/esm/lru.js.map +1 -0
- package/esm/matrix.d.ts +9 -0
- package/esm/matrix.js +35 -0
- package/esm/matrix.js.map +1 -0
- package/esm/matrixZoomData.d.ts +14 -0
- package/esm/matrixZoomData.js +93 -0
- package/esm/matrixZoomData.js.map +1 -0
- package/esm/normalizationVector.d.ts +10 -0
- package/esm/normalizationVector.js +45 -0
- package/esm/normalizationVector.js.map +1 -0
- package/esm/reader.d.ts +3 -0
- package/esm/reader.js +9 -0
- package/esm/reader.js.map +1 -0
- package/esm/types.d.ts +35 -0
- package/esm/types.js +2 -0
- package/esm/types.js.map +1 -0
- package/package.json +73 -0
- package/src/binWindow.ts +30 -0
- package/src/binary.ts +72 -0
- package/src/bufferedFile.ts +74 -0
- package/src/contactRecords.ts +25 -0
- package/src/hicFile.ts +943 -0
- package/src/index.ts +12 -0
- package/src/lru.ts +77 -0
- package/src/matrix.ts +43 -0
- package/src/matrixZoomData.ts +133 -0
- package/src/normalizationVector.ts +58 -0
- package/src/reader.ts +11 -0
- package/src/types.ts +40 -0
package/src/hicFile.ts
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import { LocalFile } from 'generic-filehandle2'
|
|
2
|
+
import { inflate } from 'pako-esm2'
|
|
3
|
+
|
|
4
|
+
import { binWindow } from './binWindow.ts'
|
|
5
|
+
import BinaryParser from './binary.ts'
|
|
6
|
+
import BufferedFile from './bufferedFile.ts'
|
|
7
|
+
import LRU from './lru.ts'
|
|
8
|
+
import Matrix from './matrix.ts'
|
|
9
|
+
import NormalizationVector from './normalizationVector.ts'
|
|
10
|
+
import { readerFromFilehandle } from './reader.ts'
|
|
11
|
+
|
|
12
|
+
import type { ContactRecords } from './contactRecords.ts'
|
|
13
|
+
import type MatrixZoomData from './matrixZoomData.ts'
|
|
14
|
+
import type {
|
|
15
|
+
BlockIndexEntry,
|
|
16
|
+
Chromosome,
|
|
17
|
+
HicMetadata,
|
|
18
|
+
HicRegion,
|
|
19
|
+
Reader,
|
|
20
|
+
} from './types.ts'
|
|
21
|
+
import type { GenericFilehandle } from 'generic-filehandle2'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Prefix of the error thrown when a region pair has no matrix at the requested
|
|
25
|
+
* binsize. A caller fetching many region pairs at once will want to drop that
|
|
26
|
+
* pair rather than fail the whole fetch, and it can only tell this apart from a
|
|
27
|
+
* real failure by the message — so it is exported instead of hand-copied.
|
|
28
|
+
*/
|
|
29
|
+
export const NO_DATA_FOR_RESOLUTION = 'No data available for resolution'
|
|
30
|
+
|
|
31
|
+
const Short_MIN_VALUE = -32768
|
|
32
|
+
const DOUBLE = 8
|
|
33
|
+
const FLOAT = 4
|
|
34
|
+
const INT = 4
|
|
35
|
+
|
|
36
|
+
interface MasterIndexEntry {
|
|
37
|
+
start: number
|
|
38
|
+
size: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HicConfig {
|
|
42
|
+
/** a `generic-filehandle2` handle, e.g. `RemoteFile` for a URL */
|
|
43
|
+
filehandle?: GenericFilehandle
|
|
44
|
+
/** a local path, node only */
|
|
45
|
+
path?: string
|
|
46
|
+
/** any object that can read `length` bytes at `position` */
|
|
47
|
+
reader?: Reader
|
|
48
|
+
/**
|
|
49
|
+
* `"position,size"` of the normalization vector index, if it is known. Skips
|
|
50
|
+
* the walk through the expected-value vectors that locating it otherwise
|
|
51
|
+
* costs on a pre-v9 file.
|
|
52
|
+
*/
|
|
53
|
+
nvi?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cached blocks are the decompressed records and nothing else. hic-straw also
|
|
57
|
+
// hung the MatrixZoomData and the block-index entry off each Block; neither was
|
|
58
|
+
// ever read back, and the zoom data pinned a whole `blockIndex` record per
|
|
59
|
+
// cached block for the lifetime of the LRU.
|
|
60
|
+
interface Block {
|
|
61
|
+
blockNumber: number
|
|
62
|
+
records: ContactRecords
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Copy a partially-filled decode buffer down to its true length, or hand it
|
|
67
|
+
* back untouched when the filter dropped nothing. A `subarray` would be free
|
|
68
|
+
* but keeps the whole oversized buffer reachable, and these land in a cache
|
|
69
|
+
* that outlives the fetch.
|
|
70
|
+
*/
|
|
71
|
+
function truncateRecords(records: ContactRecords, n: number): ContactRecords {
|
|
72
|
+
return n === records.bin1.length
|
|
73
|
+
? records
|
|
74
|
+
: {
|
|
75
|
+
bin1: records.bin1.slice(0, n),
|
|
76
|
+
bin2: records.bin2.slice(0, n),
|
|
77
|
+
counts: records.counts.slice(0, n),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every cache here is sized against the same thing, and it is worth stating
|
|
82
|
+
// once: a fetch's working set is a function of the DISPLAYED REGION COUNT, and
|
|
83
|
+
// two of the three grow with its square.
|
|
84
|
+
//
|
|
85
|
+
// A multi-region view queries every `(i, j)` pair with `i <= j`, so N displayed
|
|
86
|
+
// regions is N(N+1)/2 pairs — a whole-genome human view is 24 regions and 300
|
|
87
|
+
// pairs. Each pair needs one matrix and its blocks; each region needs one
|
|
88
|
+
// normalization vector per chromosome. Sized for one region, these caches don't
|
|
89
|
+
// merely underperform, they invert: the entries a fetch will need again are
|
|
90
|
+
// evicted by the same fetch, so the cache costs eviction bookkeeping and
|
|
91
|
+
// returns nothing.
|
|
92
|
+
//
|
|
93
|
+
// Measured on `test/data/test.hic` (hg19, 2.5 Mb, 24 regions / 300 pairs),
|
|
94
|
+
// range reads for a fetch and then an identical repeat fetch — which is what
|
|
95
|
+
// every pan issues:
|
|
96
|
+
//
|
|
97
|
+
// N pairs | before | after
|
|
98
|
+
// 4 10 | 29 / 0 | 29 / 0
|
|
99
|
+
// 5 15 | 40 / 15 | 40 / 0
|
|
100
|
+
// 10 55 | 130 / 110 | 130 / 0
|
|
101
|
+
// 24 300 | 1106 /1106 | 648 / 0
|
|
102
|
+
//
|
|
103
|
+
// The cliffs are exactly these three capacities. Note the first column: 41% of a
|
|
104
|
+
// cold whole-genome fetch's reads were re-reads of normalization vectors the
|
|
105
|
+
// same fetch had already fetched.
|
|
106
|
+
const BLOCK_CACHE_MAX_ENTRIES = 1024
|
|
107
|
+
// The bound the entry cap was standing in for. Blocks became struct-of-arrays
|
|
108
|
+
// (see contactRecords.ts), so a cached contact is 12 bytes of untraced
|
|
109
|
+
// ArrayBuffer rather than ~55 on the GC-traced heap — but a block still holds
|
|
110
|
+
// every contact in its bin square, which is the one thing here that can hold
|
|
111
|
+
// real memory, and block sizes vary by more than an order of magnitude with
|
|
112
|
+
// binsize. Measured on the file above: 0.05 MB at 2.5 Mb, 0.23 MB at 100 kb. So
|
|
113
|
+
// the entry cap now tracks the working set and this tracks the memory, instead
|
|
114
|
+
// of one number answering both badly. See LRU's WeightOpts.
|
|
115
|
+
const BLOCK_CACHE_MAX_BYTES = 128 * 1024 * 1024
|
|
116
|
+
// One entry per (chrIdx1, chrIdx2), so the working set is the pair count.
|
|
117
|
+
// 512 covers a 31-region view; human whole-genome is 300. A matrix holds the
|
|
118
|
+
// block index for each of its BP zoom levels, which is small beside a block.
|
|
119
|
+
const MATRIX_CACHE_SIZE = 512
|
|
120
|
+
// One entry per (type, chrIdx, unit, binsize), so the working set is the
|
|
121
|
+
// displayed CHROMOSOME count, not the pair count — 64 covers a whole-genome
|
|
122
|
+
// human view twice over, which is the headroom a zoom step needs (binsize is in
|
|
123
|
+
// the key, so every entry misses at a new resolution). Entries are cheap:
|
|
124
|
+
// `NormalizationVector` retains only the queried slice ±1000 values.
|
|
125
|
+
const NORM_VECTOR_CACHE_SIZE = 64
|
|
126
|
+
|
|
127
|
+
// Keyed by `${zd.getKey()}_${blockNumber}`, which already carries the binsize,
|
|
128
|
+
// so no separate resolution generation is needed to keep entries apart —
|
|
129
|
+
// hic-straw's extra `resolution` field only made the cache single-resolution
|
|
130
|
+
// (every zoom step threw the previous level away).
|
|
131
|
+
function blockCacheWeight(block: Block) {
|
|
132
|
+
const { bin1, bin2, counts } = block.records
|
|
133
|
+
return bin1.byteLength + bin2.byteLength + counts.byteLength
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getNormalizationVectorKey(
|
|
137
|
+
type: string,
|
|
138
|
+
chrIdx: number,
|
|
139
|
+
unit: string,
|
|
140
|
+
resolution: number,
|
|
141
|
+
) {
|
|
142
|
+
return `${type}_${chrIdx}_${unit}_${resolution}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class HicFile {
|
|
146
|
+
private config: HicConfig
|
|
147
|
+
private file: Reader
|
|
148
|
+
|
|
149
|
+
// Both caches hold the in-flight PROMISE, not the resolved value. A
|
|
150
|
+
// multi-region fetch runs its region pairs concurrently and they share
|
|
151
|
+
// chromosomes, so caching only the result had every concurrent pair miss and
|
|
152
|
+
// re-issue the same reads — measured +12 range requests on a 6-pair fetch.
|
|
153
|
+
// Same shape as `initPromise`/`normVectorIndexP`, rejection eviction included.
|
|
154
|
+
private normVectorCache = new LRU<
|
|
155
|
+
string,
|
|
156
|
+
Promise<NormalizationVector | undefined>
|
|
157
|
+
>(NORM_VECTOR_CACHE_SIZE)
|
|
158
|
+
private normalizationTypes = ['NONE']
|
|
159
|
+
private matrixCache = new LRU<string, Promise<Matrix | undefined>>(
|
|
160
|
+
MATRIX_CACHE_SIZE,
|
|
161
|
+
)
|
|
162
|
+
private blockCache = new LRU<string, Block>(BLOCK_CACHE_MAX_ENTRIES, {
|
|
163
|
+
maxBytes: BLOCK_CACHE_MAX_BYTES,
|
|
164
|
+
weigh: blockCacheWeight,
|
|
165
|
+
})
|
|
166
|
+
private normVectorIndexPosition = -1
|
|
167
|
+
private normVectorIndexSize = -1
|
|
168
|
+
|
|
169
|
+
private initPromise: Promise<void> | undefined
|
|
170
|
+
private normVectorIndexP: Promise<void> | undefined
|
|
171
|
+
private version = 0
|
|
172
|
+
private genomeId = ''
|
|
173
|
+
private footerPosition = 0
|
|
174
|
+
// Where the file's normalization data begins, and so also where a file
|
|
175
|
+
// carrying none of it ends. Public so a test can cut a real file there to
|
|
176
|
+
// make that second shape.
|
|
177
|
+
normExpectedValueVectorsPosition: number | undefined
|
|
178
|
+
private normVectorIndex: Record<string, BlockIndexEntry> | undefined
|
|
179
|
+
|
|
180
|
+
private chromosomes: Chromosome[] = []
|
|
181
|
+
private chromosomeIndexMap: Record<string, number> = {}
|
|
182
|
+
private chrAliasTable: Record<string, string> = {}
|
|
183
|
+
private bpResolutions: number[] = []
|
|
184
|
+
private masterIndex: Record<string, MasterIndexEntry> = {}
|
|
185
|
+
private meta: HicMetadata | undefined
|
|
186
|
+
|
|
187
|
+
constructor(config: HicConfig) {
|
|
188
|
+
const { filehandle, path, reader } = config
|
|
189
|
+
this.config = config
|
|
190
|
+
if (reader) {
|
|
191
|
+
this.file = reader
|
|
192
|
+
} else if (filehandle) {
|
|
193
|
+
this.file = readerFromFilehandle(filehandle)
|
|
194
|
+
} else if (path) {
|
|
195
|
+
this.file = readerFromFilehandle(new LocalFile(path))
|
|
196
|
+
} else {
|
|
197
|
+
throw new Error('must supply filehandle, path, or reader')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async init() {
|
|
202
|
+
// Memoize the promise, not a boolean flag: two concurrent callers must
|
|
203
|
+
// share one header parse rather than both racing readHeaderAndFooter. Clear
|
|
204
|
+
// it on failure so a later call retries instead of caching a rejected
|
|
205
|
+
// promise forever.
|
|
206
|
+
this.initPromise ??= this.readHeaderAndFooter().catch((e: unknown) => {
|
|
207
|
+
this.initPromise = undefined
|
|
208
|
+
throw e
|
|
209
|
+
})
|
|
210
|
+
return this.initPromise
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getMetaData() {
|
|
214
|
+
await this.init()
|
|
215
|
+
return this.meta!
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async readHeaderAndFooter() {
|
|
219
|
+
// `init` clears its memoized promise on failure so a transient read error
|
|
220
|
+
// retries, and this parse appends as it goes — so a retry has to start from
|
|
221
|
+
// empty or a failure part-way through the chromosome loop leaves the second
|
|
222
|
+
// run appending to the first's partial output (duplicate refNames out of
|
|
223
|
+
// `getRefNames`, a duplicated binsize list out of `getMetaData`).
|
|
224
|
+
this.chromosomes = []
|
|
225
|
+
this.chromosomeIndexMap = {}
|
|
226
|
+
this.chrAliasTable = {}
|
|
227
|
+
this.bpResolutions = []
|
|
228
|
+
this.masterIndex = {}
|
|
229
|
+
|
|
230
|
+
// Read initial fields magic, version, and footer position
|
|
231
|
+
let data = await this.file.read(0, 16)
|
|
232
|
+
if (data.byteLength === 0) {
|
|
233
|
+
throw new Error('File content is empty')
|
|
234
|
+
}
|
|
235
|
+
let binaryParser = new BinaryParser(new DataView(data))
|
|
236
|
+
binaryParser.getString() // magic
|
|
237
|
+
this.version = binaryParser.getInt()
|
|
238
|
+
if (this.version < 5) {
|
|
239
|
+
throw new Error(`Unsupported hic version: ${this.version}`)
|
|
240
|
+
}
|
|
241
|
+
this.footerPosition = binaryParser.getLong()
|
|
242
|
+
|
|
243
|
+
// Read footer to determine file position of body section (end of header)
|
|
244
|
+
await this.readFooter()
|
|
245
|
+
|
|
246
|
+
const bodyPosition = Object.values(this.masterIndex).reduce(
|
|
247
|
+
(min, entry) => Math.min(min, entry.start),
|
|
248
|
+
Number.MAX_VALUE,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
data = await this.file.read(16, bodyPosition - 16)
|
|
252
|
+
binaryParser = new BinaryParser(new DataView(data))
|
|
253
|
+
|
|
254
|
+
this.genomeId = binaryParser.getString()
|
|
255
|
+
|
|
256
|
+
if (this.version >= 9) {
|
|
257
|
+
this.normVectorIndexPosition = binaryParser.getLong()
|
|
258
|
+
this.normVectorIndexSize = binaryParser.getLong()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let nAttributes = binaryParser.getInt()
|
|
262
|
+
while (nAttributes-- > 0) {
|
|
263
|
+
binaryParser.getString() // attribute key
|
|
264
|
+
binaryParser.getString() // attribute value
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let nChrs = binaryParser.getInt()
|
|
268
|
+
let i = 0
|
|
269
|
+
while (nChrs-- > 0) {
|
|
270
|
+
const chr = {
|
|
271
|
+
index: i,
|
|
272
|
+
name: binaryParser.getString(),
|
|
273
|
+
size: this.version < 9 ? binaryParser.getInt() : binaryParser.getLong(),
|
|
274
|
+
}
|
|
275
|
+
this.chromosomes.push(chr)
|
|
276
|
+
this.chromosomeIndexMap[chr.name] = chr.index
|
|
277
|
+
i++
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let nBpResolutions = binaryParser.getInt()
|
|
281
|
+
while (nBpResolutions-- > 0) {
|
|
282
|
+
this.bpResolutions.push(binaryParser.getInt())
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Build lookup table for well-known chr aliases
|
|
286
|
+
for (const chrName of Object.keys(this.chromosomeIndexMap)) {
|
|
287
|
+
if (chrName.startsWith('chr')) {
|
|
288
|
+
this.chrAliasTable[chrName.slice(3)] = chrName
|
|
289
|
+
} else if (chrName === 'MT') {
|
|
290
|
+
this.chrAliasTable.chrM = chrName
|
|
291
|
+
} else {
|
|
292
|
+
this.chrAliasTable[`chr${chrName}`] = chrName
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.meta = {
|
|
297
|
+
version: this.version,
|
|
298
|
+
genome: this.genomeId,
|
|
299
|
+
chromosomes: this.chromosomes,
|
|
300
|
+
resolutions: this.bpResolutions,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async readFooter() {
|
|
305
|
+
const skip = this.version < 9 ? 8 : 12
|
|
306
|
+
let data = await this.file.read(this.footerPosition, skip)
|
|
307
|
+
|
|
308
|
+
let binaryParser = new BinaryParser(new DataView(data))
|
|
309
|
+
// Total size, master index + expected values
|
|
310
|
+
const nBytes =
|
|
311
|
+
this.version < 9 ? binaryParser.getInt() : binaryParser.getLong()
|
|
312
|
+
let nEntries = binaryParser.getInt()
|
|
313
|
+
|
|
314
|
+
// Estimate the size of the master index. String key length is unknown, be
|
|
315
|
+
// conservative (100 bytes).
|
|
316
|
+
const miSize = nEntries * (100 + 64 + 32)
|
|
317
|
+
data = await this.file.read(
|
|
318
|
+
this.footerPosition + skip,
|
|
319
|
+
Math.min(miSize, nBytes),
|
|
320
|
+
)
|
|
321
|
+
binaryParser = new BinaryParser(new DataView(data))
|
|
322
|
+
|
|
323
|
+
while (nEntries-- > 0) {
|
|
324
|
+
const key = binaryParser.getString()
|
|
325
|
+
const pos = binaryParser.getLong()
|
|
326
|
+
const size = binaryParser.getInt()
|
|
327
|
+
this.masterIndex[key] = { start: pos, size }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Normalized expected values start after the expected values
|
|
331
|
+
if (this.version > 5) {
|
|
332
|
+
const s = this.version < 9 ? 4 : 8
|
|
333
|
+
this.normExpectedValueVectorsPosition = this.footerPosition + s + nBytes
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async getMatrix(chrIdx1: number, chrIdx2: number) {
|
|
338
|
+
const key = Matrix.getKey(chrIdx1, chrIdx2)
|
|
339
|
+
let p = this.matrixCache.get(key)
|
|
340
|
+
if (!p) {
|
|
341
|
+
p = this.readMatrix(chrIdx1, chrIdx2).catch((e: unknown) => {
|
|
342
|
+
this.matrixCache.delete(key)
|
|
343
|
+
throw e
|
|
344
|
+
})
|
|
345
|
+
this.matrixCache.set(key, p)
|
|
346
|
+
}
|
|
347
|
+
return p
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async readMatrix(chrIdx1: number, chrIdx2: number) {
|
|
351
|
+
await this.init()
|
|
352
|
+
|
|
353
|
+
const idx = this.masterIndex[Matrix.getKey(chrIdx1, chrIdx2)]
|
|
354
|
+
let matrix: Matrix | undefined
|
|
355
|
+
if (idx) {
|
|
356
|
+
const data = await this.file.read(idx.start, idx.size)
|
|
357
|
+
matrix = Matrix.parseMatrix(data, this.chromosomes)
|
|
358
|
+
}
|
|
359
|
+
return matrix
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async getContactRecords(
|
|
363
|
+
normalization: string,
|
|
364
|
+
region1: HicRegion,
|
|
365
|
+
region2: HicRegion,
|
|
366
|
+
units: string,
|
|
367
|
+
binsize: number,
|
|
368
|
+
) {
|
|
369
|
+
await this.init()
|
|
370
|
+
|
|
371
|
+
const idx1 = this.chromosomeIndexMap[this.getFileChrName(region1.chr)]
|
|
372
|
+
const idx2 = this.chromosomeIndexMap[this.getFileChrName(region2.chr)]
|
|
373
|
+
|
|
374
|
+
// A `.hic` stores only `bin1 <= bin2`, so a query whose x window sits to the
|
|
375
|
+
// right of its y window has to be swapped or it asks for the half of the
|
|
376
|
+
// matrix that does not exist. The same-chromosome test compares **starts**,
|
|
377
|
+
// not this region's start against the other's end: upstream hic-straw used
|
|
378
|
+
// `region1.start >= region2.end`, which catches a reversed pair only while
|
|
379
|
+
// the two are disjoint, and a multi-region view is free to display two
|
|
380
|
+
// *overlapping* regions of one chromosome right-to-left. Measured on
|
|
381
|
+
// chr1 at 2.5 Mb, `(100-200Mb, 50-150Mb)` returned 78 contacts against 901
|
|
382
|
+
// for the same pair in genomic order — everything but the overlap sliver
|
|
383
|
+
// silently missing, which renders as a sparse off-diagonal block rather
|
|
384
|
+
// than as an error. Comparing starts fires on both the disjoint and the
|
|
385
|
+
// overlapping case and leaves forward order (and an identical pair) alone.
|
|
386
|
+
const transpose =
|
|
387
|
+
idx1 !== undefined &&
|
|
388
|
+
idx2 !== undefined &&
|
|
389
|
+
(idx1 > idx2 || (idx1 === idx2 && region1.start > region2.start))
|
|
390
|
+
const r1 = transpose ? region2 : region1
|
|
391
|
+
const r2 = transpose ? region1 : region2
|
|
392
|
+
|
|
393
|
+
const [x1, x2] = binWindow(r1, binsize)
|
|
394
|
+
const [y1, y2] = binWindow(r2, binsize)
|
|
395
|
+
|
|
396
|
+
// Two independent read chains, so they run concurrently rather than in
|
|
397
|
+
// sequence. The normalization vectors are keyed on (type, chr, unit,
|
|
398
|
+
// binsize) and the blocks on the region pair; neither reads anything the
|
|
399
|
+
// other produces, and awaiting them in order made every pair pay the SUM of
|
|
400
|
+
// their round-trip depths instead of the deeper of the two.
|
|
401
|
+
//
|
|
402
|
+
// Both are two hops — norm-vector header then its values, matrix header then
|
|
403
|
+
// its blocks — so a pair went 4 sequential waves deep where 2 will do
|
|
404
|
+
// (`readChainDepth.test.ts` measures this against the real file). That is
|
|
405
|
+
// the everyday single-region fetch; a multi-region view then runs several
|
|
406
|
+
// pairs at a time on top of it. This loop is latency-bound, not CPU-bound.
|
|
407
|
+
//
|
|
408
|
+
// Vectors are loop-invariant across blocks either way, so they are still
|
|
409
|
+
// resolved once per pair rather than per block, each paired with the bin
|
|
410
|
+
// offset its values start at.
|
|
411
|
+
const [norm, blocks] = await Promise.all([
|
|
412
|
+
this.getNormVectors(normalization, r1, r2, units, binsize),
|
|
413
|
+
this.getBlocks(r1, r2, binsize),
|
|
414
|
+
])
|
|
415
|
+
|
|
416
|
+
// Sum of the blocks' record counts bounds the survivors, so the output is
|
|
417
|
+
// allocated once and filled by a write cursor. Blocks overlap the window
|
|
418
|
+
// rather than nest in it, so the true count isn't known without either this
|
|
419
|
+
// upper bound or a counting pre-pass over the same data.
|
|
420
|
+
let capacity = 0
|
|
421
|
+
for (const block of blocks) {
|
|
422
|
+
if (block) {
|
|
423
|
+
capacity += block.records.bin1.length
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const outBin1 = new Int32Array(capacity)
|
|
427
|
+
const outBin2 = new Int32Array(capacity)
|
|
428
|
+
const outCounts = new Float32Array(capacity)
|
|
429
|
+
let n = 0
|
|
430
|
+
|
|
431
|
+
for (const block of blocks) {
|
|
432
|
+
// An undefined block is most likely a base-pair range outside the
|
|
433
|
+
// chromosome
|
|
434
|
+
if (!block) {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
const { bin1, bin2, counts } = block.records
|
|
438
|
+
const len = bin1.length
|
|
439
|
+
// `norm` is loop-invariant, so it selects the loop rather than being
|
|
440
|
+
// retested per record — which also hoists the vector/offset reads
|
|
441
|
+
if (norm) {
|
|
442
|
+
const { v1, v2, offset1, offset2 } = norm
|
|
443
|
+
for (let i = 0; i < len; i++) {
|
|
444
|
+
const b1 = bin1[i]!
|
|
445
|
+
const b2 = bin2[i]!
|
|
446
|
+
if (b1 >= x1 && b1 < x2 && b2 >= y1 && b2 < y2) {
|
|
447
|
+
const nvnv = v1[b1 - offset1]! * v2[b2 - offset2]!
|
|
448
|
+
if (nvnv !== 0 && !Number.isNaN(nvnv)) {
|
|
449
|
+
outBin1[n] = b1
|
|
450
|
+
outBin2[n] = b2
|
|
451
|
+
outCounts[n] = counts[i]! / nvnv
|
|
452
|
+
n++
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
for (let i = 0; i < len; i++) {
|
|
458
|
+
const b1 = bin1[i]!
|
|
459
|
+
const b2 = bin2[i]!
|
|
460
|
+
if (b1 >= x1 && b1 < x2 && b2 >= y1 && b2 < y2) {
|
|
461
|
+
outBin1[n] = b1
|
|
462
|
+
outBin2[n] = b2
|
|
463
|
+
outCounts[n] = counts[i]!
|
|
464
|
+
n++
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Views, not copies: a caller concatenating several pairs' results copies
|
|
471
|
+
// into its own exactly-sized arrays anyway.
|
|
472
|
+
const contactRecords: ContactRecords = {
|
|
473
|
+
bin1: outBin1.subarray(0, n),
|
|
474
|
+
bin2: outBin2.subarray(0, n),
|
|
475
|
+
counts: outCounts.subarray(0, n),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// What was actually applied, which is not always what was asked for:
|
|
479
|
+
// normalization vectors are stored per (type, chr, unit, binsize), so a file
|
|
480
|
+
// can offer KR at 5kb and nothing at 2.5Mb. hic-straw warns to the console
|
|
481
|
+
// and silently hands back raw counts; reporting it lets a display tell the
|
|
482
|
+
// user which scheme they are actually looking at.
|
|
483
|
+
//
|
|
484
|
+
// `transposed` says the query was swapped, so `bin1` runs along `region2`.
|
|
485
|
+
// Reported rather than left for the caller to re-derive: it is decided here
|
|
486
|
+
// from this file's own alias table and chromosome indices, and a caller
|
|
487
|
+
// re-deriving it off a divergent chr-name scheme would silently un-swap the
|
|
488
|
+
// wrong axis.
|
|
489
|
+
return {
|
|
490
|
+
records: contactRecords,
|
|
491
|
+
appliedNormalization: norm ? normalization : 'NONE',
|
|
492
|
+
transposed: transpose,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private async getNormVectors(
|
|
497
|
+
normalization: string,
|
|
498
|
+
r1: HicRegion,
|
|
499
|
+
r2: HicRegion,
|
|
500
|
+
units: string,
|
|
501
|
+
binsize: number,
|
|
502
|
+
) {
|
|
503
|
+
let result:
|
|
504
|
+
| {
|
|
505
|
+
v1: Float64Array
|
|
506
|
+
v2: Float64Array
|
|
507
|
+
offset1: number
|
|
508
|
+
offset2: number
|
|
509
|
+
}
|
|
510
|
+
| undefined
|
|
511
|
+
if (normalization && normalization !== 'NONE') {
|
|
512
|
+
const chr1 = this.getFileChrName(r1.chr)
|
|
513
|
+
const chr2 = this.getFileChrName(r2.chr)
|
|
514
|
+
const offset1 = Math.floor(r1.start / binsize)
|
|
515
|
+
const offset2 = Math.floor(r2.start / binsize)
|
|
516
|
+
const nv1 = await this.getNormalizationVector(
|
|
517
|
+
normalization,
|
|
518
|
+
chr1,
|
|
519
|
+
units,
|
|
520
|
+
binsize,
|
|
521
|
+
)
|
|
522
|
+
const nv2 =
|
|
523
|
+
chr1 === chr2
|
|
524
|
+
? nv1
|
|
525
|
+
: await this.getNormalizationVector(
|
|
526
|
+
normalization,
|
|
527
|
+
chr2,
|
|
528
|
+
units,
|
|
529
|
+
binsize,
|
|
530
|
+
)
|
|
531
|
+
if (nv1 && nv2) {
|
|
532
|
+
result = {
|
|
533
|
+
v1: await nv1.getValues(offset1, Math.ceil(r1.end / binsize)),
|
|
534
|
+
v2: await nv2.getValues(offset2, Math.ceil(r2.end / binsize)),
|
|
535
|
+
offset1,
|
|
536
|
+
offset2,
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return result
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async getBlocks(region1: HicRegion, region2: HicRegion, binSize: number) {
|
|
544
|
+
const blockKey = (blockNumber: number, zd: MatrixZoomData) =>
|
|
545
|
+
`${zd.getKey()}_${blockNumber}`
|
|
546
|
+
|
|
547
|
+
await this.init()
|
|
548
|
+
const chr1 = this.getFileChrName(region1.chr)
|
|
549
|
+
const chr2 = this.getFileChrName(region2.chr)
|
|
550
|
+
const idx1 = this.chromosomeIndexMap[chr1]
|
|
551
|
+
const idx2 = this.chromosomeIndexMap[chr2]
|
|
552
|
+
|
|
553
|
+
let blocks: (Block | undefined)[] = []
|
|
554
|
+
if (idx1 === undefined) {
|
|
555
|
+
console.warn(`No chromosome named: ${region1.chr}`)
|
|
556
|
+
} else if (idx2 === undefined) {
|
|
557
|
+
console.warn(`No chromosome named: ${region2.chr}`)
|
|
558
|
+
} else {
|
|
559
|
+
// A chr pair with no matrix at all is routine, not an anomaly: plenty of
|
|
560
|
+
// .hic files store no inter-chromosomal maps, and a multi-region view asks
|
|
561
|
+
// for every pair. Answering with no blocks, rather than warning once per
|
|
562
|
+
// pair per fetch, leaves that for the caller to notice or ignore.
|
|
563
|
+
const matrix = await this.getMatrix(idx1, idx2)
|
|
564
|
+
if (matrix) {
|
|
565
|
+
const zd = matrix.getZoomData(binSize)
|
|
566
|
+
if (!zd) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`${NO_DATA_FOR_RESOLUTION}: ${binSize} for map ${region1.chr}-${region2.chr}`,
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const blockNumbers = zd.getBlockNumbers(region1, region2, this.version)
|
|
573
|
+
const blockNumbersToQuery: number[] = []
|
|
574
|
+
for (const num of blockNumbers) {
|
|
575
|
+
const cached = this.blockCache.get(blockKey(num, zd))
|
|
576
|
+
if (cached) {
|
|
577
|
+
blocks.push(cached)
|
|
578
|
+
} else {
|
|
579
|
+
blockNumbersToQuery.push(num)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const newBlocks = await Promise.all(
|
|
584
|
+
blockNumbersToQuery.map(blockNumber =>
|
|
585
|
+
this.readBlock(blockNumber, zd),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
for (const block of newBlocks) {
|
|
589
|
+
if (block) {
|
|
590
|
+
this.blockCache.set(blockKey(block.blockNumber, zd), block)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
blocks = blocks.concat(newBlocks)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return blocks
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async readBlock(blockNumber: number, zd: MatrixZoomData) {
|
|
600
|
+
const idx = zd.blockIndex[blockNumber]
|
|
601
|
+
|
|
602
|
+
let block: Block | undefined
|
|
603
|
+
if (idx) {
|
|
604
|
+
const data = await this.file.read(idx.filePosition, idx.size)
|
|
605
|
+
const plain = inflate(new Uint8Array(data), {}) as Uint8Array
|
|
606
|
+
const parser = new BinaryParser(
|
|
607
|
+
new DataView(plain.buffer, plain.byteOffset, plain.byteLength),
|
|
608
|
+
)
|
|
609
|
+
// Total records in the block, whatever encoding follows — so every branch
|
|
610
|
+
// below knows its exact (or, for the dense encoding, upper-bound) size
|
|
611
|
+
// before it starts reading and never has to grow an array.
|
|
612
|
+
const nRecords = parser.getInt()
|
|
613
|
+
block = { blockNumber, records: this.parseBlockRecords(parser, nRecords) }
|
|
614
|
+
}
|
|
615
|
+
return block
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Decode one block's records straight into typed arrays.
|
|
620
|
+
*
|
|
621
|
+
* Every encoding here knows its length up front, so each array is allocated
|
|
622
|
+
* once and filled by a write cursor. Where a filter can drop records (the
|
|
623
|
+
* dense encoding's empty cells) the arrays are sized to the upper bound and
|
|
624
|
+
* copied down to the true length at the end — blocks are cached for the life
|
|
625
|
+
* of the session, so it is worth one memcpy not to pin an oversized buffer.
|
|
626
|
+
*/
|
|
627
|
+
private parseBlockRecords(
|
|
628
|
+
parser: BinaryParser,
|
|
629
|
+
nRecords: number,
|
|
630
|
+
): ContactRecords {
|
|
631
|
+
if (this.version < 7) {
|
|
632
|
+
const bin1 = new Int32Array(nRecords)
|
|
633
|
+
const bin2 = new Int32Array(nRecords)
|
|
634
|
+
const counts = new Float32Array(nRecords)
|
|
635
|
+
for (let i = 0; i < nRecords; i++) {
|
|
636
|
+
bin1[i] = parser.getInt()
|
|
637
|
+
bin2[i] = parser.getInt()
|
|
638
|
+
counts[i] = parser.getFloat()
|
|
639
|
+
}
|
|
640
|
+
return { bin1, bin2, counts }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const binXOffset = parser.getInt()
|
|
644
|
+
const binYOffset = parser.getInt()
|
|
645
|
+
|
|
646
|
+
const useFloatContact = parser.getByte() === 1
|
|
647
|
+
const useIntXPos = this.version < 9 ? false : parser.getByte() === 1
|
|
648
|
+
const useIntYPos = this.version < 9 ? false : parser.getByte() === 1
|
|
649
|
+
const type = parser.getByte()
|
|
650
|
+
|
|
651
|
+
if (type === 1) {
|
|
652
|
+
// List-of-rows representation. The rows partition the block's records, so
|
|
653
|
+
// `nRecords` sizes the arrays exactly; the overflow check is a
|
|
654
|
+
// corrupt-file guard, not a growth path.
|
|
655
|
+
const bin1 = new Int32Array(nRecords)
|
|
656
|
+
const bin2 = new Int32Array(nRecords)
|
|
657
|
+
const counts = new Float32Array(nRecords)
|
|
658
|
+
let n = 0
|
|
659
|
+
const rowCount = useIntYPos ? parser.getInt() : parser.getShort()
|
|
660
|
+
for (let i = 0; i < rowCount; i++) {
|
|
661
|
+
const dy = useIntYPos ? parser.getInt() : parser.getShort()
|
|
662
|
+
const binY = binYOffset + dy
|
|
663
|
+
const colCount = useIntXPos ? parser.getInt() : parser.getShort()
|
|
664
|
+
if (n + colCount > nRecords) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`hic block declares ${nRecords} records but its rows hold more`,
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
for (let j = 0; j < colCount; j++) {
|
|
670
|
+
bin1[n] =
|
|
671
|
+
binXOffset + (useIntXPos ? parser.getInt() : parser.getShort())
|
|
672
|
+
bin2[n] = binY
|
|
673
|
+
counts[n] = useFloatContact ? parser.getFloat() : parser.getShort()
|
|
674
|
+
n++
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return truncateRecords({ bin1, bin2, counts }, n)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (type === 2) {
|
|
681
|
+
// Dense representation: `nPts` counts every cell of the w-wide rectangle,
|
|
682
|
+
// empty ones included, so it is an upper bound on the surviving records.
|
|
683
|
+
const nPts = parser.getInt()
|
|
684
|
+
const w = parser.getShort()
|
|
685
|
+
const bin1 = new Int32Array(nPts)
|
|
686
|
+
const bin2 = new Int32Array(nPts)
|
|
687
|
+
const counts = new Float32Array(nPts)
|
|
688
|
+
let n = 0
|
|
689
|
+
for (let i = 0; i < nPts; i++) {
|
|
690
|
+
// read unconditionally: the parser advances a fixed stride per cell
|
|
691
|
+
// whether or not the cell holds a value
|
|
692
|
+
const c = useFloatContact ? parser.getFloat() : parser.getShort()
|
|
693
|
+
// NaN (float) and Short_MIN_VALUE (int) are the "no value" markers
|
|
694
|
+
if (useFloatContact ? !Number.isNaN(c) : c !== Short_MIN_VALUE) {
|
|
695
|
+
const row = Math.floor(i / w)
|
|
696
|
+
bin1[n] = binXOffset + (i - row * w)
|
|
697
|
+
bin2[n] = binYOffset + row
|
|
698
|
+
counts[n] = c
|
|
699
|
+
n++
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return truncateRecords({ bin1, bin2, counts }, n)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
throw new Error(`Unknown block type: ${type}`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async getNormalizationVector(
|
|
709
|
+
type: string,
|
|
710
|
+
chr: string,
|
|
711
|
+
unit: string,
|
|
712
|
+
binSize: number,
|
|
713
|
+
) {
|
|
714
|
+
await this.init()
|
|
715
|
+
|
|
716
|
+
const chrIdx = this.chromosomeIndexMap[this.getFileChrName(chr)]
|
|
717
|
+
if (chrIdx === undefined) {
|
|
718
|
+
return undefined
|
|
719
|
+
}
|
|
720
|
+
const key = getNormalizationVectorKey(type, chrIdx, unit, binSize)
|
|
721
|
+
|
|
722
|
+
// Caching the promise is what keeps concurrent region pairs sharing one
|
|
723
|
+
// vector: a chromosome appears in every pair it takes part in, so with a
|
|
724
|
+
// result-only cache each of those pairs missed while the first was still in
|
|
725
|
+
// flight and read the header again — and then held its own
|
|
726
|
+
// `NormalizationVector`, whose value cache is per instance, so the whole
|
|
727
|
+
// vector was re-read too.
|
|
728
|
+
let p = this.normVectorCache.get(key)
|
|
729
|
+
if (!p) {
|
|
730
|
+
// A file with no vectors at all, or none for this (type, chr, unit,
|
|
731
|
+
// binsize), simply answers undefined and the caller falls back to raw
|
|
732
|
+
// counts. hic-straw warns to the console here; that fires once per
|
|
733
|
+
// chromosome per region pair per fetch, and the console is the wrong place
|
|
734
|
+
// for it anyway — `getContactRecords` reports the normalization it
|
|
735
|
+
// actually applied instead.
|
|
736
|
+
p = this.readNormalizationVector(key).catch((e: unknown) => {
|
|
737
|
+
this.normVectorCache.delete(key)
|
|
738
|
+
throw e
|
|
739
|
+
})
|
|
740
|
+
this.normVectorCache.set(key, p)
|
|
741
|
+
}
|
|
742
|
+
return p
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async readNormalizationVector(key: string) {
|
|
746
|
+
const idx = (await this.getNormVectorIndex())?.[key]
|
|
747
|
+
if (!idx) {
|
|
748
|
+
return undefined
|
|
749
|
+
}
|
|
750
|
+
const data = await this.file.read(idx.filePosition, 8)
|
|
751
|
+
const parser = new BinaryParser(new DataView(data))
|
|
752
|
+
const nValues = this.version < 9 ? parser.getInt() : parser.getLong()
|
|
753
|
+
const dataType = this.version < 9 ? DOUBLE : FLOAT
|
|
754
|
+
const filePosition =
|
|
755
|
+
this.version < 9 ? idx.filePosition + 4 : idx.filePosition + 8
|
|
756
|
+
return new NormalizationVector(this.file, filePosition, nValues, dataType)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async getNormVectorIndex() {
|
|
760
|
+
// Before the version gate, not after it: `version` is 0 until the header is
|
|
761
|
+
// parsed, so calling this first — which `getNormalizationOptions` on its own
|
|
762
|
+
// does — answered "no index" for every file rather than for a v5 one. A
|
|
763
|
+
// caller that reads metadata first never sees it.
|
|
764
|
+
await this.init()
|
|
765
|
+
if (this.version >= 6) {
|
|
766
|
+
// Memoize the *attempt*, not just a populated result. A legal (if
|
|
767
|
+
// uncommon) v8 file with no norm vectors leaves `normVectorIndex`
|
|
768
|
+
// undefined, and the old `!this.normVectorIndex` guard then re-ran the
|
|
769
|
+
// discovery on every call — two calls per region pair per fetch, each
|
|
770
|
+
// walking the whole normalized-expected-values section with a chain of
|
|
771
|
+
// sequential range reads, only to rediscover there is nothing there.
|
|
772
|
+
// Cleared on failure (like `init`) so a transient read error retries
|
|
773
|
+
// rather than caching a rejection forever.
|
|
774
|
+
this.normVectorIndexP ??= this.loadNormVectorIndex().catch(
|
|
775
|
+
(e: unknown) => {
|
|
776
|
+
this.normVectorIndexP = undefined
|
|
777
|
+
throw e
|
|
778
|
+
},
|
|
779
|
+
)
|
|
780
|
+
await this.normVectorIndexP
|
|
781
|
+
}
|
|
782
|
+
return this.normVectorIndex
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private async loadNormVectorIndex() {
|
|
786
|
+
// If we know the position of the norm vector index, read it directly.
|
|
787
|
+
// This is the case for hic v9 files.
|
|
788
|
+
if (this.normVectorIndexPosition > 0 && this.normVectorIndexSize > 0) {
|
|
789
|
+
await this.readNormVectorIndex({
|
|
790
|
+
start: this.normVectorIndexPosition,
|
|
791
|
+
size: this.normVectorIndexSize,
|
|
792
|
+
})
|
|
793
|
+
} else if (this.config.nvi) {
|
|
794
|
+
const nviArray = decodeURIComponent(this.config.nvi).split(',')
|
|
795
|
+
await this.readNormVectorIndex({
|
|
796
|
+
start: parseInt(nviArray[0]!),
|
|
797
|
+
size: parseInt(nviArray[1]!),
|
|
798
|
+
})
|
|
799
|
+
} else {
|
|
800
|
+
try {
|
|
801
|
+
await this.readNormExpectedValuesAndNormVectorIndex()
|
|
802
|
+
} catch (e) {
|
|
803
|
+
// Not "expected if the file has no norm vectors" — that case never
|
|
804
|
+
// arrives here. hic-straw's own IO threw a 416 for a read past EOF and
|
|
805
|
+
// this caught it; `generic-filehandle2` deliberately turns a 416 into an
|
|
806
|
+
// empty read so callers detect EOF from a short buffer instead of
|
|
807
|
+
// needing a stat, and LocalFile/BlobFile clamp for the same reason. So
|
|
808
|
+
// the read side owns that case now, and anything reaching here is a real
|
|
809
|
+
// failure worth printing.
|
|
810
|
+
console.error(e)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async getNormalizationOptions() {
|
|
816
|
+
// Normalization options are computed as a side effect of loading the
|
|
817
|
+
// index. A bit ugly but alternatives are worse.
|
|
818
|
+
await this.getNormVectorIndex()
|
|
819
|
+
return this.normalizationTypes
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async readNormVectorIndex(range: { start: number; size: number }) {
|
|
823
|
+
await this.init()
|
|
824
|
+
const data = await this.file.read(range.start, range.size)
|
|
825
|
+
const binaryParser = new BinaryParser(new DataView(data))
|
|
826
|
+
this.normVectorIndex = {}
|
|
827
|
+
let nEntries = binaryParser.getInt()
|
|
828
|
+
while (nEntries-- > 0) {
|
|
829
|
+
this.parseNormVectorEntry(binaryParser)
|
|
830
|
+
}
|
|
831
|
+
return this.normVectorIndex
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Used when the position of the norm vector index is unknown: read through
|
|
835
|
+
// the expected values to find the index.
|
|
836
|
+
async readNormExpectedValuesAndNormVectorIndex() {
|
|
837
|
+
await this.init()
|
|
838
|
+
if (this.normExpectedValueVectorsPosition !== undefined) {
|
|
839
|
+
const nviStart = await this.skipExpectedValues(
|
|
840
|
+
this.normExpectedValueVectorsPosition,
|
|
841
|
+
)
|
|
842
|
+
let byteCount = INT
|
|
843
|
+
|
|
844
|
+
let data = await this.file.read(nviStart, INT)
|
|
845
|
+
// Possible if there are no norm vectors. A legal v8 file, though uncommon.
|
|
846
|
+
if (data.byteLength !== 0) {
|
|
847
|
+
const binaryParser = new BinaryParser(new DataView(data))
|
|
848
|
+
const nEntries = binaryParser.getInt()
|
|
849
|
+
const sizeEstimate = nEntries * 30
|
|
850
|
+
data = await this.file.read(nviStart + byteCount, sizeEstimate)
|
|
851
|
+
this.normVectorIndex = {}
|
|
852
|
+
|
|
853
|
+
const processEntries = async (remaining: number, buf: ArrayBuffer) => {
|
|
854
|
+
const parser = new BinaryParser(new DataView(buf))
|
|
855
|
+
let n = remaining
|
|
856
|
+
while (n-- > 0) {
|
|
857
|
+
if (parser.available() < 100) {
|
|
858
|
+
n++ // Reset counter as entry is not processed
|
|
859
|
+
byteCount += parser.position
|
|
860
|
+
const est = Math.max(1000, n * 30)
|
|
861
|
+
const more = await this.file.read(nviStart + byteCount, est)
|
|
862
|
+
await processEntries(n, more)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
this.parseNormVectorEntry(parser)
|
|
866
|
+
}
|
|
867
|
+
byteCount += parser.position
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
await processEntries(nEntries, data)
|
|
871
|
+
this.config.nvi = `${nviStart},${byteCount}`
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Used when the position of the norm vector index is unknown: skip the
|
|
877
|
+
// normalized expected values to find the index.
|
|
878
|
+
async skipExpectedValues(start: number) {
|
|
879
|
+
const version = this.version
|
|
880
|
+
const file = new BufferedFile({ file: this.file, size: 256000 })
|
|
881
|
+
const data = await file.read(start, INT)
|
|
882
|
+
// A file with no normalization at all ends where this section would start,
|
|
883
|
+
// so the count this is about to read is past EOF. That is the same "no norm
|
|
884
|
+
// vectors" case `readNormExpectedValuesAndNormVectorIndex` guards its own
|
|
885
|
+
// read for, and it has to be caught HERE too, because this runs first:
|
|
886
|
+
// a v9 file that records no norm-vector-index position takes the v8
|
|
887
|
+
// discovery path, and that path starts by skipping a section that isn't
|
|
888
|
+
// there. Answering `start` leaves the caller's guard to do the rest.
|
|
889
|
+
if (data.byteLength < INT) {
|
|
890
|
+
return start
|
|
891
|
+
}
|
|
892
|
+
const binaryParser = new BinaryParser(new DataView(data))
|
|
893
|
+
const nEntries = binaryParser.getInt() // Total # of expected value chunks
|
|
894
|
+
|
|
895
|
+
const parseNext = async (
|
|
896
|
+
chunkStart: number,
|
|
897
|
+
remaining: number,
|
|
898
|
+
): Promise<number> => {
|
|
899
|
+
let chunkSize = 0
|
|
900
|
+
const p0 = chunkStart
|
|
901
|
+
|
|
902
|
+
let buf = await file.read(chunkStart, 500)
|
|
903
|
+
let parser = new BinaryParser(new DataView(buf))
|
|
904
|
+
parser.getString() // type
|
|
905
|
+
parser.getString() // unit
|
|
906
|
+
parser.getInt() // binSize
|
|
907
|
+
const nValues = version < 9 ? parser.getInt() : parser.getLong()
|
|
908
|
+
chunkSize += parser.position + nValues * (version < 9 ? DOUBLE : FLOAT)
|
|
909
|
+
|
|
910
|
+
buf = await file.read(chunkStart + chunkSize, INT)
|
|
911
|
+
parser = new BinaryParser(new DataView(buf))
|
|
912
|
+
const nChrScaleFactors = parser.getInt()
|
|
913
|
+
chunkSize +=
|
|
914
|
+
INT + nChrScaleFactors * (INT + (version < 9 ? DOUBLE : FLOAT))
|
|
915
|
+
|
|
916
|
+
return remaining - 1 === 0
|
|
917
|
+
? p0 + chunkSize
|
|
918
|
+
: parseNext(p0 + chunkSize, remaining - 1)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return nEntries === 0 ? start + INT : parseNext(start + INT, nEntries)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
parseNormVectorEntry(binaryParser: BinaryParser) {
|
|
925
|
+
const type = binaryParser.getString() // 15
|
|
926
|
+
const chrIdx = binaryParser.getInt() // 4
|
|
927
|
+
const unit = binaryParser.getString() // 3
|
|
928
|
+
const binSize = binaryParser.getInt() // 4
|
|
929
|
+
const filePosition = binaryParser.getLong() // 8
|
|
930
|
+
const sizeInBytes =
|
|
931
|
+
this.version < 9 ? binaryParser.getInt() : binaryParser.getLong() // 4:8
|
|
932
|
+
const key = `${type}_${chrIdx}_${unit}_${binSize}`
|
|
933
|
+
|
|
934
|
+
if (!this.normalizationTypes.includes(type)) {
|
|
935
|
+
this.normalizationTypes.push(type)
|
|
936
|
+
}
|
|
937
|
+
this.normVectorIndex![key] = { filePosition, size: sizeInBytes }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
getFileChrName(chrAlias: string) {
|
|
941
|
+
return this.chrAliasTable[chrAlias] ?? chrAlias
|
|
942
|
+
}
|
|
943
|
+
}
|