Coverage for src / optwps / optwps.py: 96%

227 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-26 00:25 +0200

1#!/usr/bin/env python 

2""" 

3Fast Window Protection Score (WPS) Calculator for Cell-Free DNA Analysis. 

4 

5This module provides efficient computation of Window Protection Scores from BAM files, 

6designed for analyzing cell-free DNA fragmentation patterns and nucleosome positioning. 

7 

8The WPS algorithm identifies protected genomic regions by analyzing DNA fragment 

9coverage patterns. It counts fragments that span versus fragments whose endpoints 

10fall within a protection window around each genomic position. 

11 

12Example: 

13 Basic usage:: 

14 

15 from optwps import WPS 

16 

17 wps_calc = WPS(protection_size=120) 

18 wps_calc.run(bamfile='input.bam', out_filepath='output.tsv') 

19 

20 Creating separate output files:: 

21 

22 # Per chromosome 

23 wps_calc.run(bamfile='input.bam', out_filepath='wps_{chrom}.tsv') 

24 

25 # Per target region 

26 wps_calc.run(bamfile='input.bam', out_filepath='wps_{target}.tsv.gz') 

27 

28Note: 

29 - Op BAM Description 

30 - M 0 alignment match (can be a sequence match or mismatch) 

31 - I 1 insertion to the reference 

32 - D 2 deletion from the reference 

33 - N 3 skipped region from the reference 

34 - S 4 soft clipping (clipped sequences present in SEQ) 

35 - H 5 hard clipping (clipped sequences NOT present in SEQ) 

36 - P 6 padding (silent deletion from padded reference) 

37 - = 7 sequence match 

38 - X 8 sequence mismatch 

39 - pysam always uses 0-based coordinates, converting from 1-based of the sam files! 

40""" 

41 

42import os 

43import sys 

44import pysam 

45import numpy as np 

46import pandas as pd 

47import joblib 

48 

49from .read_processing import ( 

50 collect_fragment_intervals, 

51 fragment_interval, 

52 iter_pysam_reads, 

53 read_info_batches, 

54) 

55from .read_validator import ReadValidator 

56from .weighting import WeightsCalculator 

57from .utils import exopen 

58from tqdm.auto import tqdm 

59 

60 

61class ROIGenerator: 

62 """ 

63 Generate regions of interest (ROI) for processing. 

64 

65 This class generates genomic regions either from a BED file or from the entire 

66 genome in the BAM file. Regions are yielded in chunks for memory-efficient processing. 

67 

68 Args: 

69 bed_file (str, optional): Path to BED file containing regions to process. 

70 If None, the entire genome will be processed. Default: None 

71 chunk_size (int, optional): Size of chunks in base pairs for processing. 

72 Default: 1e8 (100 megabases) 

73 """ 

74 

75 def __init__(self, bed_file=None, chunk_size=1e8, njobs=1): 

76 self.bed_file = bed_file 

77 self.chunk_size = chunk_size 

78 self.njobs = njobs 

79 if self.njobs < 0: 

80 self.njobs = joblib.cpu_count() + self.njobs 

81 self.njobs = max(1, self.njobs) 

82 

83 def regions(self, bam_file=None): 

84 """ 

85 Generate regions for processing. 

86 

87 Yields genomic regions either from a BED file or from the entire genome 

88 referenced in the BAM file. Regions are chunked according to chunk_size. 

89 

90 Args: 

91 bam_file (str, optional): Path to BAM file. Required if no BED file 

92 is provided to determine genome regions. Default: None 

93 

94 Yields: 

95 tuple: (chromosome, chunk_start, chunk_end, region_id) 

96 where chunk_start/chunk_end define the current chunk being processed 

97 and region_id is either from the BED file or constructed from chrom/start/end. 

98 

99 Raises: 

100 ValueError: If neither bed_file nor bam_file can provide regions 

101 """ 

102 if (self.bed_file is None) or (not os.path.exists(self.bed_file)): 

103 input_file = ( 

104 pysam.Samfile(bam_file, "rb") 

105 if self.njobs == 1 

106 else pysam.Samfile(bam_file, "rb", threads=self.njobs) 

107 ) 

108 nchunks = sum( 

109 (input_file.get_reference_length(chrom) - 1) // self.chunk_size + 1 

110 for chrom in input_file.references 

111 ) 

112 iterator = tqdm(total=nchunks, desc="Processing genome regions") 

113 for chrom in input_file.references: 

114 chrom_length = input_file.get_reference_length(chrom) 

115 region_start = 0 

116 while region_start < chrom_length: 

117 region_end = min(region_start + self.chunk_size, chrom_length) 

118 yield chrom, region_start, region_end, chrom 

119 region_start = region_end 

120 iterator.update(1) 

121 iterator.close() 

122 else: 

123 # read number of lines in bed file 

124 nlines = sum(1 for _ in exopen(self.bed_file, "r")) 

125 with exopen(self.bed_file, "r") as bed: 

126 for line in tqdm(bed, total=nlines, desc="Processing BED regions"): 

127 ret = line.strip().split("\t") 

128 chrom, start, end = ret[:3] 

129 chrom = chrom.replace("chr", "") 

130 try: 

131 region_id = ret[3] 

132 except IndexError: 

133 region_id = f"{chrom}_{start}_{end}" 

134 chunk_start = int(start) 

135 chunk_end = min(chunk_start + self.chunk_size, int(end)) 

136 while chunk_start < int(end): 

137 yield chrom, chunk_start, chunk_end, region_id 

138 chunk_start = chunk_end 

139 chunk_end = min(chunk_start + self.chunk_size, int(end)) 

140 

141 

142class CMWriterUnpacker: 

143 """ 

144 Wrapper for file handles to provide consistent write and close interface. 

145 

146 This class handles both context managers and direct file handles, providing 

147 a unified interface for writing data and closing files. It also prevents 

148 closing stdout to avoid unexpected behavior. 

149 

150 Args: 

151 cm: A context manager or file handle object 

152 

153 Attributes: 

154 cm: The original context manager 

155 handle: The unpacked file handle 

156 """ 

157 

158 def __init__(self, cm): 

159 self.cm = cm 

160 try: 

161 self.handle = self.cm.__enter__() 

162 except AttributeError: 

163 self.handle = self.cm 

164 

165 def write(self, data): 

166 """Write data to the file handle.""" 

167 self.handle.write(data) 

168 

169 def close(self): 

170 """Close the file handle, avoiding closure of stdout.""" 

171 try: 

172 if self.handle != sys.stdout: 

173 self.handle.close() 

174 except AttributeError: 

175 self.cm.__exit__(None, None, None) 

176 

177 

178class WPS: 

179 """ 

180 Window Protection Score (WPS) calculator for cell-free DNA analysis. 

181 

182 This class computes Window Protection Scores from aligned sequencing reads in BAM format. 

183 WPS quantifies the protection of DNA fragments around each genomic position, useful for 

184 identifying nucleosome positioning and other protected regions in cell-free DNA. 

185 

186 The algorithm calculates: 

187 - Outside score: fragments that completely span the protection window 

188 - Inside score: fragment endpoints falling within the protection window 

189 - WPS = outside - inside 

190 

191 Args: 

192 bed_file (str, optional): Path to BED file with regions to process. 

193 If None, processes entire genome. Default: None 

194 mappability_file (str, optional): Path to BigWig file with mappability scores. 

195 If None, bam will be processed without mappability filtering. 

196 protection_size (int, optional): Total protection window size in base pairs. 

197 This value is divided by 2 to get the window on each side of the position. 

198 Default: 120 

199 min_insert_size (int, optional): Minimum insert/fragment size to include. 

200 If None, no minimum filter applied. Default: None 

201 max_insert_size (int, optional): Maximum insert/fragment size to include. 

202 If None, no maximum filter applied. Default: None 

203 min_mappability (float, optional): Minimum average mappability score for fragments to include. 

204 If None, no mappability filter applied. Default: 0.9 

205 correct_for_bias (bool, optional): Whether to apply bias correction weights based on fragment features (length, GC content). 

206 Default: False 

207 bias_bins (int, optional): Number of bins used for each bias-correction feature. 

208 Default: 10 

209 bias_subsample (float, optional): Fraction of reads used to estimate bias-correction weights. 

210 Default: 0.05 

211 bias_prior_count (float, optional): Pseudocount used to shrink rare-bin 

212 bias weights. 

213 Default: 20.0 

214 bias_min_weight (float, optional): Minimum fragment bias weight. Use None 

215 to disable. 

216 Default: 0.2 

217 bias_max_weight (float, optional): Maximum fragment bias weight. Use None 

218 to disable. 

219 Default: 5.0 

220 valid_chroms (set, optional): Set of valid chromosome names to process. 

221 Default: chromosomes 1-22, X, Y 

222 chunk_size (float, optional): Region chunk size for processing. 

223 Default: 1e8 (100 Mb) 

224 read_buffer_size (int, optional): Number of reads sent to each worker task. 

225 Default: 10000 

226 njobs (int, optional): Number of jobs to use for read processing and input 

227 BAM decompression. If negative, uses (number of CPUs + njobs). 

228 Default: -1 

229 

230 Attributes: 

231 bed_file (str): Path to BED file or None 

232 protection_size (int): Half of the protection window size 

233 valid_chroms (set): Set of valid chromosome names 

234 min_insert_size (int): Minimum fragment size filter 

235 max_insert_size (int): Maximum fragment size filter 

236 min_mappability (float): Minimum mappability score filter 

237 chunk_size (float): Chunk size for processing 

238 roi_generator (ROIGenerator): Region generator instance 

239 read_validator (ReadValidator): Read validator instance 

240 weights_calculator (WeightsCalculator or None): Weights calculator instance for bias correction 

241 

242 Example: 

243 >>> wps = WPS(protection_size=120, min_insert_size=120, max_insert_size=180) 

244 >>> wps.run(bamfile='sample.bam', out_filepath='wps_output.tsv') 

245 >>> # Process specific chromosomes 

246 >>> wps = WPS(valid_chroms={'1', '2', '3', 'X', 'Y'}) 

247 >>> wps.run(bamfile='sample.bam', out_filepath='chr_wps.tsv') 

248 

249 Note: 

250 - Automatically filters duplicate, QC-failed, and unmapped reads 

251 - Handles both paired-end and single-end sequencing data 

252 - Supports downsampling for high-coverage samples 

253 - Can write to separate files per chromosome or target region using placeholders 

254 """ 

255 

256 def __init__( 

257 self, 

258 bed_file=None, 

259 mappability_file=None, 

260 protection_size=120, 

261 min_insert_size=None, 

262 max_insert_size=None, 

263 min_mappability=0.9, 

264 correct_for_bias=False, 

265 bias_bins=10, 

266 bias_subsample=0.05, 

267 bias_prior_count=20.0, 

268 bias_min_weight=0.2, 

269 bias_max_weight=5.0, 

270 valid_chroms=set(map(str, list(range(1, 23)) + ["X", "Y"])), 

271 chunk_size=1e8, 

272 njobs=1, 

273 read_buffer_size=10000, 

274 ): 

275 self.bed_file = bed_file 

276 self.mappability_file = mappability_file 

277 self.protection_size = protection_size // 2 

278 if valid_chroms is not None: 

279 self.valid_chroms = [x.replace("chr", "") for x in valid_chroms] 

280 else: 

281 self.valid_chroms = None 

282 self.min_insert_size = min_insert_size 

283 self.max_insert_size = max_insert_size 

284 self.min_mappability = min_mappability 

285 self.chunk_size = chunk_size 

286 self.njobs = njobs 

287 if self.njobs < 0: 

288 self.njobs = joblib.cpu_count() + self.njobs 

289 self.njobs = max(1, self.njobs) 

290 self.read_buffer_size = read_buffer_size 

291 self.roi_generator = ROIGenerator( 

292 bed_file=self.bed_file, chunk_size=self.chunk_size, njobs=self.njobs 

293 ) 

294 self.read_validator = ReadValidator( 

295 min_insert_size=self.min_insert_size, 

296 max_insert_size=self.max_insert_size, 

297 mappability_file=self.mappability_file, 

298 min_mappability_threshold=self.min_mappability, 

299 ) 

300 self.weights_calculator = ( 

301 WeightsCalculator( 

302 mappability_file=self.mappability_file, 

303 nbins=bias_bins, 

304 subsample=bias_subsample, 

305 min_insert_size=self.min_insert_size, 

306 max_insert_size=self.max_insert_size, 

307 min_mappability_threshold=self.min_mappability, 

308 prior_count=bias_prior_count, 

309 min_weight=bias_min_weight, 

310 max_weight=bias_max_weight, 

311 njobs=self.njobs, 

312 read_buffer_size=self.read_buffer_size, 

313 ) 

314 if correct_for_bias 

315 else None 

316 ) 

317 

318 def __call__(self, *args, **kwargs): 

319 return self.run(*args, **kwargs) 

320 

321 def run( 

322 self, 

323 bamfile, 

324 out_filepath=None, 

325 downsample_ratio=None, 

326 compute_coverage=False, 

327 verbose_output=False, 

328 add_header=False, 

329 ): 

330 """ 

331 Calculate Window Protection Score for all regions and write to file. 

332 

333 Processes the BAM file to compute WPS values for each genomic position 

334 in the specified regions (or entire genome). Results are written to a 

335 tab-separated output file. 

336 

337 Args: 

338 bamfile (str): Path to input BAM file (must be sorted and indexed) 

339 out_filepath (str): Path to output TSV file, or stdout. 

340 If it contains a formatting substring {target} or {chrom}, it will be used to create per-target or 

341 per-chromosome files. Default: None (stdout) 

342 downsample_ratio (float, optional): Fraction of reads to randomly keep 

343 (0.0 to 1.0). Useful for high-coverage samples. Default: None (no downsampling) 

344 compute_coverage (bool, optional): Whether to compute and include base coverage 

345 verbose_output (bool, optional): Whether to include detailed counts 

346 add_header (bool, optional): Whether to add header to the output 

347 

348 Returns: 

349 None: Results are written directly to out_filepath 

350 

351 Output Format: 

352 Tab-separated file with columns: 

353 - chromosome: Chromosome name (without 'chr' prefix) 

354 - start: Start position (0-based) 

355 - end: End position (1-based, start + 1) 

356 - base read coverage (if compute_coverage=True) 

357 - outside: Count of fragments spanning the protection window (if verbose_output=True) 

358 - inside: Count of fragment endpoints in protection window (if verbose_output=True) 

359 - wps: Window Protection Score (outside - inside) 

360 

361 Raises: 

362 FileNotFoundError: If bamfile does not exist 

363 ValueError: If downsample_ratio is not between 0 and 1 

364 

365 Example: 

366 >>> wps = WPS() 

367 >>> wps.run(bamfile='input.bam', out_filepath='output.tsv') 

368 >>> # With downsampling 

369 >>> wps.run(bamfile='input.bam', out_filepath='output.tsv', 

370 ... downsample_ratio=0.5) 

371 >>> # Creating separate files per chromosome 

372 >>> wps.run(bamfile='input.bam', out_filepath='wps_{chrom}.tsv') 

373 >>> # Creating separate files per target region 

374 >>> wps.run(bamfile='input.bam', out_filepath='wps_{target}.tsv.gz') 

375 """ 

376 if out_filepath is None: 

377 out_filepath = "stdout" 

378 input_file = ( 

379 pysam.Samfile(bamfile, "rb") 

380 if self.njobs == 1 

381 else pysam.Samfile(bamfile, "rb", threads=self.njobs) 

382 ) 

383 prefix = ( 

384 "chr" if any(r.startswith("chr") for r in input_file.references) else "" 

385 ) 

386 use_partial_writer = "{target}" in out_filepath or "{chrom}" in out_filepath 

387 partial_writers = dict() 

388 total_outfile = None 

389 header_added = set() 

390 if not use_partial_writer: 

391 total_outfile = CMWriterUnpacker(exopen(out_filepath, "w")) 

392 try: 

393 total_outfile = total_outfile.__enter__() 

394 except AttributeError: 

395 pass 

396 

397 if self.weights_calculator is not None: 

398 self.weights_calculator.fit(input_file) 

399 

400 for chrom, start, end, region_id in self.roi_generator.regions( 

401 bam_file=bamfile 

402 ): 

403 if "chr" in chrom: 

404 chrom = chrom.replace("chr", "") 

405 if self.valid_chroms is not None and chrom not in self.valid_chroms: 

406 continue 

407 try: 

408 regionStart, regionEnd = int(start), int(end) 

409 except ValueError: 

410 continue 

411 

412 starts = [] 

413 ends = [] 

414 weights = [] 

415 reads = input_file.fetch( 

416 prefix + chrom, 

417 max(0, regionStart - self.protection_size - 1), 

418 regionEnd + self.protection_size + 1, 

419 ) 

420 can_parallel_reads = self.weights_calculator is None or isinstance( 

421 self.weights_calculator, WeightsCalculator 

422 ) 

423 if can_parallel_reads: 

424 starts, ends, weights = collect_fragment_intervals( 

425 read_info_batches(iter_pysam_reads(reads), self.read_buffer_size), 

426 min_insert_size=self.min_insert_size, 

427 max_insert_size=self.max_insert_size, 

428 mappability_path=self.mappability_file, 

429 min_mappability_threshold=self.min_mappability, 

430 upstream_limit=regionStart - self.protection_size - 1, 

431 downsample_ratio=downsample_ratio, 

432 bin_edges=getattr(self.weights_calculator, "bin_edges", None), 

433 weight_values=getattr(self.weights_calculator, "weights", None), 

434 use_weights=self.weights_calculator is not None, 

435 njobs=self.njobs, 

436 ) 

437 else: 

438 for read in iter_pysam_reads(reads): 

439 if not self.read_validator.valid_read( 

440 read, 

441 upstream_limit=regionStart - self.protection_size - 1, 

442 downsample_ratio=downsample_ratio, 

443 ): 

444 continue 

445 

446 rstart, rend = fragment_interval(read) 

447 starts.append(rstart) 

448 ends.append(rend) 

449 if self.weights_calculator is not None: 

450 weight = self.weights_calculator.transform(read) 

451 weights.append(weight) 

452 n = regionEnd - regionStart + 1 

453 if len(starts) > 0: 

454 starts = np.array(starts) 

455 ends = np.array(ends) 

456 read_weights = ( 

457 np.array(weights, dtype=float) 

458 if self.weights_calculator is not None 

459 else None 

460 ) 

461 score_dtype = float if read_weights is not None else int 

462 # Fragments fully spanning the window boundaries 

463 span_start = starts + self.protection_size - regionStart 

464 span_end = ends - self.protection_size - regionStart + 2 

465 valid = span_end >= span_start 

466 outside = np.zeros(n + 2, dtype=score_dtype) 

467 np.add.at( 

468 outside, 

469 np.clip(span_start[valid] + 1, 0, n + 1), 

470 1 if read_weights is None else read_weights[valid], 

471 ) 

472 np.add.at( 

473 outside, 

474 np.clip(span_end[valid], 0, n + 1), 

475 -1 if read_weights is None else -read_weights[valid], 

476 ) 

477 np.add.at( 

478 outside, 

479 np.clip(span_start[~valid] + 1, 0, n + 1), 

480 -1 if read_weights is None else -read_weights[~valid], 

481 ) 

482 np.add.at( 

483 outside, 

484 np.clip(span_end[~valid], 0, n + 1), 

485 1 if read_weights is None else read_weights[~valid], 

486 ) 

487 

488 outside_cum = np.cumsum(outside)[:-2] 

489 

490 # Fragments whose endpoints fall inside windows 

491 all_ends = np.concatenate([starts, ends]) - regionStart 

492 left = np.clip(all_ends - self.protection_size + 2, 0, n + 1) 

493 right = np.clip(all_ends + self.protection_size + 1, 0, n + 1) 

494 inside = np.zeros(n + 2, dtype=score_dtype) 

495 endpoint_weights = ( 

496 1 

497 if read_weights is None 

498 else np.concatenate([read_weights, read_weights]) 

499 ) 

500 np.add.at(inside, left, endpoint_weights) 

501 np.add.at( 

502 inside, 

503 right, 

504 -1 if read_weights is None else -endpoint_weights, 

505 ) 

506 inside_cum = np.cumsum(inside)[:-2] 

507 

508 wps = outside_cum - inside_cum 

509 coverage = None 

510 if compute_coverage: 

511 coverage = np.zeros(n + 1, dtype=int) 

512 np.add.at( 

513 coverage, 

514 np.clip(starts - regionStart, 0, n), 

515 1, 

516 ) 

517 np.add.at( 

518 coverage, 

519 np.clip(ends - regionStart + 1, 0, n), 

520 -1, 

521 ) 

522 coverage = np.cumsum(coverage)[:-1] 

523 else: 

524 score_dtype = float if self.weights_calculator is not None else int 

525 outside_cum = np.zeros(n, dtype=score_dtype) 

526 inside_cum = np.zeros(n, dtype=score_dtype) 

527 wps = np.zeros(n, dtype=score_dtype) 

528 coverage = None 

529 if compute_coverage: 

530 coverage = np.zeros(n, dtype=int) 

531 partial_outfile = None 

532 if use_partial_writer: 

533 formatted_out_filepath = out_filepath.format( 

534 target=region_id, 

535 chrom=chrom, 

536 ) 

537 partial_outfile = partial_writers.get( 

538 formatted_out_filepath, 

539 CMWriterUnpacker(exopen(formatted_out_filepath, "w")), 

540 ) 

541 partial_writers[formatted_out_filepath] = partial_outfile 

542 try: 

543 partial_outfile = partial_outfile.__enter__() 

544 except AttributeError: 

545 pass 

546 

547 outfile = partial_outfile if use_partial_writer else total_outfile 

548 st = np.arange(regionStart, regionEnd + 1) 

549 en = st + 1 # add end coordinate 

550 df = pd.DataFrame({"#chrom": chrom, "start": st, "end": en}) 

551 if compute_coverage: 

552 df["coverage"] = coverage 

553 if verbose_output: 

554 df["outside"] = outside_cum 

555 df["inside"] = inside_cum 

556 df["wps"] = wps 

557 header = False 

558 if add_header and outfile not in header_added: 

559 header_added.add(outfile) 

560 header = True 

561 df.to_csv(outfile, sep="\t", header=header, index=False) 

562 

563 if use_partial_writer: 

564 for writer in partial_writers.values(): 

565 writer.close() 

566 else: 

567 total_outfile.close() 

568 

569 

570def main(): 

571 """Main entry point for the command-line interface.""" 

572 from argparse import ArgumentParser 

573 

574 parser = ArgumentParser() 

575 parser.add_argument( 

576 "-i", 

577 "--input", 

578 dest="input", 

579 help="Input BAM file", 

580 required=True, 

581 ) 

582 parser.add_argument( 

583 "-o", 

584 "--output", 

585 dest="output", 

586 help="The output file path for WPS results. If not provided, results will be printed to stdout.", 

587 required=False, 

588 ) 

589 parser.add_argument( 

590 "-r", 

591 "--regions", 

592 dest="regions", 

593 help="BED file with regions of interest (default: whole genome)", 

594 default=None, 

595 ) 

596 parser.add_argument( 

597 "-w", 

598 "--protection", 

599 dest="protection", 

600 help="Base pair protection window (default: 120)", 

601 default=120, 

602 type=int, 

603 ) 

604 parser.add_argument( 

605 "--min-insert-size", 

606 dest="min_insert_size", 

607 help="Minimum read length threshold to consider (Optional)", 

608 default=None, 

609 type=int, 

610 ) 

611 parser.add_argument( 

612 "--max-insert-size", 

613 dest="max_insert_size", 

614 help="Maximum read length threshold to consider (Optional)", 

615 default=None, 

616 type=int, 

617 ) 

618 parser.add_argument( 

619 "--mappability-file", 

620 dest="mappability_file", 

621 help="BigWig file with mappability scores used to filter fragments (optional)", 

622 default=None, 

623 ) 

624 parser.add_argument( 

625 "--min-mappability", 

626 dest="min_mappability", 

627 help="Minimum average mappability score for fragments when --mappability-file is provided (default: 0.9)", 

628 default=0.9, 

629 type=float, 

630 ) 

631 parser.add_argument( 

632 "--correct-for-bias", 

633 dest="correct_for_bias", 

634 help="Apply fragment length and GC-content bias correction weights to WPS counts.", 

635 action="store_true", 

636 ) 

637 parser.add_argument( 

638 "--bias-bins", 

639 dest="bias_bins", 

640 help="Number of bins per feature for bias-correction weights (default: 10)", 

641 default=10, 

642 type=int, 

643 ) 

644 parser.add_argument( 

645 "--bias-subsample", 

646 dest="bias_subsample", 

647 help="Fraction of reads used to estimate bias-correction weights (default: 0.05)", 

648 default=0.05, 

649 type=float, 

650 ) 

651 parser.add_argument( 

652 "--bias-prior-count", 

653 dest="bias_prior_count", 

654 help="Pseudocount used to shrink rare-bin bias weights (default: 20.0)", 

655 default=20.0, 

656 type=float, 

657 ) 

658 parser.add_argument( 

659 "--bias-min-weight", 

660 dest="bias_min_weight", 

661 help=( 

662 "Minimum fragment bias weight. Use a negative value to disable " 

663 "(default: 0.2)" 

664 ), 

665 default=0.2, 

666 type=float, 

667 ) 

668 parser.add_argument( 

669 "--bias-max-weight", 

670 dest="bias_max_weight", 

671 help=( 

672 "Maximum fragment bias weight. Use a negative value to disable " 

673 "(default: 5.0)" 

674 ), 

675 default=5.0, 

676 type=float, 

677 ) 

678 parser.add_argument( 

679 "--downsample", 

680 dest="downsample", 

681 help="Ratio to down sample reads (default OFF)", 

682 default=None, 

683 type=float, 

684 ) 

685 parser.add_argument( 

686 "--chunk-size", 

687 dest="chunk_size", 

688 help="Chunk size for processing in pieces, in case of low memory (default 1e8)", 

689 default=1e8, 

690 type=int, 

691 ) 

692 parser.add_argument( 

693 "--valid-chroms", 

694 dest="valid_chroms", 

695 help="Comma-separated list of valid chromosomes to include (e.g., '1,2,3,X,Y') or 'canonical' for chromosomes 1-22, X, Y. Optional", 

696 default=None, 

697 type=str, 

698 ) 

699 parser.add_argument( 

700 "--compute-coverage", 

701 dest="compute_coverage", 

702 help="If provided, output will include base read coverage as the 4th column.", 

703 action="store_true", 

704 ) 

705 parser.add_argument( 

706 "--verbose-output", 

707 dest="verbose_output", 

708 help="If provided, output will include separate counts for 'outside' and 'inside' along with WPS.", 

709 action="store_true", 

710 ) 

711 parser.add_argument( 

712 "--add-header", 

713 dest="add_header", 

714 help="If provided, output files will include a header line.", 

715 action="store_true", 

716 ) 

717 parser.add_argument( 

718 "--njobs", 

719 dest="njobs", 

720 help="Number of jobs to use for read processing and input BAM decompression. If negative, uses (number of CPUs + njobs). Default: 1", 

721 default=1, 

722 type=int, 

723 ) 

724 parser.add_argument( 

725 "--read-buffer-size", 

726 dest="read_buffer_size", 

727 help="Number of reads sent to each worker task when --njobs is not 1. Default: 10000", 

728 default=10000, 

729 type=int, 

730 ) 

731 args = parser.parse_args() 

732 valid_chroms = None 

733 if args.valid_chroms == "canonical": 

734 valid_chroms = [str(i) for i in range(1, 23)] + ["X", "Y"] 

735 else: 

736 valid_chroms = args.valid_chroms.split(",") if args.valid_chroms else None 

737 optwps = WPS( 

738 bed_file=args.regions, 

739 protection_size=args.protection, 

740 min_insert_size=args.min_insert_size, 

741 max_insert_size=args.max_insert_size, 

742 mappability_file=args.mappability_file, 

743 min_mappability=args.min_mappability, 

744 correct_for_bias=args.correct_for_bias, 

745 bias_bins=args.bias_bins, 

746 bias_subsample=args.bias_subsample, 

747 bias_prior_count=args.bias_prior_count, 

748 bias_min_weight=None if args.bias_min_weight < 0 else args.bias_min_weight, 

749 bias_max_weight=None if args.bias_max_weight < 0 else args.bias_max_weight, 

750 chunk_size=args.chunk_size, 

751 valid_chroms=valid_chroms, 

752 njobs=args.njobs, 

753 read_buffer_size=args.read_buffer_size, 

754 ) 

755 optwps.run( 

756 bamfile=args.input, 

757 out_filepath=args.output, 

758 downsample_ratio=args.downsample, 

759 compute_coverage=args.compute_coverage, 

760 verbose_output=args.verbose_output, 

761 add_header=args.add_header, 

762 ) 

763 

764 

765if __name__ == "__main__": 

766 main()