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/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")