use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use kuva::backend::pdf::PdfBackend;
use kuva::plot::{LegendPosition, LinePlot, MarkerShape, ScatterPlot};
use kuva::render::figure::Figure;
use kuva::render::layout::{Layout, TickFormat};
use kuva::render::plots::Plot;
use crate::complexity::DuplicateLadderRow;
use crate::counts::CountHistogramRow;
const FG_PALETTE: [&str; 8] = [
"#26a8e0", "#38b44a", "#160052", "#1693b9", "#315848", "#2fae99", "#4dcc68", "#269e2a", ];
const FG_BLUE: &str = FG_PALETTE[0];
const FG_GREEN: &str = FG_PALETTE[1];
const PLOT_WIDTH: f64 = 800.0;
const PLOT_HEIGHT: f64 = 600.0;
const TEMPLATES_PER_UNIT: f64 = 1e6;
const HIST_CUTOFF_CAP: f64 = 500.0;
pub fn ladder_plot_path(prefix: &Path) -> PathBuf {
let mut name = prefix.as_os_str().to_owned();
name.push(".duplication-sampled.pdf");
PathBuf::from(name)
}
fn marginal_series(rows: &[&DuplicateLadderRow]) -> Vec<(f64, f64)> {
let n = rows.len();
if n == 0 {
return Vec::new();
}
let interval = rows[0].total;
let end = if n >= 2 && rows[n - 1].total - rows[n - 2].total < interval { n - 1 } else { n };
rows[..end]
.iter()
.map(|r| (r.total as f64 / TEMPLATES_PER_UNIT, r.window_frac_duplicates))
.collect()
}
pub fn write_duplicate_ladder_pdf(rows: &[DuplicateLadderRow], path: &Path) -> Result<()> {
let Some(first) = rows.first() else {
return Ok(());
};
let mut libraries: Vec<&str> = Vec::new();
for r in rows {
if !libraries.contains(&r.library.as_str()) {
libraries.push(&r.library);
}
}
let multi = libraries.len() > 1;
let mut plots: Vec<Plot> = Vec::new();
let (mut x_min, mut x_max) = (f64::INFINITY, 0.0_f64);
for (i, &lib) in libraries.iter().enumerate() {
let lib_rows: Vec<&DuplicateLadderRow> =
rows.iter().filter(|r| r.library.as_str() == lib).collect();
let series = marginal_series(&lib_rows);
if let Some(&(x, _)) = series.first() {
x_min = x_min.min(x);
}
if let Some(&(x, _)) = series.last() {
x_max = x_max.max(x);
}
let mut line = LinePlot::new()
.with_data(series)
.with_color(FG_PALETTE[i % FG_PALETTE.len()])
.with_stroke_width(2.5);
if multi {
line = line.with_legend(lib);
}
plots.push(Plot::Line(line));
}
let title = plot_title(&first.sample, "Duplication Rate vs. Depth");
let mut layout = Layout::auto_from_plots(&plots)
.with_width(PLOT_WIDTH)
.with_height(PLOT_HEIGHT)
.with_title(&title)
.with_x_label("Templates Processed (Millions)")
.with_y_label("Marginal Fraction Duplicated")
.with_y_tick_format(TickFormat::Percent)
.with_y_axis_min(0.0);
if x_max > x_min {
layout = layout.with_x_axis_min(x_min).with_x_axis_max(x_max);
}
if multi {
layout = layout.with_legend_position(LegendPosition::InsideTopRight);
}
let pdf_bytes = kuva::render_to_pdf(plots, layout).map_err(|e| anyhow!("{e}"))?;
std::fs::write(path, pdf_bytes)
.with_context(|| format!("writing duplication-sampled plot to {}", path.display()))?;
Ok(())
}
fn count_histogram_plot_path(prefix: &Path) -> PathBuf {
let mut name = prefix.as_os_str().to_owned();
name.push(".duplication-spectrum.pdf");
PathBuf::from(name)
}
fn histogram_cutoff(rows: &[&CountHistogramRow]) -> f64 {
let Some(first) = rows.first() else {
return 0.0;
};
let first_k = first.n_observations as f64;
let mut raw = first_k;
for (i, r) in rows.iter().enumerate().skip(1) {
let k = r.n_observations as f64;
if (i as f64 + 1.0) / k >= 0.5 {
raw = k;
} else {
break;
}
}
let capped = raw.min(HIST_CUTOFF_CAP).max(first_k);
(capped / 10.0).ceil() * 10.0
}
fn pct_tick(v: f64) -> String {
let p = v * 100.0;
if p <= 0.0 {
return "0%".to_string();
}
let e = p.log10().round() as i32;
if e >= 0 { format!("{p:.0}%") } else { format!("{:.*}%", (-e) as usize, p) }
}
fn pct_note(frac: f64) -> String {
let p = frac * 100.0;
if p > 0.0 && p < 0.001 {
return "<0.001%".to_string();
}
if p >= 10.0 {
return format!("{p:.0}%");
}
if p >= 1.0 {
return format!("{p:.1}%");
}
let decimals = ((-p.log10().floor()) as usize) + 1;
let mut s = format!("{p:.decimals$}");
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
format!("{s}%")
}
fn count_histogram_panel(rows: &[&CountHistogramRow], title: &str) -> Option<(Vec<Plot>, Layout)> {
rows.first()?;
let cutoff = histogram_cutoff(rows);
let total_mol: f64 = rows.iter().map(|r| r.n_molecules as f64).sum();
let total_rp: f64 = rows.iter().map(|r| r.n_observations as f64 * r.n_molecules as f64).sum();
if total_mol == 0.0 || total_rp == 0.0 {
return None;
}
let mut mol: Vec<(f64, f64)> = Vec::new();
let mut rp: Vec<(f64, f64)> = Vec::new();
let (mut mol_tail, mut rp_tail) = (0.0_f64, 0.0_f64);
for r in rows {
let (k, n) = (r.n_observations as f64, r.n_molecules as f64);
if k <= cutoff {
mol.push((k, n / total_mol));
rp.push((k, k * n / total_rp));
} else {
mol_tail += n;
rp_tail += k * n;
}
}
if mol.is_empty() {
return None;
}
let fmin = mol.iter().chain(rp.iter()).map(|&(_, f)| f).fold(f64::INFINITY, f64::min);
let fmax = mol.iter().chain(rp.iter()).map(|&(_, f)| f).fold(0.0, f64::max);
let y_min = 10f64.powf(fmin.log10() - 0.4);
let y_max = 10f64.powf(fmax.log10() + 0.25);
let cutoff_i = cutoff as u64;
let x_label = if mol_tail > 0.0 {
format!(
"Times Observed (k) ({} molecules, {} reads @ k>{cutoff_i})",
pct_note(mol_tail / total_mol),
pct_note(rp_tail / total_rp),
)
} else {
"Times Observed (k)".to_string()
};
let points = |data: Vec<(f64, f64)>, color: &str, label: &str| {
Plot::Scatter(
ScatterPlot::new()
.with_data(data)
.with_color(color)
.with_size(5.0)
.with_marker(MarkerShape::Cross)
.with_legend(label),
)
};
let plots: Vec<Plot> =
vec![points(mol, FG_BLUE, "Molecules"), points(rp, FG_GREEN, "Reads/Pairs")];
let mut layout = Layout::auto_from_plots(&plots)
.with_width(PLOT_WIDTH)
.with_height(PLOT_HEIGHT)
.with_title(title)
.with_x_label(&x_label)
.with_y_label("Percent")
.with_log_y()
.with_x_axis_min(0.0)
.with_x_axis_max(cutoff)
.with_y_axis_min(y_min)
.with_minor_ticks(9)
.with_show_minor_grid(true)
.with_y_tick_format(TickFormat::Custom(Arc::new(pct_tick)))
.with_legend_position(LegendPosition::InsideTopRight);
if y_max > y_min {
layout = layout.with_y_axis_max(y_max);
}
Some((plots, layout))
}
fn blank_cell_layout() -> Layout {
Layout::new((0.0, 1.0), (0.0, 1.0))
}
fn plot_title(sample: &str, what: &str) -> String {
if sample.is_empty() { what.to_string() } else { format!("{sample} — {what}") }
}
pub fn write_count_histogram_pdfs(rows: &[CountHistogramRow], prefix: &Path) -> Result<()> {
let mut libraries: Vec<&str> = Vec::new();
for r in rows {
if !libraries.contains(&r.library.as_str()) {
libraries.push(&r.library);
}
}
if libraries.is_empty() {
return Ok(());
}
let path = count_histogram_plot_path(prefix);
let sample = rows.first().map_or("", |r| r.sample.as_str());
let title = plot_title(sample, "Duplication Spectrum (η_k)");
if libraries.len() == 1 {
let series: Vec<&CountHistogramRow> = rows.iter().collect();
if let Some((plots, layout)) = count_histogram_panel(&series, &title) {
let pdf = kuva::render_to_pdf(plots, layout).map_err(|e| anyhow!("{e}"))?;
std::fs::write(&path, pdf).with_context(|| {
format!("writing duplication-spectrum plot to {}", path.display())
})?;
}
return Ok(());
}
let cols = (libraries.len() as f64).sqrt().ceil() as usize;
let n_rows = libraries.len().div_ceil(cols);
let cell_count = n_rows * cols;
let mut cell_plots: Vec<Vec<Plot>> = Vec::with_capacity(cell_count);
let mut cell_layouts: Vec<Layout> = Vec::with_capacity(cell_count);
for &lib in &libraries {
let series: Vec<&CountHistogramRow> =
rows.iter().filter(|r| r.library.as_str() == lib).collect();
match count_histogram_panel(&series, lib) {
Some((plots, layout)) => {
cell_plots.push(plots);
cell_layouts.push(layout);
}
None => {
cell_plots.push(Vec::new());
cell_layouts.push(blank_cell_layout());
}
}
}
while cell_plots.len() < cell_count {
cell_plots.push(Vec::new());
cell_layouts.push(blank_cell_layout());
}
let scene = Figure::new(n_rows, cols)
.with_plots(cell_plots)
.with_layouts(cell_layouts)
.with_title(&title)
.with_cell_size(PLOT_WIDTH, PLOT_HEIGHT)
.render();
let pdf = PdfBackend::new().render_scene(&scene).map_err(|e| anyhow!("{e}"))?;
std::fs::write(&path, pdf)
.with_context(|| format!("writing duplication-spectrum plot to {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(
library: &str,
total: u64,
duplicates: u64,
window_duplicates: u64,
) -> DuplicateLadderRow {
let window_total = 1_000_000;
DuplicateLadderRow {
sample: "s".to_string(),
library: library.to_string(),
category: "pairs",
total,
unique: total - duplicates,
duplicates,
frac_duplicates: duplicates as f64 / total as f64,
window_total,
window_unique: window_total - window_duplicates,
window_duplicates,
window_frac_duplicates: window_duplicates as f64 / window_total as f64,
}
}
#[test]
fn ladder_plot_path_appends_pdf_suffix() {
assert_eq!(
ladder_plot_path(Path::new("out/p")),
Path::new("out/p.duplication-sampled.pdf")
);
}
#[test]
fn renders_a_valid_pdf_single_library() {
let rows = vec![
row("lib", 1_000_000, 50_000, 50_000),
row("lib", 2_000_000, 90_000, 40_000),
row("lib", 2_500_000, 95_000, 5_000), ];
let tmp = tempfile::NamedTempFile::new().unwrap();
write_duplicate_ladder_pdf(&rows, tmp.path()).unwrap();
let bytes = std::fs::read(tmp.path()).unwrap();
assert!(bytes.starts_with(b"%PDF"), "expected PDF magic bytes");
}
#[test]
fn renders_one_pdf_for_multiple_libraries() {
let rows = vec![
row("libA", 1_000_000, 10_000, 10_000),
row("libA", 2_000_000, 20_000, 10_000),
row("libB", 1_000_000, 5_000, 5_000),
row("libB", 2_000_000, 9_000, 4_000),
];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.duplication-sampled.pdf");
write_duplicate_ladder_pdf(&rows, &path).unwrap();
assert!(path.exists());
let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
assert_eq!(entries.len(), 1, "exactly one plot file");
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"));
}
#[test]
fn empty_rows_writes_no_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("x.pdf");
write_duplicate_ladder_pdf(&[], &path).unwrap();
assert!(!path.exists(), "no file should be written for empty rows");
}
#[test]
fn marginal_series_drops_partial_final_window() {
let rows = [
row("lib", 1_000_000, 10_000, 10_000),
row("lib", 2_000_000, 20_000, 10_000),
row("lib", 2_300_000, 22_000, 2_000), ];
let refs: Vec<&DuplicateLadderRow> = rows.iter().collect();
let series = marginal_series(&refs);
assert_eq!(series.len(), 2, "partial final point dropped");
assert_eq!(series.last().unwrap().0, 2.0); }
fn hrow(library: &str, k: u32, n: u64) -> CountHistogramRow {
CountHistogramRow {
sample: "s".to_string(),
library: library.to_string(),
category: "pairs",
n_observations: k,
n_molecules: n,
}
}
#[test]
fn count_histogram_path_appends_suffix() {
assert_eq!(
count_histogram_plot_path(Path::new("out/p")),
Path::new("out/p.duplication-spectrum.pdf")
);
}
#[test]
fn histogram_cutoff_caps_heavy_tails() {
let rows: Vec<CountHistogramRow> = (1..=600).map(|k| hrow("lib", k, 10)).collect();
let refs: Vec<&CountHistogramRow> = rows.iter().collect();
assert_eq!(histogram_cutoff(&refs), 500.0);
}
#[test]
fn histogram_cutoff_rounds_up_to_ten() {
let rows: Vec<CountHistogramRow> = (1..=24).map(|k| hrow("lib", k, 5)).collect();
let refs: Vec<&CountHistogramRow> = rows.iter().collect();
assert_eq!(histogram_cutoff(&refs), 30.0);
}
#[test]
fn histogram_cutoff_handles_rows_starting_above_one() {
let rows: Vec<CountHistogramRow> = (2..=20).map(|k| hrow("lib", k, 5)).collect();
let refs: Vec<&CountHistogramRow> = rows.iter().collect();
assert_eq!(histogram_cutoff(&refs), 20.0);
}
#[test]
fn histogram_cutoff_never_drops_below_smallest_k() {
let rows = [hrow("lib", 600, 3), hrow("lib", 900, 1)];
let refs: Vec<&CountHistogramRow> = rows.iter().collect();
assert_eq!(histogram_cutoff(&refs), 600.0);
}
#[test]
fn count_histogram_renders_valid_pdf() {
let rows = [
hrow("lib", 1, 1_000_000),
hrow("lib", 2, 50_000),
hrow("lib", 3, 2_000),
hrow("lib", 600, 1),
];
let dir = tempfile::tempdir().unwrap();
let prefix = dir.path().join("out");
write_count_histogram_pdfs(&rows, &prefix).unwrap();
let pdf = std::fs::read(dir.path().join("out.duplication-spectrum.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn count_histogram_renders_when_all_signatures_exceed_cap() {
let rows = [hrow("lib", 600, 1)];
let dir = tempfile::tempdir().unwrap();
let prefix = dir.path().join("out");
write_count_histogram_pdfs(&rows, &prefix).unwrap();
let pdf = std::fs::read(dir.path().join("out.duplication-spectrum.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn count_histogram_multi_library_is_one_faceted_file() {
let rows = vec![
hrow("libA", 1, 100),
hrow("libA", 2, 10),
hrow("libB", 1, 50),
hrow("libB", 2, 5),
];
let dir = tempfile::tempdir().unwrap();
let prefix = dir.path().join("out");
write_count_histogram_pdfs(&rows, &prefix).unwrap();
let pdf = std::fs::read(dir.path().join("out.duplication-spectrum.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF"));
assert!(!dir.path().join("out.duplication-spectrum.libA.pdf").exists());
assert!(!dir.path().join("out.duplication-spectrum.libB.pdf").exists());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "exactly one plot file");
}
#[test]
fn count_histogram_single_library_unqualified_name() {
let rows = vec![hrow("only", 1, 100), hrow("only", 2, 10)];
let dir = tempfile::tempdir().unwrap();
let prefix = dir.path().join("out");
write_count_histogram_pdfs(&rows, &prefix).unwrap();
assert!(dir.path().join("out.duplication-spectrum.pdf").exists());
}
#[test]
fn pct_note_floors_tiny_and_formats_plain() {
assert_eq!(pct_note(1e-8), "<0.001%");
assert_eq!(pct_note(0.15), "15%");
assert_eq!(pct_note(0.000012), "0.0012%");
}
#[test]
fn plot_title_omits_dash_when_no_sample() {
assert_eq!(plot_title("", "Duplication Spectrum (η_k)"), "Duplication Spectrum (η_k)");
assert_eq!(
plot_title("NA12878", "Duplication Spectrum (η_k)"),
"NA12878 — Duplication Spectrum (η_k)"
);
}
}