py-gbcms 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gbcms/__init__.py +13 -0
- gbcms/cli.py +745 -0
- gbcms/config.py +98 -0
- gbcms/counter.py +1074 -0
- gbcms/models.py +295 -0
- gbcms/numba_counter.py +394 -0
- gbcms/output.py +573 -0
- gbcms/parallel.py +129 -0
- gbcms/processor.py +293 -0
- gbcms/reference.py +86 -0
- gbcms/variant.py +390 -0
- py_gbcms-2.0.0.dist-info/METADATA +506 -0
- py_gbcms-2.0.0.dist-info/RECORD +16 -0
- py_gbcms-2.0.0.dist-info/WHEEL +4 -0
- py_gbcms-2.0.0.dist-info/entry_points.txt +2 -0
- py_gbcms-2.0.0.dist-info/licenses/LICENSE +664 -0
gbcms/output.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Output formatting for variant counts."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from .config import Config, CountType
|
|
7
|
+
from .variant import VariantEntry
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OutputFormatter:
|
|
13
|
+
"""Formats and writes output files."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Config, sample_order: list[str]):
|
|
16
|
+
"""
|
|
17
|
+
Initialize output formatter.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
config: Configuration object
|
|
21
|
+
sample_order: Ordered list of sample names
|
|
22
|
+
"""
|
|
23
|
+
self.config = config
|
|
24
|
+
self.sample_order = sample_order
|
|
25
|
+
|
|
26
|
+
def write_vcf_output(self, variants: list[VariantEntry]) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Write output in proper VCF format with strand bias in INFO field.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
variants: List of variants with counts and strand bias
|
|
32
|
+
"""
|
|
33
|
+
logger.info(f"Writing VCF output to: {self.config.output_file}")
|
|
34
|
+
|
|
35
|
+
with open(self.config.output_file, "w") as f:
|
|
36
|
+
# Write VCF header
|
|
37
|
+
f.write("##fileformat=VCFv4.2\n")
|
|
38
|
+
f.write(f"##fileDate={datetime.now().strftime('%Y%m%d')}\n")
|
|
39
|
+
f.write("##source=py-gbcms\n")
|
|
40
|
+
f.write(
|
|
41
|
+
'##INFO=<ID=DP,Number=1,Type=Integer,Description="Total depth across all samples">\n'
|
|
42
|
+
)
|
|
43
|
+
f.write(
|
|
44
|
+
'##INFO=<ID=SB,Number=3,Type=Float,Description="Strand bias p-value, odds ratio, direction (aggregated across samples)">\n'
|
|
45
|
+
)
|
|
46
|
+
f.write(
|
|
47
|
+
'##INFO=<ID=FSB,Number=3,Type=Float,Description="Fragment strand bias p-value, odds ratio, direction (when fragment counting enabled)">\n'
|
|
48
|
+
)
|
|
49
|
+
f.write(
|
|
50
|
+
'##FORMAT=<ID=DP,Number=1,Type=Integer,Description="Total depth for this sample">\n'
|
|
51
|
+
)
|
|
52
|
+
f.write(
|
|
53
|
+
'##FORMAT=<ID=RD,Number=1,Type=Integer,Description="Reference allele depth for this sample">\n'
|
|
54
|
+
)
|
|
55
|
+
f.write(
|
|
56
|
+
'##FORMAT=<ID=AD,Number=1,Type=Integer,Description="Alternate allele depth for this sample">\n'
|
|
57
|
+
)
|
|
58
|
+
f.write(
|
|
59
|
+
'##FORMAT=<ID=DPP,Number=1,Type=Integer,Description="Positive strand depth for this sample">\n'
|
|
60
|
+
)
|
|
61
|
+
f.write(
|
|
62
|
+
'##FORMAT=<ID=RDP,Number=1,Type=Integer,Description="Positive strand reference depth for this sample">\n'
|
|
63
|
+
)
|
|
64
|
+
f.write(
|
|
65
|
+
'##FORMAT=<ID=ADP,Number=1,Type=Integer,Description="Positive strand alternate depth for this sample">\n'
|
|
66
|
+
)
|
|
67
|
+
f.write(
|
|
68
|
+
'##FORMAT=<ID=DPF,Number=1,Type=Integer,Description="Fragment depth for this sample">\n'
|
|
69
|
+
)
|
|
70
|
+
f.write(
|
|
71
|
+
'##FORMAT=<ID=RDF,Number=1,Type=Integer,Description="Fragment reference depth for this sample">\n'
|
|
72
|
+
)
|
|
73
|
+
f.write(
|
|
74
|
+
'##FORMAT=<ID=ADF,Number=1,Type=Integer,Description="Fragment alternate depth for this sample">\n'
|
|
75
|
+
)
|
|
76
|
+
f.write(
|
|
77
|
+
'##FORMAT=<ID=SB,Number=3,Type=Float,Description="Strand bias p-value, odds ratio, direction for this sample">\n'
|
|
78
|
+
)
|
|
79
|
+
f.write(
|
|
80
|
+
'##FORMAT=<ID=FSB,Number=3,Type=Float,Description="Fragment strand bias p-value, odds ratio, direction for this sample">\n'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Write column headers
|
|
84
|
+
header_cols = ["#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT"]
|
|
85
|
+
header_cols.extend(self.sample_order)
|
|
86
|
+
f.write("\t".join(header_cols) + "\n")
|
|
87
|
+
|
|
88
|
+
# Write variants
|
|
89
|
+
for variant in variants:
|
|
90
|
+
# Calculate aggregate strand bias across all samples for INFO field
|
|
91
|
+
total_sb_pval = 1.0
|
|
92
|
+
total_sb_or = 1.0
|
|
93
|
+
total_sb_dir = "none"
|
|
94
|
+
total_fsb_pval = 1.0
|
|
95
|
+
total_fsb_or = 1.0
|
|
96
|
+
total_fsb_dir = "none"
|
|
97
|
+
|
|
98
|
+
sample_sb_values = []
|
|
99
|
+
sample_fsb_values = []
|
|
100
|
+
|
|
101
|
+
for sample in self.sample_order:
|
|
102
|
+
# Get counts
|
|
103
|
+
dp = int(variant.get_count(sample, CountType.DP))
|
|
104
|
+
rd = int(variant.get_count(sample, CountType.RD))
|
|
105
|
+
ad = int(variant.get_count(sample, CountType.AD))
|
|
106
|
+
|
|
107
|
+
# Calculate strand bias for this sample
|
|
108
|
+
ref_forward = int(variant.get_count(sample, CountType.RDP))
|
|
109
|
+
ref_reverse = rd - ref_forward
|
|
110
|
+
alt_forward = int(variant.get_count(sample, CountType.ADP))
|
|
111
|
+
alt_reverse = ad - alt_forward
|
|
112
|
+
|
|
113
|
+
sb_pval, sb_or, sb_dir = self._calculate_strand_bias_for_output(
|
|
114
|
+
ref_forward, ref_reverse, alt_forward, alt_reverse
|
|
115
|
+
)
|
|
116
|
+
sample_sb_values.append(f"{sb_pval:.6f}:{sb_or:.3f}:{sb_dir}")
|
|
117
|
+
|
|
118
|
+
# Fragment strand bias (if enabled)
|
|
119
|
+
if self.config.output_fragment_count:
|
|
120
|
+
fsb_pval, fsb_or, fsb_dir = self._calculate_strand_bias_for_output(
|
|
121
|
+
ref_forward, ref_reverse, alt_forward, alt_reverse
|
|
122
|
+
)
|
|
123
|
+
sample_fsb_values.append(f"{fsb_pval:.6f}:{fsb_or:.3f}:{fsb_dir}")
|
|
124
|
+
total_fsb_pval = min(total_fsb_pval, fsb_pval)
|
|
125
|
+
total_fsb_or = (
|
|
126
|
+
min(total_fsb_or, fsb_or) if fsb_pval < total_fsb_pval else total_fsb_or
|
|
127
|
+
)
|
|
128
|
+
total_fsb_dir = fsb_dir if fsb_pval < total_fsb_pval else total_fsb_dir
|
|
129
|
+
else:
|
|
130
|
+
sample_fsb_values.append(".:.:none")
|
|
131
|
+
|
|
132
|
+
# Update aggregate values (use minimum p-value as most significant)
|
|
133
|
+
total_sb_pval = min(total_sb_pval, sb_pval)
|
|
134
|
+
total_sb_or = (
|
|
135
|
+
min(total_sb_or, sb_or) if sb_pval < total_sb_pval else total_sb_or
|
|
136
|
+
)
|
|
137
|
+
total_sb_dir = sb_dir if sb_pval < total_sb_pval else total_sb_dir
|
|
138
|
+
|
|
139
|
+
# Build INFO field
|
|
140
|
+
info_parts = [
|
|
141
|
+
f"DP={sum(int(variant.get_count(s, CountType.DP)) for s in self.sample_order)}"
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
if total_sb_pval < 1.0: # Only include if we have valid strand bias
|
|
145
|
+
info_parts.append(f"SB={total_sb_pval:.6f},{total_sb_or:.3f},{total_sb_dir}")
|
|
146
|
+
|
|
147
|
+
if self.config.output_fragment_count and total_fsb_pval < 1.0:
|
|
148
|
+
info_parts.append(
|
|
149
|
+
f"FSB={total_fsb_pval:.6f},{total_fsb_or:.3f},{total_fsb_dir}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
info_field = ";".join(info_parts)
|
|
153
|
+
|
|
154
|
+
# Build FORMAT field
|
|
155
|
+
format_parts = ["DP", "RD", "AD"]
|
|
156
|
+
|
|
157
|
+
if self.config.output_positive_count:
|
|
158
|
+
format_parts.extend(["DPP", "RDP", "ADP"])
|
|
159
|
+
|
|
160
|
+
if self.config.output_fragment_count:
|
|
161
|
+
format_parts.extend(["DPF", "RDF", "ADF"])
|
|
162
|
+
|
|
163
|
+
format_parts.extend(["SB"])
|
|
164
|
+
if self.config.output_fragment_count:
|
|
165
|
+
format_parts.extend(["FSB"])
|
|
166
|
+
|
|
167
|
+
format_field = ":".join(format_parts)
|
|
168
|
+
|
|
169
|
+
# Write variant line
|
|
170
|
+
row = [
|
|
171
|
+
variant.chrom,
|
|
172
|
+
str(variant.pos + 1), # Convert to 1-indexed
|
|
173
|
+
".", # ID
|
|
174
|
+
variant.ref,
|
|
175
|
+
variant.alt,
|
|
176
|
+
".", # QUAL
|
|
177
|
+
".", # FILTER
|
|
178
|
+
info_field,
|
|
179
|
+
format_field,
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Add sample data
|
|
183
|
+
for sample in self.sample_order:
|
|
184
|
+
dp = int(variant.get_count(sample, CountType.DP))
|
|
185
|
+
rd = int(variant.get_count(sample, CountType.RD))
|
|
186
|
+
ad = int(variant.get_count(sample, CountType.AD))
|
|
187
|
+
|
|
188
|
+
sample_data = [str(dp), str(rd), str(ad)]
|
|
189
|
+
|
|
190
|
+
if self.config.output_positive_count:
|
|
191
|
+
dpp = int(variant.get_count(sample, CountType.DPP))
|
|
192
|
+
rdp = int(variant.get_count(sample, CountType.RDP))
|
|
193
|
+
adp = int(variant.get_count(sample, CountType.ADP))
|
|
194
|
+
sample_data.extend([str(dpp), str(rdp), str(adp)])
|
|
195
|
+
|
|
196
|
+
if self.config.output_fragment_count:
|
|
197
|
+
dpf = int(variant.get_count(sample, CountType.DPF))
|
|
198
|
+
rdf = int(variant.get_count(sample, CountType.RDF))
|
|
199
|
+
adf = int(variant.get_count(sample, CountType.ADF))
|
|
200
|
+
sample_data.extend([str(dpf), str(rdf), str(adf)])
|
|
201
|
+
|
|
202
|
+
# Add strand bias data for this sample
|
|
203
|
+
sample_sb_idx = self.sample_order.index(sample)
|
|
204
|
+
sample_data.append(sample_sb_values[sample_sb_idx])
|
|
205
|
+
|
|
206
|
+
if self.config.output_fragment_count:
|
|
207
|
+
sample_data.append(sample_fsb_values[sample_sb_idx])
|
|
208
|
+
|
|
209
|
+
row.append(":".join(sample_data))
|
|
210
|
+
|
|
211
|
+
f.write("\t".join(row) + "\n")
|
|
212
|
+
|
|
213
|
+
logger.info(f"Successfully wrote {len(variants)} variants to VCF output file")
|
|
214
|
+
|
|
215
|
+
def _calculate_strand_bias_for_output(
|
|
216
|
+
self,
|
|
217
|
+
ref_forward: int,
|
|
218
|
+
ref_reverse: int,
|
|
219
|
+
alt_forward: int,
|
|
220
|
+
alt_reverse: int,
|
|
221
|
+
min_depth: int = 10,
|
|
222
|
+
) -> tuple[float, float, str]:
|
|
223
|
+
"""
|
|
224
|
+
Calculate strand bias using Fisher's exact test for output.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
ref_forward: Reference allele count on forward strand
|
|
228
|
+
ref_reverse: Reference allele count on reverse strand
|
|
229
|
+
alt_forward: Alternate allele count on forward strand
|
|
230
|
+
alt_reverse: Alternate allele count on reverse strand
|
|
231
|
+
min_depth: Minimum total depth to calculate bias
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Tuple of (p_value, odds_ratio, bias_direction)
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
import numpy as np
|
|
238
|
+
from scipy.stats import fisher_exact
|
|
239
|
+
|
|
240
|
+
# Check minimum depth requirement
|
|
241
|
+
total_depth = ref_forward + ref_reverse + alt_forward + alt_reverse
|
|
242
|
+
if total_depth < min_depth:
|
|
243
|
+
return 1.0, 1.0, "insufficient_depth"
|
|
244
|
+
|
|
245
|
+
# Create 2x2 contingency table
|
|
246
|
+
# [[ref_forward, ref_reverse],
|
|
247
|
+
# [alt_forward, alt_reverse]]
|
|
248
|
+
table = np.array([[ref_forward, ref_reverse], [alt_forward, alt_reverse]])
|
|
249
|
+
|
|
250
|
+
# Fisher's exact test
|
|
251
|
+
odds_ratio, p_value = fisher_exact(table, alternative="two-sided")
|
|
252
|
+
|
|
253
|
+
# Determine bias direction
|
|
254
|
+
total_forward = ref_forward + alt_forward
|
|
255
|
+
total_reverse = ref_reverse + alt_reverse
|
|
256
|
+
|
|
257
|
+
if total_forward > 0 and total_reverse > 0:
|
|
258
|
+
forward_ratio = ref_forward / total_forward if total_forward > 0 else 0
|
|
259
|
+
reverse_ratio = ref_reverse / total_reverse if total_reverse > 0 else 0
|
|
260
|
+
|
|
261
|
+
if forward_ratio > reverse_ratio + 0.1: # 10% threshold
|
|
262
|
+
bias_direction = "forward"
|
|
263
|
+
elif reverse_ratio > forward_ratio + 0.1:
|
|
264
|
+
bias_direction = "reverse"
|
|
265
|
+
else:
|
|
266
|
+
bias_direction = "none"
|
|
267
|
+
else:
|
|
268
|
+
bias_direction = "none"
|
|
269
|
+
|
|
270
|
+
return p_value, odds_ratio, bias_direction
|
|
271
|
+
|
|
272
|
+
except ImportError:
|
|
273
|
+
logger.warning("scipy not available for strand bias calculation")
|
|
274
|
+
return 1.0, 1.0, "scipy_unavailable"
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.warning(f"Error calculating strand bias: {e}")
|
|
277
|
+
return 1.0, 1.0, "error"
|
|
278
|
+
|
|
279
|
+
def write_maf_output(self, variants: list[VariantEntry]) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Write output in MAF format.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
variants: List of variants with counts
|
|
285
|
+
"""
|
|
286
|
+
logger.info(f"Writing MAF output to: {self.config.output_file}")
|
|
287
|
+
|
|
288
|
+
with open(self.config.output_file, "w") as f:
|
|
289
|
+
# Write header (use first variant's MAF line to get column names)
|
|
290
|
+
if variants and variants[0].maf_line:
|
|
291
|
+
# Parse header from first MAF line structure
|
|
292
|
+
header_cols = [
|
|
293
|
+
"Hugo_Symbol",
|
|
294
|
+
"Chromosome",
|
|
295
|
+
"Start_Position",
|
|
296
|
+
"End_Position",
|
|
297
|
+
"Reference_Allele",
|
|
298
|
+
"Tumor_Seq_Allele1",
|
|
299
|
+
"Tumor_Seq_Allele2",
|
|
300
|
+
"Tumor_Sample_Barcode",
|
|
301
|
+
"Matched_Norm_Sample_Barcode",
|
|
302
|
+
"Variant_Classification",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
# Add count columns for tumor and normal
|
|
306
|
+
count_cols = [
|
|
307
|
+
"t_depth",
|
|
308
|
+
"t_ref_count",
|
|
309
|
+
"t_alt_count",
|
|
310
|
+
"n_depth",
|
|
311
|
+
"n_ref_count",
|
|
312
|
+
"n_alt_count",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
if self.config.output_positive_count:
|
|
316
|
+
count_cols.extend(
|
|
317
|
+
[
|
|
318
|
+
"t_depth_forward",
|
|
319
|
+
"t_ref_count_forward",
|
|
320
|
+
"t_alt_count_forward",
|
|
321
|
+
"n_depth_forward",
|
|
322
|
+
"n_ref_count_forward",
|
|
323
|
+
"n_alt_count_forward",
|
|
324
|
+
]
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if self.config.output_fragment_count:
|
|
328
|
+
count_cols.extend(
|
|
329
|
+
[
|
|
330
|
+
"t_depth_fragment",
|
|
331
|
+
"t_ref_count_fragment",
|
|
332
|
+
"t_alt_count_fragment",
|
|
333
|
+
"n_depth_fragment",
|
|
334
|
+
"n_ref_count_fragment",
|
|
335
|
+
"n_alt_count_fragment",
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Add strand bias columns for tumor and normal samples
|
|
340
|
+
count_cols.extend(
|
|
341
|
+
[
|
|
342
|
+
"t_strand_bias_pval",
|
|
343
|
+
"t_strand_bias_or",
|
|
344
|
+
"t_strand_bias_dir",
|
|
345
|
+
"n_strand_bias_pval",
|
|
346
|
+
"n_strand_bias_or",
|
|
347
|
+
"n_strand_bias_dir",
|
|
348
|
+
]
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if self.config.output_fragment_count:
|
|
352
|
+
count_cols.extend(
|
|
353
|
+
[
|
|
354
|
+
"t_fragment_strand_bias_pval",
|
|
355
|
+
"t_fragment_strand_bias_or",
|
|
356
|
+
"t_fragment_strand_bias_dir",
|
|
357
|
+
"n_fragment_strand_bias_pval",
|
|
358
|
+
"n_fragment_strand_bias_or",
|
|
359
|
+
"n_fragment_strand_bias_dir",
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
f.write("\t".join(header_cols + count_cols) + "\n")
|
|
364
|
+
|
|
365
|
+
# Write variants
|
|
366
|
+
for variant in variants:
|
|
367
|
+
row = [
|
|
368
|
+
variant.gene,
|
|
369
|
+
variant.chrom,
|
|
370
|
+
str(variant.maf_pos + 1), # Convert back to 1-indexed
|
|
371
|
+
str(variant.maf_end_pos + 1),
|
|
372
|
+
variant.maf_ref,
|
|
373
|
+
variant.maf_alt if variant.maf_alt else "",
|
|
374
|
+
"", # Tumor_Seq_Allele2
|
|
375
|
+
variant.tumor_sample,
|
|
376
|
+
variant.normal_sample,
|
|
377
|
+
variant.effect,
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
# Get tumor counts
|
|
381
|
+
t_dp = int(variant.get_count(variant.tumor_sample, CountType.DP))
|
|
382
|
+
t_rd = int(variant.get_count(variant.tumor_sample, CountType.RD))
|
|
383
|
+
t_ad = int(variant.get_count(variant.tumor_sample, CountType.AD))
|
|
384
|
+
|
|
385
|
+
# Get normal counts
|
|
386
|
+
n_dp = int(variant.get_count(variant.normal_sample, CountType.DP))
|
|
387
|
+
n_rd = int(variant.get_count(variant.normal_sample, CountType.RD))
|
|
388
|
+
n_ad = int(variant.get_count(variant.normal_sample, CountType.AD))
|
|
389
|
+
|
|
390
|
+
row.extend([str(t_dp), str(t_rd), str(t_ad), str(n_dp), str(n_rd), str(n_ad)])
|
|
391
|
+
|
|
392
|
+
if self.config.output_positive_count:
|
|
393
|
+
t_dpp = int(variant.get_count(variant.tumor_sample, CountType.DPP))
|
|
394
|
+
t_rdp = int(variant.get_count(variant.tumor_sample, CountType.RDP))
|
|
395
|
+
t_adp = int(variant.get_count(variant.tumor_sample, CountType.ADP))
|
|
396
|
+
n_dpp = int(variant.get_count(variant.normal_sample, CountType.DPP))
|
|
397
|
+
n_rdp = int(variant.get_count(variant.normal_sample, CountType.RDP))
|
|
398
|
+
n_adp = int(variant.get_count(variant.normal_sample, CountType.ADP))
|
|
399
|
+
row.extend(
|
|
400
|
+
[str(t_dpp), str(t_rdp), str(t_adp), str(n_dpp), str(n_rdp), str(n_adp)]
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if self.config.output_fragment_count:
|
|
404
|
+
t_dpf = int(variant.get_count(variant.tumor_sample, CountType.DPF))
|
|
405
|
+
t_rdf = int(variant.get_count(variant.tumor_sample, CountType.RDF))
|
|
406
|
+
t_adf = int(variant.get_count(variant.tumor_sample, CountType.ADF))
|
|
407
|
+
n_dpf = int(variant.get_count(variant.normal_sample, CountType.DPF))
|
|
408
|
+
n_rdf = int(variant.get_count(variant.normal_sample, CountType.RDF))
|
|
409
|
+
n_adf = int(variant.get_count(variant.normal_sample, CountType.ADF))
|
|
410
|
+
row.extend(
|
|
411
|
+
[str(t_dpf), str(t_rdf), str(t_adf), str(n_dpf), str(n_rdf), str(n_adf)]
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Add strand bias information for tumor and normal
|
|
415
|
+
# Calculate tumor strand bias on-the-fly
|
|
416
|
+
t_ref_forward = int(variant.get_count(variant.tumor_sample, CountType.RDP))
|
|
417
|
+
t_ref_reverse = t_rd - t_ref_forward
|
|
418
|
+
t_alt_forward = int(variant.get_count(variant.tumor_sample, CountType.ADP))
|
|
419
|
+
t_alt_reverse = t_ad - t_alt_forward
|
|
420
|
+
|
|
421
|
+
t_sb_pval, t_sb_or, t_sb_dir = self._calculate_strand_bias_for_output(
|
|
422
|
+
t_ref_forward, t_ref_reverse, t_alt_forward, t_alt_reverse
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Calculate normal strand bias on-the-fly
|
|
426
|
+
n_ref_forward = int(variant.get_count(variant.normal_sample, CountType.RDP))
|
|
427
|
+
n_ref_reverse = n_rd - n_ref_forward
|
|
428
|
+
n_alt_forward = int(variant.get_count(variant.normal_sample, CountType.ADP))
|
|
429
|
+
n_alt_reverse = n_ad - n_alt_forward
|
|
430
|
+
|
|
431
|
+
n_sb_pval, n_sb_or, n_sb_dir = self._calculate_strand_bias_for_output(
|
|
432
|
+
n_ref_forward, n_ref_reverse, n_alt_forward, n_alt_reverse
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
row.extend(
|
|
436
|
+
[
|
|
437
|
+
f"{t_sb_pval:.6f}",
|
|
438
|
+
f"{t_sb_or:.3f}",
|
|
439
|
+
t_sb_dir,
|
|
440
|
+
f"{n_sb_pval:.6f}",
|
|
441
|
+
f"{n_sb_or:.3f}",
|
|
442
|
+
n_sb_dir,
|
|
443
|
+
]
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if self.config.output_fragment_count:
|
|
447
|
+
# Calculate fragment strand bias for tumor and normal
|
|
448
|
+
t_fsb_pval, t_fsb_or, t_fsb_dir = self._calculate_strand_bias_for_output(
|
|
449
|
+
t_ref_forward, t_ref_reverse, t_alt_forward, t_alt_reverse
|
|
450
|
+
)
|
|
451
|
+
n_fsb_pval, n_fsb_or, n_fsb_dir = self._calculate_strand_bias_for_output(
|
|
452
|
+
n_ref_forward, n_ref_reverse, n_alt_forward, n_alt_reverse
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
row.extend(
|
|
456
|
+
[
|
|
457
|
+
f"{t_fsb_pval:.6f}",
|
|
458
|
+
f"{t_fsb_or:.3f}",
|
|
459
|
+
t_fsb_dir,
|
|
460
|
+
f"{n_fsb_pval:.6f}",
|
|
461
|
+
f"{n_fsb_or:.3f}",
|
|
462
|
+
n_fsb_dir,
|
|
463
|
+
]
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
f.write("\t".join(row) + "\n")
|
|
467
|
+
|
|
468
|
+
logger.info(f"Successfully wrote {len(variants)} variants to MAF output file")
|
|
469
|
+
|
|
470
|
+
def write_fillout_output(self, variants: list[VariantEntry]) -> None:
|
|
471
|
+
"""
|
|
472
|
+
Write output in fillout format (extended MAF with all samples).
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
variants: List of variants with counts
|
|
476
|
+
"""
|
|
477
|
+
logger.info(f"Writing fillout output to: {self.config.output_file}")
|
|
478
|
+
|
|
479
|
+
with open(self.config.output_file, "w") as f:
|
|
480
|
+
# Write header
|
|
481
|
+
header_cols = [
|
|
482
|
+
"Hugo_Symbol",
|
|
483
|
+
"Chromosome",
|
|
484
|
+
"Start_Position",
|
|
485
|
+
"End_Position",
|
|
486
|
+
"Reference_Allele",
|
|
487
|
+
"Tumor_Seq_Allele1",
|
|
488
|
+
"Tumor_Seq_Allele2",
|
|
489
|
+
"Tumor_Sample_Barcode",
|
|
490
|
+
"Matched_Norm_Sample_Barcode",
|
|
491
|
+
"Variant_Classification",
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
# Add count columns for each sample
|
|
495
|
+
for sample in self.sample_order:
|
|
496
|
+
header_cols.extend([f"{sample}:DP", f"{sample}:RD", f"{sample}:AD"])
|
|
497
|
+
if self.config.output_positive_count:
|
|
498
|
+
header_cols.extend([f"{sample}:DPP", f"{sample}:RDP", f"{sample}:ADP"])
|
|
499
|
+
if self.config.output_fragment_count:
|
|
500
|
+
header_cols.extend([f"{sample}:DPF", f"{sample}:RDF", f"{sample}:ADF"])
|
|
501
|
+
|
|
502
|
+
# Add strand bias columns for each sample
|
|
503
|
+
header_cols.extend([f"{sample}:SB_PVAL", f"{sample}:SB_OR", f"{sample}:SB_DIR"])
|
|
504
|
+
|
|
505
|
+
if self.config.output_fragment_count:
|
|
506
|
+
header_cols.extend(
|
|
507
|
+
[f"{sample}:FSB_PVAL", f"{sample}:FSB_OR", f"{sample}:FSB_DIR"]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
f.write("\t".join(header_cols) + "\n")
|
|
511
|
+
|
|
512
|
+
# Write variants
|
|
513
|
+
for variant in variants:
|
|
514
|
+
row = [
|
|
515
|
+
variant.gene,
|
|
516
|
+
variant.chrom,
|
|
517
|
+
str(variant.maf_pos + 1),
|
|
518
|
+
str(variant.maf_end_pos + 1),
|
|
519
|
+
variant.maf_ref,
|
|
520
|
+
variant.maf_alt if variant.maf_alt else "",
|
|
521
|
+
"",
|
|
522
|
+
variant.tumor_sample,
|
|
523
|
+
variant.normal_sample,
|
|
524
|
+
variant.effect,
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
for sample in self.sample_order:
|
|
528
|
+
dp = int(variant.get_count(sample, CountType.DP))
|
|
529
|
+
rd = int(variant.get_count(sample, CountType.RD))
|
|
530
|
+
ad = int(variant.get_count(sample, CountType.AD))
|
|
531
|
+
row.extend([str(dp), str(rd), str(ad)])
|
|
532
|
+
|
|
533
|
+
if self.config.output_positive_count:
|
|
534
|
+
dpp = int(variant.get_count(sample, CountType.DPP))
|
|
535
|
+
rdp = int(variant.get_count(sample, CountType.RDP))
|
|
536
|
+
adp = int(variant.get_count(sample, CountType.ADP))
|
|
537
|
+
row.extend([str(dpp), str(rdp), str(adp)])
|
|
538
|
+
|
|
539
|
+
if self.config.output_fragment_count:
|
|
540
|
+
dpf = int(variant.get_count(sample, CountType.DPF))
|
|
541
|
+
rdf = int(variant.get_count(sample, CountType.RDF))
|
|
542
|
+
adf = int(variant.get_count(sample, CountType.ADF))
|
|
543
|
+
row.extend([str(dpf), str(rdf), str(adf)])
|
|
544
|
+
|
|
545
|
+
# Add strand bias information for this sample
|
|
546
|
+
# Calculate strand bias on-the-fly using normal counts
|
|
547
|
+
sample_ref_forward = int(variant.get_count(sample, CountType.RDP))
|
|
548
|
+
sample_ref_reverse = rd - sample_ref_forward
|
|
549
|
+
sample_alt_forward = int(variant.get_count(sample, CountType.ADP))
|
|
550
|
+
sample_alt_reverse = ad - sample_alt_forward
|
|
551
|
+
|
|
552
|
+
sb_pval, sb_or, sb_dir = self._calculate_strand_bias_for_output(
|
|
553
|
+
sample_ref_forward,
|
|
554
|
+
sample_ref_reverse,
|
|
555
|
+
sample_alt_forward,
|
|
556
|
+
sample_alt_reverse,
|
|
557
|
+
)
|
|
558
|
+
row.extend([f"{sb_pval:.6f}", f"{sb_or:.3f}", sb_dir])
|
|
559
|
+
|
|
560
|
+
if self.config.output_fragment_count:
|
|
561
|
+
# For fragment strand bias, use the same forward/reverse counts
|
|
562
|
+
# (fragments inherit strand orientation from their constituent reads)
|
|
563
|
+
fsb_pval, fsb_or, fsb_dir = self._calculate_strand_bias_for_output(
|
|
564
|
+
sample_ref_forward,
|
|
565
|
+
sample_ref_reverse,
|
|
566
|
+
sample_alt_forward,
|
|
567
|
+
sample_alt_reverse,
|
|
568
|
+
)
|
|
569
|
+
row.extend([f"{fsb_pval:.6f}", f"{fsb_or:.3f}", fsb_dir])
|
|
570
|
+
|
|
571
|
+
f.write("\t".join(row) + "\n")
|
|
572
|
+
|
|
573
|
+
logger.info(f"Successfully wrote {len(variants)} variants to fillout output file")
|