use anyhow::{Context, Result};
use noodles::fasta;
use noodles::sam::alignment::record::cigar::op::Kind;
use std::f64;
use std::io::Write;
use std::path::Path;
use noodles::core::{Position, Region, region::Interval};
use crate::bed::BedReader;
use crate::cigar::CigarOps;
pub fn calculate_qscore(qstring: &str) -> f64 {
let qs: Vec<f64> = qstring.chars().map(|c| (c as u8) as f64 - 33.0).collect();
let mean_err: f64 = qs
.iter()
.map(|&q| (-q * f64::consts::LN_10 / 10.0).exp())
.sum::<f64>()
/ qs.len() as f64;
let score: f64 = -10.0 * (mean_err.max(1e-4)).log10();
score
}
#[derive(Debug, Clone)]
pub struct ReadCuts {
pub read_start: usize,
pub read_end: usize,
pub ref_start: usize,
pub ref_end: usize,
pub softclip_lead_start: usize,
pub softclip_trail_end: usize,
}
pub fn get_read_cuts(
cigar_ops: &CigarOps,
align_start: usize,
align_end: usize,
region_start: usize,
region_end: usize,
) -> ReadCuts {
let mut start: usize = 0; let mut found_start: bool = false; let mut end: usize = 0;
let mut r_start: usize = 0;
let mut r_end: usize = 0;
let mut pos: usize = 0;
let mut ref_pos: usize = align_start;
let pad_ref_start = region_start;
let pad_ref_end = region_end;
let ref_start = if align_start <= pad_ref_start {
pad_ref_start
} else {
align_start
};
let ref_end = if align_end >= pad_ref_end {
pad_ref_end
} else {
align_end
};
for op in cigar_ops {
match op.kind {
Kind::Match | Kind::SequenceMatch | Kind::SequenceMismatch => {
if !found_start && ref_pos == ref_start {
start = pos;
r_start = ref_pos;
found_start = true;
}
if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) {
if !found_start && ref_pos + op.len == ref_start {
ref_pos += op.len;
pos += op.len;
start = pos;
r_start = ref_pos;
found_start = true;
} else if found_start && ref_pos + op.len == ref_end {
ref_pos += op.len;
pos += op.len;
end = pos;
r_end = ref_pos;
break;
} else {
for _ in 0..op.len {
ref_pos += 1;
pos += 1;
if (ref_pos == ref_start) || (ref_pos == ref_end) {
if found_start {
end = pos;
r_end = ref_pos;
break;
} else {
start = pos;
r_start = ref_pos;
found_start = true;
}
}
}
}
} else {
ref_pos += op.len;
pos += op.len;
}
}
Kind::Insertion | Kind::SoftClip => {
pos += op.len;
if !found_start && ref_pos == ref_start {
start = pos;
r_start = ref_pos;
found_start = true;
}
}
Kind::Deletion | Kind::Skip => {
if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) {
if !found_start && ref_pos + op.len == ref_start {
ref_pos += op.len;
start = pos;
r_start = ref_pos;
found_start = true;
} else if found_start && ref_pos + op.len == ref_end {
ref_pos += op.len;
end = pos;
r_end = ref_pos;
break;
} else {
for _ in 0..op.len {
ref_pos += 1;
if (ref_pos == ref_start) || (ref_pos == ref_end) {
if found_start {
end = pos;
r_end = ref_pos;
break;
} else {
start = pos;
r_start = ref_pos;
found_start = true;
}
}
}
}
} else {
ref_pos += op.len;
}
}
Kind::HardClip | Kind::Pad => {
continue;
}
}
}
let softclip_lead_start = if align_start > pad_ref_start && found_start {
match cigar_ops.first() {
Some(op) if op.kind == Kind::SoftClip => start.saturating_sub(op.len),
_ => start,
}
} else {
start
};
let softclip_trail_end = if end > 0 && r_end < pad_ref_end {
match cigar_ops.last() {
Some(op) if op.kind == Kind::SoftClip => end + op.len,
_ => end,
}
} else {
end
};
ReadCuts {
read_start: start,
read_end: end,
ref_start: r_start,
ref_end: r_end,
softclip_lead_start,
softclip_trail_end,
}
}
pub fn read_pos_at_ref(
cigar_ops: &CigarOps,
align_start: usize,
ref_target: usize,
) -> Option<usize> {
if ref_target <= align_start {
return Some(0);
}
let mut pos: usize = 0;
let mut ref_pos: usize = align_start;
for op in cigar_ops {
match op.kind {
Kind::Match | Kind::SequenceMatch | Kind::SequenceMismatch => {
if ref_pos + op.len >= ref_target {
return Some(pos + (ref_target - ref_pos));
}
ref_pos += op.len;
pos += op.len;
}
Kind::Insertion | Kind::SoftClip => {
pos += op.len;
}
Kind::Deletion | Kind::Skip => {
if ref_pos + op.len >= ref_target {
return Some(pos);
}
ref_pos += op.len;
}
Kind::HardClip | Kind::Pad => continue,
}
}
None
}
pub fn read_bed(path: &Path, debug: bool) -> Result<Vec<(Region, String, String)>> {
let mut regions: Vec<(Region, String, String)> = vec![];
let reader = BedReader::from_path(path).context("failed to open BED file")?;
for record in reader {
match record {
Ok(record) => {
if debug {
eprintln!("{:?}", record);
}
let chr: String = record.chrom.clone();
let start =
Position::try_from(record.start + 1).context("invalid BED start coordinate")?;
let end =
Position::try_from(record.end + 1).context("invalid BED end coordinate")?;
let interval: Interval = Interval::from(start..=end);
if debug {
eprintln!("region: {:?}", Region::new(record.chrom.clone(), interval));
}
let name = record
.name
.unwrap_or_else(|| format!("{}:{}-{}", record.chrom, record.start, record.end));
regions.push((Region::new(record.chrom, interval), name, chr));
}
Err(e) => eprintln!("Error: {}", e),
}
}
Ok(regions)
}
pub fn write_fasta_record<W: Write + ?Sized>(
writer: &mut W,
header: &str,
sequence: &str,
) -> Result<()> {
writeln!(writer, ">{}", header)?;
writeln!(writer, "{}", sequence)?;
Ok(())
}
pub fn write_fastq_record<W: Write + ?Sized>(
writer: &mut W,
header: &str,
sequence: &str,
quality: &str,
) -> Result<()> {
writeln!(writer, "@{}", header)?;
writeln!(writer, "{}", sequence)?;
writeln!(writer, "+")?;
writeln!(writer, "{}", quality)?;
Ok(())
}
fn complement_base(b: u8) -> u8 {
match b {
b'A' => b'T',
b'a' => b't',
b'T' => b'A',
b't' => b'a',
b'G' => b'C',
b'g' => b'c',
b'C' => b'G',
b'c' => b'g',
b'N' => b'N',
b'n' => b'n',
b'R' => b'Y',
b'r' => b'y',
b'Y' => b'R',
b'y' => b'r',
b'S' => b'S',
b's' => b's',
b'W' => b'W',
b'w' => b'w',
b'K' => b'M',
b'k' => b'm',
b'M' => b'K',
b'm' => b'k',
b'B' => b'V',
b'b' => b'v',
b'V' => b'B',
b'v' => b'b',
b'D' => b'H',
b'd' => b'h',
b'H' => b'D',
b'h' => b'd',
_ => b'N',
}
}
pub fn revcomp(seq: &str) -> String {
seq.bytes()
.rev()
.map(|b| complement_base(b) as char)
.collect()
}
pub fn extract_from_fasta_coords(
fasta_path: &str,
chrom: &str,
start: usize,
end: usize,
) -> Result<String> {
let mut reader = fasta::io::indexed_reader::Builder::default()
.build_from_path(fasta_path)
.context("failed to open FASTA file")?;
extract_from_fasta_coords_reader(&mut reader, chrom, start, end)
}
pub fn extract_from_fasta_coords_reader<R>(
reader: &mut fasta::io::IndexedReader<R>,
chrom: &str,
start: usize,
end: usize,
) -> Result<String>
where
R: std::io::BufRead + std::io::Seek,
{
if start == end {
return Ok(String::new());
}
let region_str = format!("{}:{}-{}", chrom, start + 1, end);
let parsed: noodles::core::Region = region_str
.parse()
.context("invalid FASTA region coordinates")?;
let sequence: Vec<u8> = reader
.query(&parsed)
.context("FASTA region query failed")?
.sequence()
.as_ref()
.to_vec();
String::from_utf8(sequence).context("FASTA sequence contains non-UTF-8 bytes")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cigar::ToCigarOps;
use std::io::Write;
#[test]
fn uniform_phred40_gives_40() {
let q: String = "I".repeat(10);
assert!((calculate_qscore(&q) - 40.0).abs() < 0.01);
}
#[test]
fn uniform_phred0_gives_0() {
let score = calculate_qscore("!");
assert!((score - 0.0).abs() < 0.01);
}
#[test]
fn mixed_quality_is_between_extremes() {
let q: String = ['!', 'I'].iter().collect(); let score = calculate_qscore(&q);
assert!(score > 0.0 && score < 40.0);
}
#[test]
fn fasta_record_format() {
let mut buf = Vec::new();
write_fasta_record(&mut buf, "read1|chr1:100-200|region", "ACGT").unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
">read1|chr1:100-200|region\nACGT\n"
);
}
#[test]
fn fasta_empty_sequence() {
let mut buf = Vec::new();
write_fasta_record(&mut buf, "h", "").unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), ">h\n\n");
}
#[test]
fn fastq_record_format() {
let mut buf = Vec::new();
write_fastq_record(&mut buf, "read1", "ACGT", "IIII").unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "@read1\nACGT\n+\nIIII\n");
}
fn temp_bed(contents: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "{}", contents).unwrap();
f
}
#[test]
fn read_bed_three_column() {
let f = temp_bed("chr1\t100\t200\n");
let regions = read_bed(f.path(), false).unwrap();
assert_eq!(regions.len(), 1);
let (_, name, chr) = ®ions[0];
assert_eq!(chr, "chr1");
assert!(name.contains("chr1"));
}
#[test]
fn read_bed_four_column_name() {
let f = temp_bed("chr4\t39318077\t39318136\tRFC1\n");
let regions = read_bed(f.path(), false).unwrap();
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].1, "RFC1");
assert_eq!(regions[0].2, "chr4");
}
#[test]
fn read_bed_multiple_regions() {
let f = temp_bed("chr1\t100\t200\nchr2\t300\t400\tFOO\n");
let regions = read_bed(f.path(), false).unwrap();
assert_eq!(regions.len(), 2);
assert_eq!(regions[1].1, "FOO");
}
#[test]
fn revcomp_simple() {
assert_eq!(revcomp("ACGT"), "ACGT"); assert_eq!(revcomp("AAAA"), "TTTT");
assert_eq!(revcomp("GCGC"), "GCGC");
}
#[test]
fn revcomp_preserves_case() {
assert_eq!(revcomp("acgt"), "acgt");
assert_eq!(revcomp("AcGt"), "aCgT");
}
#[test]
fn revcomp_iupac_codes() {
assert_eq!(revcomp("R"), "Y"); assert_eq!(revcomp("N"), "N");
}
#[test]
fn revcomp_involution() {
let seq = "ACGTNRYSWKMBDHV";
assert_eq!(revcomp(&revcomp(seq)), seq);
}
fn ref_len(ops: &CigarOps) -> usize {
ops.iter()
.filter(|op| {
matches!(
op.kind,
Kind::Match
| Kind::SequenceMatch
| Kind::SequenceMismatch
| Kind::Deletion
| Kind::Skip
)
})
.map(|op| op.len)
.sum()
}
fn cuts(cigar: &str, align_start: usize, region_start: usize, region_end: usize) -> ReadCuts {
let ops = cigar.to_cigar_ops().expect("test CIGAR should be valid");
let align_end = align_start + ref_len(&ops);
get_read_cuts(&ops, align_start, align_end, region_start, region_end)
}
#[test]
fn simple_match_middle() {
let c = cuts("10M", 1, 3, 7);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 6);
assert_eq!(c.ref_start, 3);
assert_eq!(c.ref_end, 7);
}
#[test]
fn match_bulk_shortcut_at_end() {
let c = cuts("5M", 1, 3, 6);
let c2 = cuts("10M", 1, 3, 6);
assert_eq!(c2.read_start, 2);
assert_eq!(c2.read_end, 5);
let _ = c; }
#[test]
fn insertion_inside_region_is_captured() {
let c = cuts("3M5I4M", 1, 3, 7);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 11);
assert_eq!(c.read_end - c.read_start, 9);
}
#[test]
fn insertion_before_region_not_captured_without_flank() {
let c = cuts("2M10I8M", 1, 5, 9);
assert_eq!(c.read_start, 14);
assert_eq!(c.read_end, 18);
assert_eq!(c.read_end - c.read_start, 4); }
#[test]
fn insertion_before_region_captured_with_lflank() {
let c = cuts("2M10I8M", 1, 3, 9);
assert_eq!(c.read_start, 2); assert_eq!(c.read_end, 18); assert_eq!(c.read_end - c.read_start, 16);
}
#[test]
fn insertion_after_region_not_captured_without_rflank() {
let c = cuts("5M10I5M", 1, 3, 5);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 4);
assert_eq!(c.read_end - c.read_start, 2);
}
#[test]
fn insertion_after_region_captured_with_rflank() {
let c = cuts("5M10I5M", 1, 3, 7);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 16);
assert!(c.read_end - c.read_start > 10); }
#[test]
fn deletion_inside_region_contributes_no_read_bases() {
let c = cuts("5M3D5M", 1, 3, 12);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 8);
assert_eq!(c.read_end - c.read_start, 6);
}
#[test]
fn soft_clip_shifts_read_positions() {
let c = cuts("3S7M", 1, 3, 7);
assert_eq!(c.read_start, 5); assert_eq!(c.read_end, 9); }
#[test]
fn hard_clip_ignored() {
let c = cuts("2H8M", 1, 3, 7);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 6);
}
#[test]
fn region_beyond_alignment_gives_zero_end() {
let c = cuts("5M", 1, 10, 15);
assert_eq!(c.read_end, 0);
}
#[test]
fn left_partial_starts_at_read_zero() {
let c = cuts("10M", 5, 1, 8);
assert_eq!(c.read_start, 0);
assert_eq!(c.read_end, 3);
assert_eq!(c.ref_start, 5);
assert_eq!(c.ref_end, 8);
}
#[test]
fn contained_read_returns_whole_alignment() {
let c = cuts("5M", 10, 1, 20);
assert_eq!(c.read_start, 0);
assert_eq!(c.read_end, 5);
assert_eq!(c.ref_start, 10);
assert_eq!(c.ref_end, 15);
}
#[test]
fn right_partial_ends_at_alignment_end() {
let c = cuts("5M", 1, 3, 10);
assert_eq!(c.read_start, 2);
assert_eq!(c.read_end, 5);
assert_eq!(c.ref_start, 3);
assert_eq!(c.ref_end, 6);
}
#[test]
fn alignment_starts_exactly_at_region_start() {
let c = cuts("400M", 1000, 1000, 1400);
assert_eq!(
(c.read_start, c.read_end, c.ref_start, c.ref_end),
(0, 400, 1000, 1400)
);
}
#[test]
fn region_end_coincides_with_alignment_end() {
let c = cuts("1000M", 500, 1000, 1500);
assert_eq!(
(c.read_start, c.read_end, c.ref_start, c.ref_end),
(500, 1000, 1000, 1500)
);
}
#[test]
fn leading_clip_with_alignment_at_region_start() {
let c = cuts("100S400M", 1000, 1000, 1400);
assert_eq!(
(c.read_start, c.read_end, c.ref_start, c.ref_end),
(100, 500, 1000, 1400)
);
}
#[test]
fn no_softclip_extension_when_alignment_brackets_region() {
let c = cuts("200M3000I200M", 800, 1000, 2000);
assert!(c.read_end > c.read_start);
assert_eq!(c.softclip_lead_start, c.read_start);
assert_eq!(c.softclip_trail_end, c.read_end);
}
#[test]
fn trailing_softclip_extension_detected() {
let c = cuts("600M400S", 500, 1000, 2000);
assert!(c.read_end > c.read_start);
assert_eq!(c.softclip_trail_end, c.read_end + 400);
assert_eq!(c.softclip_lead_start, c.read_start);
}
#[test]
fn leading_softclip_extension_detected() {
let c = cuts("300S400M", 1500, 1000, 2000);
assert!(c.read_end > c.read_start);
assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(300));
assert_eq!(c.softclip_trail_end, c.read_end);
}
#[test]
fn both_softclip_extensions_detected() {
let c = cuts("200S500M300S", 1200, 1000, 2000);
assert!(c.read_end > c.read_start);
assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(200));
assert_eq!(c.softclip_trail_end, c.read_end + 300);
}
#[test]
fn alignment_before_region_has_no_extension() {
let c = cuts("100M200S", 500, 1000, 2000);
assert_eq!(c.read_end, 0);
assert_eq!(c.softclip_trail_end, 0);
}
#[test]
fn trailing_softclip_extension_survives_padding() {
let c = cuts("600M400S", 500, 995, 2005);
assert_eq!(c.softclip_trail_end, c.read_end + 400);
}
fn pos_at(cigar: &str, align_start: usize, ref_target: usize) -> Option<usize> {
let ops = cigar.to_cigar_ops().unwrap();
read_pos_at_ref(&ops, align_start, ref_target)
}
#[test]
fn read_pos_at_ref_mid_match() {
assert_eq!(pos_at("20M", 1000, 1010), Some(10));
}
#[test]
fn read_pos_at_ref_matches_the_alignment_own_start() {
assert_eq!(pos_at("20M", 1000, 1000), Some(0));
}
#[test]
fn read_pos_at_ref_matches_the_alignment_own_end() {
assert_eq!(pos_at("20M", 1000, 1020), Some(20));
}
#[test]
fn read_pos_at_ref_before_alignment_start_is_zero() {
assert_eq!(pos_at("20M", 1000, 990), Some(0));
}
#[test]
fn read_pos_at_ref_past_alignment_end_is_none() {
assert_eq!(pos_at("20M", 1000, 1025), None);
}
#[test]
fn read_pos_at_ref_across_multiple_ops() {
assert_eq!(pos_at("5M3I5M", 1000, 1000), Some(0));
assert_eq!(pos_at("5M3I5M", 1000, 1005), Some(5));
assert_eq!(pos_at("5M3I5M", 1000, 1006), Some(9));
assert_eq!(pos_at("5M3I5M", 1000, 1010), Some(13));
}
#[test]
fn read_pos_at_ref_inside_deletion_maps_to_position_before_it() {
assert_eq!(pos_at("5M4D5M", 1000, 1007), Some(5));
assert_eq!(pos_at("5M4D5M", 1000, 1005), Some(5));
assert_eq!(pos_at("5M4D5M", 1000, 1009), Some(5));
assert_eq!(pos_at("5M4D5M", 1000, 1010), Some(6));
}
fn write_indexed_fasta(contents: &str) -> tempfile::NamedTempFile {
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), contents).unwrap();
let index = fasta::fs::index(f.path()).unwrap();
let mut fai_path = f.path().as_os_str().to_owned();
fai_path.push(".fai");
fasta::fai::fs::write(&fai_path, &index).unwrap();
f
}
#[test]
fn extract_from_fasta_coords_is_0_based_half_open() {
let f = write_indexed_fasta(">seq1\nACGTACGTAC\n");
let sequence = extract_from_fasta_coords(f.path().to_str().unwrap(), "seq1", 2, 5).unwrap();
assert_eq!(sequence, "GTA");
}
#[test]
fn extract_from_fasta_coords_from_start() {
let f = write_indexed_fasta(">seq1\nACGTACGTAC\n");
let sequence = extract_from_fasta_coords(f.path().to_str().unwrap(), "seq1", 0, 4).unwrap();
assert_eq!(sequence, "ACGT");
}
#[test]
fn extract_from_fasta_coords_zero_length_returns_empty_string() {
let f = write_indexed_fasta(">seq1\nACGTACGTAC\n");
let sequence = extract_from_fasta_coords(f.path().to_str().unwrap(), "seq1", 5, 5).unwrap();
assert_eq!(sequence, "");
}
}