@gmod/hic 2.0.0 → 2.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
@@ -15,6 +15,7 @@ import type {
15
15
  BaseOpts,
16
16
  BlockIndexEntry,
17
17
  Chromosome,
18
+ ExpectedValues,
18
19
  HicMetadata,
19
20
  HicRegion,
20
21
  ProgressOpts,
@@ -49,6 +50,36 @@ interface SignalOpts extends ProgressOpts {
49
50
  signal: AbortSignal
50
51
  }
51
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
+
52
83
  export interface HicConfig {
53
84
  /** a `generic-filehandle2` handle, e.g. `RemoteFile` for a URL */
54
85
  filehandle?: GenericFilehandle
@@ -180,6 +211,9 @@ export class HicFile {
180
211
  string,
181
212
  NormVectorIndex | undefined
182
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>()
183
217
  private normVectorCache = new SharedReadCache<
184
218
  string,
185
219
  NormalizationVector | undefined
@@ -196,6 +230,8 @@ export class HicFile {
196
230
  private version = 0
197
231
  private genomeId = ''
198
232
  private footerPosition = 0
233
+ // Where the raw (NONE) expected values begin, right after the master index.
234
+ private expectedValuesPosition = 0
199
235
  // Where the file's normalization data begins, and so also where a file
200
236
  // carrying none of it ends. Public so a test can cut a real file there to
201
237
  // make that second shape.
@@ -253,8 +289,11 @@ export class HicFile {
253
289
  }
254
290
  const footerPosition = binaryParser.getLong()
255
291
 
256
- const { masterIndex, normExpectedValueVectorsPosition } =
257
- await this.readFooter(version, footerPosition, signal)
292
+ const {
293
+ masterIndex,
294
+ expectedValuesPosition,
295
+ normExpectedValueVectorsPosition,
296
+ } = await this.readFooter(version, footerPosition, signal)
258
297
 
259
298
  const bodyPosition = Object.values(masterIndex).reduce(
260
299
  (min, entry) => Math.min(min, entry.start),
@@ -303,6 +342,7 @@ export class HicFile {
303
342
  throwIfAborted(signal)
304
343
  this.version = version
305
344
  this.footerPosition = footerPosition
345
+ this.expectedValuesPosition = expectedValuesPosition
306
346
  this.masterIndex = masterIndex
307
347
  this.normExpectedValueVectorsPosition = normExpectedValueVectorsPosition
308
348
  this.genomeId = genomeId
@@ -350,10 +390,15 @@ export class HicFile {
350
390
  masterIndex[key] = { start: pos, size }
351
391
  }
352
392
 
393
+ const expectedValuesPosition = footerPosition + skip + binaryParser.position
353
394
  // Normalized expected values start after the expected values
354
395
  const normExpectedValueVectorsPosition =
355
396
  version > 5 ? footerPosition + (version < 9 ? 4 : 8) + nBytes : undefined
356
- return { masterIndex, normExpectedValueVectorsPosition }
397
+ return {
398
+ masterIndex,
399
+ expectedValuesPosition,
400
+ normExpectedValueVectorsPosition,
401
+ }
357
402
  }
358
403
 
359
404
  async getMatrix(chrIdx1: number, chrIdx2: number, opts?: BaseOpts) {
@@ -871,10 +916,7 @@ export class HicFile {
871
916
  const { signal } = opts
872
917
  let index: NormVectorIndex | undefined
873
918
  if (this.normExpectedValueVectorsPosition !== undefined) {
874
- const nviStart = await this.skipExpectedValues(
875
- this.normExpectedValueVectorsPosition,
876
- opts,
877
- )
919
+ const nviStart = (await this.getExpectedSection('normalized', opts)).end
878
920
  const data = await this.file.read(nviStart, INT, { signal })
879
921
  // Possible if there are no norm vectors. A legal v8 file, though uncommon.
880
922
  if (data.byteLength !== 0) {
@@ -903,69 +945,195 @@ export class HicFile {
903
945
  return index
904
946
  }
905
947
 
906
- // Used when the position of the norm vector index is unknown: skip the
907
- // normalized expected values to find the index.
908
- 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
+ /**
1030
+ * The library's total contact count, the `ALL` whole-genome map's
1031
+ * `sumCounts`: what scales two libraries of different depth against each
1032
+ * other. Undefined for a file with no `ALL` map.
1033
+ */
1034
+ async getTotalContacts(opts?: BaseOpts) {
1035
+ await this.init(opts)
1036
+ const all = this.chromosomes.find(chr => chr.name.toUpperCase() === 'ALL')
1037
+ const zd = all
1038
+ ? (await this.getMatrix(all.index, all.index, opts))?.getZoomLevels()[0]
1039
+ : undefined
1040
+ return zd?.sumCounts
1041
+ }
1042
+
1043
+ private async getExpectedSection(
1044
+ section: 'raw' | 'normalized',
1045
+ opts: ProgressOpts,
1046
+ ) {
1047
+ return this.expectedSectionCache.get(section, opts.signal, signal =>
1048
+ this.walkExpectedSection(
1049
+ section === 'raw'
1050
+ ? this.expectedValuesPosition
1051
+ : this.normExpectedValueVectorsPosition!,
1052
+ section === 'normalized',
1053
+ { signal, onProgress: opts.onProgress },
1054
+ ),
1055
+ )
1056
+ }
1057
+
1058
+ // Walk one expected-value section, recording where each chunk's values sit
1059
+ // and its chromosome scale factors. The normalized section's end is also
1060
+ // where a pre-v9 file's normalization vector index begins.
1061
+ //
1062
+ // A chunk's two reads land a whole expected-value vector apart — one per bin
1063
+ // of the genome's longest chromosome, so megabytes at a fine binsize — which
1064
+ // is further than the buffer reaches. On a remote file that is two round
1065
+ // trips per chunk that nothing can merge, and it is why this walk, not the
1066
+ // index it leads to, is the slow part of opening a pre-v9 file. It is also
1067
+ // why the chunk is the unit worth counting: known before the first read, each
1068
+ // costing about the same. Chunks are walked in order, since each one's size
1069
+ // is known only once the one before it has been parsed.
1070
+ private async walkExpectedSection(
1071
+ start: number,
1072
+ normalized: boolean,
1073
+ opts: SignalOpts,
1074
+ ): Promise<ExpectedSection> {
909
1075
  const { signal, onProgress } = opts
910
1076
  const version = this.version
1077
+ const valueSize = version < 9 ? DOUBLE : FLOAT
911
1078
  const file = new BufferedFile({ file: this.file, size: 256000 })
1079
+ const chunks = new Map<string, ExpectedChunk>()
912
1080
  const data = await file.read(start, INT, { signal })
913
- // A file with no normalization at all ends where this section would start,
914
- // so the count this is about to read is past EOF. That is the same "no norm
915
- // vectors" case `readNormExpectedValuesAndNormVectorIndex` guards its own
916
- // read for, and it has to be caught HERE too, because this runs first:
917
- // a v9 file that records no norm-vector-index position takes the v8
918
- // discovery path, and that path starts by skipping a section that isn't
919
- // there. Answering `start` leaves the caller's guard to do the rest.
1081
+ // A file with no normalization at all ends where the normalized section
1082
+ // would start, so the count this is about to read is past EOF. A v9 file
1083
+ // that records no norm-vector-index position takes the v8 discovery path,
1084
+ // which starts by walking a section that isn't there, so the empty answer
1085
+ // has to come from here.
920
1086
  if (data.byteLength < INT) {
921
- return start
1087
+ return { chunks, end: start }
922
1088
  }
923
- const binaryParser = new BinaryParser(new DataView(data))
924
- const nEntries = binaryParser.getInt() // Total # of expected value chunks
925
-
926
- // Skip one chunk, answering where the next one starts.
927
- //
928
- // Its two reads land a whole expected-value vector apart — one per bin of
929
- // the genome, so tens of MB at a fine binsize — which is further than the
930
- // buffer reaches. On a remote file that is two round trips per chunk that
931
- // nothing can merge, and it is why this walk, not the index it is looking
932
- // for, is the slow part of opening a pre-v9 file. It is also why the chunk
933
- // is the unit worth counting: `nEntries` of them, known before the first
934
- // read, each costing about the same.
935
- const skipChunk = async (chunkStart: number) => {
936
- let chunkSize = 0
937
-
938
- let buf = await file.read(chunkStart, 500, { signal })
939
- let parser = new BinaryParser(new DataView(buf))
940
- parser.getString() // type
941
- parser.getString() // unit
942
- parser.getInt() // binSize
943
- const nValues = version < 9 ? parser.getInt() : parser.getLong()
944
- chunkSize += parser.position + nValues * (version < 9 ? DOUBLE : FLOAT)
945
-
946
- buf = await file.read(chunkStart + chunkSize, INT, { signal })
947
- parser = new BinaryParser(new DataView(buf))
948
- const nChrScaleFactors = parser.getInt()
949
- chunkSize +=
950
- INT + nChrScaleFactors * (INT + (version < 9 ? DOUBLE : FLOAT))
951
-
952
- return chunkStart + chunkSize
953
- }
954
-
955
- // A loop rather than the tail recursion this was, because the position is
956
- // the only thing carried between chunks and a counter now rides along with
957
- // it. Chunks must be walked in order — each one's size is only known once
958
- // the one before it has been parsed — so the awaits are sequential by
959
- // nature, not by oversight.
1089
+ const nEntries = new BinaryParser(new DataView(data)).getInt()
960
1090
  if (nEntries > 0) {
961
1091
  onProgress?.(0, nEntries)
962
1092
  }
963
1093
  let position = start + INT
964
1094
  for (let done = 0; done < nEntries; done++) {
965
- position = await skipChunk(position)
1095
+ let parser = new BinaryParser(
1096
+ new DataView(await file.read(position, 500, { signal })),
1097
+ )
1098
+ const type = normalized ? parser.getString() : 'NONE'
1099
+ const unit = parser.getString()
1100
+ const binSize = parser.getInt()
1101
+ const nValues = version < 9 ? parser.getInt() : parser.getLong()
1102
+ const valuesPosition = position + parser.position
1103
+ const factorsPosition = valuesPosition + nValues * valueSize
1104
+
1105
+ parser = new BinaryParser(
1106
+ new DataView(await file.read(factorsPosition, INT, { signal })),
1107
+ )
1108
+ const nFactors = parser.getInt()
1109
+ const factorBytes = nFactors * (INT + valueSize)
1110
+ parser = new BinaryParser(
1111
+ new DataView(
1112
+ await file.read(factorsPosition + INT, factorBytes, { signal }),
1113
+ ),
1114
+ )
1115
+ const chrScaleFactors = new Map<number, number>()
1116
+ for (let i = 0; i < nFactors; i++) {
1117
+ const chrIdx = parser.getInt()
1118
+ chrScaleFactors.set(
1119
+ chrIdx,
1120
+ version < 9 ? parser.getDouble() : parser.getFloat(),
1121
+ )
1122
+ }
1123
+ chunks.set(getExpectedKey(type, unit, binSize), {
1124
+ vector: new NormalizationVector(
1125
+ this.file,
1126
+ valuesPosition,
1127
+ nValues,
1128
+ valueSize,
1129
+ ),
1130
+ nValues,
1131
+ chrScaleFactors,
1132
+ })
1133
+ position = factorsPosition + INT + factorBytes
966
1134
  onProgress?.(done + 1, nEntries)
967
1135
  }
968
- return position
1136
+ return { chunks, end: position }
969
1137
  }
970
1138
 
971
1139
  private parseNormVectorEntry(
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export type { ContactRecords } from './contactRecords.ts'
6
6
  export type {
7
7
  BaseOpts,
8
8
  Chromosome,
9
+ ExpectedValues,
9
10
  HicMetadata,
10
11
  HicRegion,
11
12
  ProgressCallback,
package/src/matrix.ts CHANGED
@@ -13,6 +13,11 @@ export default class Matrix {
13
13
  this.bpZoomData = zoomDataList.filter(zd => zd.zoom.unit === 'BP')
14
14
  }
15
15
 
16
+ /** The BP zoom levels this matrix holds, finest last as the file lists them. */
17
+ getZoomLevels(): readonly MatrixZoomData[] {
18
+ return this.bpZoomData
19
+ }
20
+
16
21
  // Fetch zoom data by bin size. If no matching level exists return undefined.
17
22
  getZoomData(binSize: number) {
18
23
  return this.bpZoomData.find(zd => binSize === zd.zoom.binSize)
@@ -7,6 +7,8 @@ export default class MatrixZoomData {
7
7
  chr1: Chromosome
8
8
  chr2: Chromosome
9
9
  zoom: Zoom
10
+ /** the raw contacts summed over the whole map at this binsize */
11
+ sumCounts: number
10
12
  blockBinCount: number
11
13
  blockColumnCount: number
12
14
  blockIndex: Record<number, BlockIndexEntry>
@@ -15,6 +17,7 @@ export default class MatrixZoomData {
15
17
  chr1: Chromosome,
16
18
  chr2: Chromosome,
17
19
  zoom: Zoom,
20
+ sumCounts: number,
18
21
  blockBinCount: number,
19
22
  blockColumnCount: number,
20
23
  blockIndex: Record<number, BlockIndexEntry>,
@@ -22,6 +25,7 @@ export default class MatrixZoomData {
22
25
  this.chr1 = chr1
23
26
  this.chr2 = chr2
24
27
  this.zoom = zoom
28
+ this.sumCounts = sumCounts
25
29
  this.blockBinCount = blockBinCount
26
30
  this.blockColumnCount = blockColumnCount
27
31
  this.blockIndex = blockIndex
@@ -104,7 +108,7 @@ export default class MatrixZoomData {
104
108
  ) {
105
109
  const unit = dis.getString()
106
110
  const zoomIndex = dis.getInt()
107
- dis.getFloat() // sumCounts
111
+ const sumCounts = dis.getFloat()
108
112
  dis.getFloat() // occupiedCellCount
109
113
  dis.getFloat() // stdDev
110
114
  dis.getFloat() // percent95
@@ -125,6 +129,7 @@ export default class MatrixZoomData {
125
129
  chr1,
126
130
  chr2,
127
131
  { index: zoomIndex, unit, binSize },
132
+ sumCounts,
128
133
  blockBinCount,
129
134
  blockColumnCount,
130
135
  blockIndex,
package/src/types.ts CHANGED
@@ -68,6 +68,18 @@ export interface BlockIndexEntry {
68
68
  size: number
69
69
  }
70
70
 
71
+ /**
72
+ * The expected contact count for one region pair, the denominator of
73
+ * observed/expected. Within a chromosome it depends only on the distance in
74
+ * bins, so it is the expected-value vector's entries over the distances the
75
+ * pair spans: `values[|bin2 - bin1| - firstDistance]`, already divided by the
76
+ * chromosome's scale factor and holding the vector's last value past its end.
77
+ * Between chromosomes it is one number, the map's average contact.
78
+ */
79
+ export type ExpectedValues =
80
+ | { kind: 'intra'; firstDistance: number; values: Float64Array }
81
+ | { kind: 'inter'; average: number }
82
+
71
83
  export interface HicMetadata {
72
84
  version: number
73
85
  genome: string