|
| 1 | +""" |
| 2 | +CoverageCalculatorPy.py |
| 3 | +
|
| 4 | +Calculates coverage statistics from depth of coverage file |
| 5 | +
|
| 6 | +Aurthor: Christopher Medway |
| 7 | +Created: 11th November 2018 |
| 8 | +Version: 0.0.1 |
| 9 | +Updated: 11th November 2018 |
| 10 | +""" |
| 11 | + |
| 12 | +import argparse |
| 13 | +import tabix |
| 14 | +import os |
| 15 | +import logging |
| 16 | + |
| 17 | + |
| 18 | +def get_args(): |
| 19 | + """ |
| 20 | + uses argparse package to extract command line arguments |
| 21 | + """ |
| 22 | + |
| 23 | + # argparse object |
| 24 | + parser = argparse.ArgumentParser( |
| 25 | + description='accepts a bedfile and GATK depthOfCoverage file and generates coverage metrics') |
| 26 | + |
| 27 | + parser.add_argument('-B', '--bedfile', help='path to bedfile', required=True) |
| 28 | + parser.add_argument('-D', '--depthfile', help='path to depth file', required=True) |
| 29 | + parser.add_argument('-p', '--padding', help='basepair padding', required=False, default=0) |
| 30 | + parser.add_argument('-d', '--depth', help='coverage threshold', required=False, default=100) |
| 31 | + parser.add_argument('-o', "--outname", help="output file name", required=False, default="output") |
| 32 | + parser.add_argument('-O', '--outdir', help="output file directory", required=False, default="./") |
| 33 | + parser.add_argument('-g', '--groupfile', help="file of annotations to group bed intervals", required=False) |
| 34 | + |
| 35 | + args = parser.parse_args() |
| 36 | + return args |
| 37 | + |
| 38 | + |
| 39 | +def main(args): |
| 40 | + """ |
| 41 | + main function iterates over intervals in given bedfile |
| 42 | + """ |
| 43 | + |
| 44 | + # setup logger |
| 45 | + logger = logging.getLogger('CoverageCalculatorPy') |
| 46 | + logger.setLevel(logging.DEBUG) |
| 47 | + handler = logging.StreamHandler() |
| 48 | + handler.setLevel(logging.DEBUG) |
| 49 | + formatter = logging.Formatter( |
| 50 | + '%(levelname)s\t%(asctime)s\t%(name)s\t%(message)s' |
| 51 | + ) |
| 52 | + handler.setFormatter(formatter) |
| 53 | + logger.addHandler(handler) |
| 54 | + logger.info('running CoverageCalculatorPy.py...') |
| 55 | + |
| 56 | + |
| 57 | + # make output directory if it doesn't exist |
| 58 | + if not os.path.exists(args.outdir): |
| 59 | + os.makedirs(args.outdir) |
| 60 | + |
| 61 | + # file handel for coverage output file |
| 62 | + covr_outfile = args.outdir + args.outname + ".coverage" |
| 63 | + logger.info('coverage information for each interval in given bedfile will be written to: ' + str(covr_outfile)) |
| 64 | + |
| 65 | + # remove file if exists |
| 66 | + if os.path.exists(covr_outfile): |
| 67 | + os.remove(covr_outfile) |
| 68 | + |
| 69 | + # open coverage output file & write header |
| 70 | + covfile = open(covr_outfile, 'a+') |
| 71 | + covfile.write( |
| 72 | + "CHR" + |
| 73 | + "\t" + |
| 74 | + "START" + |
| 75 | + "\t" + |
| 76 | + "END" + |
| 77 | + "\t" + |
| 78 | + "META" + |
| 79 | + "\t" + |
| 80 | + "AVG_DEPTH" + |
| 81 | + "\t" + |
| 82 | + "PERC_COVERAGE@" + str(args.depth) + "\n" |
| 83 | + ) |
| 84 | + |
| 85 | + # variables to store running totals of depth / coverage |
| 86 | + # over all intervals in file |
| 87 | + mem_depth = 0 |
| 88 | + mem_meets_depth = 0 |
| 89 | + mem_length = 0 |
| 90 | + |
| 91 | + # setup groups / features variables if this file has been included --groupsfile |
| 92 | + if args.groupfile is not None: |
| 93 | + logger.info('group file included: ' + str(args.groupfile)) |
| 94 | + # initialise variables to store depth / coverage over feature |
| 95 | + feature = [] |
| 96 | + feature_length = 0 |
| 97 | + feature_depth = 0 |
| 98 | + feature_meets_depth = 0 |
| 99 | + last_feature = 0 |
| 100 | + |
| 101 | + # features made into list |
| 102 | + for line in open(args.groupfile): |
| 103 | + feature.append(line.rstrip()) |
| 104 | + |
| 105 | + # check bedfile and groups are same length - throw error if different |
| 106 | + num_ln_grp = len(feature) - 1 # minus one because grp file has a header |
| 107 | + num_ln_bed = sum(1 for line in open(args.bedfile)) |
| 108 | + |
| 109 | + if num_ln_bed != num_ln_grp: |
| 110 | + logger.error('bedfile and groupfile do not have the same number of entries! Groupfile must have a header') |
| 111 | + |
| 112 | + |
| 113 | + # prepare output file for merged data - this always includes a data for all intervals in .bed |
| 114 | + # but can also include groups if the file is given as an argument |
| 115 | + mrg_outfile = args.outdir + args.outname + ".totalCoverage" |
| 116 | + logger.info('coverage information across all intervals in bedfile will be written to: ' + str(mrg_outfile)) |
| 117 | + |
| 118 | + # remove merged file if exists |
| 119 | + if os.path.exists(mrg_outfile): |
| 120 | + os.remove(mrg_outfile) |
| 121 | + |
| 122 | + # open coverage output file & write header |
| 123 | + mrgfile = open(mrg_outfile, 'a+') |
| 124 | + |
| 125 | + mrgfile.write( |
| 126 | + "FEATURE" + |
| 127 | + "\t" + |
| 128 | + "AVG_DEPTH" + |
| 129 | + "\t" + |
| 130 | + "PERC_COVERAGE@" + str(args.depth) + "\n" |
| 131 | + ) |
| 132 | + |
| 133 | + |
| 134 | + # prepare output file for gaps |
| 135 | + # file handel |
| 136 | + gaps_outfile = args.outdir + args.outname + ".gaps" |
| 137 | + logger.info('gaps - intervals not covered at a minimum of ' + str(args.depth) + ' will be written to: ' + str(gaps_outfile)) |
| 138 | + |
| 139 | + # remove file if exists |
| 140 | + if os.path.exists(gaps_outfile): |
| 141 | + os.remove(gaps_outfile) |
| 142 | + |
| 143 | + # open gaps output file & write header |
| 144 | + gapsfile = open(gaps_outfile, 'a+') |
| 145 | + gapsfile.write("##DEPTH_THRESHOLD=" + str(args.depth) + "\n") |
| 146 | + |
| 147 | + #prepare missing file outfile |
| 148 | + # file handel |
| 149 | + missing_outfile = args.outdir + args.outname + ".missing" |
| 150 | + logger.info('intervals which do not a corresponding depth in ' + args.depthfile + ' will be written to: ' + str(missing_outfile)) |
| 151 | + |
| 152 | + # is exists, remove file |
| 153 | + if os.path.exists(missing_outfile): |
| 154 | + os.remove(missing_outfile) |
| 155 | + |
| 156 | + # open gaps output file & write header |
| 157 | + missingfile = open(missing_outfile, 'a+') |
| 158 | + |
| 159 | + |
| 160 | + # iterate over each line of the bed file |
| 161 | + with open(args.bedfile) as bed: |
| 162 | + |
| 163 | + # counter used to access element of the features list that correpondes |
| 164 | + # to a bedfile entry. Start on element 1 because 0 contains features header |
| 165 | + cnt_bed_ln = 1 |
| 166 | + |
| 167 | + for line in bed: |
| 168 | + bedlist = line.split() |
| 169 | + chr = bedlist[0] |
| 170 | + start = int(bedlist[1]) |
| 171 | + end = int(bedlist[2]) |
| 172 | + |
| 173 | + # does bedfile contain 4th column of metadata |
| 174 | + if len(bedlist) > 3: |
| 175 | + meta = str(bedlist[3]) |
| 176 | + else: |
| 177 | + meta = "" |
| 178 | + |
| 179 | + # function prints coverage for given interval, but also returns stats for aggregation across intervals |
| 180 | + depth, meets_depth, length = get_avg_depth(covfile, chr, start, end, meta, args.depthfile, int(args.depth)) |
| 181 | + |
| 182 | + # storing coverage info across all intervals in given bedfile |
| 183 | + mem_depth = mem_depth + depth |
| 184 | + mem_meets_depth = mem_meets_depth + meets_depth |
| 185 | + mem_length = mem_length + length |
| 186 | + |
| 187 | + # storing coverage info across features if --groupfile argument included |
| 188 | + if args.groupfile is not None: |
| 189 | + current_feature = feature[cnt_bed_ln] |
| 190 | + |
| 191 | + if cnt_bed_ln == 1: |
| 192 | + last_feature = current_feature |
| 193 | + |
| 194 | + if current_feature != last_feature: |
| 195 | + mrgfile.write( |
| 196 | + str(feature[cnt_bed_ln - 1] + '_' + feature[0]) + |
| 197 | + "\t" + |
| 198 | + str(round(feature_depth / feature_length, 0)) + |
| 199 | + "\t" + |
| 200 | + str(round((feature_meets_depth / feature_length)*100, 1)) + "\n") |
| 201 | + |
| 202 | + feature_depth = 0 |
| 203 | + feature_meets_depth = 0 |
| 204 | + feature_length = 0 |
| 205 | + |
| 206 | + feature_depth = feature_depth + depth |
| 207 | + feature_meets_depth = feature_meets_depth + meets_depth |
| 208 | + feature_length = feature_length + length |
| 209 | + last_feature = current_feature |
| 210 | + |
| 211 | + # reports gaps for given feature and appends to gaps file |
| 212 | + get_gaps(gapsfile, chr, start, end, meta, args.depthfile, int(args.depth)) |
| 213 | + |
| 214 | + # reports coordinates which are unavilable (i.e in bed but not in depthfile) and appends to file |
| 215 | + report_missing_regions(missingfile, chr, start, end, meta, args.depthfile) |
| 216 | + |
| 217 | + cnt_bed_ln = cnt_bed_ln + 1 |
| 218 | + |
| 219 | + # if --groups has been used, last interval in bed will always be the end of a feature - so print |
| 220 | + if args.groupfile is not None: |
| 221 | + mrgfile.write( |
| 222 | + str(feature[cnt_bed_ln - 1] + '_' + feature[0]) + |
| 223 | + "\t" + |
| 224 | + str(round(feature_depth / feature_length, 0)) + |
| 225 | + "\t" + |
| 226 | + str(round((feature_meets_depth / feature_length)*100, 1)) + "\n") |
| 227 | + |
| 228 | + # print accumated depth / coverage across entire bedfile |
| 229 | + mrgfile.write( |
| 230 | + args.outname + |
| 231 | + "\t" + |
| 232 | + str(round(mem_depth / mem_length, 0)) + |
| 233 | + "\t" + |
| 234 | + str(round((mem_meets_depth / mem_length) * 100, 1)) + "\n" |
| 235 | + ) |
| 236 | + |
| 237 | + covfile.close() |
| 238 | + |
| 239 | + |
| 240 | + |
| 241 | + |
| 242 | +def get_bed_lines(depthfile, chr, start, end): |
| 243 | + """ |
| 244 | + given a genomic interval, extract entries from tabix indexed depth of coverage file |
| 245 | + depth of coverage file is generated using GATK3 |
| 246 | +
|
| 247 | + :param depthfile: depth of coverage file generated in GATK3 and manually tabix indexed (see documentation) |
| 248 | + :param chr: chromosome |
| 249 | + :param start: start coordinate 0-based |
| 250 | + :param end: end coordinate 0-based |
| 251 | + :return: records: object containing bed intervals |
| 252 | + """ |
| 253 | + tb = tabix.open(depthfile) |
| 254 | + records = tb.query(chr, start, end) |
| 255 | + return records |
| 256 | + |
| 257 | + |
| 258 | +def get_avg_depth(covfile, chr, start, end, meta, depthfile, depth_threshold): |
| 259 | + """ |
| 260 | + function to iterate over interval and calculate average coverage & percent of bases meeting given |
| 261 | + depth threshold |
| 262 | + :param covfile: coverage output filehandle |
| 263 | + :param chr: chromosome |
| 264 | + :param start: start coordinate 0-based |
| 265 | + :param end: end coordinate 0-based |
| 266 | + :param meta: meta information from bedfile 4th column |
| 267 | + :param depthfile: depth of coverage file generated in GATK3 and manually tabix indexed (see documentation) |
| 268 | + :param depth_threshold: minimum depth of coverage for PASS |
| 269 | + """ |
| 270 | + |
| 271 | + # get depthfile entry for interval |
| 272 | + records = get_bed_lines(depthfile, chr, start, end) |
| 273 | + |
| 274 | + # intialise variables used in forloop |
| 275 | + tot_depth = 0 |
| 276 | + meets_depth = 0 # counts the number of bases meeting min depth requirement |
| 277 | + length = (end - start) |
| 278 | + |
| 279 | + for record in records: |
| 280 | + depth = int(record[2]) |
| 281 | + tot_depth = tot_depth + depth # accumaulated depth |
| 282 | + if depth >= depth_threshold: |
| 283 | + meets_depth = int(meets_depth) + 1 |
| 284 | + avg_depth = round(tot_depth / length, 0) |
| 285 | + perc_coverage = round((meets_depth / length) * 100, 1) |
| 286 | + |
| 287 | + covfile.write( |
| 288 | + str(chr) + |
| 289 | + "\t" + |
| 290 | + str(start) + |
| 291 | + "\t" + |
| 292 | + str(end) + |
| 293 | + "\t" + |
| 294 | + str(meta) + |
| 295 | + "\t" + |
| 296 | + str(avg_depth) + |
| 297 | + "\t" + |
| 298 | + str(perc_coverage) + |
| 299 | + "\n" |
| 300 | + ) |
| 301 | + return tot_depth, meets_depth, length |
| 302 | + |
| 303 | + |
| 304 | +def get_gaps(gapsfile, chr, start, end, meta, depthfile, depth_threshold): |
| 305 | + """ |
| 306 | + identifies and prints coordinates, within the given bed interval, that fall below the given |
| 307 | + depth threshold |
| 308 | + """ |
| 309 | + # get depthfile entry for interval |
| 310 | + records = get_bed_lines(depthfile, chr, start, end) |
| 311 | + |
| 312 | + # initialise variables used in loop |
| 313 | + first_entry = 1 |
| 314 | + coord = 0 |
| 315 | + gap_start = 0 |
| 316 | + |
| 317 | + for record in records: |
| 318 | + |
| 319 | + coord = int(record[1]) |
| 320 | + depth = int(record[2]) |
| 321 | + |
| 322 | + if first_entry == 1: |
| 323 | + gap_start = coord |
| 324 | + first_entry = 0 |
| 325 | + |
| 326 | + if depth >= depth_threshold: |
| 327 | + if coord != gap_start: |
| 328 | + # this is the end of a gap and should be printed |
| 329 | + gapsfile.write(str(chr) + "\t" + str(gap_start - 1) + "\t" + str(coord - 1) + "\n") |
| 330 | + gap_start = coord + 1 |
| 331 | + else: |
| 332 | + # this is not the end of a gap |
| 333 | + gap_start = gap_start + 1 |
| 334 | + |
| 335 | + # if entire interval is missing |
| 336 | + if coord == 0: |
| 337 | + print("") |
| 338 | + # if interval ended on a gap, print final row |
| 339 | + elif coord != gap_start - 1: |
| 340 | + gapsfile.write(str(chr) + "\t" + str(gap_start - 1) + "\t" + str(coord) + "\n") |
| 341 | + |
| 342 | + |
| 343 | +def report_missing_regions(missingfile, chr, start, end, meta, depthfile): |
| 344 | + """ |
| 345 | + identifies and reports coordinates which are given in the bedfile, but are not included |
| 346 | + in the depth of coverage file |
| 347 | + """ |
| 348 | + # get depthfile entry for interval |
| 349 | + records = get_bed_lines(depthfile, chr, start, end) |
| 350 | + |
| 351 | + # initialse variables used in loop |
| 352 | + coords_in_depthfile = [] |
| 353 | + |
| 354 | + for record in records: |
| 355 | + coords_in_depthfile.append(int(record[1])) |
| 356 | + |
| 357 | + coords_in_bed = list(range(start+1, end+1)) |
| 358 | + missing = sorted(list(set(coords_in_bed) - set(coords_in_depthfile))) |
| 359 | + |
| 360 | + i = 0 |
| 361 | + while i < len(missing): |
| 362 | + if i == 0: |
| 363 | + start = missing[i] |
| 364 | + else: |
| 365 | + if missing[i] - 1 != missing[i - 1]: |
| 366 | + missingfile.write(str(chr) + "\t" + str(start - 1) + "\t" + str(missing[i]) + "\t" + str(meta) + "\n") |
| 367 | + start = missing[i] + 1 |
| 368 | + elif i + 1 == len(missing): |
| 369 | + missingfile.write(str(chr) + "\t" + str(start - 1) + "\t" + str(missing[i]) + "\t" + str(meta) + "\n") |
| 370 | + |
| 371 | + i = i + 1 |
| 372 | + |
| 373 | + |
| 374 | +if __name__ == '__main__': |
| 375 | + args = get_args() |
| 376 | + main(args) |
0 commit comments