Skip to main content

gwseq_io/hic/
matrix.rs

1//! Matrix metadata and the two-dimensional locus.
2
3use indexmap::IndexMap;
4
5use crate::bytes::LeCursor;
6use crate::error::{Error, Result};
7use crate::genomic::{ChrEntry, ChrMap};
8use crate::source::ByteSource;
9
10use super::header::HiCIndexItem;
11
12/// One side of a two-dimensional window, resolved against the bin grid.
13#[derive(Debug, Clone)]
14pub struct Side {
15    pub chr: ChrEntry,
16    pub start: i64,
17    pub end: i64,
18    pub binned_start: i64,
19    pub binned_end: i64,
20    pub bin_start: i64,
21    pub bin_end: i64,
22}
23
24/// A request's two windows.
25#[derive(Debug, Clone)]
26pub struct Loc2D {
27    pub x: Side,
28    pub y: Side,
29    pub bin_size: i64,
30    /// The request named its chromosomes the other way round, so the axes were
31    /// swapped to read them — a hic file stores only one of the two — and the
32    /// result is transposed back before it is handed over.
33    pub reversed: bool,
34}
35
36impl Loc2D {
37    #[inline]
38    pub fn is_intra(&self) -> bool {
39        self.x.chr.index == self.y.chr.index
40    }
41
42    /// Distance from the diagonal in base pairs.
43    ///
44    /// On one chromosome that is simply `|y - x|`. Across two it is the distance
45    /// to the line the window's own corners define, which is what makes
46    /// `min_distance`/`max_distance` mean something on an inter-chromosomal
47    /// matrix at all.
48    pub fn distance_from_diagonal(&self, x: i64, y: i64) -> i64 {
49        if self.is_intra() {
50            return (y - x).abs();
51        }
52        let x_span = self.x.binned_end - self.x.binned_start;
53        let y_span = self.y.binned_end - self.y.binned_start;
54        if x_span <= 0 || y_span <= 0 {
55            return 0;
56        }
57        let a = y_span as f64 / x_span as f64;
58        let b = self.y.binned_start as f64 - a * self.x.binned_start as f64;
59        let vertical = (y as f64 - (a * x as f64 + b)).abs();
60        let horizontal = (x as f64 - (y as f64 - b) / a).abs();
61        vertical.min(horizontal).round() as i64
62    }
63}
64
65/// Turn a pair of genomic intervals into the two-dimensional window a hic
66/// matrix is read through.
67///
68/// `bin_count` cannot be honoured exactly: a hic file holds a fixed set of
69/// resolutions, so the nearest one is chosen and the window ends up with however
70/// many bins that gives — unlike the bbi readers, where a locus is rescaled to
71/// the bin count asked for. `exact_bin_count` is what resizes it afterwards.
72// Eight: the map and the file's resolutions, then the request's ids, starts,
73// ends and three binning parameters. A struct would hide which of the three a
74// caller actually set, and that is what this branches on.
75#[allow(clippy::too_many_arguments)]
76pub fn parse_loc2d(
77    map: &ChrMap,
78    available_bin_sizes: &[i64],
79    chr_ids: &[String],
80    starts: &[i64],
81    ends: &[i64],
82    bin_size: Option<i64>,
83    bin_count: Option<i64>,
84    full_bin: bool,
85) -> Result<Loc2D> {
86    // Every path below reads a resolution out of this, and a file carrying none
87    // for the unit asked for — which is every file without fragment-delimited
88    // maps, for unit "frag" — would otherwise be indexed into empty.
89    if available_bin_sizes.is_empty() {
90        return Err(Error::invalid("file has no resolution for this unit"));
91    }
92    if bin_count == Some(0) {
93        return Err(Error::invalid(
94            "bin count must be positive, or negative to disregard it",
95        ));
96    }
97    // A negative bin size means "the finest the file has". Zero means nothing,
98    // and would reach the binning arithmetic below as a division by it.
99    if bin_size == Some(0) {
100        return Err(Error::invalid(
101            "bin size must be positive, or negative to use the finest available",
102        ));
103    }
104
105    let pair = |values: &[i64], what: &str| -> Result<(i64, i64)> {
106        match values {
107            [only] => Ok((*only, *only)),
108            [a, b] => Ok((*a, *b)),
109            _ => Err(Error::invalid(format!("1 or 2 {what} must be specified"))),
110        }
111    };
112    let ids = match chr_ids {
113        [only] => (only.clone(), only.clone()),
114        [a, b] => (a.clone(), b.clone()),
115        _ => return Err(Error::invalid("1 or 2 chromosomes must be specified")),
116    };
117    let (x_start, y_start) = pair(starts, "start positions")?;
118    let (x_end, y_end) = pair(ends, "end positions")?;
119
120    let make = |id: &str, start: i64, end: i64| -> Result<Side> {
121        let chr = map.resolve(id)?.clone();
122        if start > end {
123            return Err(Error::invalid(format!(
124                "window {}:{start}-{end} ends before it starts",
125                chr.id
126            )));
127        }
128        Ok(Side {
129            chr,
130            start,
131            end,
132            binned_start: 0,
133            binned_end: 0,
134            bin_start: 0,
135            bin_end: 0,
136        })
137    };
138    let mut x = make(&ids.0, x_start, x_end)?;
139    let mut y = make(&ids.1, y_start, y_end)?;
140
141    // A hic file stores one side of the diagonal, indexed by the lower
142    // chromosome first, so a request naming them the other way round is read
143    // swapped and transposed back on the way out.
144    let mut reversed = false;
145    if x.chr.index > y.chr.index {
146        std::mem::swap(&mut x, &mut y);
147        reversed = true;
148    }
149
150    let bin_size = match bin_count {
151        Some(count) if count > 0 => {
152            let span = ((x.end - x.start) + (y.end - y.start)) / 2;
153            let wanted = (span + count - 1) / count;
154            *available_bin_sizes
155                .iter()
156                .min_by_key(|available| (*available - wanted).abs())
157                .expect("checked non-empty")
158        }
159        _ => match bin_size {
160            Some(size) if size > 0 => size,
161            _ => *available_bin_sizes.iter().min().expect("checked non-empty"),
162        },
163    };
164
165    for side in [&mut x, &mut y] {
166        side.binned_start = side.start / bin_size * bin_size;
167        side.binned_end = if full_bin {
168            (side.end + bin_size - 1) / bin_size * bin_size
169        } else {
170            side.end / bin_size * bin_size
171        };
172        side.bin_start = side.binned_start / bin_size;
173        side.bin_end = side.binned_end / bin_size;
174    }
175
176    Ok(Loc2D {
177        x,
178        y,
179        bin_size,
180        reversed,
181    })
182}
183
184/// One resolution of one chromosome pair: where its blocks are and how they are
185/// laid out.
186#[derive(Debug, Clone)]
187pub struct MatrixMetadata {
188    pub chr1_index: i64,
189    pub chr2_index: i64,
190    pub unit: String,
191    pub bin_size: i64,
192    pub sum_counts: f32,
193    pub block_bin_count: i64,
194    pub block_column_count: i64,
195    pub blocks: IndexMap<i64, HiCIndexItem>,
196}
197
198/// The key a matrix is cached under.
199pub fn matrix_key(chr1: i64, chr2: i64, bin_size: i64, unit: &str) -> String {
200    format!("chr_index={chr1}_{chr2}|bin_size={bin_size}|unit={unit}")
201}
202
203pub fn read_matrix_metadata(
204    source: &dyn ByteSource,
205    item: HiCIndexItem,
206    chr1_index: i64,
207    chr2_index: i64,
208) -> Result<Vec<MatrixMetadata>> {
209    let path = source.path();
210    // The master index says how long the record is, so it is read in one go
211    // rather than streamed.
212    let buf = source.read_at(item.position, item.size.max(0) as usize)?;
213    let mut c = LeCursor::new(&buf, item.position, path);
214
215    let file_chr1 = c.read_i32()? as i64;
216    let file_chr2 = c.read_i32()? as i64;
217    let bin_size_count = c.read_i32()? as i64;
218    if file_chr1 != chr1_index || file_chr2 != chr2_index {
219        return Err(Error::corrupt(
220            path,
221            item.position,
222            "matrix metadata chr indices mismatch",
223        ));
224    }
225
226    let mut matrices = Vec::with_capacity(bin_size_count.clamp(0, 64) as usize);
227    for _ in 0..bin_size_count.max(0) {
228        let unit = c.take_cstr()?.to_ascii_lowercase();
229        c.skip(4)?; // bin size index in the header
230        let sum_counts = c.read_f32()?;
231        c.skip(4)?; // occupied cell count
232        c.skip(4)?; // 5th percentile estimate
233        c.skip(4)?; // 95th percentile estimate
234        let bin_size = c.read_i32()? as i64;
235        let block_bin_count = c.read_i32()? as i64;
236        let block_column_count = c.read_i32()? as i64;
237        let block_count = c.read_i32()? as i64;
238
239        let mut blocks = IndexMap::with_capacity(block_count.clamp(0, 1 << 16) as usize);
240        for _ in 0..block_count.max(0) {
241            let number = c.read_i32()? as i64;
242            let position = c.read_u64()?;
243            let size = c.read_i32()? as i64;
244            blocks.insert(number, HiCIndexItem { position, size });
245        }
246        matrices.push(MatrixMetadata {
247            chr1_index: file_chr1,
248            chr2_index: file_chr2,
249            unit,
250            bin_size,
251            sum_counts,
252            block_bin_count,
253            block_column_count,
254            blocks,
255        });
256    }
257    Ok(matrices)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn map() -> ChrMap {
265        ChrMap::from_indexed_entries([
266            ("chr1".to_string(), 1_000_000, 0),
267            ("chr2".to_string(), 500_000, 1),
268        ])
269    }
270
271    fn loc(chr_ids: &[&str], starts: &[i64], ends: &[i64], bin: Option<i64>) -> Result<Loc2D> {
272        let ids: Vec<String> = chr_ids.iter().map(|s| s.to_string()).collect();
273        parse_loc2d(
274            &map(),
275            &[5000, 10000, 25000],
276            &ids,
277            starts,
278            ends,
279            bin,
280            None,
281            false,
282        )
283    }
284
285    #[test]
286    fn one_chromosome_is_used_for_both_axes() {
287        let l = loc(&["chr1"], &[0], &[100_000], Some(5000)).unwrap();
288        assert_eq!(l.x.chr.id, "chr1");
289        assert_eq!(l.y.chr.id, "chr1");
290        assert!(l.is_intra());
291        assert_eq!((l.x.bin_start, l.x.bin_end), (0, 20));
292    }
293
294    #[test]
295    fn the_axes_swap_when_the_second_chromosome_sorts_first() {
296        let l = loc(&["chr2", "chr1"], &[0, 0], &[100_000, 100_000], Some(5000)).unwrap();
297        assert!(l.reversed, "the request named them the other way round");
298        assert_eq!(l.x.chr.id, "chr1", "the lower chromosome is read first");
299        assert_eq!(l.y.chr.id, "chr2");
300
301        // Named in file order, nothing is swapped.
302        let l = loc(&["chr1", "chr2"], &[0, 0], &[100_000, 100_000], Some(5000)).unwrap();
303        assert!(!l.reversed);
304    }
305
306    #[test]
307    fn a_negative_bin_size_takes_the_finest_resolution() {
308        assert_eq!(
309            loc(&["chr1"], &[0], &[100_000], Some(-1)).unwrap().bin_size,
310            5000
311        );
312        assert_eq!(
313            loc(&["chr1"], &[0], &[100_000], None).unwrap().bin_size,
314            5000
315        );
316    }
317
318    #[test]
319    fn a_bin_count_picks_the_nearest_available_resolution() {
320        let ids = ["chr1".to_string()];
321        // 100 kb over 10 bins wants 10 000, which the file has exactly.
322        let l = parse_loc2d(
323            &map(),
324            &[5000, 10000, 25000],
325            &ids,
326            &[0],
327            &[100_000],
328            None,
329            Some(10),
330            false,
331        )
332        .unwrap();
333        assert_eq!(l.bin_size, 10000);
334        // 100 kb over 4 bins wants 25 000.
335        let l = parse_loc2d(
336            &map(),
337            &[5000, 10000, 25000],
338            &ids,
339            &[0],
340            &[100_000],
341            None,
342            Some(4),
343            false,
344        )
345        .unwrap();
346        assert_eq!(l.bin_size, 25000);
347        // Nothing exact: 100 kb over 7 bins wants ~14 286, nearest is 10 000.
348        let l = parse_loc2d(
349            &map(),
350            &[5000, 10000, 25000],
351            &ids,
352            &[0],
353            &[100_000],
354            None,
355            Some(7),
356            false,
357        )
358        .unwrap();
359        assert_eq!(l.bin_size, 10000);
360    }
361
362    #[test]
363    fn full_bin_rounds_the_end_up_instead_of_down() {
364        let down = loc(&["chr1"], &[0], &[12_000], Some(5000)).unwrap();
365        assert_eq!(down.x.bin_end, 2); // 12 000 floors to bin 2
366        let ids = ["chr1".to_string()];
367        let up = parse_loc2d(
368            &map(),
369            &[5000],
370            &ids,
371            &[0],
372            &[12_000],
373            Some(5000),
374            None,
375            true,
376        )
377        .unwrap();
378        assert_eq!(up.x.bin_end, 3);
379    }
380
381    #[test]
382    fn the_refusals_say_what_was_wrong() {
383        let ids = ["chr1".to_string()];
384        let err = parse_loc2d(&map(), &[], &ids, &[0], &[10], None, None, false)
385            .unwrap_err()
386            .to_string();
387        assert!(err.contains("no resolution for this unit"), "{err}");
388
389        let err = parse_loc2d(&map(), &[5000], &ids, &[0], &[10], Some(0), None, false)
390            .unwrap_err()
391            .to_string();
392        assert!(err.contains("bin size must be positive"), "{err}");
393
394        let err = parse_loc2d(&map(), &[5000], &ids, &[0], &[10], None, Some(0), false)
395            .unwrap_err()
396            .to_string();
397        assert!(err.contains("bin count must be positive"), "{err}");
398
399        let err = loc(&["chr1"], &[100], &[10], Some(5000))
400            .unwrap_err()
401            .to_string();
402        assert!(err.contains("ends before it starts"), "{err}");
403
404        let three = ["chr1".to_string(), "chr2".to_string(), "chr1".to_string()];
405        let err = parse_loc2d(&map(), &[5000], &three, &[0], &[10], None, None, false)
406            .unwrap_err()
407            .to_string();
408        assert!(err.contains("1 or 2 chromosomes"), "{err}");
409    }
410
411    #[test]
412    fn distance_from_the_diagonal_is_the_offset_on_one_chromosome() {
413        let l = loc(&["chr1"], &[0], &[100_000], Some(5000)).unwrap();
414        assert_eq!(l.distance_from_diagonal(10_000, 10_000), 0);
415        assert_eq!(l.distance_from_diagonal(10_000, 35_000), 25_000);
416        assert_eq!(l.distance_from_diagonal(35_000, 10_000), 25_000);
417    }
418
419    #[test]
420    fn across_two_chromosomes_it_is_measured_against_the_windows_own_line() {
421        let l = loc(&["chr1", "chr2"], &[0, 0], &[100_000, 100_000], Some(5000)).unwrap();
422        assert!(!l.is_intra());
423        // The window is square, so its "diagonal" is y = x.
424        assert_eq!(l.distance_from_diagonal(20_000, 20_000), 0);
425        assert!(l.distance_from_diagonal(20_000, 60_000) > 0);
426    }
427
428    #[test]
429    fn the_matrix_key_is_built_the_same_way_both_ways() {
430        assert_eq!(
431            matrix_key(0, 1, 5000, "bp"),
432            "chr_index=0_1|bin_size=5000|unit=bp"
433        );
434    }
435}