#!/usr/bin/env python3 """Preflight local Gtars uniwig coverage and bigWig generation.""" from __future__ import annotations import argparse import math import sys from _common import ( HARD_MAX_BYTES, HARD_MAX_RECORDS, HARD_MAX_WORKERS, SafetyError, add_path_mode_argument, display_path, fail_json, inspect_bed, int_type, load_chrom_sizes, local_path, print_json, sha256_file, ) TOOL = "gtars-coverage-preflight" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Check a sorted local BED/narrowPeak input, chromosome sizes, bounds, " "and output budget for gtars uniwig 0.9.0. Nothing is generated." ) ) parser.add_argument("--input", required=True) parser.add_argument("--input-type", choices=("bed", "narrowpeak"), default="bed") parser.add_argument("--chrom-sizes", required=True) parser.add_argument("--assembly", required=True) parser.add_argument("--output-prefix", required=True) parser.add_argument( "--output-type", choices=("bw", "wig", "bedgraph", "npy"), default="bw", ) parser.add_argument( "--count-type", choices=("start", "end", "core", "all"), default="core", ) parser.add_argument( "--smooth-size", type=int_type(minimum=1, maximum=10_000_000, label="smooth-size"), default=5, ) parser.add_argument( "--step-size", type=int_type(minimum=1, maximum=10_000_000, label="step-size"), default=1, ) parser.add_argument( "--threads", type=int_type(minimum=1, maximum=HARD_MAX_WORKERS, label="threads"), default=1, ) parser.add_argument( "--zoom", type=int_type(minimum=0, maximum=10_000, label="zoom"), default=1, ) parser.add_argument( "--streaming", action="store_true", help="Use BED streaming; only wig/bedgraph output is supported upstream.", ) parser.add_argument( "--dense", type=int_type(minimum=-1, maximum=MAX_DENSE_GAP, label="dense"), default=100, help="Streaming gap fill: 0 sparse, -1 fully dense, N fills gaps <=N.", ) parser.add_argument( "--max-bytes", type=int_type(minimum=1, maximum=HARD_MAX_BYTES, label="max-bytes"), default=2 * 1024**3, ) parser.add_argument( "--max-records", type=int_type( minimum=1, maximum=HARD_MAX_RECORDS, label="max-records", ), default=5_000_000, ) parser.add_argument( "--max-estimated-bytes", type=int_type( minimum=1, maximum=HARD_MAX_BYTES, label="max-estimated-bytes", ), default=4 * 1024**3, help="Planning cap for dense uncompressed coverage values.", ) add_path_mode_argument(parser) return parser MAX_DENSE_GAP = 10_000_000 def preflight(args: argparse.Namespace) -> tuple[dict, int]: assembly = args.assembly.strip() if not assembly or len(assembly) > 200: raise SafetyError("assembly must contain 1-200 characters") input_path = local_path(args.input, kind="file") chrom_path = local_path(args.chrom_sizes, kind="file") output_prefix = local_path(args.output_prefix, must_exist=False) chrom_sizes, chrom_order = load_chrom_sizes( chrom_path, max_bytes=min(args.max_bytes, 128 * 1024**2), max_records=min(args.max_records, 1_000_000), ) chrom_digest, chrom_bytes = sha256_file( chrom_path, max_bytes=min(args.max_bytes, 128 * 1024**2), ) bed = inspect_bed( input_path, max_bytes=args.max_bytes, max_records=args.max_records, chrom_sizes=chrom_sizes, chrom_order=chrom_order, ) errors: dict[str, int] = dict(bed["errors"]) warnings: dict[str, int] = {} if bed["records"] == 0: errors["no_data_records"] = 1 if bed["out_of_order_records"]: errors["sorting_required"] = bed["out_of_order_records"] if args.input_type == "narrowpeak" and (bed["minimum_columns"] or 0) < 10: errors["narrowpeak_requires_ten_columns"] = 1 if output_prefix.exists(): errors["output_prefix_already_exists"] = 1 if args.streaming and args.output_type not in {"wig", "bedgraph"}: errors["streaming_output_type_unsupported"] = 1 if args.streaming and args.input_type != "bed": errors["streaming_requires_bed_input"] = 1 count_outputs = 3 if args.count_type == "all" else 1 genome_span = sum(chrom_sizes.values()) estimated_values = math.ceil(genome_span / args.step_size) * count_outputs estimated_uncompressed_bytes = estimated_values * 8 if estimated_uncompressed_bytes > args.max_estimated_bytes: errors["estimated_dense_coverage_exceeds_budget"] = 1 if args.threads > 1: warnings["parallel_memory_may_scale_with_threads"] = args.threads if args.output_type == "bw": warnings["bigwig_size_is_data_dependent_estimate_is_not_file_size"] = 1 if args.streaming and args.dense == -1: warnings["fully_dense_streaming_requested"] = 1 argv = [ "gtars", "uniwig", "--file", "", "--filetype", args.input_type, "--chromref", "", "--smoothsize", str(args.smooth_size), "--stepsize", str(args.step_size), "--fileheader", "", "--outputtype", args.output_type, "--counttype", args.count_type, ] if args.streaming: argv.extend(["--streaming", "--dense", str(args.dense)]) else: argv.extend(["--threads", str(args.threads), "--zoom", str(args.zoom)]) report = { "ok": not errors, "ready_to_execute": not errors, "tool": TOOL, "contract": { "assembly": assembly, "coordinate_system": "0-based-half-open", "gtars_cli_version": "0.9.0", "commands_executed": False, "files_written": False, "network_used": False, "symlinks_allowed": False, }, "input": { "path": display_path(input_path, 1, args.path_mode), "sha256": bed["sha256"], "size_bytes": bed["size_bytes"], "records": bed["records"], "minimum_columns": bed["minimum_columns"], "out_of_order_records": bed["out_of_order_records"], }, "chromosome_sizes": { "path": display_path(chrom_path, 2, args.path_mode), "sha256": chrom_digest, "size_bytes": chrom_bytes, "contig_count": len(chrom_sizes), "total_span": genome_span, }, "output": { "prefix": display_path(output_prefix, 3, args.path_mode), "type": args.output_type, "count_type": args.count_type, }, "resource_estimate": { "step_size": args.step_size, "count_outputs": count_outputs, "dense_value_upper_bound": estimated_values, "uncompressed_bytes_proxy": estimated_uncompressed_bytes, "budget_bytes": args.max_estimated_bytes, "not_a_bigwig_size_prediction": True, }, "errors": dict(sorted(errors.items())), "warnings": dict(sorted(warnings.items())), "argv_template": argv, "approval_gate": [ "review the sorted/bounds report and assembly checksum", "pilot one bounded contig or small synthetic BED first", "set CPU, RAM, disk, and wall-time limits before running", "inspect the resulting bigWig header and chromosome dictionary", ], } return report, 0 if not errors else 2 def main() -> int: args = build_parser().parse_args() try: report, status = preflight(args) print_json(report) return status except (OSError, SafetyError, UnicodeError) as exc: return fail_json(TOOL, exc) if __name__ == "__main__": sys.exit(main())