bed.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. //! BED file I/O, overlap queries, and genomic region annotation.
  2. //!
  3. //! All coordinates follow **BED convention: 0-based, half-open `[start, end)`**.
  4. //! This matches [`GenomeRange`]'s internal `Range<u32>` representation, so BED
  5. //! values are stored as-is without conversion.
  6. //!
  7. //! # Main types and functions
  8. //!
  9. //! | Item | Purpose |
  10. //! |------|---------|
  11. //! | [`BedRow`] | One parsed BED line (up to BED6) |
  12. //! | [`read_bed`] | Load a BED file into memory, skipping headers and blank lines |
  13. //! | [`fetch_bed`] | Random-access region fetch from a Tabix-indexed `.bed.gz` |
  14. //! | [`annotate_with_bed`] | Annotate a [`Variants`] collection from a BED region set |
  15. //! | [`bedrow_overlaps_par`] | Parallel overlap query: which BED rows hit a set of query ranges |
  16. //! | [`GenesBedIndex`] | Per-contig indexed structure for fast gene-name lookup |
  17. //! | [`convert_bgz_with_tabix`] | Compress a BED file to BGZF + Tabix index via [`BgzTabixWriter`] |
  18. //! | [`parse_centromere_intervals`] | Extract centromeric intervals from a UCSC cytoband file |
  19. use std::{
  20. io::{BufRead, BufReader},
  21. path::Path,
  22. str::FromStr,
  23. sync::Arc,
  24. };
  25. use anyhow::Context;
  26. use log::warn;
  27. use rayon::prelude::*;
  28. use crate::{
  29. annotation::Annotation,
  30. io::{readers::TabixReader, writers::{BgzTabixWriter, IndexFormat}},
  31. positions::{
  32. GenomePosition, GenomeRange, GetGenomeRange, extract_contig_indices, find_contig_indices, overlaps_par
  33. },
  34. variant::variant_collection::Variants,
  35. };
  36. use super::readers::get_reader;
  37. /// One parsed row from a BED file (up to BED6).
  38. ///
  39. /// Coordinates in `range` are **0-based, half-open** as stored in the BED file.
  40. /// Optional fields (`name`, `score`, `strand`) are `None` when the column is absent
  41. /// or unparseable.
  42. #[derive(Debug, Clone)]
  43. pub struct BedRow {
  44. /// Genomic interval — 0-based half-open `[start, end)`
  45. pub range: GenomeRange,
  46. /// BED column 4: feature name
  47. pub name: Option<String>,
  48. /// BED column 5: score (0–1000 per spec, stored as `u16`)
  49. pub score: Option<u16>,
  50. /// BED column 6: strand — `true` = `+`, `false` = `-`
  51. pub strand: Option<bool>,
  52. }
  53. /// Parse a tab-separated BED line into a [`BedRow`].
  54. ///
  55. /// Expects at least 3 tab-separated columns: `chrom`, `start`, `end`.
  56. /// Columns 4–6 (`name`, `score`, `strand`) are optional; missing or unparseable
  57. /// values are stored as `None`. Coordinates are stored as-is (0-based half-open).
  58. ///
  59. /// # Errors
  60. ///
  61. /// Returns an error if fewer than 3 columns are present or if `start`/`end`
  62. /// cannot be parsed as `u32`.
  63. impl FromStr for BedRow {
  64. type Err = anyhow::Error;
  65. fn from_str(s: &str) -> anyhow::Result<Self> {
  66. let v: Vec<&str> = s.split('\t').collect();
  67. let range: GenomeRange = (
  68. *v.first()
  69. .ok_or(anyhow::anyhow!("Can't get contig from: {s}"))?,
  70. *v.get(1)
  71. .ok_or(anyhow::anyhow!("Can't get position from: {s}"))?,
  72. *v.get(2)
  73. .ok_or(anyhow::anyhow!("Can't get position from: {s}"))?,
  74. )
  75. .try_into()
  76. .context(format!("Can't parse range from: {s}"))?;
  77. Ok(Self {
  78. range,
  79. name: v.get(3).map(|v| v.to_string()),
  80. score: v.get(4).and_then(|v| v.parse().ok()),
  81. strand: v.get(5).and_then(|&v| match v {
  82. "+" => Some(true),
  83. "-" => Some(false),
  84. _ => None,
  85. }),
  86. })
  87. }
  88. }
  89. impl GetGenomeRange for BedRow {
  90. fn range(&self) -> &GenomeRange {
  91. &self.range
  92. }
  93. }
  94. fn is_bed_metadata_line(line: &str) -> bool {
  95. let line = line.trim_start();
  96. line.is_empty()
  97. || line.starts_with('#')
  98. || line.starts_with("track")
  99. || line.starts_with("browser")
  100. }
  101. fn bed_row_overlaps_region(row: &BedRow, region: &GenomeRange) -> bool {
  102. if row.range.contig != region.contig {
  103. return false;
  104. }
  105. let row_start = row.range.range.start;
  106. let row_end = row.range.range.end;
  107. if row_start == row_end {
  108. region.range.contains(&row_start)
  109. } else {
  110. row_end > region.range.start && row_start < region.range.end
  111. }
  112. }
  113. /// Load a BED file into memory as a vector of [`BedRow`]s.
  114. ///
  115. /// Skips blank lines and lines starting with `#`, `track`, or `browser`
  116. /// (standard UCSC header conventions). All other lines are parsed via
  117. /// [`BedRow::from_str`].
  118. ///
  119. /// # Arguments
  120. ///
  121. /// * `path` - Path to a BED file (plain text or gzip-compressed)
  122. ///
  123. /// # Returns
  124. ///
  125. /// A vector of parsed rows in file order.
  126. ///
  127. /// # Errors
  128. ///
  129. /// Returns an error if the file cannot be opened or if any data line fails to parse.
  130. /// I/O errors on individual lines are logged as warnings and skipped.
  131. pub fn read_bed(path: &str) -> anyhow::Result<Vec<BedRow>> {
  132. let reader = BufReader::new(get_reader(path)?);
  133. let mut res = Vec::new();
  134. for (i, line) in reader.lines().enumerate() {
  135. let line_no = i + 1;
  136. match line {
  137. Ok(line) => {
  138. if is_bed_metadata_line(&line) {
  139. continue;
  140. }
  141. res.push(line.parse().with_context(|| {
  142. format!("failed parsing BED record at {path}:{line_no}: {line}")
  143. })?);
  144. }
  145. Err(e) => warn!("Can't read {path}:{line_no}: {e}"),
  146. }
  147. }
  148. Ok(res)
  149. }
  150. /// Annotate variants that overlap a BED region set and return coverage statistics.
  151. ///
  152. /// Loads the BED file, finds all variants whose position overlaps any BED region
  153. /// (via [`overlaps_par`]), pushes `annotation` onto each matching variant, and
  154. /// returns the total covered base-pair count alongside the overlap count.
  155. ///
  156. /// # Arguments
  157. ///
  158. /// * `variants` - Variant collection to annotate in place
  159. /// * `bed_path` - Path to the BED file defining the region class
  160. /// * `annotation` - Annotation tag to attach to each overlapping variant
  161. ///
  162. /// # Returns
  163. ///
  164. /// `(total_bp, overlap_count)` where `total_bp` is the sum of all BED interval
  165. /// lengths and `overlap_count` is the number of variants that received the annotation.
  166. ///
  167. /// # Errors
  168. ///
  169. /// Returns an error if the BED file cannot be read.
  170. pub fn annotate_with_bed(
  171. variants: &mut Variants,
  172. bed_path: &str,
  173. annotation: Annotation,
  174. ) -> anyhow::Result<(usize, usize)> {
  175. let bed_rows = read_bed(bed_path)?;
  176. let ranges: Vec<&GenomeRange> = bed_rows.iter().map(|b| &b.range).collect();
  177. let total_bp: usize = ranges.iter().map(|r| r.length() as usize).sum();
  178. let positions: Vec<&GenomePosition> = variants.data.iter().map(|v| &v.position).collect();
  179. let overlaps = overlaps_par(&positions, &ranges);
  180. for &idx in &overlaps {
  181. variants.data[idx].annotations.push(annotation.clone());
  182. }
  183. Ok((total_bp, overlaps.len()))
  184. }
  185. /// Return every BED row that overlaps at least one query range, parallelised per contig.
  186. ///
  187. /// Both inputs must be sorted by `(contig, start)`. Each matching row appears exactly
  188. /// once in the output regardless of how many queries it overlaps.
  189. ///
  190. /// Uses an anchored-scan algorithm (O(n + m)) that is correct even when rows or
  191. /// queries overlap each other (e.g. nested genes). The anchor `j0` advances only
  192. /// when a query definitively ends before the current row starts, so no hit can be
  193. /// missed.
  194. ///
  195. /// # Arguments
  196. ///
  197. /// * `rows` - BED rows sorted by `(contig, start)`
  198. /// * `queries` - Query ranges sorted by `(contig, start)`
  199. ///
  200. /// # Returns
  201. ///
  202. /// Matching BED rows in per-contig order. Cross-contig order is unspecified (parallel
  203. /// execution).
  204. pub fn bedrow_overlaps_par(rows: &[BedRow], queries: &[&GenomeRange]) -> Vec<BedRow>
  205. where
  206. BedRow: Clone + Send + Sync,
  207. {
  208. let row_ranges: Vec<&GenomeRange> = rows.iter().map(|r| &r.range).collect();
  209. let (row_contigs, query_contigs) = rayon::join(
  210. || extract_contig_indices(&row_ranges),
  211. || extract_contig_indices(queries),
  212. );
  213. row_contigs
  214. .into_par_iter()
  215. .filter_map(|(contig, r_start, r_end)| {
  216. let (q_start, q_end) = find_contig_indices(&query_contigs, contig)?;
  217. let r_slice = &rows[r_start..r_end];
  218. let q_slice = &queries[q_start..q_end];
  219. let mut hits = Vec::new();
  220. let mut j0 = 0usize;
  221. for row in r_slice {
  222. let r = row.range();
  223. while j0 < q_slice.len() && q_slice[j0].range.end <= r.range.start {
  224. j0 += 1;
  225. }
  226. if j0 < q_slice.len() && q_slice[j0].range.start < r.range.end {
  227. hits.push(row.clone());
  228. }
  229. }
  230. Some(hits)
  231. })
  232. .flatten()
  233. .collect()
  234. }
  235. /// Per-contig indexed BED structure for fast gene-name lookup.
  236. ///
  237. /// Rows are sorted by `(contig, start, end)` at construction time and stored in
  238. /// 256 contig slots (one per encoded `u8` contig). Lookups are O(rows before `end`)
  239. /// with early termination — correct even for very long host genes.
  240. #[derive(Clone)]
  241. pub struct GenesBedIndex {
  242. by_contig: Vec<Arc<[BedRow]>>,
  243. }
  244. impl GenesBedIndex {
  245. /// Build a [`GenesBedIndex`] from an unsorted vector of [`BedRow`]s.
  246. ///
  247. /// Rows are sorted by `(contig, start, end)` internally.
  248. pub fn new(mut rows: Vec<BedRow>) -> Self {
  249. rows.sort_unstable_by(|a, b| {
  250. a.range
  251. .contig
  252. .cmp(&b.range.contig)
  253. .then_with(|| a.range.range.start.cmp(&b.range.range.start))
  254. .then_with(|| a.range.range.end.cmp(&b.range.range.end))
  255. });
  256. let mut tmp: Vec<Vec<BedRow>> = vec![Vec::new(); 256];
  257. for r in rows {
  258. tmp[r.range.contig as usize].push(r);
  259. }
  260. Self {
  261. by_contig: tmp.into_iter().map(Arc::<[BedRow]>::from).collect(),
  262. }
  263. }
  264. /// Return the names of all genes overlapping `[start, end)` on `contig`.
  265. ///
  266. /// Coordinates are 0-based half-open. If `start > end` the arguments are
  267. /// swapped defensively. Genes without a name field are silently skipped.
  268. ///
  269. /// # Arguments
  270. ///
  271. /// * `contig` - Encoded contig index (see `contig_to_num`)
  272. /// * `start` - Query start, 0-based inclusive
  273. /// * `end` - Query end, 0-based exclusive
  274. ///
  275. /// # Returns
  276. ///
  277. /// Names of overlapping genes in start-sorted order.
  278. pub fn query_genes(&self, contig: u8, start: u32, end: u32) -> Vec<String> {
  279. let (s, e) = if start <= end {
  280. (start, end)
  281. } else {
  282. (end, start)
  283. };
  284. let rows = &self.by_contig[contig as usize];
  285. rows.iter()
  286. .take_while(|r| r.range.range.start < e)
  287. .filter(|r| r.range.range.end > s)
  288. .filter_map(|r| r.name.clone())
  289. .collect()
  290. }
  291. }
  292. /// Compress a BED file to BGZF and build a Tabix index (`.tbi`) in a single pass.
  293. ///
  294. /// Delegates all BGZF and Tabix mechanics to [`BgzTabixWriter`]. BED-specific work
  295. /// here is limited to column parsing and coordinate conversion.
  296. ///
  297. /// Output files are `<input>.gz` and `<input>.gz.tbi`. Both are written atomically.
  298. /// The input must be tab-delimited, contig-grouped, and coordinate-sorted.
  299. ///
  300. /// # Coordinate conversion
  301. ///
  302. /// BED coordinates are **0-based half-open `[start, end)`**. [`BgzTabixWriter::write_bed_record`]
  303. /// converts them to 1-based Tabix positions automatically:
  304. /// - `tabix_start = bed_start + 1`
  305. /// - `tabix_end = bed_end` (0-based exclusive = 1-based inclusive numerically)
  306. ///
  307. /// # Arguments
  308. ///
  309. /// * `input` - Path to the input BED file (plain text or BGZF)
  310. /// * `force` - Overwrite existing output files if present
  311. ///
  312. /// # Errors
  313. ///
  314. /// Returns an error if the input cannot be read, any data line is malformed, or
  315. /// the output cannot be written.
  316. pub fn convert_bgz_with_tabix(input: impl AsRef<Path>, force: bool) -> anyhow::Result<()> {
  317. let input = input.as_ref();
  318. let out_bgz = format!("{}.gz", input.display());
  319. let reader = get_reader(&input.to_string_lossy())?;
  320. let mut reader = BufReader::new(reader);
  321. let mut writer = BgzTabixWriter::new(&out_bgz, IndexFormat::Tbi, force)?;
  322. let mut line = String::new();
  323. loop {
  324. line.clear();
  325. if reader.read_line(&mut line)? == 0 {
  326. break;
  327. }
  328. if is_bed_metadata_line(&line) {
  329. writer.write_header(line.as_bytes())?;
  330. continue;
  331. }
  332. let mut it = line.split('\t');
  333. let rname = it.next().context("BED: missing contig")?;
  334. let start0: u32 = it
  335. .next()
  336. .context("BED: missing start")?
  337. .parse()
  338. .context("BED: invalid start")?;
  339. let end0: u32 = it
  340. .next()
  341. .context("BED: missing end")?
  342. .trim()
  343. .parse()
  344. .context("BED: invalid end")?;
  345. writer.write_bed_record(line.as_bytes(), rname, start0, end0)?;
  346. }
  347. writer.finish()
  348. }
  349. /// Fetch BED rows overlapping `region` from a Tabix-indexed BGZF file.
  350. ///
  351. /// Delegates the BGZF seek and line extraction to [`fetch_tabix_lines`], then
  352. /// parses each returned line as a [`BedRow`] and applies a secondary overlap
  353. /// filter to discard any false positives from the Tabix bin query.
  354. ///
  355. /// # Coordinate system
  356. ///
  357. /// `region` is **0-based half-open** `[start, end)`. The secondary filter uses
  358. /// the same convention: a row `[rs, re)` is kept iff `rs < region.end && re > region.start`.
  359. ///
  360. /// # Arguments
  361. ///
  362. /// * `bgz_path` - Path to the `.bed.gz` file; index expected at `<bgz_path>.tbi`
  363. /// * `region` - Genomic region to query (0-based half-open)
  364. ///
  365. /// # Returns
  366. ///
  367. /// Parsed [`BedRow`]s that genuinely overlap `region`, in file order.
  368. ///
  369. /// # Errors
  370. ///
  371. /// Returns an error if the index or file cannot be read, or if a fetched line
  372. /// fails to parse as a BED row.
  373. pub fn fetch_bed_with_reader<R: std::io::Read + std::io::Seek>(
  374. reader: &mut TabixReader<R>,
  375. region: &GenomeRange,
  376. ) -> anyhow::Result<Vec<BedRow>> {
  377. let mut rows = Vec::new();
  378. reader.fetch_with(region, |line| {
  379. let row: BedRow = line
  380. .parse()
  381. .with_context(|| format!("failed to parse BED line: {line}"))?;
  382. // Secondary overlap filter: discard tabix-bin false positives.
  383. if bed_row_overlaps_region(&row, region) {
  384. rows.push(row);
  385. }
  386. Ok(())
  387. })?;
  388. Ok(rows)
  389. }
  390. /// Parse centromeric intervals from a UCSC cytoband BED file.
  391. ///
  392. /// Identifies rows whose stain field (column 5) equals `acen` and returns their
  393. /// coordinates as `(chrom, start, end)`. Coordinates are 0-based half-open as
  394. /// stored in the file.
  395. ///
  396. /// Expected column layout: `chrom\tstart\tend\tband_name\tstain`
  397. ///
  398. /// # Arguments
  399. ///
  400. /// * `path` - Path to the cytoband BED file
  401. ///
  402. /// # Returns
  403. ///
  404. /// A vector of `(chrom, start, end)` tuples for all `acen` bands, in file order.
  405. ///
  406. /// # Errors
  407. ///
  408. /// Returns an error if the file cannot be opened, a line has fewer than 5 columns,
  409. /// or a coordinate field cannot be parsed as `u64`.
  410. pub fn parse_centromere_intervals(path: &str) -> anyhow::Result<Vec<(String, u64, u64)>> {
  411. let reader = BufReader::new(
  412. get_reader(path).with_context(|| format!("Cannot open cytobands BED: {path}"))?,
  413. );
  414. let mut intervals = Vec::new();
  415. for (i, line) in reader.lines().enumerate() {
  416. let line_no = i + 1;
  417. let line = line.with_context(|| format!("failed reading {path}:{line_no}"))?;
  418. if is_bed_metadata_line(&line) {
  419. continue;
  420. }
  421. let f: Vec<&str> = line.split('\t').collect();
  422. anyhow::ensure!(
  423. f.len() >= 5,
  424. "Expected 5 columns in cytoband BED at {path}:{line_no}, got {}: {line}",
  425. f.len()
  426. );
  427. if f[4].trim() == "acen" {
  428. intervals.push((
  429. f[0].to_string(),
  430. f[1].parse::<u64>()
  431. .with_context(|| format!("bad cytoband start at {path}:{line_no}: {}", f[1]))?,
  432. f[2].parse::<u64>()
  433. .with_context(|| format!("bad cytoband end at {path}:{line_no}: {}", f[2]))?,
  434. ));
  435. }
  436. }
  437. Ok(intervals)
  438. }
  439. #[cfg(test)]
  440. mod tests {
  441. use super::*;
  442. use crate::helpers::test_init;
  443. #[test]
  444. fn bed_to_bgz() -> anyhow::Result<()> {
  445. test_init();
  446. let a = "/mnt/beegfs02/scratch/t_steimle/data/wgs/CHAHA/norm/CHAHA_norm_modkit_pileup.bed";
  447. convert_bgz_with_tabix(a, true)?;
  448. Ok(())
  449. }
  450. #[test]
  451. fn zero_width_bed_row_overlaps_containing_base_only() {
  452. let row: BedRow = "chr1\t10\t10\tins".parse().unwrap();
  453. assert!(!bed_row_overlaps_region(
  454. &row,
  455. &GenomeRange::new("chr1", 9, 10)
  456. ));
  457. assert!(bed_row_overlaps_region(
  458. &row,
  459. &GenomeRange::new("chr1", 10, 11)
  460. ));
  461. assert!(!bed_row_overlaps_region(
  462. &row,
  463. &GenomeRange::new("chr1", 11, 12)
  464. ));
  465. assert!(!bed_row_overlaps_region(
  466. &row,
  467. &GenomeRange::new("chr2", 10, 11)
  468. ));
  469. }
  470. }