12 · Coverage Depth Analysis

Per-position sequencing depth with gene annotations and DPI comparison

genomics
virology
coverage
sequencing

Aggregates per-base sequencing depth across replicates grouped by days post-infection (DPI). Produces a log-scale depth profile with gene boundary markers and SEM ribbons, using the Okabe-Ito colorblind-safe palette.

Overview

Item Details
Input coverage.csv · gene_coords.csv · manifest.csv (generated by data/simulate_data.py)
Key packages pandas, numpy, matplotlib
Statistics Mean depth ± SEM per position (aggregated across replicates)
Output Coverage depth profile (PNG + SVG) with gene annotations · aggregated summary CSV
Download template.qmd

← Gallery Download .qmd


User Configuration

Code
# ── USER CONFIGURATION ────────────────────────────────────────────────────────

# Data directory (relative to this notebook)
DATA_DIR = "data"

# Output directory for figures and summary tables
OUTPUT_DIR = "outputs"

# Okabe-Ito colorblind-safe palette (mapped to DPI values in display order).
# Any DPI present in the data but absent here falls back to the palette cycle below.
DPI_COLORS = {
    1: "#E69F00",   # orange
    3: "#56B4E9",   # sky blue
    5: "#009E73",   # bluish green
}

# Fallback palette for DPI values not listed in DPI_COLORS
OKABE_ITO_CYCLE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442",
                   "#0072B2", "#D55E00", "#CC79A7", "#000000"]

# Display order for DPI groups. None = derive (ascending) from the data, which is
# what you want unless you need a specific non-numeric ordering.
DPI_ORDER = None

# Figure dimensions (inches)
FIG_WIDTH  = 12
FIG_HEIGHT = 5

# Figure DPI for PNG raster output
FIG_DPI = 300

# Log scale toggle (True = log10 y-axis)
LOG_SCALE = True

# ─────────────────────────────────────────────────────────────────────────────

Setup

Code
import os
import warnings

import pandas as pd
import matplotlib.pyplot as plt

# Narrow suppression only: matplotlib emits this for any non-positive value on a
# log axis. Zero-depth positions are reported explicitly further down instead.
warnings.filterwarnings("ignore", category=UserWarning,
                        message="Data has no positive values")

os.makedirs(OUTPUT_DIR, exist_ok=True)

# ── Publication‑ready matplotlib style (white bg, no grid, black spines) ─────

# Reproducible SVG output: matplotlib stamps a wall-clock date and randomly
# generated element IDs into every SVG, so re-rendering an unchanged figure
# produced a large but meaningless git diff. A fixed salt and a fixed
# SOURCE_DATE_EPOCH make repeat renders byte-identical.
os.environ.setdefault("SOURCE_DATE_EPOCH", "1735689600")   # 2025-01-01 UTC
plt.rcParams["svg.hashsalt"] = "lab-bioinfo-templates"

plt.rcParams.update({
    "axes.facecolor":       "white",
    "figure.facecolor":     "white",
    "savefig.facecolor":    "white",
    "axes.grid":            False,
    "axes.edgecolor":       "black",
    "axes.linewidth":       0.8,
    "xtick.color":          "black",
    "ytick.color":          "black",
    "axes.spines.top":      False,
    "axes.spines.right":    False,
    "font.family":          "sans-serif",
    "font.sans-serif":      ["Arial", "DejaVu Sans"],
    "font.size":            10,
    "axes.titlesize":       12,
    "axes.labelsize":       11,
    "xtick.labelsize":      9,
    "ytick.labelsize":      9,
    "legend.fontsize":      9,
    "legend.frameon":       False,
    "figure.dpi":           100,
    "savefig.dpi":          300,
    "savefig.bbox":         "tight",
})

print(f"Output will be saved to: {os.path.abspath(OUTPUT_DIR)}")
Output will be saved to: /home/runner/work/lab-bioinfo-templates/lab-bioinfo-templates/templates/12_coverage-depth-analysis/outputs

Load Data

Code
def require_columns(df, required, name):
    """Fail loudly if an input table is missing a column the analysis depends on."""
    missing = set(required) - set(df.columns)
    if missing:
        raise ValueError(
            f"{name} is missing required column(s): {sorted(missing)}. "
            f"Found: {list(df.columns)}"
        )

cov      = pd.read_csv(os.path.join(DATA_DIR, "coverage.csv"))
genes    = pd.read_csv(os.path.join(DATA_DIR, "gene_coords.csv"))
manifest = pd.read_csv(os.path.join(DATA_DIR, "manifest.csv"))

require_columns(cov, ["Position", "Depth", "sample_id", "dpi"], "coverage.csv")
require_columns(genes, ["gene", "start", "end"], "gene_coords.csv")
require_columns(manifest, ["sample_id", "dpi", "replicate"], "manifest.csv")

# Display order for DPI groups, plus a colour for every one of them.
dpi_order = list(DPI_ORDER) if DPI_ORDER else sorted(cov["dpi"].dropna().unique())
unconfigured = [d for d in sorted(cov["dpi"].dropna().unique()) if d not in dpi_order]
if unconfigured:
    raise ValueError(
        f"DPI_ORDER does not list every DPI present in coverage.csv: {unconfigured} "
        f"would be dropped from the figure. Configured: {dpi_order}. "
        f"Set DPI_ORDER = None to derive it from the data."
    )

dpi_colors = {
    d: DPI_COLORS.get(d, OKABE_ITO_CYCLE[i % len(OKABE_ITO_CYCLE)])
    for i, d in enumerate(dpi_order)
}

print(f"Coverage:  {len(cov):,} rows · {cov['sample_id'].nunique()} samples")
print(f"Genes:     {len(genes)} annotated features")
print(f"Manifest:  {len(manifest)} samples across {manifest['dpi'].nunique()} DPI groups")
print(f"DPI order: {dpi_order}")

cov.head(5)
Coverage:  103,023 rows · 9 samples
Genes:     9 annotated features
Manifest:  9 samples across 3 DPI groups
DPI order: [np.int64(1), np.int64(3), np.int64(5)]
CHROM Position Depth sample_id dpi replicate
0 PathogenX 1 49 DPI1_R1_Lung 1 1
1 PathogenX 2 121 DPI1_R1_Lung 1 1
2 PathogenX 3 221 DPI1_R1_Lung 1 1
3 PathogenX 4 350 DPI1_R1_Lung 1 1
4 PathogenX 5 549 DPI1_R1_Lung 1 1
Code
genes
gene start end
0 nsp1 1 1800
1 nsp2 1801 4500
2 nsp3 4501 5700
3 nsp4 5701 7500
4 capsid 7501 8300
5 E3 8301 9000
6 E2 9001 10200
7 6K 10201 10400
8 E1 10401 11400

Aggregate Depth by DPI

Code
agg = (
    cov.groupby(["dpi", "Position"])["Depth"]
       .agg(["mean", "sem", "count"])
       .reset_index()
)

print(f"Aggregated depth table: {len(agg)} rows (position × DPI)")
agg.head(8)
Aggregated depth table: 34341 rows (position × DPI)
dpi Position mean sem count
0 1 1 60.333333 7.310571 3
1 1 2 157.333333 27.388156 3
2 1 3 286.666667 72.778965 3
3 1 4 429.000000 51.228247 3
4 1 5 696.000000 84.583292 3
5 1 6 876.333333 131.821007 3
6 1 7 1139.000000 63.269266 3
7 1 8 1196.000000 198.199395 3
Code
for dpi_val in dpi_order:
    sub = agg[agg["dpi"] == dpi_val]
    n_reps = manifest[manifest["dpi"] == dpi_val]["replicate"].nunique()
    per_pos = sorted(sub["count"].unique())
    print(
        f"  DPI {dpi_val}: {n_reps} replicates, "
        f"mean depth = {sub['mean'].mean():.0f} ± {sub['sem'].mean():.0f} SEM "
        f"({per_pos} replicate(s) per position)"
    )
  DPI 1: 3 replicates, mean depth = 10585 ± 1641 SEM ([np.int64(3)] replicate(s) per position)
  DPI 3: 3 replicates, mean depth = 29416 ± 5555 SEM ([np.int64(3)] replicate(s) per position)
  DPI 5: 3 replicates, mean depth = 56182 ± 9138 SEM ([np.int64(3)] replicate(s) per position)

Coverage Depth Plot

Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))

# ── Gene boundary vertical lines and labels ───────────────────────────────────

for _, gene in genes.iterrows():
    ax.axvline(gene["start"],  color="grey", linestyle="--", linewidth=0.5, alpha=0.5)
    ax.axvline(gene["end"],    color="grey", linestyle="--", linewidth=0.5, alpha=0.5)
    midpoint = (gene["start"] + gene["end"]) / 2
    ax.annotate(
        gene["gene"],
        xy=(midpoint, 1.02),
        xycoords=("data", "axes fraction"),
        ha="center",
        va="bottom",
        fontsize=7,
        fontstyle="italic",
        color="grey",
    )

# ── Alternating gene background shading ───────────────────────────────────────

for i, (_, gene) in enumerate(genes.iterrows()):
    if i % 2 == 0:
        ax.axvspan(gene["start"], gene["end"], alpha=0.04, color="grey", zorder=0)

# ── Depth curves with SEM ribbons ─────────────────────────────────────────────

genome_start = agg["Position"].min()
genome_end   = agg["Position"].max()

for dpi_val in dpi_order:
    sub = agg[agg["dpi"] == dpi_val].sort_values("Position")
    color = dpi_colors[dpi_val]
    ax.plot(
        sub["Position"],
        sub["mean"],
        color=color,
        linewidth=1.2,
        label=f"DPI {dpi_val}",
        zorder=2,
    )
    ax.fill_between(
        sub["Position"],
        sub["mean"] - sub["sem"],
        sub["mean"] + sub["sem"],
        color=color,
        alpha=0.15,
        linewidth=0,
        zorder=1,
    )

# ── Axes and labels ───────────────────────────────────────────────────────────

ax.set_xlim(genome_start, genome_end)
ax.set_xlabel("Genomic position (nt)")

if LOG_SCALE:
    ax.set_yscale("log")
    # Plain "log10": the ₁₀ subscript glyph is missing from Arial and renders as
    # tofu boxes on the published figure.
    ax.set_ylabel("Depth (log10 scale)")
    # Bound the log axis by the data, not by a magic floor: a hardcoded lower
    # limit silently pushes low-depth positions off the bottom of the chart.
    positive = agg.loc[agg["mean"] > 0, "mean"]
    n_zero = int((agg["mean"] <= 0).sum())
    if n_zero:
        print(f"Note: {n_zero} position×DPI mean(s) are 0 and cannot be shown "
              f"on a log axis — switch LOG_SCALE to False to see them.")
    # The terminal tapers drive depth toward zero at both genome ends. Bounding the
    # axis by the outright minimum would hand half the plot to those few bases, so
    # bound by a low percentile and let the tapers run off the bottom.
    floor = max(positive.quantile(0.001) * 0.5, positive.min() * 0.7)
    ax.set_ylim(floor, agg["mean"].max() * 1.3)
else:
    ax.set_ylabel("Sequencing depth")
    ax.set_ylim(0, agg["mean"].max() * 1.15)

ax.set_title(
    "Genome Coverage Depth by DPI\n"
    f"({len(manifest['sample_id'].unique())} samples · mean ± SEM across replicates)",
    fontsize=12,
    pad=14,
)

n_per_dpi = manifest.groupby("dpi")["sample_id"].nunique()
legend_title = "Days post-infection\n" + ", ".join(
    f"DPI {d}: n={int(n_per_dpi.get(d, 0))}" for d in dpi_order
)

ax.legend(
    title=legend_title,
    loc="lower left",   # upper right sits on top of the depth curves
    frameon=False,
)

# ── Save ──────────────────────────────────────────────────────────────────────

for fmt in ["png", "svg"]:
    out_path = os.path.join(OUTPUT_DIR, f"coverage_depth.{fmt}")
    fig.savefig(out_path, format=fmt, dpi=FIG_DPI)
    print(f"Saved: {os.path.abspath(out_path)}")

plt.show()
Saved: /home/runner/work/lab-bioinfo-templates/lab-bioinfo-templates/templates/12_coverage-depth-analysis/outputs/coverage_depth.png
Saved: /home/runner/work/lab-bioinfo-templates/lab-bioinfo-templates/templates/12_coverage-depth-analysis/outputs/coverage_depth.svg

Summary Export

Code
agg.to_csv(os.path.join(OUTPUT_DIR, "coverage_depth_summary.csv"), index=False)
print(f"Saved: {os.path.join(OUTPUT_DIR, 'coverage_depth_summary.csv')}")

# Per-DPI genome-wide summary
per_dpi = (
    agg.groupby("dpi")
       .agg(
           mean_depth=("mean", "mean"),
           median_depth=("mean", "median"),
           min_depth=("mean", "min"),
           max_depth=("mean", "max"),
           mean_sem=("sem", "mean"),
       )
       .round(1)
)

per_dpi
Saved: outputs/coverage_depth_summary.csv
mean_depth median_depth min_depth max_depth mean_sem
dpi
1 10585.5 9429.7 60.3 26509.7 1641.0
3 29415.6 26237.3 126.7 68510.0 5554.9
5 56181.8 52016.0 259.3 130232.7 9138.2

Analysis complete. Depth profile saved to outputs/.