Skip to content

Commit 222010d

Browse files
committed
updated scripts
1 parent a495928 commit 222010d

6 files changed

Lines changed: 2182 additions & 117 deletions

File tree

docker-cle-mopathtools/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ RUN apt-get update -y && \
1414
procps \
1515
ca-certificates \
1616
curl \
17-
wget \
1817
gawk \
18+
wget \
1919
tzdata \
2020
openssh-client \
2121
less \
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
#!/usr/bin/env python
2+
3+
import sys, os, re, csv, pysam, json, binascii, math, argparse
4+
import sqlite3
5+
import pandas as pd
6+
import numpy as np
7+
from time import gmtime, strftime
8+
from natsort import natsort_keygen
9+
from pathlib import Path
10+
11+
__version__ = '1.0.0'
12+
13+
def sanitize_for_json(obj):
14+
"""Recursively replace inf and nan with None."""
15+
if isinstance(obj, dict):
16+
return {k: sanitize_for_json(v) for k, v in obj.items()}
17+
elif isinstance(obj, list):
18+
return [sanitize_for_json(v) for v in obj]
19+
elif isinstance(obj, float):
20+
if math.isinf(obj) or math.isnan(obj):
21+
return None # Will become null in JSON
22+
return obj
23+
24+
def parse_dragen_metrics_file(metrics_file):
25+
df = pd.read_csv(metrics_file,sep=',',names=['category','readgroup','metric','value','percent'])
26+
# add sequential index as column for sorting later
27+
df.insert(0,'index',range(len(df)))
28+
29+
df = df[df['readgroup'].isna()].drop(columns='readgroup')
30+
df['metric'] = df['category'] + ': ' + df['metric']
31+
dfpct = df[df['percent'].notna()].copy()
32+
df = df.drop(columns='percent')
33+
dfpct['metric'] = dfpct['metric'].apply(lambda x: x + ' (%)')
34+
dfpct['value'] = dfpct['percent']
35+
dfpct = dfpct.drop(columns='percent')
36+
df = pd.concat([df,dfpct])
37+
38+
# change metric with format [\d+x: inf) to >\d+x
39+
df.loc[df['metric'].str.contains(r'\[\s*\d+x: inf\)'), 'metric'] = df[df['metric'].str.contains(r'\[\s*\d+x: inf\)')]['metric'].str.replace(r'\[\s*(\d+)x: inf\)', r'>\1x', regex=True)
40+
41+
# change 'PCT of' to "Percent of"
42+
df.loc[df['metric'].str.contains('PCT of'), 'metric'] = df[df['metric'].str.contains('PCT of')]['metric'].str.replace('PCT of', 'Percent of', regex=False)
43+
44+
# if metric contains "Number of" and contains "(%)" replace "Number of" with "Percent"
45+
df.loc[df['metric'].str.contains('Number of') & df['metric'].str.contains('\(%\)'), 'metric'] = df[df['metric'].str.contains('Number of') & df['metric'].str.contains('\(%\)')]['metric'].str.replace('Number of', 'Percent', regex=False)
46+
47+
# convert value to numeric where possible
48+
df['value'] = pd.to_numeric(df['value'], errors='coerce')
49+
50+
# Duplicate metrics for large values with new units, keeping originals
51+
df_g = df[df['value'] > 5e9].copy()
52+
df_g['metric'] = df_g['metric'] + ' (G)'
53+
df_g['value'] = (df_g['value'] / 1e9).round(1)
54+
df_m = df[(df['value'] > 5e6) & (df['value'] <= 5e9)].copy()
55+
df_m['metric'] = df_m['metric'] + ' (M)'
56+
df_m['value'] = (df_m['value'] / 1e6).round(1)
57+
df = pd.concat([df, df_g, df_m], ignore_index=True)
58+
59+
# order by index column
60+
df = df.sort_values(by=['index','metric']).drop(columns='index').reset_index(drop=True)
61+
62+
return df[['category','metric','value']]
63+
64+
def parse_coverage_report(coverage_report_file,aggregate_keys=['gene','biomarker']):
65+
df = pd.read_csv(coverage_report_file, header=None, sep="\t")
66+
df.columns = df.iloc[0]
67+
df.columns = df.columns.str.replace('^#', '', regex=True)
68+
df = df.drop(df.index[0])
69+
df.fillna(0, inplace=True)
70+
df['start'] = df['start'].astype(int)
71+
df['end'] = df['end'].astype(int)
72+
# Convert columns 5 onward to float in place to avoid dtype conflict
73+
for col in df.columns[5:]:
74+
df[col] = pd.to_numeric(df[col], errors='coerce')
75+
76+
df.insert(4,'region',df['info'].str.split('|',expand=True).loc[:,1]) # parse region from info column
77+
df.insert(5,'region_type',df['info'].str.split('|',expand=True).loc[:,0]) # parse region from info
78+
df.reset_index(drop=True, inplace=True)
79+
80+
# if df['region'] contains any of the aggregate keys
81+
if aggregate_keys is not None and len(df['region_type'].isin(aggregate_keys)) > 0:
82+
for key in aggregate_keys:
83+
sdf = df[df['region_type']==key].copy()
84+
sdf['length'] = sdf['end']-sdf['start']+1
85+
pct_cov_columns = [x for x in sdf.columns if "pct_above" in x]
86+
sdf[pct_cov_columns] = sdf[pct_cov_columns].astype(float).div(100).mul(sdf['length'], axis=0)
87+
aggregate_funcs = {key: 'sum' for key in pct_cov_columns + ['total_cvg','length']}
88+
aggregate_funcs['min_cvg'] = 'min'
89+
aggregate_funcs['max_cvg'] = 'max'
90+
aggregate_funcs['start'] = 'min'
91+
aggregate_funcs['end'] = 'max'
92+
sdf = sdf.groupby(['gene']).agg(aggregate_funcs).reset_index()
93+
sdf[pct_cov_columns] = sdf[pct_cov_columns].div(sdf['length'], axis=0).mul(100)
94+
sdf[pct_cov_columns] = sdf[pct_cov_columns].round(1)
95+
sdf['mean_cvg'] = sdf['total_cvg'] / sdf['length']
96+
97+
sdf['region'] = key
98+
sdf['region_type'] = key
99+
100+
sdf = sdf.drop(columns='length')
101+
sdf = sdf.reindex(columns=df.columns)
102+
103+
df = pd.concat([df,sdf],axis=0)
104+
105+
df[['mean_cvg','min_cvg','max_cvg']] = df[['mean_cvg','min_cvg','max_cvg']].astype(int)
106+
107+
return df
108+
109+
def parse_wgs_histogram(wgs_hist_file, coverage_depths=[1,5,10,20,30,50,100,200,500,1000]):
110+
dtype = {'Depth': str, 'Overall': str}
111+
histDf = (pd.read_csv(
112+
wgs_hist_file,
113+
sep=",",
114+
dtype=dtype
115+
)
116+
.iloc[:-1] # Omits the last row (replaces the "2000+" string filter)
117+
.assign(
118+
Depth=lambda x: pd.to_numeric(x['Depth'], errors='coerce') + 1,
119+
Overall=lambda x: pd.to_numeric(x['Overall'], errors='coerce')
120+
)
121+
.assign(Fraction=lambda x: 100 - (x['Overall'].cumsum() / x['Overall'].sum()) * 100)
122+
.query('Depth in @coverage_depths')
123+
.sort_values('Depth', ascending=False)
124+
)
125+
126+
histDf['category'] = "COVERAGE SUMMARY"
127+
histDf['metric'] = histDf.apply(lambda x: f"COVERAGE SUMMARY: Percent of genome with coverage >{x['Depth']}x",axis=1)
128+
histDf['value'] = histDf.apply(lambda x: round(x['Fraction'], 1), axis=1)
129+
130+
return histDf[['category','metric','value']]
131+
132+
# parse haplotect loci file or dataframe into per-site genotypes
133+
def pack_haplotect(haplotectlocidf=None,haplotect_file=None):
134+
df = pd.DataFrame()
135+
if haplotectlocidf is not None:
136+
df = haplotectlocidf[['chr','snp1','snp2','total_reads','haplotype_counts']].copy()
137+
elif haplotect_file is not None and Path(haplotect_file).is_file():
138+
haplotectlocidf = pd.read_csv(haplotect_file,sep='\t')
139+
haplotectlocidf.columns = haplotectlocidf.columns.str.replace('#', '')
140+
df = haplotectlocidf.iloc[:-2]
141+
else:
142+
return None
143+
144+
# parse haplotype strings
145+
df['sites'] = df['haplotype_counts'].str.strip(';')
146+
df['sites'] = df['sites'].str.split(';')
147+
df = df.explode('sites')
148+
df[['hap','counts']] = df['sites'].str.split(':',expand=True)
149+
df = df.dropna(subset=['counts'])
150+
df['counts'] = df['counts'].astype(int)
151+
# filter to get only homozygous and major heterozygous haplotypes (avoid contaminating haps)
152+
df['fraction'] = df['counts']/df['total_reads'].astype(int)
153+
df = df[df['fraction']>.40] # 40% is arbitrary but should be very specific
154+
# parse haplotypes into sites and make genotypes
155+
df[['allele1','allele2']] = df['hap'].apply(lambda x: pd.Series(list(x)))
156+
df1 = df[['chr','snp1','allele1']]
157+
df1.columns = ['chr','pos','gt']
158+
df2 = df[['chr','snp2','allele2']]
159+
df2.columns = ['chr','pos','gt']
160+
df = pd.concat([df1,df2],ignore_index=True).drop_duplicates()
161+
df = df.groupby(['chr','pos'])[['gt']].agg(list).reset_index()
162+
df['gt'] = df['gt'].apply(lambda x: "".join(sorted(x)) if len(x)==2 else "".join(x+x))
163+
df = df.sort_values(
164+
by=['chr','pos'],
165+
key=natsort_keygen())
166+
167+
# pack into string chr1,pos1,gt1|chr2,pos2,gt2|...
168+
genotypes = '|'.join(df.apply(lambda row: f"{row['chr']},{row['pos']},{row['gt']}", axis=1))
169+
170+
return genotypes
171+
172+
#
173+
# Script
174+
#
175+
176+
def main():
177+
178+
parser = argparse.ArgumentParser(description='Collect Dragen QC and coverage metrics.')
179+
parser.add_argument('-c','--coverage-report',help='Mopath coverage report file')
180+
parser.add_argument('-m','--mapping-metrics',help='Dragen mapping metrics file')
181+
parser.add_argument('-n','--cnv-metrics',help='Dragen CNV metrics file')
182+
parser.add_argument('-u','--umi-metrics',help='Dragen UMI metrics file')
183+
parser.add_argument('-w','--wgs-coverage-metrics',help='Dragen WGS metrics file')
184+
parser.add_argument('-f','--wgs-fine-hist',help='Dragen WGS fine histogram file')
185+
parser.add_argument('-t','--haplotect',help='Haplotect file')
186+
187+
parser.add_argument('-l','--coverage-qc-levels',default='10,20,40,60,80,100,150,200',help='Genome-wide coverage levels to collect for WGS assays.')
188+
parser.add_argument('-s','--coverage-summary-keys',default='gene,biomarker',help='Strings to use for target coverage summarization. [default: gene,biomarker]')
189+
190+
# outfile name
191+
parser.add_argument('-o','--outfile',help='Output file name.',default=None)
192+
193+
parser.add_argument('-v', '--version', action='version', version='%(prog)s: ' + __version__)
194+
195+
args = parser.parse_args()
196+
197+
# Exit and print usage if no arguments provided
198+
if len(sys.argv) == 1:
199+
parser.print_help()
200+
sys.exit(1)
201+
202+
qcDf = pd.DataFrame(columns=['category','metric','value'])
203+
covDf = pd.DataFrame(columns=['gene','region','start','end','region_type','mean_cvg','min_cvg','max_cvg'])
204+
205+
if args.mapping_metrics is not None and Path(args.mapping_metrics).is_file():
206+
mapping_df = parse_dragen_metrics_file(args.mapping_metrics)
207+
if not qcDf.empty and not mapping_df.empty:
208+
qcDf = pd.concat([qcDf, mapping_df])
209+
elif not mapping_df.empty:
210+
qcDf = mapping_df
211+
212+
if args.cnv_metrics is not None and Path(args.cnv_metrics).is_file():
213+
cnv_df = parse_dragen_metrics_file(args.cnv_metrics)
214+
#
215+
# Chromosome number and ploidy
216+
#
217+
ploidy = float(cnv_df[cnv_df["metric"].str.contains("Overall ploidy")]["value"].tolist()[0])
218+
dragen_chromosome_number = int(round(ploidy * 23, 0))
219+
chromosomes = dragen_chromosome_number if abs(dragen_chromosome_number - 46) > 2 else max(46, dragen_chromosome_number)
220+
cnv_df = pd.concat([cnv_df, pd.DataFrame([{'category': 'CNV SUMMARY', 'metric': 'CNV SUMMARY: Chromosome number', 'value': chromosomes}])], ignore_index=True)
221+
222+
if not qcDf.empty and not cnv_df.empty:
223+
qcDf = pd.concat([qcDf, cnv_df])
224+
elif not cnv_df.empty:
225+
qcDf = cnv_df
226+
227+
if args.umi_metrics is not None and Path(args.umi_metrics).is_file():
228+
umiDf = parse_dragen_metrics_file(args.umi_metrics)
229+
230+
consensusReads = round(umiDf.loc[umiDf.metric=='UMI STATISTICS: Consensus pairs emitted','value'].astype(int).tolist()[0] * 2 / umiDf.loc[umiDf.metric=='UMI STATISTICS: Number of reads','value'].astype(int).tolist()[0] * 100,1)
231+
duplicateReads = round(100-consensusReads,1)
232+
233+
umiDf = pd.concat([umiDf,pd.DataFrame.from_dict({0:['UMI STATISTICS','UMI STATISTICS: Consensus reads (%)',consensusReads,0],
234+
1:['UMI STATISTICS','UMI STATISTICS: Duplicate reads (%)',duplicateReads,1]},orient='index',columns=['category','metric','value','qcmetric'])],axis=0)
235+
236+
# Custom on-target read calculation
237+
# Enrichment Rate we use for UMI collapsed data is from the “On target number of reads ” from umi_metrics.csv divided by “Mapped reads” from mapping_metrics.csv. As a first step to troubleshooting on-target rate, it would great to confirm if you are reporting this value.
238+
239+
ontargetReads = umiDf.loc[umiDf.metric=='UMI STATISTICS: On target number of reads','value'].astype(int).tolist()[0]
240+
mappedReads = qcDf.loc[qcDf.metric=='MAPPING/ALIGNING SUMMARY: Mapped reads','value'].astype(int).tolist()[0]
241+
umiOnTargetRate = round(ontargetReads / mappedReads * 100,1)
242+
243+
umiDf = pd.concat([umiDf,pd.DataFrame.from_dict({0:['UMI STATISTICS','UMI STATISTICS: On-target rate (%)',umiOnTargetRate,0]},orient='index',columns=['category','metric','value'])],axis=0)
244+
245+
qcDf = pd.concat([qcDf,umiDf])
246+
247+
if args.wgs_coverage_metrics is not None and Path(args.wgs_coverage_metrics).is_file():
248+
qcDf = pd.concat([qcDf,parse_dragen_metrics_file(args.wgs_coverage_metrics)])
249+
250+
if args.wgs_fine_hist is not None and Path(args.wgs_fine_hist).is_file():
251+
qcDf = pd.concat([qcDf,parse_wgs_histogram(args.wgs_fine_hist, coverage_depths=[int(x) for x in args.coverage_qc_levels.split(',')])])
252+
253+
if args.coverage_report is not None and Path(args.coverage_report).is_file():
254+
covDf = parse_coverage_report(args.coverage_report, aggregate_keys=args.coverage_summary_keys.split(',') if args.coverage_summary_keys else None)
255+
256+
# Calculate coverage summary for assay targets
257+
coverageLevelLabels = covDf.columns[13:].tolist()
258+
covDf['bases'] = covDf['end'] - covDf['start'] + 1
259+
assayCov = round(covDf.apply(lambda x: x[coverageLevelLabels] / 100 * x['bases'],axis=1).sum() / covDf['bases'].sum() * 100,1)
260+
assayCov.index = assayCov.index.str.replace("pct_above_","COVERAGE SUMMARY: Percent of assay with coverage >") + 'x'
261+
assayCovDf = assayCov.to_frame(name='value').reset_index().rename(columns={0:'metric'})
262+
assayCovDf['category'] = 'COVERAGE SUMMARY'
263+
qcDf = pd.concat([qcDf,assayCovDf.reindex(columns=qcDf.columns)])
264+
265+
if args.haplotect is not None and Path(args.haplotect).is_file():
266+
haplotectlocidf = pd.read_csv(args.haplotect,sep='\t')
267+
haplotectlocidf.columns = haplotectlocidf.columns.str.replace('#', '')
268+
haplotectdf = pd.DataFrame([haplotectlocidf.iloc[-1,:-2].tolist()],columns=haplotectlocidf.iloc[-2,:-2].tolist())
269+
haplotectdf.columns = haplotectdf.columns.str.replace('#', '')
270+
haplotectdf['informative_snppairs'] = haplotectdf['informative_snppairs'].astype(int)
271+
haplotectdf['mle_estimate'] = haplotectdf['mle_estimate'].astype(float)
272+
haplotectdf['contamination_fraction'] = haplotectdf['contamination_fraction'].fillna(0)
273+
haplotectdf['contamination_fraction'] = haplotectdf['contamination_fraction'].astype(float)
274+
275+
haplotectlocidf = haplotectlocidf.iloc[:-2]
276+
# make distance, total_reads, haplotype_counts, contamination_fraction columns numeric
277+
cols_to_numeric = ['distance','total_reads','contamination_fraction']
278+
for col in cols_to_numeric:
279+
haplotectlocidf[col] = pd.to_numeric(haplotectlocidf[col], errors='coerce')
280+
281+
haplotectdf = haplotectdf.transpose().reset_index().drop(index=0)
282+
haplotectdf.columns = ['metric','value']
283+
haplotectdf['category'] = 'HAPLOTECT'
284+
haplotectdf['metric'] = haplotectdf['metric'].apply(lambda v: f'HAPLOTECT: {v}')
285+
# add haplotect genotypes
286+
haplotectdf = pd.concat([
287+
haplotectdf,
288+
pd.DataFrame.from_dict(
289+
{0: ['HAPLOTECT', 'HAPLOTECT: Genotypes', pack_haplotect(haplotectlocidf=haplotectlocidf)]},
290+
orient='index',
291+
columns=['category', 'metric', 'value']
292+
)
293+
], axis=0)
294+
295+
qcDf = pd.concat([qcDf,haplotectdf.reindex(columns=qcDf.columns)])
296+
297+
qcDf['qcmetric'] = 0
298+
qcDf['qcflag'] = 0
299+
300+
########################
301+
#
302+
# Start report
303+
#
304+
########################
305+
306+
if qcDf.empty and covDf.empty:
307+
print("No QC or coverage data found to report.",file=sys.stderr)
308+
sys.exit(0)
309+
310+
# make dict for report and redirect output for text report
311+
jsonout = {}
312+
313+
jsonout['ASSAY'] = qcDf.to_dict('split')
314+
jsonout['ASSAY'].pop('index', None)
315+
316+
coverageLevelLabels = [x for x in covDf.columns if "pct_above" in x]
317+
318+
xdf = covDf[(covDf['region_type']=='hotspot')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels]
319+
jsonout['HOTSPOT_COVERAGE'] = xdf.to_dict('split')
320+
jsonout['HOTSPOT_COVERAGE'].pop('index', None)
321+
322+
xdf = covDf[(covDf['region_type']=='gene') & (covDf['region']!='gene')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels]
323+
jsonout['EXON_COVERAGE'] = xdf.to_dict('split')
324+
jsonout['EXON_COVERAGE'].pop('index', None)
325+
326+
xdf = covDf[(covDf['region_type']=='gene') & (covDf['region']=='gene')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels]
327+
jsonout['GENE_COVERAGE'] = xdf.to_dict('split')
328+
jsonout['GENE_COVERAGE'].pop('index', None)
329+
330+
xdf = covDf[(covDf['region_type']=='sv')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels]
331+
jsonout['TRANSCRIPT_COVERAGE'] = xdf.to_dict('split')
332+
jsonout['TRANSCRIPT_COVERAGE'].pop('index', None)
333+
334+
# biomarker coverage
335+
xdf = covDf[(covDf['region_type']=='biomarker')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels]
336+
jsonout['BIOMARKER_COVERAGE'] = xdf.to_dict('split')
337+
jsonout['BIOMARKER_COVERAGE'].pop('index', None)
338+
339+
jsonout['HAPLOTECT_LOCI'] = haplotectlocidf.to_dict('split')
340+
jsonout['HAPLOTECT_LOCI'].pop('index', None)
341+
342+
# dump json to outfile or stdout
343+
if args.outfile is None:
344+
json.dump(sanitize_for_json(jsonout),sys.stdout,indent=" ",allow_nan=False)
345+
346+
else:
347+
j = open(args.outfile, "w")
348+
json.dump(sanitize_for_json(jsonout),j,indent=" ",allow_nan=False)
349+
j.close()
350+
351+
352+
if __name__ == "__main__":
353+
main()

0 commit comments

Comments
 (0)