@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/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
- private blockCache = new LRU<string, Block>(BLOCK_CACHE_MAX_ENTRIES, {
163
- maxBytes: BLOCK_CACHE_MAX_BYTES,
164
- weigh: blockCacheWeight,
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
- this.getBlocks(r1, r2, binsize),
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(region1: HicRegion, region2: HicRegion, binSize: number) {
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
- const plain = inflate(new Uint8Array(data), {}) as Uint8Array
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
- this.normVectorIndexP ??= this.loadNormVectorIndex().catch(
775
- (e: unknown) => {
776
- this.normVectorIndexP = undefined
777
- throw e
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(start: number) {
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
- const parseNext = async (
896
- chunkStart: number,
897
- remaining: number,
898
- ): Promise<number> => {
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 remaining - 1 === 0
917
- ? p0 + chunkSize
918
- : parseNext(p0 + chunkSize, remaining - 1)
981
+ return chunkStart + chunkSize
919
982
  }
920
983
 
921
- return nEntries === 0 ? start + INT : parseNext(start + INT, nEntries)
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
@@ -7,6 +7,8 @@ export type {
7
7
  Chromosome,
8
8
  HicMetadata,
9
9
  HicRegion,
10
+ ProgressCallback,
11
+ ProgressOpts,
10
12
  Reader,
11
13
  Zoom,
12
14
  } from './types.ts'
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