@gmod/hic 1.3.2 → 2.1.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
@@ -5,7 +5,6 @@ import { LocalFile } from 'generic-filehandle2'
5
5
  import { binWindow } from './binWindow.ts'
6
6
  import BinaryParser from './binary.ts'
7
7
  import BufferedFile from './bufferedFile.ts'
8
- import LRU from './lru.ts'
9
8
  import Matrix from './matrix.ts'
10
9
  import NormalizationVector from './normalizationVector.ts'
11
10
  import { readerFromFilehandle } from './reader.ts'
@@ -16,6 +15,7 @@ import type {
16
15
  BaseOpts,
17
16
  BlockIndexEntry,
18
17
  Chromosome,
18
+ ExpectedValues,
19
19
  HicMetadata,
20
20
  HicRegion,
21
21
  ProgressOpts,
@@ -50,6 +50,36 @@ interface SignalOpts extends ProgressOpts {
50
50
  signal: AbortSignal
51
51
  }
52
52
 
53
+ /** One expected-value vector: its values on disk and its chromosome scale factors. */
54
+ interface ExpectedChunk {
55
+ vector: NormalizationVector
56
+ nValues: number
57
+ chrScaleFactors: Map<number, number>
58
+ }
59
+
60
+ /** A walked expected-value section, and where the next section starts. */
61
+ interface ExpectedSection {
62
+ chunks: Map<string, ExpectedChunk>
63
+ end: number
64
+ }
65
+
66
+ function getExpectedKey(type: string, unit: string, binSize: number) {
67
+ return `${type}_${unit}_${binSize}`
68
+ }
69
+
70
+ // The range of `|bin2 - bin1|` over two half-open bin windows.
71
+ function distanceRange(
72
+ [x1, x2]: readonly [number, number],
73
+ [y1, y2]: readonly [number, number],
74
+ ) {
75
+ const overlap = x1 < y2 && y1 < x2
76
+ const nearest = overlap
77
+ ? 0
78
+ : Math.min(Math.abs(y1 - (x2 - 1)), Math.abs(x1 - (y2 - 1)))
79
+ const farthest = Math.max(Math.abs(y2 - 1 - x1), Math.abs(x2 - 1 - y1))
80
+ return [nearest, farthest] as const
81
+ }
82
+
53
83
  export interface HicConfig {
54
84
  /** a `generic-filehandle2` handle, e.g. `RemoteFile` for a URL */
55
85
  filehandle?: GenericFilehandle
@@ -66,8 +96,7 @@ export interface HicConfig {
66
96
  /**
67
97
  * Ceiling on the decompressed contacts the block cache holds, in bytes.
68
98
  * Defaults to 128 MB. Lower it where the budget is tight and re-reading is
69
- * cheaper than the memory; raising it buys nothing once a fetch's working set
70
- * fits, since the entry cap bounds the cache too.
99
+ * cheaper than the memory.
71
100
  */
72
101
  blockCacheMaxBytes?: number
73
102
  }
@@ -75,7 +104,7 @@ export interface HicConfig {
75
104
  // Cached blocks are the decompressed records and nothing else. hic-straw also
76
105
  // hung the MatrixZoomData and the block-index entry off each Block; neither was
77
106
  // ever read back, and the zoom data pinned a whole `blockIndex` record per
78
- // cached block for the lifetime of the LRU.
107
+ // cached block for the lifetime of the cache.
79
108
  interface Block {
80
109
  blockNumber: number
81
110
  records: ContactRecords
@@ -122,15 +151,13 @@ function truncateRecords(records: ContactRecords, n: number): ContactRecords {
122
151
  // The cliffs are exactly these three capacities. Note the first column: 41% of a
123
152
  // cold whole-genome fetch's reads were re-reads of normalization vectors the
124
153
  // same fetch had already fetched.
125
- const BLOCK_CACHE_MAX_ENTRIES = 1024
126
- // The bound the entry cap was standing in for. Blocks became struct-of-arrays
127
- // (see contactRecords.ts), so a cached contact is 12 bytes of untraced
128
- // ArrayBuffer rather than ~55 on the GC-traced heap — but a block still holds
129
- // every contact in its bin square, which is the one thing here that can hold
130
- // real memory, and block sizes vary by more than an order of magnitude with
131
- // binsize. Measured on the file above: 0.05 MB at 2.5 Mb, 0.23 MB at 100 kb. So
132
- // the entry cap now tracks the working set and this tracks the memory, instead
133
- // of one number answering both badly. See LRU's WeightOpts.
154
+ //
155
+ // Blocks became struct-of-arrays (see contactRecords.ts), so a cached contact is
156
+ // 12 bytes of untraced ArrayBuffer rather than ~55 on the GC-traced heap — but a
157
+ // block still holds every contact in its bin square, which is the one thing here
158
+ // that can hold real memory, and block sizes vary by more than an order of
159
+ // magnitude with binsize. Measured on the file above: 0.05 MB at 2.5 Mb, 0.23 MB
160
+ // at 100 kb. So the block cache's only bound is its byte budget.
134
161
  //
135
162
  // This is the one capacity here a caller can override (`blockCacheMaxBytes`),
136
163
  // because it is the only one that bounds MEMORY. The two entry caps below are
@@ -152,7 +179,10 @@ const NORM_VECTOR_CACHE_SIZE = 64
152
179
  // so no separate resolution generation is needed to keep entries apart —
153
180
  // hic-straw's extra `resolution` field only made the cache single-resolution
154
181
  // (every zoom step threw the previous level away).
155
- function blockCacheWeight(block: Block) {
182
+ function blockCacheWeight(block: Block | undefined) {
183
+ if (!block) {
184
+ return 0
185
+ }
156
186
  const { bin1, bin2, counts } = block.records
157
187
  return bin1.byteLength + bin2.byteLength + counts.byteLength
158
188
  }
@@ -167,7 +197,7 @@ function getNormalizationVectorKey(
167
197
  }
168
198
 
169
199
  export class HicFile {
170
- private config: HicConfig
200
+ private nvi: string | undefined
171
201
  private file: Reader
172
202
 
173
203
  // These caches share the in-flight read, not just its result. A
@@ -181,6 +211,9 @@ export class HicFile {
181
211
  string,
182
212
  NormVectorIndex | undefined
183
213
  >()
214
+ // 'raw' and 'normalized', each walked once: the sections carry no index, so
215
+ // a chunk's position is known only by walking every chunk before it.
216
+ private expectedSectionCache = new SharedReadCache<string, ExpectedSection>()
184
217
  private normVectorCache = new SharedReadCache<
185
218
  string,
186
219
  NormalizationVector | undefined
@@ -190,31 +223,32 @@ export class HicFile {
190
223
  })
191
224
  // Assigned in the constructor rather than here, since its byte budget is
192
225
  // configurable and a field initializer cannot see `config`.
193
- private blockCache: LRU<string, Block>
226
+ private blockCache: SharedReadCache<string, Block | undefined>
194
227
  private normVectorIndexPosition = -1
195
228
  private normVectorIndexSize = -1
196
229
 
197
230
  private version = 0
198
231
  private genomeId = ''
199
232
  private footerPosition = 0
233
+ // Where the raw (NONE) expected values begin, right after the master index.
234
+ private expectedValuesPosition = 0
200
235
  // Where the file's normalization data begins, and so also where a file
201
236
  // carrying none of it ends. Public so a test can cut a real file there to
202
237
  // make that second shape.
203
238
  normExpectedValueVectorsPosition: number | undefined
204
239
 
205
240
  private chromosomes: Chromosome[] = []
206
- private chromosomeIndexMap: Record<string, number> = {}
207
- private chrAliasTable: Record<string, string> = {}
241
+ private chromosomeIndexMap = new Map<string, number>()
208
242
  private bpResolutions: number[] = []
209
243
  private masterIndex: Record<string, MasterIndexEntry> = {}
210
244
  private meta: HicMetadata | undefined
211
245
 
212
246
  constructor(config: HicConfig) {
213
- const { filehandle, path, reader, blockCacheMaxBytes } = config
214
- this.config = config
215
- this.blockCache = new LRU<string, Block>(BLOCK_CACHE_MAX_ENTRIES, {
216
- maxBytes: blockCacheMaxBytes ?? BLOCK_CACHE_MAX_BYTES,
217
- weigh: blockCacheWeight,
247
+ const { filehandle, path, reader, nvi, blockCacheMaxBytes } = config
248
+ this.nvi = nvi
249
+ this.blockCache = new SharedReadCache({
250
+ maxSize: blockCacheMaxBytes ?? BLOCK_CACHE_MAX_BYTES,
251
+ sizeOf: blockCacheWeight,
218
252
  })
219
253
  if (reader) {
220
254
  this.file = reader
@@ -255,8 +289,11 @@ export class HicFile {
255
289
  }
256
290
  const footerPosition = binaryParser.getLong()
257
291
 
258
- const { masterIndex, normExpectedValueVectorsPosition } =
259
- await this.readFooter(version, footerPosition, signal)
292
+ const {
293
+ masterIndex,
294
+ expectedValuesPosition,
295
+ normExpectedValueVectorsPosition,
296
+ } = await this.readFooter(version, footerPosition, signal)
260
297
 
261
298
  const bodyPosition = Object.values(masterIndex).reduce(
262
299
  (min, entry) => Math.min(min, entry.start),
@@ -282,7 +319,7 @@ export class HicFile {
282
319
  }
283
320
 
284
321
  const chromosomes: Chromosome[] = []
285
- const chromosomeIndexMap: Record<string, number> = {}
322
+ const chromosomeIndexMap = new Map<string, number>()
286
323
  let nChrs = binaryParser.getInt()
287
324
  let i = 0
288
325
  while (nChrs-- > 0) {
@@ -292,7 +329,7 @@ export class HicFile {
292
329
  size: version < 9 ? binaryParser.getInt() : binaryParser.getLong(),
293
330
  }
294
331
  chromosomes.push(chr)
295
- chromosomeIndexMap[chr.name] = chr.index
332
+ chromosomeIndexMap.set(chr.name, chr.index)
296
333
  i++
297
334
  }
298
335
 
@@ -302,20 +339,10 @@ export class HicFile {
302
339
  bpResolutions.push(binaryParser.getInt())
303
340
  }
304
341
 
305
- const chrAliasTable: Record<string, string> = {}
306
- for (const chrName of Object.keys(chromosomeIndexMap)) {
307
- if (chrName.startsWith('chr')) {
308
- chrAliasTable[chrName.slice(3)] = chrName
309
- } else if (chrName === 'MT') {
310
- chrAliasTable.chrM = chrName
311
- } else {
312
- chrAliasTable[`chr${chrName}`] = chrName
313
- }
314
- }
315
-
316
342
  throwIfAborted(signal)
317
343
  this.version = version
318
344
  this.footerPosition = footerPosition
345
+ this.expectedValuesPosition = expectedValuesPosition
319
346
  this.masterIndex = masterIndex
320
347
  this.normExpectedValueVectorsPosition = normExpectedValueVectorsPosition
321
348
  this.genomeId = genomeId
@@ -324,7 +351,6 @@ export class HicFile {
324
351
  this.chromosomes = chromosomes
325
352
  this.chromosomeIndexMap = chromosomeIndexMap
326
353
  this.bpResolutions = bpResolutions
327
- this.chrAliasTable = chrAliasTable
328
354
  this.meta = {
329
355
  version,
330
356
  genome: genomeId,
@@ -364,10 +390,15 @@ export class HicFile {
364
390
  masterIndex[key] = { start: pos, size }
365
391
  }
366
392
 
393
+ const expectedValuesPosition = footerPosition + skip + binaryParser.position
367
394
  // Normalized expected values start after the expected values
368
395
  const normExpectedValueVectorsPosition =
369
396
  version > 5 ? footerPosition + (version < 9 ? 4 : 8) + nBytes : undefined
370
- return { masterIndex, normExpectedValueVectorsPosition }
397
+ return {
398
+ masterIndex,
399
+ expectedValuesPosition,
400
+ normExpectedValueVectorsPosition,
401
+ }
371
402
  }
372
403
 
373
404
  async getMatrix(chrIdx1: number, chrIdx2: number, opts?: BaseOpts) {
@@ -397,14 +428,13 @@ export class HicFile {
397
428
  normalization: string,
398
429
  region1: HicRegion,
399
430
  region2: HicRegion,
400
- units: string,
401
431
  binsize: number,
402
432
  opts?: ProgressOpts,
403
433
  ) {
404
434
  await this.init(opts)
405
435
 
406
- const idx1 = this.chromosomeIndexMap[this.getFileChrName(region1.chr)]
407
- const idx2 = this.chromosomeIndexMap[this.getFileChrName(region2.chr)]
436
+ const idx1 = this.chromosomeIndexMap.get(region1.chr)
437
+ const idx2 = this.chromosomeIndexMap.get(region2.chr)
408
438
 
409
439
  // A `.hic` stores only `bin1 <= bin2`, so a query whose x window sits to the
410
440
  // right of its y window has to be swapped or it asks for the half of the
@@ -444,7 +474,7 @@ export class HicFile {
444
474
  // resolved once per pair rather than per block, each paired with the bin
445
475
  // offset its values start at.
446
476
  const [norm, blocks] = await Promise.all([
447
- this.getNormVectors(normalization, r1, r2, units, binsize, opts),
477
+ this.getNormVectors(normalization, r1, r2, binsize, opts),
448
478
  // The measurable half of the pair: blocks are counted and the two norm
449
479
  // vectors are not, because the vector chain is two hops whatever the
450
480
  // query and the block chain is the one that grows with the region.
@@ -522,9 +552,8 @@ export class HicFile {
522
552
  //
523
553
  // `transposed` says the query was swapped, so `bin1` runs along `region2`.
524
554
  // Reported rather than left for the caller to re-derive: it is decided here
525
- // from this file's own alias table and chromosome indices, and a caller
526
- // re-deriving it off a divergent chr-name scheme would silently un-swap the
527
- // wrong axis.
555
+ // from this file's chromosome indices, which a caller would otherwise have
556
+ // to look up again to know which axis to un-swap.
528
557
  return {
529
558
  records: contactRecords,
530
559
  appliedNormalization: norm ? normalization : 'NONE',
@@ -536,59 +565,29 @@ export class HicFile {
536
565
  normalization: string,
537
566
  r1: HicRegion,
538
567
  r2: HicRegion,
539
- units: string,
540
568
  binsize: number,
541
569
  opts?: BaseOpts,
542
570
  ) {
571
+ if (!normalization || normalization === 'NONE') {
572
+ return undefined
573
+ }
543
574
  const signalOpts = { signal: opts?.signal }
544
- let result:
545
- | {
546
- v1: Float64Array
547
- v2: Float64Array
548
- offset1: number
549
- offset2: number
550
- }
551
- | undefined
552
- if (normalization && normalization !== 'NONE') {
553
- const chr1 = this.getFileChrName(r1.chr)
554
- const chr2 = this.getFileChrName(r2.chr)
555
- const offset1 = Math.floor(r1.start / binsize)
556
- const offset2 = Math.floor(r2.start / binsize)
557
- const nv1 = await this.getNormalizationVector(
558
- normalization,
559
- chr1,
560
- units,
561
- binsize,
562
- signalOpts,
563
- )
564
- const nv2 =
565
- chr1 === chr2
566
- ? nv1
567
- : await this.getNormalizationVector(
568
- normalization,
569
- chr2,
570
- units,
571
- binsize,
572
- signalOpts,
573
- )
574
- if (nv1 && nv2) {
575
- result = {
576
- v1: await nv1.getValues(
577
- offset1,
578
- Math.ceil(r1.end / binsize),
579
- signalOpts,
580
- ),
581
- v2: await nv2.getValues(
582
- offset2,
583
- Math.ceil(r2.end / binsize),
584
- signalOpts,
585
- ),
586
- offset1,
587
- offset2,
588
- }
589
- }
575
+ const [offset1, end1] = binWindow(r1, binsize)
576
+ const [offset2, end2] = binWindow(r2, binsize)
577
+ // Awaited in sequence, an inter-chromosomal pair would pay four
578
+ // round-trip waves for what is two hops per vector.
579
+ const [nv1, nv2] = await Promise.all([
580
+ this.getNormalizationVector(normalization, r1.chr, binsize, signalOpts),
581
+ this.getNormalizationVector(normalization, r2.chr, binsize, signalOpts),
582
+ ])
583
+ if (!nv1 || !nv2) {
584
+ return undefined
590
585
  }
591
- return result
586
+ const [v1, v2] = await Promise.all([
587
+ nv1.getValues(offset1, end1, signalOpts),
588
+ nv2.getValues(offset2, end2, signalOpts),
589
+ ])
590
+ return { v1, v2, offset1, offset2 }
592
591
  }
593
592
 
594
593
  async getBlocks(
@@ -601,10 +600,8 @@ export class HicFile {
601
600
  `${zd.getKey()}_${blockNumber}`
602
601
 
603
602
  await this.init(opts)
604
- const chr1 = this.getFileChrName(region1.chr)
605
- const chr2 = this.getFileChrName(region2.chr)
606
- const idx1 = this.chromosomeIndexMap[chr1]
607
- const idx2 = this.chromosomeIndexMap[chr2]
603
+ const idx1 = this.chromosomeIndexMap.get(region1.chr)
604
+ const idx2 = this.chromosomeIndexMap.get(region2.chr)
608
605
 
609
606
  let blocks: (Block | undefined)[] = []
610
607
  if (idx1 === undefined) {
@@ -627,45 +624,42 @@ export class HicFile {
627
624
  }
628
625
 
629
626
  const blockNumbers = zd.getBlockNumbers(region1, region2, this.version)
630
- const blockNumbersToQuery: number[] = []
631
- for (const num of blockNumbers) {
632
- const cached = this.blockCache.get(blockKey(num, zd))
633
- if (cached) {
634
- blocks.push(cached)
635
- } else {
636
- blockNumbersToQuery.push(num)
637
- }
638
- }
627
+ const keys = blockNumbers.map(num => blockKey(num, zd))
628
+ const cached = keys.map(key => this.blockCache.has(key))
639
629
 
640
630
  // Cached blocks are already done, not absent: a pan that reuses most
641
631
  // of its blocks reads most of the way along rather than restarting at
642
632
  // zero, which is what the bar should say — the work left is the reads.
633
+ // A block another fetch has in flight counts as cached too.
643
634
  //
644
635
  // A query covering no blocks at all reports nothing rather than `0, 0`:
645
636
  // there is no fraction in it, and a caller dividing would get NaN.
646
637
  const { onProgress } = opts ?? {}
647
- let done = blocks.length
648
- if (blockNumbers.length > 0) {
649
- onProgress?.(done, blockNumbers.length)
638
+ const total = blockNumbers.length
639
+ let done = cached.filter(Boolean).length
640
+ if (total > 0) {
641
+ onProgress?.(done, total)
650
642
  }
651
- // Still one `Promise.all`, so every block is still issued in one wave:
652
- // what a remote file pays is round-trip DEPTH, and counting the
653
- // completions must not turn one wave into a queue. `readChainDepth`
654
- // pins that.
655
- const newBlocks = await Promise.all(
656
- blockNumbersToQuery.map(async blockNumber => {
657
- const block = await this.readBlock(blockNumber, zd, signalOpts)
658
- done++
659
- onProgress?.(done, blockNumbers.length)
643
+ // One `Promise.all`, so every block is issued in one wave: what a
644
+ // remote file pays is round-trip DEPTH, and counting the completions
645
+ // must not turn one wave into a queue. `readChainDepth` pins that.
646
+ //
647
+ // A block number missing from the index has no data, so it skips the
648
+ // cache, which would otherwise keep a weightless entry for it forever.
649
+ blocks = await Promise.all(
650
+ blockNumbers.map(async (num, i) => {
651
+ const block = zd.blockIndex[num]
652
+ ? await this.blockCache.get(keys[i]!, opts?.signal, signal =>
653
+ this.readBlock(num, zd, { signal }),
654
+ )
655
+ : undefined
656
+ if (!cached[i]) {
657
+ done++
658
+ onProgress?.(done, total)
659
+ }
660
660
  return block
661
661
  }),
662
662
  )
663
- for (const block of newBlocks) {
664
- if (block) {
665
- this.blockCache.set(blockKey(block.blockNumber, zd), block)
666
- }
667
- }
668
- blocks = blocks.concat(newBlocks)
669
663
  }
670
664
  }
671
665
  return blocks
@@ -793,17 +787,16 @@ export class HicFile {
793
787
  async getNormalizationVector(
794
788
  type: string,
795
789
  chr: string,
796
- unit: string,
797
790
  binSize: number,
798
791
  opts?: BaseOpts,
799
792
  ) {
800
793
  await this.init(opts)
801
794
 
802
- const chrIdx = this.chromosomeIndexMap[this.getFileChrName(chr)]
795
+ const chrIdx = this.chromosomeIndexMap.get(chr)
803
796
  if (chrIdx === undefined) {
804
797
  return undefined
805
798
  }
806
- const key = getNormalizationVectorKey(type, chrIdx, unit, binSize)
799
+ const key = getNormalizationVectorKey(type, chrIdx, 'BP', binSize)
807
800
 
808
801
  // Sharing the in-flight read is what keeps concurrent region pairs sharing
809
802
  // one vector: a chromosome appears in every pair it takes part in, so with a
@@ -872,8 +865,8 @@ export class HicFile {
872
865
  },
873
866
  opts.signal,
874
867
  )
875
- } else if (this.config.nvi) {
876
- const nviArray = decodeURIComponent(this.config.nvi).split(',')
868
+ } else if (this.nvi) {
869
+ const nviArray = decodeURIComponent(this.nvi).split(',')
877
870
  return this.readNormVectorIndex(
878
871
  {
879
872
  start: parseInt(nviArray[0]!),
@@ -923,114 +916,210 @@ export class HicFile {
923
916
  const { signal } = opts
924
917
  let index: NormVectorIndex | undefined
925
918
  if (this.normExpectedValueVectorsPosition !== undefined) {
926
- const nviStart = await this.skipExpectedValues(
927
- this.normExpectedValueVectorsPosition,
928
- opts,
929
- )
930
- let byteCount = INT
931
-
932
- let data = await this.file.read(nviStart, INT, { signal })
919
+ const nviStart = (await this.getExpectedSection('normalized', opts)).end
920
+ const data = await this.file.read(nviStart, INT, { signal })
933
921
  // Possible if there are no norm vectors. A legal v8 file, though uncommon.
934
922
  if (data.byteLength !== 0) {
935
- const binaryParser = new BinaryParser(new DataView(data))
936
- const nEntries = binaryParser.getInt()
937
- const sizeEstimate = nEntries * 30
938
- data = await this.file.read(nviStart + byteCount, sizeEstimate, {
939
- signal,
940
- })
923
+ const nEntries = new BinaryParser(new DataView(data)).getInt()
941
924
  const found: NormVectorIndex = { entries: {}, types: ['NONE'] }
942
925
 
943
- const processEntries = async (remaining: number, buf: ArrayBuffer) => {
944
- const parser = new BinaryParser(new DataView(buf))
945
- let n = remaining
946
- while (n-- > 0) {
947
- if (parser.available() < 100) {
948
- n++ // Reset counter as entry is not processed
949
- byteCount += parser.position
950
- const est = Math.max(1000, n * 30)
951
- const more = await this.file.read(nviStart + byteCount, est, {
952
- signal,
953
- })
954
- await processEntries(n, more)
955
- return
956
- }
957
- this.parseNormVectorEntry(parser, found)
926
+ let position = nviStart + INT
927
+ let requested = nEntries * 30
928
+ let buf = await this.file.read(position, requested, { signal })
929
+ let parser = new BinaryParser(new DataView(buf))
930
+ for (let n = nEntries; n > 0; n--) {
931
+ // A short read means the buffer already runs to EOF, and refilling
932
+ // it would only return the same bytes again.
933
+ if (parser.available() < 100 && buf.byteLength === requested) {
934
+ position += parser.position
935
+ requested = Math.max(1000, n * 30)
936
+ buf = await this.file.read(position, requested, { signal })
937
+ parser = new BinaryParser(new DataView(buf))
958
938
  }
959
- byteCount += parser.position
939
+ this.parseNormVectorEntry(parser, found)
960
940
  }
961
-
962
- await processEntries(nEntries, data)
963
941
  throwIfAborted(signal)
964
- this.config.nvi = `${nviStart},${byteCount}`
965
942
  index = found
966
943
  }
967
944
  }
968
945
  return index
969
946
  }
970
947
 
971
- // Used when the position of the norm vector index is unknown: skip the
972
- // normalized expected values to find the index.
973
- private async skipExpectedValues(start: number, opts: SignalOpts) {
948
+ /**
949
+ * The expected count for a region pair at `binsize`, the denominator of
950
+ * observed/expected, as straw computes it. `normalization` must be the one
951
+ * the pair's records carry (`getContactRecords`' `appliedNormalization`),
952
+ * since each normalization has its own vector. Undefined where the file has
953
+ * no expected value for it: no vector at this binsize, or a version 5 file
954
+ * asked for a normalized one.
955
+ *
956
+ * Within a chromosome the vector is read for only the distances the pair
957
+ * spans. Between chromosomes straw divides by the map's average contact, the
958
+ * raw `sumCounts` over its bins, whatever the normalization.
959
+ */
960
+ async getExpectedValues(
961
+ normalization: string,
962
+ region1: HicRegion,
963
+ region2: HicRegion,
964
+ binsize: number,
965
+ opts?: ProgressOpts,
966
+ ): Promise<ExpectedValues | undefined> {
967
+ await this.init(opts)
968
+ const idx1 = this.chromosomeIndexMap.get(region1.chr)
969
+ const idx2 = this.chromosomeIndexMap.get(region2.chr)
970
+ if (idx1 === undefined || idx2 === undefined) {
971
+ return undefined
972
+ }
973
+ if (idx1 !== idx2) {
974
+ const matrix = await this.getMatrix(idx1, idx2, opts)
975
+ const zd = matrix?.getZoomData(binsize)
976
+ if (!zd) {
977
+ return undefined
978
+ }
979
+ // straw's bin counts leave out a chromosome's partial last bin
980
+ const nBins1 = Math.max(
981
+ 1,
982
+ Math.floor(this.chromosomes[idx1]!.size / binsize),
983
+ )
984
+ const nBins2 = Math.max(
985
+ 1,
986
+ Math.floor(this.chromosomes[idx2]!.size / binsize),
987
+ )
988
+ return { kind: 'inter', average: zd.sumCounts / nBins1 / nBins2 }
989
+ }
990
+ const normalized = !!normalization && normalization !== 'NONE'
991
+ if (normalized && this.normExpectedValueVectorsPosition === undefined) {
992
+ return undefined
993
+ }
994
+ const section = await this.getExpectedSection(
995
+ normalized ? 'normalized' : 'raw',
996
+ opts ?? {},
997
+ )
998
+ const chunk = section.chunks.get(
999
+ getExpectedKey(normalized ? normalization : 'NONE', 'BP', binsize),
1000
+ )
1001
+ if (!chunk || chunk.nValues === 0) {
1002
+ return undefined
1003
+ }
1004
+ const [first, last] = distanceRange(
1005
+ binWindow(region1, binsize),
1006
+ binWindow(region2, binsize),
1007
+ )
1008
+ const signalOpts = { signal: opts?.signal }
1009
+ const stored = await chunk.vector.getValues(first, last + 1, signalOpts)
1010
+ const tail =
1011
+ stored.length < last + 1 - first
1012
+ ? (
1013
+ await chunk.vector.getValues(
1014
+ chunk.nValues - 1,
1015
+ chunk.nValues,
1016
+ signalOpts,
1017
+ )
1018
+ )[0]!
1019
+ : 0
1020
+ // straw divides by 1 where the section names no factor for the chromosome
1021
+ const factor = chunk.chrScaleFactors.get(idx1) ?? 1
1022
+ const values = new Float64Array(last + 1 - first)
1023
+ for (let i = 0; i < values.length; i++) {
1024
+ values[i] = (i < stored.length ? stored[i]! : tail) / factor
1025
+ }
1026
+ return { kind: 'intra', firstDistance: first, values }
1027
+ }
1028
+
1029
+ private async getExpectedSection(
1030
+ section: 'raw' | 'normalized',
1031
+ opts: ProgressOpts,
1032
+ ) {
1033
+ return this.expectedSectionCache.get(section, opts.signal, signal =>
1034
+ this.walkExpectedSection(
1035
+ section === 'raw'
1036
+ ? this.expectedValuesPosition
1037
+ : this.normExpectedValueVectorsPosition!,
1038
+ section === 'normalized',
1039
+ { signal, onProgress: opts.onProgress },
1040
+ ),
1041
+ )
1042
+ }
1043
+
1044
+ // Walk one expected-value section, recording where each chunk's values sit
1045
+ // and its chromosome scale factors. The normalized section's end is also
1046
+ // where a pre-v9 file's normalization vector index begins.
1047
+ //
1048
+ // A chunk's two reads land a whole expected-value vector apart — one per bin
1049
+ // of the genome's longest chromosome, so megabytes at a fine binsize — which
1050
+ // is further than the buffer reaches. On a remote file that is two round
1051
+ // trips per chunk that nothing can merge, and it is why this walk, not the
1052
+ // index it leads to, is the slow part of opening a pre-v9 file. It is also
1053
+ // why the chunk is the unit worth counting: known before the first read, each
1054
+ // costing about the same. Chunks are walked in order, since each one's size
1055
+ // is known only once the one before it has been parsed.
1056
+ private async walkExpectedSection(
1057
+ start: number,
1058
+ normalized: boolean,
1059
+ opts: SignalOpts,
1060
+ ): Promise<ExpectedSection> {
974
1061
  const { signal, onProgress } = opts
975
1062
  const version = this.version
1063
+ const valueSize = version < 9 ? DOUBLE : FLOAT
976
1064
  const file = new BufferedFile({ file: this.file, size: 256000 })
1065
+ const chunks = new Map<string, ExpectedChunk>()
977
1066
  const data = await file.read(start, INT, { signal })
978
- // A file with no normalization at all ends where this section would start,
979
- // so the count this is about to read is past EOF. That is the same "no norm
980
- // vectors" case `readNormExpectedValuesAndNormVectorIndex` guards its own
981
- // read for, and it has to be caught HERE too, because this runs first:
982
- // a v9 file that records no norm-vector-index position takes the v8
983
- // discovery path, and that path starts by skipping a section that isn't
984
- // there. Answering `start` leaves the caller's guard to do the rest.
1067
+ // A file with no normalization at all ends where the normalized section
1068
+ // would start, so the count this is about to read is past EOF. A v9 file
1069
+ // that records no norm-vector-index position takes the v8 discovery path,
1070
+ // which starts by walking a section that isn't there, so the empty answer
1071
+ // has to come from here.
985
1072
  if (data.byteLength < INT) {
986
- return start
1073
+ return { chunks, end: start }
987
1074
  }
988
- const binaryParser = new BinaryParser(new DataView(data))
989
- const nEntries = binaryParser.getInt() // Total # of expected value chunks
990
-
991
- // Skip one chunk, answering where the next one starts.
992
- //
993
- // Its two reads land a whole expected-value vector apart — one per bin of
994
- // the genome, so tens of MB at a fine binsize — which is further than the
995
- // buffer reaches. On a remote file that is two round trips per chunk that
996
- // nothing can merge, and it is why this walk, not the index it is looking
997
- // for, is the slow part of opening a pre-v9 file. It is also why the chunk
998
- // is the unit worth counting: `nEntries` of them, known before the first
999
- // read, each costing about the same.
1000
- const skipChunk = async (chunkStart: number) => {
1001
- let chunkSize = 0
1002
-
1003
- let buf = await file.read(chunkStart, 500, { signal })
1004
- let parser = new BinaryParser(new DataView(buf))
1005
- parser.getString() // type
1006
- parser.getString() // unit
1007
- parser.getInt() // binSize
1008
- const nValues = version < 9 ? parser.getInt() : parser.getLong()
1009
- chunkSize += parser.position + nValues * (version < 9 ? DOUBLE : FLOAT)
1010
-
1011
- buf = await file.read(chunkStart + chunkSize, INT, { signal })
1012
- parser = new BinaryParser(new DataView(buf))
1013
- const nChrScaleFactors = parser.getInt()
1014
- chunkSize +=
1015
- INT + nChrScaleFactors * (INT + (version < 9 ? DOUBLE : FLOAT))
1016
-
1017
- return chunkStart + chunkSize
1018
- }
1019
-
1020
- // A loop rather than the tail recursion this was, because the position is
1021
- // the only thing carried between chunks and a counter now rides along with
1022
- // it. Chunks must be walked in order — each one's size is only known once
1023
- // the one before it has been parsed — so the awaits are sequential by
1024
- // nature, not by oversight.
1075
+ const nEntries = new BinaryParser(new DataView(data)).getInt()
1025
1076
  if (nEntries > 0) {
1026
1077
  onProgress?.(0, nEntries)
1027
1078
  }
1028
1079
  let position = start + INT
1029
1080
  for (let done = 0; done < nEntries; done++) {
1030
- position = await skipChunk(position)
1081
+ let parser = new BinaryParser(
1082
+ new DataView(await file.read(position, 500, { signal })),
1083
+ )
1084
+ const type = normalized ? parser.getString() : 'NONE'
1085
+ const unit = parser.getString()
1086
+ const binSize = parser.getInt()
1087
+ const nValues = version < 9 ? parser.getInt() : parser.getLong()
1088
+ const valuesPosition = position + parser.position
1089
+ const factorsPosition = valuesPosition + nValues * valueSize
1090
+
1091
+ parser = new BinaryParser(
1092
+ new DataView(await file.read(factorsPosition, INT, { signal })),
1093
+ )
1094
+ const nFactors = parser.getInt()
1095
+ const factorBytes = nFactors * (INT + valueSize)
1096
+ parser = new BinaryParser(
1097
+ new DataView(
1098
+ await file.read(factorsPosition + INT, factorBytes, { signal }),
1099
+ ),
1100
+ )
1101
+ const chrScaleFactors = new Map<number, number>()
1102
+ for (let i = 0; i < nFactors; i++) {
1103
+ const chrIdx = parser.getInt()
1104
+ chrScaleFactors.set(
1105
+ chrIdx,
1106
+ version < 9 ? parser.getDouble() : parser.getFloat(),
1107
+ )
1108
+ }
1109
+ chunks.set(getExpectedKey(type, unit, binSize), {
1110
+ vector: new NormalizationVector(
1111
+ this.file,
1112
+ valuesPosition,
1113
+ nValues,
1114
+ valueSize,
1115
+ ),
1116
+ nValues,
1117
+ chrScaleFactors,
1118
+ })
1119
+ position = factorsPosition + INT + factorBytes
1031
1120
  onProgress?.(done + 1, nEntries)
1032
1121
  }
1033
- return position
1122
+ return { chunks, end: position }
1034
1123
  }
1035
1124
 
1036
1125
  private parseNormVectorEntry(
@@ -1044,15 +1133,11 @@ export class HicFile {
1044
1133
  const filePosition = binaryParser.getLong() // 8
1045
1134
  const sizeInBytes =
1046
1135
  this.version < 9 ? binaryParser.getInt() : binaryParser.getLong() // 4:8
1047
- const key = `${type}_${chrIdx}_${unit}_${binSize}`
1136
+ const key = getNormalizationVectorKey(type, chrIdx, unit, binSize)
1048
1137
 
1049
1138
  if (!index.types.includes(type)) {
1050
1139
  index.types.push(type)
1051
1140
  }
1052
1141
  index.entries[key] = { filePosition, size: sizeInBytes }
1053
1142
  }
1054
-
1055
- getFileChrName(chrAlias: string) {
1056
- return this.chrAliasTable[chrAlias] ?? chrAlias
1057
- }
1058
1143
  }