@gmod/hic 1.0.0 → 1.2.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/README.md +51 -6
- package/dist/hicFile.d.ts +19 -16
- package/dist/hicFile.js +84 -26
- package/dist/hicFile.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +23 -0
- package/esm/hicFile.d.ts +19 -16
- package/esm/hicFile.js +84 -26
- package/esm/hicFile.js.map +1 -1
- package/esm/index.d.ts +1 -1
- package/esm/types.d.ts +23 -0
- package/package.json +10 -4
- package/src/hicFile.ts +115 -39
- package/src/index.ts +2 -0
- package/src/types.ts +25 -0
package/src/hicFile.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { inflateRawUnknownSize } from '@gmod/inflate'
|
|
1
2
|
import { LocalFile } from 'generic-filehandle2'
|
|
2
|
-
import { inflate } from 'pako-esm2'
|
|
3
3
|
|
|
4
4
|
import { binWindow } from './binWindow.ts'
|
|
5
5
|
import BinaryParser from './binary.ts'
|
|
@@ -16,6 +16,8 @@ import type {
|
|
|
16
16
|
Chromosome,
|
|
17
17
|
HicMetadata,
|
|
18
18
|
HicRegion,
|
|
19
|
+
ProgressCallback,
|
|
20
|
+
ProgressOpts,
|
|
19
21
|
Reader,
|
|
20
22
|
} from './types.ts'
|
|
21
23
|
import type { GenericFilehandle } from 'generic-filehandle2'
|
|
@@ -51,6 +53,13 @@ export interface HicConfig {
|
|
|
51
53
|
* costs on a pre-v9 file.
|
|
52
54
|
*/
|
|
53
55
|
nvi?: string
|
|
56
|
+
/**
|
|
57
|
+
* Ceiling on the decompressed contacts the block cache holds, in bytes.
|
|
58
|
+
* Defaults to 128 MB. Lower it where the budget is tight and re-reading is
|
|
59
|
+
* cheaper than the memory; raising it buys nothing once a fetch's working set
|
|
60
|
+
* fits, since the entry cap bounds the cache too.
|
|
61
|
+
*/
|
|
62
|
+
blockCacheMaxBytes?: number
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
// Cached blocks are the decompressed records and nothing else. hic-straw also
|
|
@@ -112,6 +121,11 @@ const BLOCK_CACHE_MAX_ENTRIES = 1024
|
|
|
112
121
|
// binsize. Measured on the file above: 0.05 MB at 2.5 Mb, 0.23 MB at 100 kb. So
|
|
113
122
|
// the entry cap now tracks the working set and this tracks the memory, instead
|
|
114
123
|
// of one number answering both badly. See LRU's WeightOpts.
|
|
124
|
+
//
|
|
125
|
+
// This is the one capacity here a caller can override (`blockCacheMaxBytes`),
|
|
126
|
+
// because it is the only one that bounds MEMORY. The two entry caps below are
|
|
127
|
+
// working-set sizes derived from how many region pairs a fetch runs at once,
|
|
128
|
+
// which the library knows and the caller would only have to rederive.
|
|
115
129
|
const BLOCK_CACHE_MAX_BYTES = 128 * 1024 * 1024
|
|
116
130
|
// One entry per (chrIdx1, chrIdx2), so the working set is the pair count.
|
|
117
131
|
// 512 covers a 31-region view; human whole-genome is 300. A matrix holds the
|
|
@@ -159,10 +173,9 @@ export class HicFile {
|
|
|
159
173
|
private matrixCache = new LRU<string, Promise<Matrix | undefined>>(
|
|
160
174
|
MATRIX_CACHE_SIZE,
|
|
161
175
|
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
176
|
+
// Assigned in the constructor rather than here, since its byte budget is
|
|
177
|
+
// configurable and a field initializer cannot see `config`.
|
|
178
|
+
private blockCache: LRU<string, Block>
|
|
166
179
|
private normVectorIndexPosition = -1
|
|
167
180
|
private normVectorIndexSize = -1
|
|
168
181
|
|
|
@@ -185,8 +198,12 @@ export class HicFile {
|
|
|
185
198
|
private meta: HicMetadata | undefined
|
|
186
199
|
|
|
187
200
|
constructor(config: HicConfig) {
|
|
188
|
-
const { filehandle, path, reader } = config
|
|
201
|
+
const { filehandle, path, reader, blockCacheMaxBytes } = config
|
|
189
202
|
this.config = config
|
|
203
|
+
this.blockCache = new LRU<string, Block>(BLOCK_CACHE_MAX_ENTRIES, {
|
|
204
|
+
maxBytes: blockCacheMaxBytes ?? BLOCK_CACHE_MAX_BYTES,
|
|
205
|
+
weigh: blockCacheWeight,
|
|
206
|
+
})
|
|
190
207
|
if (reader) {
|
|
191
208
|
this.file = reader
|
|
192
209
|
} else if (filehandle) {
|
|
@@ -215,7 +232,7 @@ export class HicFile {
|
|
|
215
232
|
return this.meta!
|
|
216
233
|
}
|
|
217
234
|
|
|
218
|
-
async readHeaderAndFooter() {
|
|
235
|
+
private async readHeaderAndFooter() {
|
|
219
236
|
// `init` clears its memoized promise on failure so a transient read error
|
|
220
237
|
// retries, and this parse appends as it goes — so a retry has to start from
|
|
221
238
|
// empty or a failure part-way through the chromosome loop leaves the second
|
|
@@ -301,7 +318,7 @@ export class HicFile {
|
|
|
301
318
|
}
|
|
302
319
|
}
|
|
303
320
|
|
|
304
|
-
async readFooter() {
|
|
321
|
+
private async readFooter() {
|
|
305
322
|
const skip = this.version < 9 ? 8 : 12
|
|
306
323
|
let data = await this.file.read(this.footerPosition, skip)
|
|
307
324
|
|
|
@@ -347,7 +364,7 @@ export class HicFile {
|
|
|
347
364
|
return p
|
|
348
365
|
}
|
|
349
366
|
|
|
350
|
-
async readMatrix(chrIdx1: number, chrIdx2: number) {
|
|
367
|
+
private async readMatrix(chrIdx1: number, chrIdx2: number) {
|
|
351
368
|
await this.init()
|
|
352
369
|
|
|
353
370
|
const idx = this.masterIndex[Matrix.getKey(chrIdx1, chrIdx2)]
|
|
@@ -365,6 +382,7 @@ export class HicFile {
|
|
|
365
382
|
region2: HicRegion,
|
|
366
383
|
units: string,
|
|
367
384
|
binsize: number,
|
|
385
|
+
opts?: ProgressOpts,
|
|
368
386
|
) {
|
|
369
387
|
await this.init()
|
|
370
388
|
|
|
@@ -410,7 +428,10 @@ export class HicFile {
|
|
|
410
428
|
// offset its values start at.
|
|
411
429
|
const [norm, blocks] = await Promise.all([
|
|
412
430
|
this.getNormVectors(normalization, r1, r2, units, binsize),
|
|
413
|
-
|
|
431
|
+
// The measurable half of the pair: blocks are counted and the two norm
|
|
432
|
+
// vectors are not, because the vector chain is two hops whatever the
|
|
433
|
+
// query and the block chain is the one that grows with the region.
|
|
434
|
+
this.getBlocks(r1, r2, binsize, opts),
|
|
414
435
|
])
|
|
415
436
|
|
|
416
437
|
// Sum of the blocks' record counts bounds the survivors, so the output is
|
|
@@ -540,7 +561,12 @@ export class HicFile {
|
|
|
540
561
|
return result
|
|
541
562
|
}
|
|
542
563
|
|
|
543
|
-
async getBlocks(
|
|
564
|
+
async getBlocks(
|
|
565
|
+
region1: HicRegion,
|
|
566
|
+
region2: HicRegion,
|
|
567
|
+
binSize: number,
|
|
568
|
+
opts?: ProgressOpts,
|
|
569
|
+
) {
|
|
544
570
|
const blockKey = (blockNumber: number, zd: MatrixZoomData) =>
|
|
545
571
|
`${zd.getKey()}_${blockNumber}`
|
|
546
572
|
|
|
@@ -580,10 +606,28 @@ export class HicFile {
|
|
|
580
606
|
}
|
|
581
607
|
}
|
|
582
608
|
|
|
609
|
+
// Cached blocks are already done, not absent: a pan that reuses most
|
|
610
|
+
// of its blocks reads most of the way along rather than restarting at
|
|
611
|
+
// zero, which is what the bar should say — the work left is the reads.
|
|
612
|
+
//
|
|
613
|
+
// A query covering no blocks at all reports nothing rather than `0, 0`:
|
|
614
|
+
// there is no fraction in it, and a caller dividing would get NaN.
|
|
615
|
+
const { onProgress } = opts ?? {}
|
|
616
|
+
let done = blocks.length
|
|
617
|
+
if (blockNumbers.length > 0) {
|
|
618
|
+
onProgress?.(done, blockNumbers.length)
|
|
619
|
+
}
|
|
620
|
+
// Still one `Promise.all`, so every block is still issued in one wave:
|
|
621
|
+
// what a remote file pays is round-trip DEPTH, and counting the
|
|
622
|
+
// completions must not turn one wave into a queue. `readChainDepth`
|
|
623
|
+
// pins that.
|
|
583
624
|
const newBlocks = await Promise.all(
|
|
584
|
-
blockNumbersToQuery.map(blockNumber =>
|
|
585
|
-
this.readBlock(blockNumber, zd)
|
|
586
|
-
|
|
625
|
+
blockNumbersToQuery.map(async blockNumber => {
|
|
626
|
+
const block = await this.readBlock(blockNumber, zd)
|
|
627
|
+
done++
|
|
628
|
+
onProgress?.(done, blockNumbers.length)
|
|
629
|
+
return block
|
|
630
|
+
}),
|
|
587
631
|
)
|
|
588
632
|
for (const block of newBlocks) {
|
|
589
633
|
if (block) {
|
|
@@ -602,7 +646,14 @@ export class HicFile {
|
|
|
602
646
|
let block: Block | undefined
|
|
603
647
|
if (idx) {
|
|
604
648
|
const data = await this.file.read(idx.filePosition, idx.size)
|
|
605
|
-
|
|
649
|
+
// `.subarray(2)` drops the zlib header: libdeflate's raw-deflate path is
|
|
650
|
+
// the fast one, and a `.hic` records only a block's *compressed* size, so
|
|
651
|
+
// there is no known output size to hand the exact-size entry point.
|
|
652
|
+
// `inflateRawUnknownSize` guesses 4x the input, which clears this
|
|
653
|
+
// format's ~2.3x ratio in one pass.
|
|
654
|
+
const plain = await inflateRawUnknownSize(
|
|
655
|
+
new Uint8Array(data).subarray(2),
|
|
656
|
+
)
|
|
606
657
|
const parser = new BinaryParser(
|
|
607
658
|
new DataView(plain.buffer, plain.byteOffset, plain.byteLength),
|
|
608
659
|
)
|
|
@@ -756,7 +807,7 @@ export class HicFile {
|
|
|
756
807
|
return new NormalizationVector(this.file, filePosition, nValues, dataType)
|
|
757
808
|
}
|
|
758
809
|
|
|
759
|
-
async getNormVectorIndex() {
|
|
810
|
+
async getNormVectorIndex(opts?: ProgressOpts) {
|
|
760
811
|
// Before the version gate, not after it: `version` is 0 until the header is
|
|
761
812
|
// parsed, so calling this first — which `getNormalizationOptions` on its own
|
|
762
813
|
// does — answered "no index" for every file rather than for a v5 one. A
|
|
@@ -771,18 +822,21 @@ export class HicFile {
|
|
|
771
822
|
// sequential range reads, only to rediscover there is nothing there.
|
|
772
823
|
// Cleared on failure (like `init`) so a transient read error retries
|
|
773
824
|
// rather than caching a rejection forever.
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
)
|
|
825
|
+
// The walk runs once, so its progress belongs to the call that performs
|
|
826
|
+
// it: a caller joining an in-flight or finished load is not waiting on
|
|
827
|
+
// reads and is told nothing, which is the truth rather than a silence.
|
|
828
|
+
this.normVectorIndexP ??= this.loadNormVectorIndex(
|
|
829
|
+
opts?.onProgress,
|
|
830
|
+
).catch((e: unknown) => {
|
|
831
|
+
this.normVectorIndexP = undefined
|
|
832
|
+
throw e
|
|
833
|
+
})
|
|
780
834
|
await this.normVectorIndexP
|
|
781
835
|
}
|
|
782
836
|
return this.normVectorIndex
|
|
783
837
|
}
|
|
784
838
|
|
|
785
|
-
private async loadNormVectorIndex() {
|
|
839
|
+
private async loadNormVectorIndex(onProgress?: ProgressCallback) {
|
|
786
840
|
// If we know the position of the norm vector index, read it directly.
|
|
787
841
|
// This is the case for hic v9 files.
|
|
788
842
|
if (this.normVectorIndexPosition > 0 && this.normVectorIndexSize > 0) {
|
|
@@ -798,7 +852,7 @@ export class HicFile {
|
|
|
798
852
|
})
|
|
799
853
|
} else {
|
|
800
854
|
try {
|
|
801
|
-
await this.readNormExpectedValuesAndNormVectorIndex()
|
|
855
|
+
await this.readNormExpectedValuesAndNormVectorIndex(onProgress)
|
|
802
856
|
} catch (e) {
|
|
803
857
|
// Not "expected if the file has no norm vectors" — that case never
|
|
804
858
|
// arrives here. hic-straw's own IO threw a 416 for a read past EOF and
|
|
@@ -812,14 +866,14 @@ export class HicFile {
|
|
|
812
866
|
}
|
|
813
867
|
}
|
|
814
868
|
|
|
815
|
-
async getNormalizationOptions() {
|
|
869
|
+
async getNormalizationOptions(opts?: ProgressOpts) {
|
|
816
870
|
// Normalization options are computed as a side effect of loading the
|
|
817
871
|
// index. A bit ugly but alternatives are worse.
|
|
818
|
-
await this.getNormVectorIndex()
|
|
872
|
+
await this.getNormVectorIndex(opts)
|
|
819
873
|
return this.normalizationTypes
|
|
820
874
|
}
|
|
821
875
|
|
|
822
|
-
async readNormVectorIndex(range: { start: number; size: number }) {
|
|
876
|
+
private async readNormVectorIndex(range: { start: number; size: number }) {
|
|
823
877
|
await this.init()
|
|
824
878
|
const data = await this.file.read(range.start, range.size)
|
|
825
879
|
const binaryParser = new BinaryParser(new DataView(data))
|
|
@@ -833,11 +887,14 @@ export class HicFile {
|
|
|
833
887
|
|
|
834
888
|
// Used when the position of the norm vector index is unknown: read through
|
|
835
889
|
// the expected values to find the index.
|
|
836
|
-
async readNormExpectedValuesAndNormVectorIndex(
|
|
890
|
+
private async readNormExpectedValuesAndNormVectorIndex(
|
|
891
|
+
onProgress?: ProgressCallback,
|
|
892
|
+
) {
|
|
837
893
|
await this.init()
|
|
838
894
|
if (this.normExpectedValueVectorsPosition !== undefined) {
|
|
839
895
|
const nviStart = await this.skipExpectedValues(
|
|
840
896
|
this.normExpectedValueVectorsPosition,
|
|
897
|
+
onProgress,
|
|
841
898
|
)
|
|
842
899
|
let byteCount = INT
|
|
843
900
|
|
|
@@ -875,7 +932,10 @@ export class HicFile {
|
|
|
875
932
|
|
|
876
933
|
// Used when the position of the norm vector index is unknown: skip the
|
|
877
934
|
// normalized expected values to find the index.
|
|
878
|
-
async skipExpectedValues(
|
|
935
|
+
private async skipExpectedValues(
|
|
936
|
+
start: number,
|
|
937
|
+
onProgress?: ProgressCallback,
|
|
938
|
+
) {
|
|
879
939
|
const version = this.version
|
|
880
940
|
const file = new BufferedFile({ file: this.file, size: 256000 })
|
|
881
941
|
const data = await file.read(start, INT)
|
|
@@ -892,12 +952,17 @@ export class HicFile {
|
|
|
892
952
|
const binaryParser = new BinaryParser(new DataView(data))
|
|
893
953
|
const nEntries = binaryParser.getInt() // Total # of expected value chunks
|
|
894
954
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
955
|
+
// Skip one chunk, answering where the next one starts.
|
|
956
|
+
//
|
|
957
|
+
// Its two reads land a whole expected-value vector apart — one per bin of
|
|
958
|
+
// the genome, so tens of MB at a fine binsize — which is further than the
|
|
959
|
+
// buffer reaches. On a remote file that is two round trips per chunk that
|
|
960
|
+
// nothing can merge, and it is why this walk, not the index it is looking
|
|
961
|
+
// for, is the slow part of opening a pre-v9 file. It is also why the chunk
|
|
962
|
+
// is the unit worth counting: `nEntries` of them, known before the first
|
|
963
|
+
// read, each costing about the same.
|
|
964
|
+
const skipChunk = async (chunkStart: number) => {
|
|
899
965
|
let chunkSize = 0
|
|
900
|
-
const p0 = chunkStart
|
|
901
966
|
|
|
902
967
|
let buf = await file.read(chunkStart, 500)
|
|
903
968
|
let parser = new BinaryParser(new DataView(buf))
|
|
@@ -913,15 +978,26 @@ export class HicFile {
|
|
|
913
978
|
chunkSize +=
|
|
914
979
|
INT + nChrScaleFactors * (INT + (version < 9 ? DOUBLE : FLOAT))
|
|
915
980
|
|
|
916
|
-
return
|
|
917
|
-
? p0 + chunkSize
|
|
918
|
-
: parseNext(p0 + chunkSize, remaining - 1)
|
|
981
|
+
return chunkStart + chunkSize
|
|
919
982
|
}
|
|
920
983
|
|
|
921
|
-
|
|
984
|
+
// A loop rather than the tail recursion this was, because the position is
|
|
985
|
+
// the only thing carried between chunks and a counter now rides along with
|
|
986
|
+
// it. Chunks must be walked in order — each one's size is only known once
|
|
987
|
+
// the one before it has been parsed — so the awaits are sequential by
|
|
988
|
+
// nature, not by oversight.
|
|
989
|
+
if (nEntries > 0) {
|
|
990
|
+
onProgress?.(0, nEntries)
|
|
991
|
+
}
|
|
992
|
+
let position = start + INT
|
|
993
|
+
for (let done = 0; done < nEntries; done++) {
|
|
994
|
+
position = await skipChunk(position)
|
|
995
|
+
onProgress?.(done + 1, nEntries)
|
|
996
|
+
}
|
|
997
|
+
return position
|
|
922
998
|
}
|
|
923
999
|
|
|
924
|
-
parseNormVectorEntry(binaryParser: BinaryParser) {
|
|
1000
|
+
private parseNormVectorEntry(binaryParser: BinaryParser) {
|
|
925
1001
|
const type = binaryParser.getString() // 15
|
|
926
1002
|
const chrIdx = binaryParser.getInt() // 4
|
|
927
1003
|
const unit = binaryParser.getString() // 3
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -9,6 +9,31 @@ export interface Reader {
|
|
|
9
9
|
read: (position: number, length: number) => Promise<ArrayBuffer>
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Progress within one phase: `current` of `total` units done, called as the
|
|
14
|
+
* phase advances and once with `current` 0 before it starts, so a caller can
|
|
15
|
+
* show a determinate bar from the first moment rather than after the first
|
|
16
|
+
* unit lands.
|
|
17
|
+
*
|
|
18
|
+
* The unit is whatever that phase measures — expected-value chunks for the
|
|
19
|
+
* normalization-index walk, blocks for a contact fetch — and a caller renders
|
|
20
|
+
* `current / total` without needing to know which. `total` never changes within
|
|
21
|
+
* a call, and `current` never goes backwards.
|
|
22
|
+
*
|
|
23
|
+
* A phase with nothing to do does not call this at all, rather than calling it
|
|
24
|
+
* with a `total` of 0 that a caller could only divide by.
|
|
25
|
+
*/
|
|
26
|
+
export type ProgressCallback = (current: number, total: number) => void
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Progress reporting for one call. Per call rather than per file: progress
|
|
30
|
+
* belongs to the operation a caller is waiting on, and a file-wide callback
|
|
31
|
+
* could not say which of several concurrent fetches it was describing.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProgressOpts {
|
|
34
|
+
onProgress?: ProgressCallback
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export interface Chromosome {
|
|
13
38
|
index: number
|
|
14
39
|
name: string
|