view nanopore_qc.R @ 2:5c7848b91964 draft default tip

planemo upload for repository https://github.com/jvolkening/galaxy-tools/tree/master/tools/nanopore_qc commit efc73adf2038b9c6504c60f35202e91dbc7f4d48-dirty
author jvolkening
date Sat, 02 Mar 2024 05:33:33 +0000
parents e0006d8bf849
children
line wrap: on
line source

#!/usr/bin/Rscript

# NanoporeQC was forked from:
# MinionQC version 1.0
# Copyright (C) 2017 Robert Lanfear
# 
# Modifications (c) 2018-2024 Jeremy Volkening
#
# Released under the MIT license (see LICENSE)

# supress warnings
options(warn=-1)

libs <- c(
    'ggplot2',
    'tidyr',
    'dplyr',
    'readr',
    'yaml',
    'scales',
    'futile.logger',
    'data.table',
    'optparse'
)
for (l in libs) {
    library(l, character.only = T, quietly = T)
}

# option parsing #

parser <- OptionParser()

parser <- add_option(parser, 
                     opt_str = c("-i", "--input"), 
                     type = "character",
                     dest = 'input.file',
                     help="Input file or directory (required). Either a full path to a sequence_summary.txt file, or a full path to a directory containing one or more such files. In the latter case the directory is searched recursively."
                     )

parser <- add_option(parser, 
                     opt_str = c("-o", "--outputdirectory"), 
                     type = "character",
                     dest = 'output.dir',
                     help="Output directory (required). If a single sequencing_summary.txt file is passed as input, then the output directory will contain just the plots associated with that file. If a directory containing more than one sequencing_summary.txt files is passed as input, then the plots will be put into sub-directories that have the same names as the parent directories of each sequencing_summary.txt file"
                     )

parser <- add_option(parser, 
                     opt_str = c("-q", "--qscore_cutoff"), 
                     type="double", 
                     default=7.0,
                     dest = 'q',
                     help="The cutoff value for the mean Q score of a read (default 7). Used to create separate plots for reads above and below this threshold"
                     )

parser <- add_option(parser, 
                     opt_str = c("-d", "--discard_failed"), 
                     type="logical", 
                     default=FALSE,
                     dest = 'filt.failed',
                     help="Discard reads that failed Albacore filtering"
                     )

opt = parse_args(parser)

input.file  = opt$input.file
output.dir  = opt$output.dir
filt.failed = opt$filt.failed
q           = opt$q

# this is how we label the reads at least as good as q
q_title = paste("Q>=", q, sep="")


# build the map for R9.5, which has this unusual non-sequential numbering layout
map <- rbind(
    data.frame( channel=33:64,   row=rep(1:4,   each=8), col=rep(1:8, 4)  ),
    data.frame( channel=481:512, row=rep(5:8,   each=8), col=rep(1:8, 4)  ),
    data.frame( channel=417:448, row=rep(9:12,  each=8), col=rep(1:8, 4)  ),
    data.frame( channel=353:384, row=rep(13:16, each=8), col=rep(1:8, 4)  ),
    data.frame( channel=289:320, row=rep(17:20, each=8), col=rep(1:8, 4)  ),
    data.frame( channel=225:256, row=rep(21:24, each=8), col=rep(1:8, 4)  ),
    data.frame( channel=161:192, row=rep(25:28, each=8), col=rep(1:8, 4)  ),
    data.frame( channel=97:128,  row=rep(29:32, each=8), col=rep(1:8, 4)  ),
    data.frame( channel=1:32,    row=rep(1:4,   each=8), col=rep(16:9, 4) ),
    data.frame( channel=449:480, row=rep(5:8,   each=8), col=rep(16:9, 4) ),
    data.frame( channel=385:416, row=rep(9:12,  each=8), col=rep(16:9, 4) ),
    data.frame( channel=321:352, row=rep(13:16, each=8), col=rep(16:9, 4) ),
    data.frame( channel=257:288, row=rep(17:20, each=8), col=rep(16:9, 4) ),
    data.frame( channel=193:224, row=rep(21:24, each=8), col=rep(16:9, 4) ),
    data.frame( channel=129:160, row=rep(25:28, each=8), col=rep(16:9, 4) ),
    data.frame( channel=65:96,   row=rep(29:32, each=8), col=rep(16:9, 4) )
)

add_cols <- function(d, min.q){
    # take a sequencing sumamry file (d), and a minimum Q value you are interested in (min.q)
    # return the same data frame with the following columns added 
        # cumulative.bases
        # hour of run
        # reads.per.hour
    
    d = subset(d, mean_qscore_template >= min.q)
    
    if(nrow(d)==0){ 
        flog.error(paste("There are no reads with a mean Q score higher than your cutoff of ", min.q, ". Please choose a lower cutoff and try again.", sep = ""))
        quit()
    }
    
    d = merge(d, map, by="channel")
    d = d[with(d, order(start_time)), ] # sort by read length
    d$cumulative.bases.time = cumsum(as.numeric(d$start_time))
    d = d[with(d, order(-sequence_length_template)), ] # sort by read length
    d$cumulative.bases = cumsum(as.numeric(d$sequence_length_template))
    d$hour = d$start_time %/% 3600
    
    # add the reads generated for each hour
    reads.per.hour = as.data.frame(table(d$hour))
    names(reads.per.hour) = c("hour", "reads_per_hour")
    reads.per.hour$hour = as.numeric(as.character(reads.per.hour$hour))
    d = merge(d, reads.per.hour, by = c("hour"))    
    return(d)
}


load_summary <- function(filepath, min.q){
    # load a sequencing summary and add some info
    # min.q is a vector of length 2 defining 2 levels of min.q to have
    # by default the lowest value is -Inf, i.e. includes all reads. The 
    # other value in min.q is set by the user at the command line
    d = read_tsv(
        filepath,
        col_types = cols_only(
            channel = 'i', 
            passes_filtering = 'c',
            num_events_template = 'i', 
            sequence_length_template = 'i', 
            mean_qscore_template = 'n',
            sequence_length_2d = 'i',
            mean_qscore_2d = 'n',
            start_time = 'n'
        )
    )
    
    if("sequence_length_2d" %in% names(d)){
        # it's a 1D2 or 2D run
        d$sequence_length_template = as.numeric(as.character(d$sequence_length_2d))
        d$mean_qscore_template = as.numeric(as.character(d$mean_qscore_2d))
        d$num_events_template = NA
        d$start_time = as.numeric(as.character(d$start_time))
        
    }else{
        d$sequence_length_template = as.numeric(as.character(d$sequence_length_template))
        d$mean_qscore_template = as.numeric(as.character(d$mean_qscore_template))
        d$num_events_template = as.numeric(as.character(d$num_events_template))
        d$start_time = as.numeric(as.character(d$start_time))
    }

    # ignore 0-length reads
    d <- d[d$sequence_length_template > 0,]
    # ignore reads failing filtering
    if (filt.failed) {
        d <- d[d$passes_filtering == 'True',]
    }
        
    d$events_per_base = d$num_events_template/d$sequence_length_template

    flowcell = basename(dirname(filepath))
    
    # add columns for all the reads
    d1 = add_cols(d, min.q[1])
    d1$Q_cutoff = "All reads"
    
    # add columns for just the reads that pass the user Q threshold
    d2 = add_cols(d, min.q[2])
    d2$Q_cutoff = q_title
    
    # bind those two together into one data frame
    d = as.data.frame(rbindlist(list(d1, d2)))

    # name the flowcell (useful for analyses with >1 flowcell)
    d$flowcell = flowcell
    
    # make sure this is a factor
    d$Q_cutoff = as.factor(d$Q_cutoff)

    
    keep = c("hour","start_time", "channel", "sequence_length_template", "mean_qscore_template", "row", "col", "cumulative.bases", "cumulative.bases.time", "reads_per_hour", "Q_cutoff", "flowcell", "events_per_base")
    d = d[keep]

    d$start_bin = cut(d$start_time, 9,labels=c(1:9))
        
    return(d)
}

reads.gt <- function(d, len){
    # return the number of reads in data frame d
    # that are at least as long as length len
    return(length(which(d$sequence_length_template>=len)))
}

bases.gt <- function(d, len){
    # return the number of bases contained in reads from 
    # data frame d
    # that are at least as long as length len
    reads = subset(d, sequence_length_template >= len)
    return(sum(as.numeric(reads$sequence_length_template)))
}

log10_minor_break = function (...){
    # function to add minor breaks to a log10 graph
    # hat-tip: https://stackoverflow.com/questions/30179442/plotting-minor-breaks-on-a-log-scale-with-ggplot
    function(x) {
        minx         = floor(min(log10(x), na.rm=T))-1;
        maxx         = ceiling(max(log10(x), na.rm=T))+1;
        n_major      = maxx-minx+1;
        major_breaks = seq(minx, maxx, by=1)
        minor_breaks = 
            rep(log10(seq(1, 9, by=1)), times = n_major)+
            rep(major_breaks, each = 9)
        return(10^(minor_breaks))
    }
}

binSearch <- function(min, max, df, t = 100000) {
    # binary search algorithm, thanks to https://stackoverflow.com/questions/46292438/optimising-a-calculation-on-every-cumulative-subset-of-a-vector-in-r/46303384#46303384
    # the aim is to return the number of reads in a dataset (df)
    # that comprise the largest subset of reads with an N50 of t
    # we use this to calculte the number of 'ultra long' reads
    # which are defined as those with N50 > 100KB
    mid = floor(mean(c(min, max)))
    if (mid == min) {
        if (df$sequence_length_template[min(which(df$cumulative.bases>df$cumulative.bases[min]/2))] < t) {
            return(min - 1)
        } else {
            return(max - 1)
        }
    }
    
    n = df$sequence_length_template[min(which(df$cumulative.bases>df$cumulative.bases[mid]/2))]
    if (n >= t) {
        return(binSearch(mid, max, df))
    } else {
        return(binSearch(min, mid, df))
    }
}


summary.stats <- function(d, Q_cutoff="All reads"){
    # Calculate summary stats for a single value of min.q
    
    rows = which(as.character(d$Q_cutoff)==Q_cutoff)
    d = d[rows,]
    d = d[with(d, order(-sequence_length_template)), ] # sort by read length, just in case
    
    total.bases = sum(as.numeric(d$sequence_length_template))
    total.reads = nrow(d)
    N50.length = d$sequence_length_template[min(which(d$cumulative.bases > (total.bases/2)))]
    mean.length = round(mean(as.numeric(d$sequence_length_template)), digits = 1)
    median.length = round(median(as.numeric(d$sequence_length_template)), digits = 1)
    max.length = max(as.numeric(d$sequence_length_template))
    mean.q = round(mean(d$mean_qscore_template), digits = 1)
    median.q = round(median(d$mean_qscore_template), digits = 1)
    
    #calculate ultra-long reads and bases (max amount of data with N50>100KB)
    ultra.reads = binSearch(1, nrow(d), d, t = 100000)    
    if(ultra.reads>=1){
        ultra.gigabases = sum(as.numeric(d$sequence_length_template[1:ultra.reads]))/1000000000
    }else{
        ultra.gigabases = 0
    }
        
    reads = list(
                reads.gt(d, 10000), 
                reads.gt(d, 20000), 
                reads.gt(d, 50000),
                reads.gt(d, 100000),
                reads.gt(d, 200000),
                reads.gt(d, 500000),
                reads.gt(d, 1000000),
                ultra.reads)
    names(reads) = c(">10kb", ">20kb", ">50kb", ">100kb", ">200kb", ">500kb", ">1m", "ultralong")

    bases = list(
                bases.gt(d, 10000)/1000000000, 
                bases.gt(d, 20000)/1000000000, 
                bases.gt(d, 50000)/1000000000,
                bases.gt(d, 100000)/1000000000,
                bases.gt(d, 200000)/1000000000,
                bases.gt(d, 500000)/1000000000,
                bases.gt(d, 1000000)/1000000000,
                ultra.gigabases)
    names(bases) = c(">10kb", ">20kb", ">50kb", ">100kb", ">200kb", ">500kb", ">1m", "ultralong")
    
    return(list('total.gigabases' = total.bases/1000000000,
                'total.reads' = total.reads,
                'N50.length' = N50.length, 
                'mean.length' = mean.length, 
                'median.length' = median.length,
                'max.length' = max.length,
                'mean.q' = mean.q,
                'median.q' = median.q,
                'reads' = reads,
                'gigabases' = bases
                ))
}

channel.summary <- function(d){
    # calculate summaries of what happened in each of the channels 
    # of a flowcell
    
    a <- d %>% group_by(channel) %>% summarize(
        total.bases = sum(sequence_length_template), 
        total.reads = sum(which(sequence_length_template>=0)), 
        mean.read.length = mean(sequence_length_template), 
        median.read.length = median(sequence_length_template)
    )
        
    #b = melt(a, id.vars = c("channel"))
    b = gather(a, key, -channel)
    return(b)    
}


single.flowcell <- function(input.file, output.dir, q=8){
    # wrapper function to analyse data from a single flowcell
    # input.file is a sequencing_summary.txt file from a 1D run
    # output.dir is the output directory into which to write results
    # q is the cutoff used for Q values, set by the user
    flog.info(paste("Loading input file:", input.file))
    d = load_summary(input.file, min.q=c(-Inf, q))

    flowcell = unique(d$flowcell)

    flog.info(paste(sep = "", flowcell, ": creating output directory:", output.dir))
    dir.create(output.dir)
    out.txt = file.path(output.dir, "summary.yaml")
    
    flog.info(paste(sep = "", flowcell, ": summarising input file for flowcell"))
    all.reads.summary = summary.stats(d, Q_cutoff = "All reads")
    q10.reads.summary = summary.stats(d, Q_cutoff = q_title)
    
    summary = list("input file" = input.file,
                   "All reads" = all.reads.summary,
                   cutoff = q10.reads.summary,
                   "notes" = 'ultralong reads refers to the largest set of reads with N50>100KB')
    
    names(summary)[3] = q_title
    
    write(as.yaml(summary), out.txt)
    
    muxes = seq(from = 0, to = max(d$hour), by = 8)
    
    # make plots
    flog.info(paste(sep = "", flowcell, ": plotting length histogram"))
    len.lims <- quantile(d$sequence_length_template,c(0.01,0.99))
    len.lims[1] <- max(1,len.lims[1])
    
    p1 = ggplot(d, aes(x = sequence_length_template)) + 
        geom_histogram(bins = 200, fill="steelblue") + 
        scale_x_log10(breaks=round(10^pretty(log10(len.lims),n=5),0),limits=len.lims) + 
        facet_wrap(~Q_cutoff, ncol = 1, scales = "free_y") + 
        #theme(text = element_text(size = 15)) +
        theme_linedraw() +
        xlab("Read length") +
        ylab("Number of reads")
    ggsave(filename = file.path(output.dir, "length_histogram.png"), width = 7, height = 4, dpi=300, plot = p1)
    ggsave(filename = file.path(output.dir, "length_histogram.screen.png"), width = 7, height = 4, dpi=90, plot = p1)

    flog.info(paste(sep = "", flowcell, ": plotting mean Q score histogram"))
    q.lims <- quantile(d$mean_qscore_template,c(0.005,0.995))
    p2 = ggplot(subset(d, Q_cutoff=="All reads"), aes(x = mean_qscore_template)) + 
        geom_histogram(bins = 200, fill="steelblue") + 
        xlim(q.lims) +
        geom_vline(xintercept=q) +
        #theme(text = element_text(size = 15)) +
        theme_linedraw() +
        xlab("Mean Q score of read") +
        ylab("Number of reads")
    ggsave(filename = file.path(output.dir, "q_histogram.png"), width = 7, height = 4, dpi=300, plot = p2)
    ggsave(filename = file.path(output.dir, "q_histogram.screen.png"), width = 7, height = 4, dpi=90, plot = p2)

   
    flog.info(paste(sep = "", flowcell, ": plotting flowcell overview"))

    df <- data.frame(row=integer(),col=integer(),bin=integer(), quality=numeric())

    # swap rows and columns
    for (b in c(1,5,9)) {
        m <- matrix(data=rep(0,512),nrow=16,ncol=32)
        for (r in 1:32) {
        for (c in 1:16) {
            sub <- subset(d, Q_cutoff=="All reads" & col == c & row == r & start_bin == b)
            Q <-  median(sub$mean_qscore_template)
            df <- rbind(df, list(row=r, col=c, bin=b, quality=Q) )
        }
        }
    }

    df$bin <- factor(df$bin)
    levels(df$bin) <- c("Run start", "Run middle", "Run end")
    p5 = ggplot(df, aes(col, row)) + 
        geom_tile(aes(fill = quality), color="white") +
        scale_fill_gradient(low = "white", high="steelblue") +
        xlab("Column") +
        ylab("Row") +
        facet_wrap(~bin, ncol=3) +
        theme_linedraw() +
        theme(panel.grid.minor = element_blank(), panel.grid.major=element_blank())
        #theme(text = element_text(size = 40), axis.text.x = element_text(size=12), axis.text.y = element_text(size=12), legend.text=element_text(size=12))
    ggsave(filename = file.path(output.dir, "flowcell_overview.png"), width = 7, height = 4, dpi=300, plot = p5)
    ggsave(filename = file.path(output.dir, "flowcell_overview.screen.png"), width = 7, height = 4, dpi=90, plot = p5)

    flog.info(paste(sep = "", flowcell, ": plotting cumulative yield summary"))
    p5a = ggplot(d, aes(x=start_time/3600, y=cumulative.bases.time, colour = Q_cutoff)) + 
        geom_line(size = 1) + 
        geom_vline(xintercept = muxes, colour = 'red', linetype = 'dashed', alpha = 0.5) +
        xlab("Elapsed time (hrs)") +
        ylab("Total yield in bases") +
        scale_colour_discrete(guide = guide_legend(title = "Reads")) +
        scale_x_continuous() +
        theme_linedraw()
        #theme(text = element_text(size = 15))

    ggsave(filename = file.path(output.dir, "cumulative_yield.png"), width = 7, height = 4, dpi=300, plot = p5a)
    ggsave(filename = file.path(output.dir, "cumulative_yield.screen.png"), width = 7, height = 4, dpi=90, plot = p5a)

    flog.info(paste(sep = "", flowcell, ": plotting flowcell yield summary"))
    p6 = ggplot(d, aes(x=sequence_length_template, y=cumulative.bases, colour = Q_cutoff)) + 
        geom_line(size = 1) + 
        xlab("Minimum read length") +
        ylab("Total yield in bases") +
        scale_colour_discrete(guide = guide_legend(title = "Reads")) +
        #theme(text = element_text(size = 15))
        theme_linedraw()
    xmax = max(d$sequence_length_template[which(d$cumulative.bases > 0.01 * max(d$cumulative.bases))])
    p6 = p6 + scale_x_continuous(limits = c(0, xmax))

    ggsave(filename = file.path(output.dir, "yield_summary.png"), width = 7, height = 4, dpi=300, plot = p6)
    ggsave(filename = file.path(output.dir, "yield_summary.screen.png"), width = 7, height = 4, dpi=90, plot = p6)
    
    flog.info(paste(sep = "", flowcell, ": plotting sequence length over time"))
    e = subset(d, Q_cutoff=="All reads")
    e$Q = paste(">=", q, sep="")
    e$Q[which(e$mean_qscore_template<q)] = paste("<", q, sep="")
    p7 = ggplot(e, aes(x=start_time/3600, y=sequence_length_template, colour = Q, group = Q)) + 
        geom_vline(xintercept = muxes, colour = 'red', linetype = 'dashed', alpha = 0.5) +
        geom_smooth() + 
        xlab("Elapsed time (hrs)") + 
        ylab("Mean read length") + 
        ylim(0, NA) +
        theme_linedraw()
    ggsave(filename = file.path(output.dir, "length_by_hour.png"), width = 7, height = 4, dpi=300, plot = p7)
    ggsave(filename = file.path(output.dir, "length_by_hour.screen.png"), width = 7, height = 4, dpi=90, plot = p7)
    
    flog.info(paste(sep = "", flowcell, ": plotting Q score over time"))
    p8 = ggplot(e, aes(x=start_time/3600, y=mean_qscore_template, colour = Q, group = Q)) + 
        geom_vline(xintercept = muxes, colour = 'red', linetype = 'dashed', alpha = 0.5) +
        geom_smooth() + 
        xlab("Elapsed time (hrs)") + 
        ylab("Mean Q score") + 
        ylim(0, NA) +
        theme_linedraw()
    ggsave(filename = file.path(output.dir, "q_by_hour.png"), width = 7, height = 4, dpi=300, plot = p8)
    ggsave(filename = file.path(output.dir, "q_by_hour.screen.png"), width = 7, height = 4, dpi=90, plot = p8)
    
    flog.info(paste(sep = "", flowcell, ": plotting reads per hour"))
    f = d[c("hour", "reads_per_hour", "Q_cutoff")]
    f = f[!duplicated(f),]
    g = subset(f, Q_cutoff=="All reads")
    h = subset(f, Q_cutoff==q_title)
    max = max(f$hour)
    # all of this is just to fill in hours with no reads recorded
    all = 0:max
    add.g = all[which(all %in% g$hour == FALSE)]
    if(length(add.g)>0){
        add.g = data.frame(hour = add.g, reads_per_hour = 0, Q_cutoff = "All reads")
        g = rbind(g, add.g)
    }
    add.h = all[which(all %in% h$hour == FALSE)]
    if(length(add.h)>0){
        add.h = data.frame(hour = add.h, reads_per_hour = 0, Q_cutoff = q_title)
        h = rbind(h, add.h)
    }
    i = rbind(g, h)
    i$Q_cutoff = as.character(i$Q_cutoff)
    i$Q_cutoff[which(i$Q_cutoff==q_title)] = paste("Q>=", q, sep="")
    p9 = ggplot(i, aes(x=hour, y=reads_per_hour, colour = Q_cutoff, group = Q_cutoff)) + 
        geom_vline(xintercept = muxes, colour = 'red', linetype = 'dashed', alpha = 0.5) +
        #geom_point() +
        geom_line(size=1) +
        xlab("Elapsed time (hrs)") + 
        ylab("Number of reads per hour") + 
        ylim(0, NA) + 
        scale_color_discrete(guide = guide_legend(title = "Reads")) +
        theme_linedraw()
    ggsave(filename = file.path(output.dir, "reads_per_hour.png"), width = 7, height = 4, dpi=300, plot = p9)
    ggsave(filename = file.path(output.dir, "reads_per_hour.screen.png"), width = 7, height = 4, dpi=90, plot = p9)
    
    flog.info(paste(sep = "", flowcell, ": plotting read length vs. q score scatterplot"))
    p10 = ggplot(subset(d, Q_cutoff=="All reads"), aes(x = sequence_length_template, y = mean_qscore_template, colour = events_per_base)) + 
        geom_point(alpha=0.05, size = 0.4) + 
        scale_x_log10(minor_breaks=log10_minor_break()) + 
        labs(colour='Events per base\n(log scale)\n')  + 
        theme(text = element_text(size = 15)) +
        xlab("Read length") +
        ylab("Mean Q score of read") +
        theme_linedraw()

    if(max(d$events_per_base, na.rm=T)>0){
        # a catch for 1D2 runs which don't have events per base
        p10 = p10 + scale_colour_distiller(trans = "log", labels = scientific, palette = 'Spectral') 
    }

    ggsave(filename = file.path(output.dir, "length_vs_q.png"), width = 7, height = 4, dpi=300, plot = p10)
    ggsave(filename = file.path(output.dir, "length_vs_q.screen.png"), width = 7, height = 4, dpi=90, plot = p10)
    
    return(d)
}

if(file_test("-f", input.file)==TRUE){
    # if it's an existing file (not a folder) just run one analysis
    d = single.flowcell(input.file, output.dir, q)
}else{
    #WTF
    flog.warn(paste("Could find a sequencing summary file in your input which was: ", 
        input.file, 
        "\nThe input must be a sequencing_summary.txt file produced by Albacore"))
}