#[derive(Debug, Clone, PartialEq)]
pub struct CopyNumberRegion {
pub chrom: String,
pub start: u64,
pub end: u64,
pub tumor_cn: u32,
pub normal_cn: u32,
pub major_cn: Option<u32>,
pub minor_cn: Option<u32>,
}
impl CopyNumberRegion {
pub fn new(
chrom: impl Into<String>,
start: u64,
end: u64,
tumor_cn: u32,
normal_cn: u32,
) -> Self {
Self {
chrom: chrom.into(),
start,
end,
tumor_cn,
normal_cn,
major_cn: None,
minor_cn: None,
}
}
pub fn with_allele_specific(
chrom: impl Into<String>,
start: u64,
end: u64,
tumor_cn: u32,
normal_cn: u32,
major_cn: u32,
minor_cn: u32,
) -> Self {
debug_assert_eq!(
major_cn + minor_cn,
tumor_cn,
"major_cn + minor_cn must equal tumor_cn"
);
Self {
chrom: chrom.into(),
start,
end,
tumor_cn,
normal_cn,
major_cn: Some(major_cn),
minor_cn: Some(minor_cn),
}
}
pub fn contains(&self, chrom: &str, pos: u64) -> bool {
self.chrom == chrom && pos >= self.start && pos < self.end
}
}
pub fn adjusted_coverage(
base_coverage: f64,
purity: f64,
tumor_cn: u32,
normal_cn: u32,
ploidy: u32,
) -> f64 {
if ploidy == 0 {
return 0.0;
}
let effective = purity * (tumor_cn as f64) + (1.0 - purity) * (normal_cn as f64);
base_coverage * effective / (ploidy as f64)
}
#[cfg(test)]
pub fn loh_vaf(purity: f64, major_cn: u32, minor_cn: u32, normal_cn: u32) -> f64 {
let total_tumor_cn = major_cn + minor_cn;
let denominator = purity * (total_tumor_cn as f64) + (1.0 - purity) * (normal_cn as f64);
if denominator == 0.0 {
return 0.0;
}
let numerator = purity * (major_cn as f64) + (1.0 - purity) * 0.5 * (normal_cn as f64);
numerator / denominator
}
#[cfg(test)]
pub fn vaf_in_amplified_region(
purity: f64,
multiplicity: u32,
tumor_cn: u32,
normal_cn: u32,
) -> f64 {
crate::variants::vaf::expected_vaf(1.0, multiplicity, purity, tumor_cn, normal_cn)
}
pub fn find_cn_region<'a>(
regions: &'a [CopyNumberRegion],
chrom: &str,
pos: u64,
) -> Option<&'a CopyNumberRegion> {
regions.iter().find(|r| r.contains(chrom, pos))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_amplification_coverage() {
let base = 30.0;
let purity = 1.0;
let ploidy = 2;
let diploid = adjusted_coverage(base, purity, 2, 2, ploidy);
let amplified = adjusted_coverage(base, purity, 4, 2, ploidy);
assert!(
(diploid - 30.0).abs() < 1e-10,
"diploid coverage should be 30, got {diploid}"
);
assert!(
(amplified - 60.0).abs() < 1e-10,
"CN=4 coverage should be 60, got {amplified}"
);
assert!(
(amplified / diploid - 2.0).abs() < 1e-10,
"CN=4 should be ~2x CN=2, ratio was {}",
amplified / diploid
);
}
#[test]
fn test_deletion_coverage() {
let base = 30.0;
let purity = 1.0;
let ploidy = 2;
let diploid = adjusted_coverage(base, purity, 2, 2, ploidy);
let deleted = adjusted_coverage(base, purity, 1, 2, ploidy);
assert!(
(deleted - 15.0).abs() < 1e-10,
"CN=1 coverage should be 15, got {deleted}"
);
assert!(
(deleted / diploid - 0.5).abs() < 1e-10,
"CN=1 should be ~0.5x CN=2, ratio was {}",
deleted / diploid
);
}
#[test]
fn test_homodel_coverage() {
let base = 30.0;
let ploidy = 2;
let pure_homdel = adjusted_coverage(base, 1.0, 0, 2, ploidy);
assert!(
pure_homdel < 1e-10,
"pure homdel should produce ~0 coverage, got {pure_homdel}"
);
let impure_homdel = adjusted_coverage(base, 0.7, 0, 2, ploidy);
assert!(
(impure_homdel - 9.0).abs() < 1e-10,
"impure homdel should have only normal-contamination coverage (~9.0), got {impure_homdel}"
);
}
#[test]
fn test_loh_allele_frequency() {
let vaf = loh_vaf(1.0, 2, 0, 2);
assert!(
(vaf - 1.0).abs() < 1e-10,
"pure LOH major allele VAF should be ~1.0, got {vaf}"
);
let vaf_impure = loh_vaf(0.5, 2, 0, 2);
assert!(
(vaf_impure - 0.75).abs() < 1e-10,
"impure LOH major allele VAF should be 0.75, got {vaf_impure}"
);
}
#[test]
fn test_cn_with_purity() {
let base = 100.0;
let ploidy = 2;
let cov = adjusted_coverage(base, 0.7, 4, 2, ploidy);
assert!(
(cov - 170.0).abs() < 1e-10,
"coverage with purity=0.7, CN=4 should be 170.0, got {cov}"
);
let cov_balanced = adjusted_coverage(base, 0.5, 2, 2, ploidy);
assert!(
(cov_balanced - 100.0).abs() < 1e-10,
"balanced CN with purity=0.5 should give base coverage, got {cov_balanced}"
);
}
#[test]
fn test_vaf_in_amplified_region() {
let vaf_1 = vaf_in_amplified_region(1.0, 1, 4, 2);
assert!(
(vaf_1 - 0.25).abs() < 1e-10,
"mult=1 in CN=4 should give VAF=0.25, got {vaf_1}"
);
let vaf_3 = vaf_in_amplified_region(1.0, 3, 4, 2);
assert!(
(vaf_3 - 0.75).abs() < 1e-10,
"mult=3 in CN=4 should give VAF=0.75, got {vaf_3}"
);
let vaf_unbalanced = vaf_in_amplified_region(1.0, 2, 3, 2);
assert!(
(vaf_unbalanced - 2.0 / 3.0).abs() < 1e-10,
"mult=2 in CN=3 should give VAF=2/3, got {vaf_unbalanced}"
);
}
#[test]
fn test_cn_config_parsing() {
use crate::io::config::load;
use std::io::Write;
use tempfile::NamedTempFile;
let yaml = r#"
reference: /tmp/ref.fa
output:
directory: /tmp/out
copy_number:
- region: "chr7:55000000-55200000"
tumor_cn: 4
normal_cn: 2
- region: "chr17:7500000-7700000"
tumor_cn: 1
normal_cn: 2
- region: "chr13:32300000-32400000"
tumor_cn: 0
normal_cn: 2
"#;
let mut f = NamedTempFile::new().unwrap();
f.write_all(yaml.as_bytes()).unwrap();
let cfg = load(f.path()).unwrap();
let cn = cfg
.copy_number
.as_ref()
.expect("copy_number should be present");
assert_eq!(cn.len(), 3);
assert_eq!(cn[0].region, "chr7:55000000-55200000");
assert_eq!(cn[0].tumor_cn, 4);
assert_eq!(cn[0].normal_cn, 2);
assert_eq!(cn[1].region, "chr17:7500000-7700000");
assert_eq!(cn[1].tumor_cn, 1);
assert_eq!(cn[1].normal_cn, 2);
assert_eq!(cn[2].region, "chr13:32300000-32400000");
assert_eq!(cn[2].tumor_cn, 0);
assert_eq!(cn[2].normal_cn, 2);
}
}