Skip to main content

gwseq_io/hic/
reader.rs

1//! The HiC reader.
2//!
3//! Three memoised caches — expected-value vectors, normalization vectors and
4//! matrix metadata. Each is a `Mutex<HashMap<Key, Arc<_>>>`: the `Arc` is what
5//! lets a lookup drop the lock before the vector is used, and it is carried
6//! into the read rather than cloned out of, since a vector is one `f32` per bin
7//! of a chromosome and copying two per read would dominate a small request.
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use ndarray::Array2;
13use parking_lot::Mutex;
14
15use crate::arrays::CooMatrix;
16use crate::error::{Error, Result};
17use crate::genomic::{ChrMap, Locs};
18use crate::parallel::Executor;
19use crate::source::ByteSource;
20
21use super::block::{block_numbers, read_block, ContactRecord, RecordContext};
22use super::header::{vector_key, HiCFooter, HiCHeader};
23use super::matrix::{matrix_key, parse_loc2d, Loc2D, MatrixMetadata};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum HiCMode {
27    #[default]
28    Observed,
29    /// Observed / expected.
30    Oe,
31    Expected,
32}
33
34impl std::str::FromStr for HiCMode {
35    type Err = Error;
36    fn from_str(s: &str) -> Result<Self> {
37        match s.to_ascii_lowercase().as_str() {
38            "observed" => Ok(HiCMode::Observed),
39            "oe" => Ok(HiCMode::Oe),
40            "expected" => Ok(HiCMode::Expected),
41            o => Err(Error::invalid(format!(
42                "mode {o} invalid (observed, oe or expected)"
43            ))),
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum Unit {
50    #[default]
51    Bp,
52    Frag,
53}
54
55impl Unit {
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Unit::Bp => "bp",
59            Unit::Frag => "frag",
60        }
61    }
62}
63
64impl std::str::FromStr for Unit {
65    type Err = Error;
66    fn from_str(s: &str) -> Result<Self> {
67        match s.to_ascii_lowercase().as_str() {
68            "bp" => Ok(Unit::Bp),
69            "frag" => Ok(Unit::Frag),
70            o => Err(Error::invalid(format!("unit {o} invalid (bp or frag)"))),
71        }
72    }
73}
74
75/// The vectors a read values its contacts through.
76#[derive(Debug, Clone, Default)]
77pub struct Normalizations {
78    pub x: Arc<Vec<f32>>,
79    pub y: Arc<Vec<f32>>,
80    pub expected: Arc<Vec<f32>>,
81}
82
83#[derive(Debug)]
84struct Inner {
85    source: Arc<dyn ByteSource>,
86    executor: Executor,
87}
88
89pub struct HiCReader {
90    inner: Option<Inner>,
91    path: String,
92    header: HiCHeader,
93    footer: HiCFooter,
94
95    expected_cache: Mutex<HashMap<String, Arc<Vec<f32>>>>,
96    norm_cache: Mutex<HashMap<String, Arc<Vec<f32>>>>,
97    matrix_cache: Mutex<HashMap<String, Arc<MatrixMetadata>>>,
98}
99
100impl std::fmt::Debug for HiCReader {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("HiCReader")
103            .field("path", &self.path)
104            .field("version", &self.header.version)
105            .field("chromosomes", &self.header.chr_map.len())
106            .field("closed", &self.is_closed())
107            .finish()
108    }
109}
110
111impl HiCReader {
112    pub fn open(
113        path: &str,
114        parallel: i64,
115        block_size: Option<u64>,
116        max_blocks: Option<usize>,
117    ) -> Result<Self> {
118        let source = crate::source::open(path, block_size, max_blocks)?;
119        Self::from_source(source, path, parallel)
120    }
121
122    pub(crate) fn from_source(
123        source: Arc<dyn ByteSource>,
124        path: &str,
125        parallel: i64,
126    ) -> Result<Self> {
127        let header = super::header::read_header(source.as_ref())?;
128        let footer = super::header::read_footer(source.as_ref(), &header)?;
129        Ok(Self {
130            inner: Some(Inner {
131                source,
132                executor: Executor::new(parallel)?,
133            }),
134            path: path.to_string(),
135            header,
136            footer,
137            expected_cache: Mutex::new(HashMap::new()),
138            norm_cache: Mutex::new(HashMap::new()),
139            matrix_cache: Mutex::new(HashMap::new()),
140        })
141    }
142
143    pub fn header(&self) -> &HiCHeader {
144        &self.header
145    }
146    pub fn footer(&self) -> &HiCFooter {
147        &self.footer
148    }
149    pub fn chr_sizes(&self) -> &ChrMap {
150        &self.header.chr_map
151    }
152    pub fn normalizations(&self) -> &[String] {
153        &self.footer.normalizations
154    }
155    pub fn units(&self) -> &[String] {
156        &self.footer.units
157    }
158    /// The resolutions the file was built at, for one unit, in file order.
159    ///
160    /// Left in the order the header lists them — largest first, as a hic file
161    /// writes them — rather than sorted, because a caller indexing this by
162    /// position would otherwise get a different resolution than the file's own
163    /// order gives.
164    pub fn bin_sizes(&self, unit: Unit) -> &[i64] {
165        self.header.resolutions(unit)
166    }
167    pub fn path(&self) -> &str {
168        &self.path
169    }
170    pub fn is_closed(&self) -> bool {
171        self.inner.is_none()
172    }
173    pub fn parallel(&self) -> usize {
174        self.inner.as_ref().map_or(0, |i| i.executor.parallel())
175    }
176
177    pub fn close(&mut self) {
178        if let Some(inner) = self.inner.take() {
179            inner.source.close();
180        }
181    }
182
183    fn inner(&self) -> Result<&Inner> {
184        self.inner.as_ref().ok_or_else(|| Error::Closed {
185            path: self.path.clone(),
186        })
187    }
188
189    /// Resolve a request's two windows against the file's own resolutions.
190    ///
191    /// The coordinates go through `Locs` before `parse_loc2d`. The two checks
192    /// are not the same one: `Locs` demands one start and one end *per chromosome id*,
193    /// while `parse_loc2d` accepts one or two of each and duplicates a single
194    /// value across both axes. Without the first, two chromosomes and one start
195    /// read the same window twice instead of being refused.
196    pub fn parse_loc(&self, req: &HiCRequest) -> Result<Loc2D> {
197        let locs = Locs::spans(&req.chr_ids, &req.starts, &req.ends)?;
198        parse_loc2d(
199            &self.header.chr_map,
200            self.header.resolutions(req.unit),
201            &locs.chr_ids,
202            &locs.starts,
203            &locs.ends,
204            req.bin_size,
205            req.bin_count.map(|n| n as i64),
206            req.full_bin,
207        )
208    }
209
210    fn expected_values(
211        &self,
212        chr: i64,
213        normalization: &str,
214        bin_size: i64,
215        unit: Unit,
216    ) -> Result<Arc<Vec<f32>>> {
217        let key = vector_key(normalization, bin_size, unit.as_str(), Some(chr));
218        if let Some(hit) = self.expected_cache.lock().get(&key) {
219            return Ok(hit.clone());
220        }
221        let values = Arc::new(super::header::compute_expected_values(
222            &self.footer,
223            chr,
224            unit.as_str(),
225            bin_size,
226            normalization,
227        )?);
228        self.expected_cache.lock().insert(key, values.clone());
229        Ok(values)
230    }
231
232    fn normalization_vector(
233        &self,
234        inner: &Inner,
235        chr: i64,
236        normalization: &str,
237        bin_size: i64,
238        unit: Unit,
239    ) -> Result<Arc<Vec<f32>>> {
240        let key = vector_key(normalization, bin_size, unit.as_str(), Some(chr));
241        if let Some(hit) = self.norm_cache.lock().get(&key) {
242            return Ok(hit.clone());
243        }
244        let values = Arc::new(super::header::read_normalization_vector(
245            inner.source.as_ref(),
246            &self.footer,
247            self.header.version,
248            chr,
249            unit.as_str(),
250            bin_size,
251            normalization,
252        )?);
253        self.norm_cache.lock().insert(key, values.clone());
254        Ok(values)
255    }
256
257    /// The matrix for one chromosome pair at one resolution, or `None` when the
258    /// file holds no matrix for that **pair** at all.
259    ///
260    /// A miss reads the whole record — every resolution of that pair — and
261    /// caches all of them, since the file stores them together and a second
262    /// resolution of the same pair is the common next request.
263    ///
264    /// The two absences are different answers. A pair the file simply does not
265    /// carry is ordinary — anything against chrM, or a pair below the coverage
266    /// threshold whatever wrote the file used — and comes back as no contacts,
267    /// which is what straw and hictk report and what a caller sweeping every
268    /// pair of a genome needs. A *resolution* the pair does not have is the
269    /// caller asking for something the file cannot serve, and still raises,
270    /// listing what it has. The chromosome names were resolved through `ChrMap`
271    /// long before here, so a typo is already an `UnknownChromosome` and cannot
272    /// arrive as an empty matrix.
273    fn matrix(
274        &self,
275        inner: &Inner,
276        loc: &Loc2D,
277        unit: Unit,
278    ) -> Result<Option<Arc<MatrixMetadata>>> {
279        let (chr1, chr2) = (loc.x.chr.index as i64, loc.y.chr.index as i64);
280        let key = matrix_key(chr1, chr2, loc.bin_size, unit.as_str());
281        if let Some(hit) = self.matrix_cache.lock().get(&key) {
282            return Ok(Some(hit.clone()));
283        }
284
285        let index_key = format!("{chr1}_{chr2}");
286        let Some(item) = self.footer.master_index.get(&index_key).copied() else {
287            return Ok(None);
288        };
289        let matrices =
290            super::matrix::read_matrix_metadata(inner.source.as_ref(), item, chr1, chr2)?;
291
292        let mut available = Vec::new();
293        {
294            let mut cache = self.matrix_cache.lock();
295            for matrix in matrices {
296                available.push(format!("{}{}", matrix.bin_size, matrix.unit));
297                let entry_key = matrix_key(chr1, chr2, matrix.bin_size, &matrix.unit);
298                cache.insert(entry_key, Arc::new(matrix));
299            }
300            if let Some(hit) = cache.get(&key) {
301                return Ok(Some(hit.clone()));
302            }
303        }
304        Err(Error::invalid(format!(
305            "no matrix for {key} (available for this pair: {})",
306            available.join(", ")
307        )))
308    }
309
310    /// Every contact of a window, decoded on the reader's threads.
311    fn records(&self, req: &HiCRequest, loc: &Loc2D) -> Result<Vec<ContactRecord>> {
312        let inner = self.inner()?;
313        let normalization = req.normalization.to_ascii_lowercase();
314        let Some(matrix) = self.matrix(inner, loc, req.unit)? else {
315            // No matrix for this pair: no contacts, and every bin reads as
316            // `def_value`. See `matrix`.
317            return Ok(Vec::new());
318        };
319
320        // Held as `Arc`s rather than cloned out of them, which is the whole
321        // reason the caches hand `Arc`s back: a normalization vector is one
322        // `f32` per bin of a chromosome, and copying two of them per read was
323        // the cost this was built to avoid.
324        let mut vectors = Normalizations::default();
325        if normalization != "none" {
326            vectors.x = self.normalization_vector(
327                inner,
328                loc.x.chr.index as i64,
329                &normalization,
330                loc.bin_size,
331                req.unit,
332            )?;
333            vectors.y = self.normalization_vector(
334                inner,
335                loc.y.chr.index as i64,
336                &normalization,
337                loc.bin_size,
338                req.unit,
339            )?;
340        }
341        if req.mode != HiCMode::Observed && loc.is_intra() {
342            vectors.expected = self.expected_values(
343                loc.x.chr.index as i64,
344                &normalization,
345                loc.bin_size,
346                req.unit,
347            )?;
348        }
349
350        // The mean of an inter-chromosomal matrix, which stands in for the
351        // expected value there. Counted in bins, so a chromosome shorter than
352        // one bin holds none of them and there is no mean to take.
353        let mut average_value = f32::NAN;
354        if !loc.is_intra() {
355            let x_bins = loc.x.chr.size / loc.bin_size;
356            let y_bins = loc.y.chr.size / loc.bin_size;
357            if x_bins > 0 && y_bins > 0 {
358                average_value = matrix.sum_counts / x_bins as f32 / y_bins as f32;
359            }
360        }
361
362        let numbers = block_numbers(
363            loc,
364            &matrix,
365            req.max_distance,
366            self.header.version,
367            req.triangle,
368        )?;
369        let blocks: Vec<_> = numbers
370            .iter()
371            .filter_map(|n| matrix.blocks.get(n).copied())
372            .collect();
373
374        let ctx = RecordContext {
375            loc,
376            normalization: &normalization,
377            mode: req.mode,
378            vectors: &vectors,
379            average_value,
380            min_distance: req.min_distance,
381            max_distance: req.max_distance,
382        };
383        let per_block = inner.executor.map_batches(&blocks, |_, block| {
384            let raw = inner
385                .source
386                .read_exact_at(block.position, block.size.max(0) as usize)?;
387            read_block(raw, self.header.version, *block, &ctx, &self.path)
388        })?;
389        Ok(per_block.into_iter().flatten().collect())
390    }
391
392    /// Dense `(loc1 bins, loc2 bins)`.
393    ///
394    /// A bin holding a contact the file **cannot value** — one the chosen
395    /// normalization has no factor for, or an expected value of zero — comes
396    /// back `NaN`, which is a different answer from `def_value`, meaning "not
397    /// observed at all". This matches straw and hictk.
398    ///
399    /// An out-of-range window pads with `def_value` like bbi; only the block
400    /// enumeration is clamped.
401    pub fn read_values(&self, req: &HiCRequest) -> Result<Array2<f32>> {
402        let loc = self.parse_loc(req)?;
403        let records = self.records(req, &loc)?;
404
405        let rows = (loc.x.bin_end - loc.x.bin_start).max(0) as usize;
406        let cols = (loc.y.bin_end - loc.y.bin_start).max(0) as usize;
407        let mut flat = vec![req.def_value; rows * cols];
408        for record in &records {
409            let r = record.x_bin - loc.x.bin_start;
410            let c = record.y_bin - loc.y.bin_start;
411            if r >= 0 && (r as usize) < rows && c >= 0 && (c as usize) < cols {
412                flat[r as usize * cols + c as usize] = record.value;
413            }
414            // The file stores one side of the diagonal, so the other is filled
415            // from it — unless `triangle`, which asks for the stored side alone.
416            if loc.is_intra() && !req.triangle {
417                let r = record.y_bin - loc.x.bin_start;
418                let c = record.x_bin - loc.y.bin_start;
419                if r >= 0 && (r as usize) < rows && c >= 0 && (c as usize) < cols {
420                    flat[r as usize * cols + c as usize] = record.value;
421                }
422            }
423        }
424
425        let (mut rows, mut cols, mut flat) = if loc.reversed {
426            (cols, rows, transpose(&flat, rows, cols))
427        } else {
428            (rows, cols, flat)
429        };
430
431        if req.exact_bin_count {
432            if let Some(count) = req.bin_count {
433                flat = crate::arrays::bilinear(&flat, (rows, cols), (count, count))?;
434                rows = count;
435                cols = count;
436            }
437        }
438        Array2::from_shape_vec((rows, cols), flat)
439            .map_err(|e| Error::invalid(format!("output shape {rows}x{cols}: {e}")))
440    }
441
442    /// The same, sparse.
443    ///
444    /// No `def_value`: a cell this does not list is one no contact reached,
445    /// which is what a sparse matrix says by leaving it out.
446    pub fn read_sparse_values(&self, req: &HiCRequest) -> Result<CooMatrix> {
447        let loc = self.parse_loc(req)?;
448        let records = self.records(req, &loc)?;
449
450        let rows = (loc.x.bin_end - loc.x.bin_start).max(0) as usize;
451        let cols = (loc.y.bin_end - loc.y.bin_start).max(0) as usize;
452        let mut out = CooMatrix {
453            shape: (rows, cols),
454            ..Default::default()
455        };
456        let push = |r: i64, c: i64, value: f32, out: &mut CooMatrix| {
457            if r >= 0 && (r as usize) < rows && c >= 0 && (c as usize) < cols {
458                out.values.push(value);
459                out.row.push(r as u32);
460                out.col.push(c as u32);
461            }
462        };
463        for record in &records {
464            push(
465                record.x_bin - loc.x.bin_start,
466                record.y_bin - loc.y.bin_start,
467                record.value,
468                &mut out,
469            );
470            // Except on the diagonal itself, where the two are the same cell.
471            // Writing it twice is invisible in the dense matrix, which assigns,
472            // and doubles it in this one, which is a list of entries a reader is
473            // expected to sum.
474            if loc.is_intra() && !req.triangle && record.x_bin != record.y_bin {
475                push(
476                    record.y_bin - loc.x.bin_start,
477                    record.x_bin - loc.y.bin_start,
478                    record.value,
479                    &mut out,
480                );
481            }
482        }
483
484        // By row then column, so the entries come back in a stable order
485        // whatever order the blocks were decoded in.
486        let mut out = sort_row_major(out);
487
488        if loc.reversed {
489            std::mem::swap(&mut out.row, &mut out.col);
490            out.shape = (out.shape.1, out.shape.0);
491            // Swapping the two coordinate arrays leaves them ordered by the
492            // *old* row, which is the new column. Sorting again is what keeps
493            // the row-major promise on a reversed request, and what
494            // `compress_sparse_by_color` needs downstream.
495            out = sort_row_major(out);
496        }
497
498        if req.exact_bin_count {
499            if let Some(count) = req.bin_count {
500                out = crate::arrays::bilinear_sparse(&out, (count, count))?;
501            }
502        }
503        Ok(out)
504    }
505}
506
507/// The entries of a COO matrix ordered by row then column.
508///
509/// Not a stable sort of the arrays in place: the three run in parallel, so the
510/// permutation is worked out once and applied to all three.
511fn sort_row_major(coo: CooMatrix) -> CooMatrix {
512    let mut order: Vec<usize> = (0..coo.values.len()).collect();
513    order.sort_by_key(|i| (coo.row[*i], coo.col[*i]));
514    CooMatrix {
515        values: order.iter().map(|i| coo.values[*i]).collect(),
516        row: order.iter().map(|i| coo.row[*i]).collect(),
517        col: order.iter().map(|i| coo.col[*i]).collect(),
518        shape: coo.shape,
519    }
520}
521
522fn transpose(flat: &[f32], rows: usize, cols: usize) -> Vec<f32> {
523    let mut out = vec![0.0f32; flat.len()];
524    for r in 0..rows {
525        for c in 0..cols {
526            out[c * rows + r] = flat[r * cols + c];
527        }
528    }
529    out
530}
531
532#[derive(Debug, Clone)]
533pub struct HiCRequest {
534    pub chr_ids: Vec<String>,
535    pub starts: Vec<i64>,
536    pub ends: Vec<i64>,
537    /// `None` takes the smallest available. Must be one the file has.
538    pub bin_size: Option<i64>,
539    /// Takes precedence over `bin_size`, selecting the closest bin size that
540    /// gives about this many bins.
541    pub bin_count: Option<usize>,
542    /// Resize the output to match `bin_count` exactly, bilinearly.
543    pub exact_bin_count: bool,
544    pub full_bin: bool,
545    pub def_value: f32,
546    /// On one chromosome a hic file stores one side of the diagonal only.
547    /// `triangle` reads that side alone rather than mirroring it — so a window
548    /// lying entirely on the *other* side comes back empty.
549    pub triangle: bool,
550    pub min_distance: Option<i64>,
551    pub max_distance: Option<i64>,
552    pub normalization: String,
553    pub mode: HiCMode,
554    pub unit: Unit,
555}
556
557impl HiCRequest {
558    pub fn new(chr_ids: Vec<String>, starts: Vec<i64>, ends: Vec<i64>) -> Self {
559        Self {
560            chr_ids,
561            starts,
562            ends,
563            bin_size: None,
564            bin_count: None,
565            exact_bin_count: false,
566            full_bin: false,
567            def_value: 0.0,
568            triangle: false,
569            min_distance: None,
570            max_distance: None,
571            normalization: "none".into(),
572            mode: HiCMode::Observed,
573            unit: Unit::Bp,
574        }
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use std::str::FromStr;
582
583    #[test]
584    fn modes_and_units_parse_and_refuse() {
585        assert_eq!(HiCMode::from_str("oe").unwrap(), HiCMode::Oe);
586        assert_eq!(HiCMode::from_str("OBSERVED").unwrap(), HiCMode::Observed);
587        let err = HiCMode::from_str("median").unwrap_err().to_string();
588        assert!(err.contains("mode median invalid"), "{err}");
589
590        assert_eq!(Unit::from_str("BP").unwrap(), Unit::Bp);
591        assert_eq!(Unit::from_str("frag").unwrap(), Unit::Frag);
592        let err = Unit::from_str("kb").unwrap_err().to_string();
593        assert!(err.contains("unit kb invalid (bp or frag)"), "{err}");
594    }
595
596    #[test]
597    fn transposing_swaps_the_axes() {
598        // 2x3 row-major becomes 3x2.
599        let flat = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
600        assert_eq!(transpose(&flat, 2, 3), [1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
601    }
602}