From 5e4b70092aebdde59feb6ba6db167f1996e4d883 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Tue, 2 Sep 2025 02:12:58 -0400 Subject: [PATCH 01/49] update max copy number calculation --- python/cnv_plots_json.py | 15 +++++++------ src/cnv_caller.cpp | 47 ++++++++++++++++------------------------ src/sv_caller.cpp | 17 --------------- 3 files changed, 27 insertions(+), 52 deletions(-) diff --git a/python/cnv_plots_json.py b/python/cnv_plots_json.py index 768058e9..9f2c9458 100644 --- a/python/cnv_plots_json.py +++ b/python/cnv_plots_json.py @@ -5,7 +5,7 @@ import plotly from plotly.subplots import make_subplots -min_sv_length = 200000 # Minimum SV length in base pairs +min_sv_length = 60000 # Minimum SV length in base pairs # Set up argument parser parser = argparse.ArgumentParser(description='Generate CNV plots from JSON data.') @@ -20,12 +20,12 @@ # State marker colors # https://community.plotly.com/t/plotly-colours-list/11730/6 state_colors_dict = { - '1': 'red', - '2': 'darkred', - '3': 'darkgreen', + '1': 'darkred', + '2': 'red', + '3': 'gray', '4': 'green', - '5': 'darkblue', - '6': 'blue', + '5': 'blue', + '6': 'darkblue', } sv_type_dict = { @@ -39,6 +39,7 @@ # If a chromosome is specified, filter the SVs by that chromosome if args.chromosome and sv['chromosome'] != args.chromosome: + print(f"Skipping SV {sv['chromosome']}:{sv['start']}-{sv['end']} of type {sv['sv_type']} (not on chromosome {args.chromosome})") continue # Filter out SVs that are smaller than the minimum length @@ -224,7 +225,7 @@ # Set the title of the plot. fig.update_layout( - title_text = f"{sv_type_dict[sv_type]} at {chromosome}:{start}-{end} ({sv_length} bp) (LLH={likelihood})", + title_text = f"{sv_type_dict[sv_type]} at {chromosome}:{start}-{end} ({sv_length} bp)", title_x = 0.5, showlegend = False, ) diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index 66f1f146..f9f722d9 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -171,22 +171,6 @@ std::tuple CNVCaller::runCopyNumberPrediction(std printError("ERROR: Invalid SV region for copy number prediction: " + chr + ":" + std::to_string((int)start_pos) + "-" + std::to_string((int)end_pos)); return std::make_tuple(0.0, SVType::UNKNOWN, Genotype::UNKNOWN, 0); } - /* - // Check that there is no large number of zero-depth positions in the region - int zero_depth_count = 0; - for (uint32_t pos = start_pos; pos <= end_pos; pos++) - { - if (pos < pos_depth_map.size() && pos_depth_map[pos] == 0) - { - zero_depth_count++; - } - } - if (zero_depth_count > 0.1 * (end_pos - start_pos + 1)) - { - printError("WARNING: Too many zero-depth positions in the SV region for copy number prediction, skipping: " + chr + ":" + std::to_string((int)start_pos) + "-" + std::to_string((int)end_pos)); - return std::make_tuple(0.0, SVType::UNKNOWN, Genotype::UNKNOWN, 0); - } - */ // Run the Viterbi algorithm on SNPs in the SV region // Only extend the region if "save CNV data" is enabled @@ -226,27 +210,34 @@ std::tuple CNVCaller::runCopyNumberPrediction(std std::vector& state_sequence = prediction.first; double likelihood = prediction.second; - // Determine if there is a majority state within the SV region - int max_state = 0; - int max_count = 0; + // Get state percentages + std::unordered_map state_pct; + double state_count = (double) state_sequence.size(); + double largest_non_neutral_pct = 0.0; + int non_neutral_state = 0; for (int i = 0; i < 6; i++) { - int state_count = std::count(state_sequence.begin(), state_sequence.end(), i+1); - if (state_count > max_count) + state_pct[i+1] = (double)std::count(state_sequence.begin(), state_sequence.end(), i+1) / state_count; + if (i+1 != 3 && state_pct[i+1] > largest_non_neutral_pct) { - max_state = i+1; - max_count = state_count; + largest_non_neutral_pct = state_pct[i+1]; + non_neutral_state = i+1; } } - // If there is no majority state, then set the state to unknown - double pct_threshold = 0.50; - int state_count = (int) state_sequence.size(); - if ((double) max_count / (double) state_count < pct_threshold) + // Use the state exceeding the threshold if non-neutral + double pct_threshold = 0.3; + int max_state = 0; // Unknown state + if (largest_non_neutral_pct > pct_threshold) { - max_state = 0; + max_state = non_neutral_state; + + // Use the neutral state if it exceeds the threshold + } else if (state_pct[3] > pct_threshold) { + max_state = 3; } + // Determine the genotype and SV type Genotype genotype = getGenotypeFromCNState(max_state); SVType predicted_cnv_type = getSVTypeFromCNState(max_state); diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index 29dab604..2a735d48 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -415,23 +415,6 @@ void SVCaller::findSplitSVSignatures(std::unordered_map 1) { - // std::sort(supp_positions.begin(), supp_positions.end()); - // int supp_start = supp_positions.front(); - // int supp_end = supp_positions.back(); - // int sv_length = std::abs(supp_start - supp_end); - - // // Use 50bp as the minimum length for an inversion - // if (sv_length >= 50 && sv_length <= max_length) { - // SVEvidenceFlags aln_type; - // aln_type.set(static_cast(SVDataType::SUPPINV)); - // SVCall sv_candidate(supp_start, supp_end, SVType::INV, getSVTypeSymbol(SVType::INV), aln_type, Genotype::UNKNOWN, 0.0, 0, 0, supp_cluster_size); - // // SVCall sv_candidate(supp_start, supp_end, SVType::INV, getSVTypeSymbol(SVType::INV), SVDataType::SUPPINV, Genotype::UNKNOWN, 0.0, 0, 0, supp_cluster_size); - // addSVCall(chr_sv_calls, sv_candidate); - // } - // } - // ------------------------------- // SPLIT INSERTION CALLS int read_distance = 0; From 4bd038c2c0df3e114895126ca9da447bd7530fc4 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Thu, 18 Sep 2025 15:22:36 -0400 Subject: [PATCH 02/49] update conda build --- .gitignore | 1 + Makefile | 11 +- __main__.py | 283 ---------------------------------------------- conda/meta.yaml | 41 ++++--- include/version.h | 2 - setup.py | 65 ----------- 6 files changed, 28 insertions(+), 375 deletions(-) delete mode 100644 __main__.py delete mode 100644 include/version.h delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index b7478d26..ab6f055d 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ upset_plot*.png # Temporary files lib/.nfs* valgrind.log +include/version.h # Log files *.log diff --git a/Makefile b/Makefile index 207f7e09..f87e52f4 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,10 @@ LIB_DIR := $(CURDIR)/lib # Version header VERSION := $(shell git describe --tags --always) VERSION_HEADER := $(INCL_DIR)/version.h -.PHONY: $(VERSION_HEADER) +.PHONY: $(VERSION_HEADER) clean all debug] +$(VERSION_HEADER): + @echo "Updating version header with: $(VERSION)" + @mkdir -p $(INCL_DIR) @echo "#pragma once" > $@ @echo "#define VERSION \"$(VERSION)\"" >> $@ @@ -31,19 +34,19 @@ OBJECTS := $(patsubst $(SRC_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(SOURCES)) TARGET := $(BUILD_DIR)/contextsv # Default target -all: $(TARGET) +all: $(VERSION_HEADER) $(TARGET) # Debug target debug: CXXFLAGS += -DDEBUG debug: all # Link the executable -$(TARGET): $(OBJECTS) +$(TARGET): $(OBJECTS) $(VERSION_HEADER) @mkdir -p $(BUILD_DIR) $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) $(LDLIBS) # Compile source files -$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp +$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp $(VERSION_HEADER) @mkdir -p $(BUILD_DIR) $(CXX) $(CXXFLAGS) -c $< -o $@ diff --git a/__main__.py b/__main__.py deleted file mode 100644 index 3821b8d1..00000000 --- a/__main__.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -__main__.py: Run the program. -""" - -__version__ = "0.0.1" - -import os -import sys -import argparse -import logging as log -from io import StringIO - -from lib import contextsv -from python import cnv_plots - -# Set up logging. -log.basicConfig( - level=log.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - log.StreamHandler(sys.stdout) - ] -) - -# Define a class for redirecting c++ stdout to python logging info. -class LogRedirect(StringIO): - """Redirect c++ stdout to python logging info.""" - def write(self, buf): - super(LogRedirect, self).write(buf) - log.info(buf) - -# Redirect c++ stdout to python logging info. -sys.stdout = LogRedirect() - -def main(): - """Entry point and user interface for the program.""" - - # Grab the command line arguments using argparse. - parser = argparse.ArgumentParser( - description="ContextSV: A tool for integrative structural variant detection." - ) - - # Add common arguments. - parser.add_argument( - "-lr", "--long-read", - help="path to the long read alignment BAM file", - required=True - ) - - parser.add_argument( - "-g", "--reference", - help="path to the reference genome FASTA file", - required=False - ) - - parser.add_argument( - "-s", "--snps", - help="path to the SNPs VCF file", - required=False - ) - - parser.add_argument( - '-c', '--chr', - help="chromosome to analyze (e.g. 1, 2, 3, ..., X, Y)", - required=False, - default="", - type=str - ) - - parser.add_argument( - "-r", "--region", - help="region to analyze (e.g. 1:1000-2000)", - required=False, - default="", - type=str - ) - - # Specify the ethnicity of the sample for obtaining population allele - # frequencies from a database such as gnomAD. If not provided, the allele - # frequencies will be obtained for all populations. - parser.add_argument( - "-e", "--ethnicity", - help="ethnicity of the sample (e.g. afr, amr, eas, fin, nfe, oth, sas, asj)", - required=False - ) - - # Text file with VCF filepaths of SNP population allele frequencies for each - # chromosome from a database such as gnomAD (e.g. 1=chr1.vcf.gz\n2=chr2.vcf.gz\n...). - parser.add_argument( - "--pfb", - help="path to the file with SNP population frequency VCF filepaths (see docs for format)", - required=False - ) - - parser.add_argument( - "-o", "--output", - help="path to the output directory", - required=True - ) - - # Thread count. - parser.add_argument( - "-t", "--threads", - help="number of threads to use", - required=False, - default=1, - type=int - ) - - # HMM file path. - parser.add_argument( - "--hmm", - help="path to the PennCNV HMM file", - required=False - ) - - # Window size for calculating log2 ratios for CNV predictions. - parser.add_argument( - "--window-size", - help="window size for calculating log2 ratios for CNV predictions (default: 2500 bp)", - required=False, - type=int, - default=2500 - ) - - # Minimum SV length for copy number variation (CNV) predictions. - parser.add_argument( - "--min-cnv-length", - help="minimum SV length for CNV predictions (default: 1000 bp)", - required=False, - type=int, - default=1000 - ) - - # Verbose mode. - parser.add_argument( - "-d", "--debug", - help="debug mode (verbose logging)", - action="store_true", - default=False - ) - - parser.add_argument( - "-v", "--version", - help="print the version number and exit", - action="version", - version=f"contextSV version {__version__}" - ) - - # Extend SNP-based CNV predictions to regions surrounding SVs (+/- 1/2 SV - # length) and save CNV data to TSV. This will be useful for plotting CNV - # data around SVs, but takes longer to run. - parser.add_argument( - "--save-cnv", - required=False, - action="store_true", - default=False, - help=argparse.SUPPRESS - ) - - # Mode 1: SV detection mode. - # Short read alignment file (BAM), reference genome, and short read SNPs file. - parser.add_argument( - "-sr", "--short-read", - required=False, - help=argparse.SUPPRESS - ) - - # ----------------------------------------------------------------------- # - - # Run the program. - - # Get the command line arguments. - args = parser.parse_args() - - # Ensure BAM, reference, and SNPs files are provided. - arg_error = False - if (args.long_read is None): - log.error("Please provide the long read alignment file (BAM).") - arg_error = True - - if (args.reference is None): - log.error("Please provide the reference genome.") - arg_error = True - - # Short read alignment file is optional. Use the long read alignment - # file if it is not provided. - if (args.short_read is None): - log.warning("Short read alignment file not provided. Using long read alignment file in its place.") - args.short_read = args.long_read - - # SNPs file is required - if (args.snps is None): - log.error("Please provide the SNPs file.") - arg_error = True - - # Exit if there are any errors. - if (arg_error): - # Exit with error code 1. - sys.exit(1) - - # Set all None values to empty strings. - for key, value in vars(args).items(): - if value is None: - setattr(args, key, "") - else: - log.info("Setting %s to %s", key, value) - - # Loop and set all None values to empty strings. - for key, value in vars(args).items(): - if value is None: - setattr(args, key, "") - - # Set input parameters - input_data = contextsv.InputData() - input_data.setVerbose(args.debug) - input_data.setLongReadBam(args.long_read) - input_data.setRefGenome(args.reference) - input_data.setSNPFilepath(args.snps) - input_data.setEthnicity(args.ethnicity) - input_data.setThreadCount(args.threads) - input_data.setChromosome(args.chr) - input_data.setRegion(args.region) - input_data.setAlleleFreqFilepaths(args.pfb) - input_data.setHMMFilepath(args.hmm) - input_data.setOutputDir(args.output) - input_data.saveCNVData(args.save_cnv) - input_data.setWindowSize(args.window_size) - input_data.setMinCNVLength(args.min_cnv_length) - - # Run the analysis - contextsv.run(input_data) - - # Determine the data paths for downstream analysis. - vcf_path = os.path.join(args.output, "output.vcf") - output_dir = args.output - # cnv_data_path = os.path.join(args.output, "cnv_data.tsv") - - # Generate python-based CNV plots if SNP-based CNV predictions are enabled - if (args.save_cnv): - log.info("Generating CNV plots...") - - # Find all TSV files in the output directory - for file in os.listdir(output_dir): - if file.endswith(".tsv"): - cnv_data_path = os.path.join(output_dir, file) - - # Set the HTML output path by changing the file extension - output_html = os.path.splitext(cnv_data_path)[0] + ".html" - - # Generate the CNV plot for the current TSV file - cnv_plots.run(cnv_data_path, output_html) - # cnv_plots.run(vcf_path, cnv_data_path, output_dir, region) - - log.info("Complete. File saved to %s\nThank you for using ContextSV!", vcf_path) - -if __name__ == '__main__': - - # Check if the user specified the --merge flag. - if "--merge" in sys.argv: - # Ensure the user provided the correct number of arguments (last 2 are - # optional). - if len(sys.argv) < 2: - log.error("Usage: python __main__.py --merge (optional: )") - sys.exit(1) - - # The second argument is the input VCF file. - input_vcf = sys.argv[2] - - # The third argument is the epsilon value for the DBSCAN clustering. If - # empty, set to 34. - epsilon = sys.argv[3] if len(sys.argv) >= 4 else 34 - - # The fourth argument is the suffix for the output file. If empty, set - # to ".merged" - suffix = sys.argv[4] if len(sys.argv) >= 5 else ".merged" - - # Run the SV merger. - from python import sv_merger - sv_merger.sv_merger(input_vcf, mode='dbscan', eps=int(epsilon), suffix=suffix) - - # Run the program. - main() diff --git a/conda/meta.yaml b/conda/meta.yaml index 698a62e4..d217ee43 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -1,46 +1,45 @@ -{% set version = "1.0.0" %} -{% set sha256 = "" %} +{% set version = environ.get('GIT_DESCRIBE_TAG').lstrip('v') %} +{% set revision = environ.get('GIT_FULL_HASH') %} package: - name: longreadsum + name: contextsv version: {{ version }} source: - url: https://github.com/WGLab/LongReadSum/archive/refs/tags/v{{ version }}.tar.gz - sha256: '{{ sha256 }}' + path: ../ + git_lfs: false + +channels: + - conda-forge + - bioconda + - defaults build: number: 0 - skip: true # [py2k] - entry_points: - - contextsv = entry_point:main requirements: build: - {{ compiler('cxx') }} - make host: - - python - - swig - - htslib + - htslib=1.20 run: - - python - - numpy - - htslib - - swig - - plotly - - pandas - - scikit-learn - - joblib + - htslib=1.20 test: commands: - contextsv --help + - test -f $PREFIX/bin/contextsv + - contextsv --version about: home: https://github.com/WGLab/ContextSV license: MIT - summary: 'Long read structural variant calling tool' + license_family: MIT + license_file: LICENSE + summary: 'Long-read structural variant caller' description: | - An alignment-based, generalized structural variant caller for long-read sequencing/mapping data. + ContextSV is a tool for calling structural variants from long read + sequencing data that integrates important copy number and single-nucleotide + allele frequency information to improve accuracy. dev_url: https://github.com/WGLab/ContextSV doc_url: https://github.com/WGLab/ContextSV#readme diff --git a/include/version.h b/include/version.h deleted file mode 100644 index d38178a8..00000000 --- a/include/version.h +++ /dev/null @@ -1,2 +0,0 @@ -#pragma once -#define VERSION "v0,1,0-41-gd62fe12" diff --git a/setup.py b/setup.py deleted file mode 100644 index c8591523..00000000 --- a/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -setup.py: - This file is used to install the package. -""" - -import os -import glob -from setuptools import setup, find_packages, Extension - -print("Running setup.py...") - - -# Set the project metadata -NAME = "contextsv" -VERSION = "1.0.0" -AUTHOR = "WGLab" -DESCRIPTION = "ContextSV: A tool for integrative structural variant detection." - -# Get the conda environment's include path -conda_prefix = os.environ.get("CONDA_PREFIX") -if conda_prefix is None: - raise AssertionError("CONDA_PREFIX is not set.") - -conda_include_dir = os.path.join(conda_prefix, "include") - -# Get the conda environment's lib path -conda_lib_dir = os.path.join(conda_prefix, "lib") - -# Set the project dependencies -SRC_DIR = "src" -# SRC_FILES = glob.glob(os.path.join(SRC_DIR, "*.cpp")) -SRC_FILES = glob.glob(os.path.join(SRC_DIR, "*.cpp")) -SRC_FILES = [f for f in SRC_FILES if "main.cpp" not in f] # Ignore the main.cpp file - -INCLUDE_DIR = "include" -INCLUDE_FILES = glob.glob(os.path.join(INCLUDE_DIR, "*.h")) - -# Set up the extension -ext = Extension( - name="_" + NAME, - sources=SRC_FILES, - include_dirs=[INCLUDE_DIR, conda_include_dir], - extra_compile_args=["-std=c++17"], - language="c++", - libraries=["hts"], - library_dirs=[conda_lib_dir] -) - -# Set up the module -setup( - name=NAME, - version=VERSION, - author=AUTHOR, - description=DESCRIPTION, - ext_modules=[ext], - py_modules=[NAME], - packages=find_packages(), - test_suite="tests", - entry_points={ - "console_scripts": [ - "contextsv = contextsv:main" - ] - } -) - From 3eeaf47ecfcbbd9847873a9a22a2e9464e102d28 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 14:47:46 -0400 Subject: [PATCH 03/49] add sv detection test and fix singleton cluster error --- .github/workflows/build-tests.yml | 4 +- .gitignore | 1 - Makefile | 17 +-- include/cnv_caller.h | 3 - include/utils.h | 37 +----- src/cnv_caller.cpp | 82 -------------- src/fasta_query.cpp | 2 +- src/main.cpp | 14 ++- src/sv_caller.cpp | 14 +-- src/sv_object.cpp | 4 +- src/utils.cpp | 88 ++++----------- tests/data/ref.fa | 0 tests/data/snps.vcf.gz | Bin 92733 -> 0 bytes tests/data/snps.vcf.gz.csi | Bin 1094 -> 0 bytes tests/data/snps.vcf.gz.tbi | Bin 1279 -> 0 bytes tests/data/test.bam | Bin 118472 -> 0 bytes tests/data/test.bam.bai | Bin 30248 -> 0 bytes tests/test_general.py | 182 ++++++++++++++++++------------ 18 files changed, 152 insertions(+), 296 deletions(-) delete mode 100644 tests/data/ref.fa delete mode 100644 tests/data/snps.vcf.gz delete mode 100644 tests/data/snps.vcf.gz.csi delete mode 100644 tests/data/snps.vcf.gz.tbi delete mode 100644 tests/data/test.bam delete mode 100644 tests/data/test.bam.bai diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 5c4bbb13..3869e823 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -17,11 +17,11 @@ jobs: with: repo: 'WGLab/ContextSV' version: 'tags/v0.1.0' - file: 'TestData.zip' + file: 'SampleData.zip' - name: Unzip assets shell: bash --login {0} - run: unzip TestData.zip + run: unzip SampleData.zip - name: Set up conda (Miniconda only) uses: conda-incubator/setup-miniconda@v2 diff --git a/.gitignore b/.gitignore index ab6f055d..b7478d26 100644 --- a/.gitignore +++ b/.gitignore @@ -93,7 +93,6 @@ upset_plot*.png # Temporary files lib/.nfs* valgrind.log -include/version.h # Log files *.log diff --git a/Makefile b/Makefile index f87e52f4..ae39b32b 100644 --- a/Makefile +++ b/Makefile @@ -4,16 +4,6 @@ SRC_DIR := $(CURDIR)/src BUILD_DIR := $(CURDIR)/build LIB_DIR := $(CURDIR)/lib -# Version header -VERSION := $(shell git describe --tags --always) -VERSION_HEADER := $(INCL_DIR)/version.h -.PHONY: $(VERSION_HEADER) clean all debug] -$(VERSION_HEADER): - @echo "Updating version header with: $(VERSION)" - @mkdir -p $(INCL_DIR) - @echo "#pragma once" > $@ - @echo "#define VERSION \"$(VERSION)\"" >> $@ - # Conda environment directories CONDA_PREFIX := $(shell echo $$CONDA_PREFIX) CONDA_INCL_DIR := $(CONDA_PREFIX)/include @@ -34,23 +24,22 @@ OBJECTS := $(patsubst $(SRC_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(SOURCES)) TARGET := $(BUILD_DIR)/contextsv # Default target -all: $(VERSION_HEADER) $(TARGET) +all: $(TARGET) # Debug target debug: CXXFLAGS += -DDEBUG debug: all # Link the executable -$(TARGET): $(OBJECTS) $(VERSION_HEADER) +$(TARGET): $(OBJECTS) @mkdir -p $(BUILD_DIR) $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) $(LDLIBS) # Compile source files -$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp $(VERSION_HEADER) +$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp @mkdir -p $(BUILD_DIR) $(CXX) $(CXXFLAGS) -c $< -o $@ # Clean the build directory clean: rm -rf $(BUILD_DIR) - \ No newline at end of file diff --git a/include/cnv_caller.h b/include/cnv_caller.h index afdd78b3..293e547a 100644 --- a/include/cnv_caller.h +++ b/include/cnv_caller.h @@ -105,9 +105,6 @@ class CNVCaller { void readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, uint32_t end_pos, std::vector& snp_pos, std::unordered_map& snp_baf, std::unordered_map& snp_pfb, const InputData& input_data) const; - // Save a TSV with B-allele frequencies, log2 ratios, and copy number predictions - void saveSVCopyNumberToTSV(SNPData& snp_data, std::string filepath, std::string chr, uint32_t start, uint32_t end, std::string sv_type, double likelihood) const; - void saveSVCopyNumberToJSON(SNPData& before_sv, SNPData& after_sv, SNPData& snp_data, std::string chr, uint32_t start, uint32_t end, std::string sv_type, double likelihood, const std::string& filepath) const; }; diff --git a/include/utils.h b/include/utils.h index d95f0a8a..2d713e1d 100644 --- a/include/utils.h +++ b/include/utils.h @@ -12,40 +12,7 @@ #include /// @endcond - -// Guard to close the BAM file -// struct BamFileGuard { -// samFile* fp_in; -// hts_idx_t* idx; -// bam_hdr_t* bamHdr; - -// BamFileGuard(samFile* fp_in, hts_idx_t* idx, bam_hdr_t* bamHdr) -// : fp_in(fp_in), idx(idx), bamHdr(bamHdr) {} - -// ~BamFileGuard() { -// if (idx) { -// hts_idx_destroy(idx); -// idx = nullptr; -// } -// if (bamHdr) { -// bam_hdr_destroy(bamHdr); -// bamHdr = nullptr; -// } -// if (fp_in) { -// sam_close(fp_in); -// fp_in = nullptr; -// } -// } - -// BamFileGuard(const BamFileGuard&) = delete; // Non-copyable -// BamFileGuard& operator=(const BamFileGuard&) = delete; // Non-assignable -// }; - -// Print the progress of a task -void printProgress(int progress, int total); - -// Run bcftools to determine the chr notation of a VCF file -bool isChrNotation(std::string vcf_filepath); +std::string currentVersion(); // Print a message to stdout in a thread-safe manner void printMessage(std::string message); @@ -55,8 +22,6 @@ void printError(std::string message); std::string getElapsedTime(std::chrono::high_resolution_clock::time_point start, std::chrono::high_resolution_clock::time_point end); -std::string removeChrPrefix(std::string chr); - void printMemoryUsage(const std::string &functionName); bool fileExists(const std::string &filepath); diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index f9f722d9..c91d1d31 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -808,88 +808,6 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui bcf_sr_destroy(pfb_reader); } -void CNVCaller::saveSVCopyNumberToTSV(SNPData& snp_data, std::string filepath, std::string chr, uint32_t start, uint32_t end, std::string sv_type, double likelihood) const -{ - // Open the TSV file for writing - std::ofstream tsv_file(filepath); - if (!tsv_file.is_open()) - { - std::cerr << "ERROR: Could not open TSV file for writing: " << filepath << std::endl; - exit(1); - } - - // Ensure all values are valid, and print an error message if not - if (chr == "" || start == 0 || end == 0 || sv_type == "") - { - std::cerr << "ERROR: Invalid SV information for TSV file: " << chr << ":" << start << "-" << end << " " << sv_type << std::endl; - exit(1); - } - - if (snp_data.pos.size() == 0) - { - std::cerr << "ERROR: No SNP data available for TSV file: " << chr << ":" << start << "-" << end << " " << sv_type << std::endl; - exit(1); - } - - if (likelihood == 0.0) - { - std::cerr << "ERROR: Invalid likelihood value for TSV file: " << chr << ":" << start << "-" << end << " " << sv_type << std::endl; - exit(1); - } - - if (sv_type.empty()) - { - std::cerr << "ERROR: Invalid SV type for TSV file: " << chr << ":" << start << "-" << end << " " << sv_type << std::endl; - exit(1); - } - - // Format the size string in kb - std::stringstream ss; - ss << std::fixed << std::setprecision(6) << likelihood; - std::string likelihood_str = ss.str(); - - // Print SV information to the TSV file - tsv_file << "SVTYPE=" << sv_type << std::endl; - tsv_file << "POS=" << chr << ":" << start << "-" << end << std::endl; - tsv_file << "HMM_LOGLH=" << likelihood_str << std::endl; - - // Write the header - tsv_file << "chromosome\tposition\tsnp\tb_allele_freq\tlog2_ratio\tcnv_state\tpopulation_freq" << std::endl; - - // Write the data - int snp_count = (int) snp_data.pos.size(); - for (int i = 0; i < snp_count; i++) - { - // Get the SNP data - uint32_t pos = snp_data.pos[i]; - bool is_snp = snp_data.is_snp[i]; - double pfb = snp_data.pfb[i]; - double baf = snp_data.baf[i]; - double log2_ratio = snp_data.log2_cov[i]; - int cn_state = snp_data.state_sequence[i]; - - // If the SNP is not a SNP, then set the BAF to 0.0 - if (!is_snp) - { - baf = 0.0; - } - - // Write the TSV line (chrom, pos, baf, lrr, state) - tsv_file << \ - chr << "\t" << \ - pos << "\t" << \ - is_snp << "\t" << \ - baf << "\t" << \ - log2_ratio << "\t" << \ - cn_state << "\t" << \ - pfb << \ - std::endl; - } - - // Close the file - tsv_file.close(); -} - void CNVCaller::saveSVCopyNumberToJSON(SNPData &before_sv, SNPData &after_sv, SNPData &snp_data, std::string chr, uint32_t start, uint32_t end, std::string sv_type, double likelihood, const std::string& filepath) const { // Append the SV information to the JSON file diff --git a/src/fasta_query.cpp b/src/fasta_query.cpp index e4f0e1dc..237db443 100644 --- a/src/fasta_query.cpp +++ b/src/fasta_query.cpp @@ -174,7 +174,7 @@ uint32_t ReferenceGenome::getChromosomeLength(std::string chr) const } catch (const std::out_of_range& e) { - printError("Chromosome " + chr + " not found in reference genome"); + printError("Length for chromosome " + chr + " not found in reference genome"); return 0; } } diff --git a/src/main.cpp b/src/main.cpp index 874f444f..5311d056 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,16 +5,17 @@ #include #include #include +#include +#include +#include // For signal handling #include #include -// #include /// @endcond #include "input_data.h" -#include "version.h" #include "utils.h" @@ -37,10 +38,11 @@ void printBanner() { std::time_t now = std::time(nullptr); char date_str[100]; + std::string version = currentVersion(); std::strftime(date_str, sizeof(date_str), "%Y-%m-%d", std::localtime(&now)); std::cout << "═══════════════════════════════════════════════════════════════" << std::endl; std::cout << " ContextSV - Long-read Structural Variant Caller" << std::endl; - std::cout << " Version: " << VERSION << std::endl; + std::cout << " Version: " << version << std::endl; std::cout << " Date: " << date_str << std::endl; std::cout << "═══════════════════════════════════════════════════════════════" << std::endl; } @@ -125,7 +127,8 @@ void runContextSV(const std::unordered_map& args) } void printUsage(const std::string& programName) { - std::cerr << "Usage: " << programName << " [options]\n" + std::cout << "ContextSV version " << currentVersion() << std::endl; + std::cout << "Usage: " << programName << " [options]\n" << "Options:\n" << " -b, --bam Long-read BAM file (required)\n" << " -r, --ref Reference genome FASTA file (required)\n" @@ -191,7 +194,7 @@ std::unordered_map parseArguments(int argc, char* argv } else if (arg == "--debug") { args["debug"] = "true"; } else if ((arg == "-v" || arg == "--version")) { - std::cout << "ContextSV version " << VERSION << std::endl; + std::cout << "ContextSV version " << currentVersion() << std::endl; exit(0); } else if (arg == "-h" || arg == "--help") { printUsage(argv[0]); @@ -218,6 +221,7 @@ std::unordered_map parseArguments(int argc, char* argv int main(int argc, char* argv[]) { auto args = parseArguments(argc, argv); runContextSV(args); + std::cout << "ContextSV finished successfully!" << std::endl; return 0; } diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index 2a735d48..41d654d0 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -26,7 +26,6 @@ #include "ThreadPool.h" #include "utils.h" #include "sv_types.h" -#include "version.h" #include "fasta_query.h" #include "dbscan.h" #include "dbscan1d.h" @@ -149,7 +148,7 @@ void SVCaller::findSplitSVSignatures(std::unordered_map qpos = getAlignmentReadPositions(bam1); - primary_map[bam1->core.tid][qname] = PrimaryAlignment{bam1->core.pos + 1, bam_endpos(bam1), qpos.first, qpos.second, !(bam1->core.flag & BAM_FREVERSE), 0}; + primary_map[bam1->core.tid][qname] = PrimaryAlignment{static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE), 0}; alignment_tids.insert(bam1->core.tid); primary_count++; @@ -159,7 +158,7 @@ void SVCaller::findSplitSVSignatures(std::unordered_map qpos = getAlignmentReadPositions(bam1); - supp_map[qname].push_back(SuppAlignment{bam1->core.tid, bam1->core.pos + 1, bam_endpos(bam1), qpos.first, qpos.second, !(bam1->core.flag & BAM_FREVERSE)}); + supp_map[qname].push_back(SuppAlignment{bam1->core.tid, static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE)}); alignment_tids.insert(bam1->core.tid); supp_qnames.insert(qname); supplementary_count++; @@ -738,13 +737,6 @@ void SVCaller::processChromosome(const std::string& chr, std::vector& ch bam_hdr_destroy(bamHdr); printMessage(chr + ": Merging CIGAR..."); - // Save JSON if chr21 - // if (chr == "chr21") { - // std::string json_fp = input_data.getOutputDir() + "/" + chr + ".json"; - // mergeSVs(chr_sv_calls, dbscan_epsilon, dbscan_min_pts, true, json_fp); - // } else { - // mergeSVs(chr_sv_calls, dbscan_epsilon, dbscan_min_pts, false); - // } mergeSVs(chr_sv_calls, dbscan_epsilon, dbscan_min_pts, false); int region_sv_count = getSVCount(chr_sv_calls); @@ -1168,7 +1160,7 @@ void SVCaller::saveToVCF(const std::unordered_map& sv_calls, double epsilon, int min_pts, bool k continue; } - DEBUG_PRINT("Merging SV type: " + getSVTypeString(sv_type) + " (epsilon=" + std::to_string(epsilon) + ", min_pts=" + std::to_string(min_pts) + ", num SVs=" + std::to_string(sv_calls.size()) + ")"); std::vector merged_sv_type_calls; // Create a vector of SV calls for the current SV type and size interval @@ -81,12 +80,13 @@ void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool k std::copy_if(sv_calls.begin(), sv_calls.end(), std::back_inserter(sv_type_calls), [sv_type](const SVCall& sv_call) { return sv_call.sv_type == sv_type; }); + DEBUG_PRINT("Merging SV type: " + getSVTypeString(sv_type) + " (epsilon=" + std::to_string(epsilon) + ", min_pts=" + std::to_string(min_pts) + ", num SVs=" + std::to_string(sv_type_calls.size()) + ")"); if (sv_type_calls.size() < 2) { // Add all unclustered points to the merged list for (const auto& sv_call : sv_type_calls) { SVCall noise_sv_call = sv_call; - merged_sv_type_calls.push_back(noise_sv_call); + merged_sv_calls.push_back(noise_sv_call); } continue; } diff --git a/src/utils.cpp b/src/utils.cpp index a27263b7..0944c5d8 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -10,71 +10,28 @@ #include /// @endcond +#ifndef VERSION +#define VERSION "vUNKNOWN" +#endif + -// Define print mutex std::mutex print_mtx; -// Print a progress bar -void printProgress(int progress, int total) -{ - float percent = (float)progress / (float)total * 100.0; - int num_hashes = (int)(percent / 2.0); - - // Print the progress bar - printf("\r["); - for (int i = 0; i < num_hashes; i++) - { - printf("#"); - } - for (int i = 0; i < 50 - num_hashes; i++) - { - printf(" "); - } - printf("] %3.2f%%", percent); - fflush(stdout); - - // Print a new line if finished - if (progress == total) - { - printf("\n"); - } + +static std::string run_cmd(const char* cmd) { + std::array buffer; + std::string result; + std::unique_ptr pipe(popen(cmd, "r"), pclose); + if (!pipe) return {}; + while (fgets(buffer.data(), buffer.size(), pipe.get())) result += buffer.data(); + if (!result.empty() && result.back() == '\n') result.pop_back(); + return result; } -// Run bcftools to determine the chr notation of a VCF file -bool isChrNotation(std::string vcf_filepath) -{ - // Create the command to extract the chromosomes from the VCF - std::string cmd = "bcftools query -f '%CHROM\n' " + vcf_filepath + " 2>/dev/null"; - - // Open the pipe - FILE* pipe = popen(cmd.c_str(), "r"); - if (pipe == NULL) - { - std::cerr << "Error: could not open pipe" << std::endl; - return false; - } - - // Read the first line - const int line_size = 256; - char buffer[line_size]; - if (!fgets(buffer, line_size, pipe)) - { - std::cerr << "Error reading from pipe" << std::endl; - return false; - } - - // Check if the first line contains "chr" using std::string::find - bool is_chr_notation = false; - std::string line(buffer); - if (line.find("chr") != std::string::npos) - { - is_chr_notation = true; - } - - // Close the pipe - pclose(pipe); - - return is_chr_notation; +std::string currentVersion() { + auto gitv = run_cmd("git describe --tags --always 2>/dev/null"); + if (!gitv.empty()) return gitv; + return VERSION; } // Thread-safe print message function @@ -102,15 +59,6 @@ std::string getElapsedTime(std::chrono::high_resolution_clock::time_point start, return elapsed_time; } -// Function to remove the 'chr' prefix from chromosome names -std::string removeChrPrefix(std::string chr) -{ - if (chr.find("chr") != std::string::npos) { - return chr.substr(3); - } - return chr; -} - void printMemoryUsage(const std::string& functionName) { struct rusage usage; getrusage(RUSAGE_SELF, &usage); @@ -141,3 +89,5 @@ void closeJSON(const std::string &filepath) json_file << "]"; // Close the JSON array json_file.close(); } + + diff --git a/tests/data/ref.fa b/tests/data/ref.fa deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/snps.vcf.gz b/tests/data/snps.vcf.gz deleted file mode 100644 index 6f0175b0e282d3436ee9be1bef5cb788b7750d2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 92733 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 diff --git a/tests/data/snps.vcf.gz.tbi b/tests/data/snps.vcf.gz.tbi deleted file mode 100644 index 5110e9133ff95eb0f28a08c8d4ad017c1dcdbc98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1279 zcmbtUYfKXd050`4fdd^wmxxiaW>n^5jm}tuT&dIOTxA7oxrQk|DpXcx zqkzRn#cW2i-6$AjTzjo;IVmEz2HmyIJZftVHMJbaW-B6<;ePJ#z8~MuFJJOKQk3sa zXT8JZOx9=X*sQ11hX>&;@kf%Zqmt~=w4Au?OrY_8r?u|oCf}v4#f!eZm{ZeE9#-Z_ z?NOtOZ%z#F{-tK;hke~jVFNxM+U``~ zmB(TNeXvFyHR92_uz4Y`c7-$2i=j~9L|BK4FAxzF*(Y*;O8eyp+zV+xmMhCZqz}_L zO?V{@a8Bd;{LH*O`#K%UTWWrTp*=s;5Ez=Z-5sXL>Vst*B-p~V7QY3eF85@xf zf5171k9X|g)Z*j6i*E;>I67I!=fnUi=A^OY=(Dw|hquP6xFcO3?Xtq~+vyYmb6+3C z)Ocm#Jd+AveJpLF3D+-747PUje&xK zyRZ?TwzHg{5IgquhJGG~s_Lp0I&|3rhiNb0chfRu?chbGsVXCcW)M6bdZ6W0j}Cpz zC%&QmKD#B3BIS_9hM^8qtO!HfLxfF-dR1LTv_F>Dm4TtJG}R@C`JF*?D~3L5wbWDO z#{*ZcG)XD4dz!~lX>xiun!lvTwY<|oid_H54N+wMev4R#wqCb9qR3efJqSetw(YbZ zX&0srk#3lK=&5#r4!G*oUKOxy5`OESGmg8puHu=Ob?B3y zPb6Td@)w~}a3FL$m05%YANROb6q&MvXvWYChj}OMzfg2%k|Og#A&3M+5+;H6-zja= z;Cg*rMH!!GjLazKuRR`BahN|cY$`?iSJ3|Q+P2HMzMO67hr?Y*oCF-Uvd5MoL6_Pc zA1)3B8X1W8YhDOj1yF_cI|iZstJ0WH5NUDzgdEo!=L@DPTQH+l!dGWoK#B}5XOE_{@f4tbBR8GjeLTP@m_o`7$QDP9*2hwO7fgH(X-_&R?2@a4rERH_wnA*KhSWomjD0& diff --git a/tests/data/test.bam b/tests/data/test.bam deleted file mode 100644 index 75a95e3ea784ea6975dd96867a5c20db35b9eca5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 118472 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
A5`-eHGqecW}^o8i2?&?yXRX`rJ>Gy}4) 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

NC*J*uVu)?a2V`$2srZM-=vcH(#lFG?v+qg!{~ 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 0 - - # Check that the VCF file has the correct number of lines. - with open(output_file, 'r', encoding='utf-8') as f: - assert len(f.readlines()) == 22 - - # Check that the VCF file has the correct header, and the correct - # VCF CHROM, POS, and INFO fields in the next 2 lines. - header_line = 17 - with open(output_file, 'r', encoding='utf-8') as f: - for i, line in enumerate(f): - if i == header_line: - assert line.strip() == "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE" - elif i == header_line + 1: - # Get the first, second, and eighth fields from the line - fields = line.strip().split('\t') - assert fields[0] == "21" - assert fields[1] == "14458394" - assert fields[7] == "END=14458394;SVTYPE=INS;SVLEN=1344;SUPPORT=1;SVMETHOD=CONTEXTSVv0.1;ALN=CIGARINS;CLIPSUP=0;REPTYPE=NA;HMM=0.000000" - elif i == header_line + 2: - fields = line.strip().split('\t') - assert fields[0] == "21" - assert fields[1] == "14502888" - assert fields[7] == "END=14502953;SVTYPE=BOUNDARY;SVLEN=65;SUPPORT=1;SVMETHOD=CONTEXTSVv0.1;ALN=BOUNDARY;CLIPSUP=0;REPTYPE=NA;HMM=-4.606171" + TEST_DATA_DIR = os.path.abspath(str("SampleData")) + +TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') +BAM_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.bam') +REF_FILE = os.path.join(TEST_DATA_DIR, 'GRCh38_noalts_chr3.fa') +SNPS_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.snps.vcf.gz') +PFB_FILE = os.path.join(TEST_DATA_DIR, 'chr3_pfb.txt') +GAP_FILE = os.path.join(TEST_DATA_DIR, 'Gaps-HG38-UCSC-chr3.bed') +HMM_FILE = os.path.join(TEST_DATA_DIR, 'wgs_test.hmm') + +def test_run_help(): + """Ensure the binary runs with --help and exits cleanly.""" + result = subprocess.run( + ["./build/contextsv", "--help"], + capture_output=True, + text=True, + check=True + ) + + # Print output for debugging purposes + print("STDOUT:", result.stdout) + + # Check process exited successfully + assert result.returncode == 0, f"Non-zero exit: {result.returncode}\n{result.stderr}" + assert result.stdout, "No output from --help" + assert "Usage:" in result.stdout, "Help text missing 'Usage:'" + assert "Options:" in result.stdout, "Help text missing 'Options:'" + +def test_run_version(): + """Ensure the binary runs with --version and exits cleanly.""" + result = subprocess.run( + ["./build/contextsv", "--version"], + capture_output=True, + text=True, + check=True + ) + + # Print output for debugging purposes + print("STDOUT:", result.stdout) + + # Check process exited successfully + assert result.returncode == 0, f"Non-zero exit: {result.returncode}\n{result.stderr}" + assert result.stdout, "No output from --version" + assert "ContextSV version" in result.stdout, "Version text missing 'ContextSV version'" + +def test_run_basic(): + """Run ContextSV with basic required parameters.""" + out_dir = os.path.join(TEST_OUTDIR, 'test_run_basic') + os.makedirs(out_dir, exist_ok=True) + + result = subprocess.run( + ["./build/contextsv", + "--bam", BAM_FILE, + "--ref", REF_FILE, + "--snp", SNPS_FILE, + "--outdir", out_dir, + "--hmm", HMM_FILE, + "--eth", "nfe", + "--pfb", PFB_FILE, + "--sample-size", "20", + "--min-cnv", "2000", + "--eps", "0.1", + "--min-pts-pct", "0.1", + "--assembly-gaps", GAP_FILE, + "--chr", "chr3", + "--save-cnv", + "--debug" + ], + capture_output=True, + text=True, + check=True + ) + + # Print output for debugging purposes + print("STDOUT:", result.stdout) + print("STDERR:", result.stderr) + + # Check process exited successfully + assert result.returncode == 0, f"Non-zero exit: {result.returncode}\n{result.stderr}" + assert "ContextSV finished successfully!" in result.stdout, "Did not complete successfully" + + # Check for expected output files + expected_files = [ + os.path.join(out_dir, 'CNVCalls.json'), + os.path.join(out_dir, 'output.vcf') + ] + for ef in expected_files: + assert os.path.isfile(ef), f"Expected output file not found: {ef}" + + # Find the large duplication in the VCF output + # chr3 61149366 . N . PASS END=61925600;SVTYPE=DUP;SVLEN=776235;SVMETHOD=ContextSVv1.0.0-1-g4bd038c;ALN=SPLIT,HMM;HMM=-2533.541937;SUPPORT=63;CLUSTER=23;ALNOFFSET=0;CN=6 GT:DP 1/1:63 + vcf_file = os.path.join(out_dir, 'output.vcf') + found_dup = False + with open(vcf_file, 'r') as vf: + for line in vf: + if line.startswith('#'): + continue + fields = line.strip().split('\t') + if len(fields) < 8: + continue + chrom, pos, id, ref, alt, qual, filter, info = fields[:8] + if (chrom == 'chr3' and pos == '61149366' and alt == ''): + found_dup = True + info_dict = dict(item.split('=') for item in info.split(';') if '=' in item) + assert info_dict.get('SVTYPE') == 'DUP', "SVTYPE is not DUP" + assert int(info_dict.get('SVLEN', 0)) == 776235, f"SVLEN is not 776235, got {info_dict.get('SVLEN')}" + assert int(info_dict.get('CN', 0)) == 6, f"CN is not 6, got {info_dict.get('CN')}" break - \ No newline at end of file + assert found_dup, "Expected duplication not found in VCF output" + + \ No newline at end of file From 6cf5d52381456ca6ca2ab95b6bcfc4c0fe6e4884 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 14:50:52 -0400 Subject: [PATCH 04/49] add json test --- tests/test_general.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index 6fbf4549..c93e0861 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -126,4 +126,28 @@ def test_run_basic(): break assert found_dup, "Expected duplication not found in VCF output" - \ No newline at end of file + # Find the large duplication in the CNVCalls.json output + # [ + # { + # "chromosome": "chr3", + # "start": 61149366, + # "end": 61925600, + # "sv_type": "DUP", + # "likelihood": -2533.54, + # "size": 776235, + + json_file = os.path.join(out_dir, 'CNVCalls.json') + found_dup_json = False + import json + with open(json_file, 'r') as jf: + cnv_data = json.load(jf) + for entry in cnv_data: + if (entry.get('chromosome') == 'chr3' and + entry.get('start') == 61149366 and + entry.get('end') == 61925600 and + entry.get('sv_type') == 'DUP'): + found_dup_json = True + assert abs(entry.get('likelihood', 0) + 2533.54) < 0.1, f"Likelihood is not -2533.54, got {entry.get('likelihood')}" + assert entry.get('size') == 776235, f"Size is not 776235, got {entry.get('size')}" + break + assert found_dup_json, "Expected duplication not found in CNVCalls.json output" From c2e2d13e827c2179da92db0b916383c5859fb164 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 14:59:51 -0400 Subject: [PATCH 05/49] update action --- .github/workflows/build-tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 3869e823..9b7f9096 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -47,13 +47,19 @@ jobs: ls -l $CONDA_PREFIX/include/htslib make CONDA_PREFIX=$CONDA_PREFIX - - name: Run unit tests + - name: Test installation shell: bash --login {0} run: | source $(conda info --base)/etc/profile.d/conda.sh conda activate contextsv ./build/contextsv --version ./build/contextsv --help + + - name: Run tests + shell: bash --login {0} + run: | + mkdir -p output + python -m pytest -s -v # run: | # source $(conda info --base)/etc/profile.d/conda.sh From fd98c6fe8875ed1870aa63cf582e012cab37431a Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:09:30 -0400 Subject: [PATCH 06/49] update action --- .github/workflows/build-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 9b7f9096..4f06d63b 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -21,7 +21,7 @@ jobs: - name: Unzip assets shell: bash --login {0} - run: unzip SampleData.zip + run: unzip SampleData.zip -d tests/data - name: Set up conda (Miniconda only) uses: conda-incubator/setup-miniconda@v2 @@ -54,7 +54,7 @@ jobs: conda activate contextsv ./build/contextsv --version ./build/contextsv --help - + - name: Run tests shell: bash --login {0} run: | From d9ff4d384a5eadf302e291fb5e12b87d4f14a173 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:16:24 -0400 Subject: [PATCH 07/49] actions update --- src/utils.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils.cpp b/src/utils.cpp index 0944c5d8..4994970e 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -4,6 +4,7 @@ #include // getrusage #include #include +#include #include #include #include From 878bd8b1c50dc828db403efe897dff7ddfa759e7 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:19:18 -0400 Subject: [PATCH 08/49] update action --- .github/workflows/build-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 4f06d63b..be6a0470 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -58,6 +58,8 @@ jobs: - name: Run tests shell: bash --login {0} run: | + source $(conda info --base)/etc/profile.d/conda.sh + conda activate contextsv mkdir -p output python -m pytest -s -v From 9399728db786f0ce049b8452331be6f42aec33e1 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:23:18 -0400 Subject: [PATCH 09/49] dir fix --- tests/test_general.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index c93e0861..73ce96c1 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -16,7 +16,11 @@ if os.getcwd() == local_dir: TEST_DATA_DIR = os.path.join(local_dir, 'tests/data') else: - TEST_DATA_DIR = os.path.abspath(str("SampleData")) + TEST_DATA_DIR = os.path.abspath(str("tests/data")) + +print("Current working directory:", os.getcwd()) +print("Test data directory:", TEST_DATA_DIR) +print("Contents of test data directory:", os.listdir(TEST_DATA_DIR) if os.path.exists(TEST_DATA_DIR) else "Directory does not exist") TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') BAM_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.bam') From 004d3d31f8ee77a045e2bc3a45fc6c3dcdd67655 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:28:22 -0400 Subject: [PATCH 10/49] update action --- .github/workflows/build-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index be6a0470..ece7eb68 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -21,7 +21,7 @@ jobs: - name: Unzip assets shell: bash --login {0} - run: unzip SampleData.zip -d tests/data + run: unzip SampleData.zip -d tests - name: Set up conda (Miniconda only) uses: conda-incubator/setup-miniconda@v2 From 3df7bc37bd7b0e0dbebddcda3386503677262c7a Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:33:41 -0400 Subject: [PATCH 11/49] update action --- tests/test_general.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index 73ce96c1..d8b1a287 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -26,10 +26,19 @@ BAM_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.bam') REF_FILE = os.path.join(TEST_DATA_DIR, 'GRCh38_noalts_chr3.fa') SNPS_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.snps.vcf.gz') -PFB_FILE = os.path.join(TEST_DATA_DIR, 'chr3_pfb.txt') GAP_FILE = os.path.join(TEST_DATA_DIR, 'Gaps-HG38-UCSC-chr3.bed') HMM_FILE = os.path.join(TEST_DATA_DIR, 'wgs_test.hmm') +# Create a PFB file pointing to the SNP file in the test data directory +PFB_FILE = os.path.join(TEST_DATA_DIR, 'chr3_pfb.txt') +# Remove any existing PFB file to avoid conflicts +if os.path.exists(PFB_FILE): + os.remove(PFB_FILE) + +PFB_SNP_FILE = os.path.join(TEST_DATA_DIR, 'chr3_gnomad_snps.vcf.gz') +with open(PFB_FILE, 'w', encoding='utf-8') as pf: + pf.write(f"3={PFB_SNP_FILE}\n") + def test_run_help(): """Ensure the binary runs with --help and exits cleanly.""" result = subprocess.run( From 06349128efa4e3c49f8ca3d660f5dc61893ce5f5 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:37:50 -0400 Subject: [PATCH 12/49] debug vcf --- tests/test_general.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index d8b1a287..0dd244e6 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -34,7 +34,7 @@ # Remove any existing PFB file to avoid conflicts if os.path.exists(PFB_FILE): os.remove(PFB_FILE) - + PFB_SNP_FILE = os.path.join(TEST_DATA_DIR, 'chr3_gnomad_snps.vcf.gz') with open(PFB_FILE, 'w', encoding='utf-8') as pf: pf.write(f"3={PFB_SNP_FILE}\n") @@ -130,6 +130,10 @@ def test_run_basic(): if len(fields) < 8: continue chrom, pos, id, ref, alt, qual, filter, info = fields[:8] + + # Print each VCF record for debugging + print(f"VCF Record: {chrom}, {pos}, {id}, {ref}, {alt}, {qual}, {filter}, {info}") + if (chrom == 'chr3' and pos == '61149366' and alt == ''): found_dup = True info_dict = dict(item.split('=') for item in info.split(';') if '=' in item) From d1ed86f8bd5145a8c564c3e69f9ecf58d1a948d5 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:41:04 -0400 Subject: [PATCH 13/49] debug output --- tests/test_general.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_general.py b/tests/test_general.py index 0dd244e6..bdbfd115 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -79,6 +79,15 @@ def test_run_basic(): out_dir = os.path.join(TEST_OUTDIR, 'test_run_basic') os.makedirs(out_dir, exist_ok=True) + print("Input parameters:") + print(f"BAM_FILE: {BAM_FILE}") + print(f"REF_FILE: {REF_FILE}") + print(f"SNPS_FILE: {SNPS_FILE}") + print(f"PFB_FILE: {PFB_FILE}") + print(f"HMM_FILE: {HMM_FILE}") + print(f"Output directory: {out_dir}") + print("GAP_FILE:", GAP_FILE) + result = subprocess.run( ["./build/contextsv", "--bam", BAM_FILE, From b8cc232329c36ee8509a1d451bc5df1695c78537 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 15:41:52 -0400 Subject: [PATCH 14/49] debug output --- tests/test_general.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_general.py b/tests/test_general.py index bdbfd115..40cd08fa 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -133,6 +133,8 @@ def test_run_basic(): found_dup = False with open(vcf_file, 'r') as vf: for line in vf: + print("VCF Line:", line.strip()) # Print each line for debugging + if line.startswith('#'): continue fields = line.strip().split('\t') From 80ccb82573af584fe02b78eaa494a15ea3e7b452 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 17:38:50 -0400 Subject: [PATCH 15/49] use gnomad snp isec file --- tests/test_general.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_general.py b/tests/test_general.py index 40cd08fa..46df2b07 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -35,7 +35,7 @@ if os.path.exists(PFB_FILE): os.remove(PFB_FILE) -PFB_SNP_FILE = os.path.join(TEST_DATA_DIR, 'chr3_gnomad_snps.vcf.gz') +PFB_SNP_FILE = os.path.join(TEST_DATA_DIR, 'chr3_gnomad_snps_isec.vcf.gz') with open(PFB_FILE, 'w', encoding='utf-8') as pf: pf.write(f"3={PFB_SNP_FILE}\n") @@ -79,6 +79,13 @@ def test_run_basic(): out_dir = os.path.join(TEST_OUTDIR, 'test_run_basic') os.makedirs(out_dir, exist_ok=True) + vcf_file = os.path.join(out_dir, 'output.vcf') + if os.path.exists(vcf_file): + os.remove(vcf_file) + json_file = os.path.join(out_dir, 'CNVCalls.json') + if os.path.exists(json_file): + os.remove(json_file) + print("Input parameters:") print(f"BAM_FILE: {BAM_FILE}") print(f"REF_FILE: {REF_FILE}") @@ -129,7 +136,6 @@ def test_run_basic(): # Find the large duplication in the VCF output # chr3 61149366 . N . PASS END=61925600;SVTYPE=DUP;SVLEN=776235;SVMETHOD=ContextSVv1.0.0-1-g4bd038c;ALN=SPLIT,HMM;HMM=-2533.541937;SUPPORT=63;CLUSTER=23;ALNOFFSET=0;CN=6 GT:DP 1/1:63 - vcf_file = os.path.join(out_dir, 'output.vcf') found_dup = False with open(vcf_file, 'r') as vf: for line in vf: @@ -164,7 +170,6 @@ def test_run_basic(): # "likelihood": -2533.54, # "size": 776235, - json_file = os.path.join(out_dir, 'CNVCalls.json') found_dup_json = False import json with open(json_file, 'r') as jf: From ab2195191916cdfaea171ec9a1590e9aaf5ea0e4 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 17:44:25 -0400 Subject: [PATCH 16/49] update test --- tests/test_general.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index 46df2b07..8235dab4 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -180,7 +180,6 @@ def test_run_basic(): entry.get('end') == 61925600 and entry.get('sv_type') == 'DUP'): found_dup_json = True - assert abs(entry.get('likelihood', 0) + 2533.54) < 0.1, f"Likelihood is not -2533.54, got {entry.get('likelihood')}" assert entry.get('size') == 776235, f"Size is not 776235, got {entry.get('size')}" break assert found_dup_json, "Expected duplication not found in CNVCalls.json output" From a8cf86235af36b4deba80ce38387d54d60c025da Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 19:28:50 -0400 Subject: [PATCH 17/49] add dockerfile and update readme --- Dockerfile | 21 +++++++++++++++++++++ README.md | 26 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d651f250 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use the miniconda container +FROM continuumio/miniconda3:main + +# Version argument (set during build) +ARG CONTEXTSV_VERSION + +WORKDIR /app + +RUN apt-get update +RUN conda update conda + +# Install ContextSV +RUN conda config --add channels wglab +RUN conda config --add channels conda-forge +RUN conda config --add channels bioconda +RUN conda create -n contextsv python=3.9 +RUN echo "conda activate contextsv" >> ~/.bashrc +SHELL ["/bin/bash", "--login", "-c"] +RUN conda install -n contextsv -c wglab -c conda-forge -c jannessp -c bioconda contextsv=${CONTEXTSV_VERSION} && conda clean -afy + +ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "contextsv", "contextsv"] diff --git a/README.md b/README.md index 2cb17bf8..f9e38bac 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,32 @@ Class documentation is available at & args) } void printUsage(const std::string& programName) { - std::cout << "ContextSV version " << currentVersion() << std::endl; + std::cout << "ContextSV v" << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_PATCH << std::endl; std::cout << "Usage: " << programName << " [options]\n" << "Options:\n" << " -b, --bam Long-read BAM file (required)\n" @@ -194,7 +193,7 @@ std::unordered_map parseArguments(int argc, char* argv } else if (arg == "--debug") { args["debug"] = "true"; } else if ((arg == "-v" || arg == "--version")) { - std::cout << "ContextSV version " << currentVersion() << std::endl; + std::cout << "ContextSV v" << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_PATCH << std::endl; exit(0); } else if (arg == "-h" || arg == "--help") { printUsage(argv[0]); diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index 41d654d0..d59e6598 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -30,6 +30,7 @@ #include "dbscan.h" #include "dbscan1d.h" #include "debug.h" +#include "version.h" /// @endcond # define DUP_SEQSIM_THRESHOLD 0.9 // Sequence similarity threshold for duplication detection @@ -1160,7 +1161,7 @@ void SVCaller::saveToVCF(const std::unordered_map /// @endcond -#ifndef VERSION -#define VERSION "vUNKNOWN" -#endif - std::mutex print_mtx; - -static std::string run_cmd(const char* cmd) { - std::array buffer; - std::string result; - std::unique_ptr pipe(popen(cmd, "r"), pclose); - if (!pipe) return {}; - while (fgets(buffer.data(), buffer.size(), pipe.get())) result += buffer.data(); - if (!result.empty() && result.back() == '\n') result.pop_back(); - return result; -} - -std::string currentVersion() { - auto gitv = run_cmd("git describe --tags --always 2>/dev/null"); - if (!gitv.empty()) return gitv; - return VERSION; -} - // Thread-safe print message function void printMessage(std::string message) { diff --git a/tests/test_general.py b/tests/test_general.py index 8235dab4..c1c59da7 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -18,10 +18,6 @@ else: TEST_DATA_DIR = os.path.abspath(str("tests/data")) -print("Current working directory:", os.getcwd()) -print("Test data directory:", TEST_DATA_DIR) -print("Contents of test data directory:", os.listdir(TEST_DATA_DIR) if os.path.exists(TEST_DATA_DIR) else "Directory does not exist") - TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') BAM_FILE = os.path.join(TEST_DATA_DIR, 'chr3_test.bam') REF_FILE = os.path.join(TEST_DATA_DIR, 'GRCh38_noalts_chr3.fa') @@ -72,7 +68,6 @@ def test_run_version(): # Check process exited successfully assert result.returncode == 0, f"Non-zero exit: {result.returncode}\n{result.stderr}" assert result.stdout, "No output from --version" - assert "ContextSV version" in result.stdout, "Version text missing 'ContextSV version'" def test_run_basic(): """Run ContextSV with basic required parameters.""" @@ -86,15 +81,6 @@ def test_run_basic(): if os.path.exists(json_file): os.remove(json_file) - print("Input parameters:") - print(f"BAM_FILE: {BAM_FILE}") - print(f"REF_FILE: {REF_FILE}") - print(f"SNPS_FILE: {SNPS_FILE}") - print(f"PFB_FILE: {PFB_FILE}") - print(f"HMM_FILE: {HMM_FILE}") - print(f"Output directory: {out_dir}") - print("GAP_FILE:", GAP_FILE) - result = subprocess.run( ["./build/contextsv", "--bam", BAM_FILE, @@ -139,8 +125,6 @@ def test_run_basic(): found_dup = False with open(vcf_file, 'r') as vf: for line in vf: - print("VCF Line:", line.strip()) # Print each line for debugging - if line.startswith('#'): continue fields = line.strip().split('\t') @@ -148,9 +132,6 @@ def test_run_basic(): continue chrom, pos, id, ref, alt, qual, filter, info = fields[:8] - # Print each VCF record for debugging - print(f"VCF Record: {chrom}, {pos}, {id}, {ref}, {alt}, {qual}, {filter}, {info}") - if (chrom == 'chr3' and pos == '61149366' and alt == ''): found_dup = True info_dict = dict(item.split('=') for item in info.split(';') if '=' in item) @@ -158,6 +139,7 @@ def test_run_basic(): assert int(info_dict.get('SVLEN', 0)) == 776235, f"SVLEN is not 776235, got {info_dict.get('SVLEN')}" assert int(info_dict.get('CN', 0)) == 6, f"CN is not 6, got {info_dict.get('CN')}" break + assert found_dup, "Expected duplication not found in VCF output" # Find the large duplication in the CNVCalls.json output @@ -182,4 +164,5 @@ def test_run_basic(): found_dup_json = True assert entry.get('size') == 776235, f"Size is not 776235, got {entry.get('size')}" break + assert found_dup_json, "Expected duplication not found in CNVCalls.json output" From 64a5eb9f3d4944e10b2d1cd716c164110c719c05 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 21:18:32 -0400 Subject: [PATCH 19/49] remove region arg --- include/input_data.h | 6 ----- src/input_data.cpp | 56 ++------------------------------------------ src/main.cpp | 4 ---- 3 files changed, 2 insertions(+), 64 deletions(-) diff --git a/include/input_data.h b/include/input_data.h index 1e2c3c1e..32c78ce2 100644 --- a/include/input_data.h +++ b/include/input_data.h @@ -77,11 +77,6 @@ class InputData { std::string getChromosome() const; bool isSingleChr() const; - // Set the region to analyze. - void setRegion(std::string region); - std::pair getRegion() const; - bool isRegionSet() const; - // Set the output directory where the results will be written. void setOutputDir(std::string dirpath); std::string getOutputDir() const; @@ -116,7 +111,6 @@ class InputData { double dbscan_min_pts_pct; std::string chr; // Chromosome to analyze std::pair start_end; // Region to analyze - bool region_set; // True if a region is set int thread_count; std::string hmm_filepath; std::string cnv_filepath; diff --git a/src/input_data.cpp b/src/input_data.cpp index 3b53a7d7..936f7e62 100644 --- a/src/input_data.cpp +++ b/src/input_data.cpp @@ -21,8 +21,6 @@ InputData::InputData() this->ref_filepath = ""; this->snp_vcf_filepath = ""; this->chr = ""; - this->start_end = std::make_pair(0, 0); - this->region_set = false; this->output_dir = ""; this->sample_size = 20; this->min_cnv_length = 2000; // Default minimum CNV length @@ -49,14 +47,6 @@ void InputData::printParameters() const DEBUG_PRINT("Minimum CNV length: " << this->min_cnv_length); DEBUG_PRINT("DBSCAN epsilon: " << this->dbscan_epsilon); DEBUG_PRINT("DBSCAN minimum points percentage: " << this->dbscan_min_pts_pct * 100.0f << "%"); - if (this->region_set) - { - DEBUG_PRINT("Region set to: chr" + this->chr + ":" + std::to_string(this->start_end.first) + "-" + std::to_string(this->start_end.second)); - } - else - { - DEBUG_PRINT("Running on whole genome"); - } } std::string InputData::getLongReadBam() const @@ -218,47 +208,6 @@ bool InputData::isSingleChr() const return this->single_chr; } -void InputData::setRegion(std::string region) -{ - // Check if the region is valid - if (region != "") - { - // Split the region by colon - std::istringstream ss(region); - std::string token; - std::vector region_tokens; - - while (std::getline(ss, token, '-')) - { - region_tokens.push_back(token); - } - - // Check if the region is valid - if (region_tokens.size() == 2) - { - // Get the start and end positions - int32_t start = std::stoi(region_tokens[0]); - int32_t end = std::stoi(region_tokens[1]); - - // Set the region - this->start_end = std::make_pair(start, end); - this->region_set = true; - - std::cout << "Region set to " << this->chr << ":" << start << "-" << end << std::endl; - } - } -} - -std::pair InputData::getRegion() const -{ - return this->start_end; -} - -bool InputData::isRegionSet() const -{ - return this->region_set; -} - void InputData::setAlleleFreqFilepaths(std::string filepath) { // Check if empty string @@ -275,19 +224,18 @@ void InputData::setAlleleFreqFilepaths(std::string filepath) exit(1); } - // If a region is set, load only the chromosome in the region + // If a chr is specified, load only the file for that chromosome std::string target_chr; if (this->chr != "") { target_chr = this->chr; - // Check if the region is in chr notation + // Check if in chr notation if (target_chr.find("chr") != std::string::npos) { // Remove the chr notation target_chr = target_chr.substr(3, target_chr.size() - 3); } - //std::cout << "Loading population allele frequency file for chromosome " << target_chr << std::endl; } // Load the file and create a map of chromosome -> VCF file diff --git a/src/main.cpp b/src/main.cpp index 19831028..45984eb2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -68,9 +68,6 @@ void runContextSV(const std::unordered_map& args) if (args.find("chr") != args.end()) { input_data.setChromosome(args.at("chr")); } - if (args.find("region") != args.end()) { - input_data.setRegion(args.at("region")); - } if (args.find("thread-count") != args.end()) { input_data.setThreadCount(std::stoi(args.at("thread-count"))); } @@ -134,7 +131,6 @@ void printUsage(const std::string& programName) { << " -s, --snp SNPs VCF file (required)\n" << " -o, --outdir Output directory (required)\n" << " -c, --chr Chromosome\n" - << " -r, --region Region (start-end)\n" << " -t, --threads Number of threads\n" << " -h, --hmm HMM file\n" << " -n, --sample-size Sample size for HMM predictions\n" From 71f2ce3b65e26cb3e158a0a3394828703c749779 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 21:22:55 -0400 Subject: [PATCH 20/49] update dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d651f250..95933f28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,6 @@ RUN conda config --add channels bioconda RUN conda create -n contextsv python=3.9 RUN echo "conda activate contextsv" >> ~/.bashrc SHELL ["/bin/bash", "--login", "-c"] -RUN conda install -n contextsv -c wglab -c conda-forge -c jannessp -c bioconda contextsv=${CONTEXTSV_VERSION} && conda clean -afy +RUN conda install -n contextsv -c wglab -c conda-forge -c bioconda contextsv=${CONTEXTSV_VERSION} && conda clean -afy ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "contextsv", "contextsv"] From 69b231445b2f8b56450482396454d61dcac82138 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 19 Sep 2025 21:27:47 -0400 Subject: [PATCH 21/49] update readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index f9e38bac..3a7e0cda 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ First, install [Docker](https://docs.docker.com/engine/install/). Pull the latest image from Docker hub, which contains the latest release and its dependencies. ``` -docker pull genomicslab/longreadsum +docker pull genomicslab/contextsv ``` @@ -66,7 +66,6 @@ Options: -s, --snp SNPs VCF file (required) -o, --outdir Output directory (required) -c, --chr Chromosome - -r, --region Region (start-end) -t, --threads Number of threads -h, --hmm HMM file -n, --sample-size Sample size for HMM predictions From 89fa6b98204d8e0b68ccfa17399a5bfd2a580021 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Sun, 22 Feb 2026 23:59:10 -0500 Subject: [PATCH 22/49] ref genome missing contig fix --- src/sv_caller.cpp | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index d59e6598..77f27dd1 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -791,11 +791,13 @@ void SVCaller::run(const InputData& input_data) int chr_thread_count = input_data.getThreadCount(); // Initialize the chromosome position depth map and mean coverage map + std::unordered_set invalid_chr; // Track chromosomes not found in the reference genome for (const auto& chr : chromosomes) { uint32_t chr_len = ref_genome.getChromosomeLength(chr); if (chr_len == 0) { - printError("Chromosome " + chr + " not found in reference genome"); - return; + // printError("Chromosome " + chr + " not found in reference genome"); + invalid_chr.insert(chr); + continue; // continue; } chr_pos_depth_map[chr] = std::vector(chr_len+1, 0); // 1-based index @@ -803,14 +805,38 @@ void SVCaller::run(const InputData& input_data) } cnv_caller.calculateMeanChromosomeCoverage(chromosomes, chr_pos_depth_map, chr_mean_cov_map, bam_filepath, chr_thread_count); - // Remove chromosomes with no reads (mean coverage is zero) + // Remove invalid chromosomes that are not found in the reference genome + if (!invalid_chr.empty()) { + printMessage("Removing chromosomes not found in the reference genome..."); + std::vector valid_chr; + for (const auto& chr : chromosomes) { + if (invalid_chr.find(chr) == invalid_chr.end()) { + valid_chr.push_back(chr); + } + } + if (valid_chr.empty()) { + printError("No valid chromosomes found for analysis. Exiting."); + return; + } else { + chromosomes = valid_chr; + } + } + + // Remove chromosomes with no reads (mean coverage is zero) or not found in the reference genome printMessage("Removing chromosomes with no reads..."); std::vector valid_chr; for (const auto& chr : chromosomes) { if (chr_mean_cov_map.find(chr) != chr_mean_cov_map.end()) { valid_chr.push_back(chr); - } - chromosomes = valid_chr; + } else { + printError("Chromosome " + chr + " has no coverage and will be removed from analysis"); + } + } + if (valid_chr.empty()) { + printError("No valid chromosomes found for analysis. Exiting."); + return; + } else { + chromosomes = valid_chr; } std::unordered_map> whole_genome_sv_calls; int current_chr = 0; From 918ee1cf5ae895325baa791e8bbb4d40ac41f86e Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Wed, 25 Feb 2026 19:10:37 -0500 Subject: [PATCH 23/49] remove single chr mode --- include/fasta_query.h | 2 +- include/input_data.h | 5 --- include/sv_caller.h | 1 - src/fasta_query.cpp | 10 +++--- src/input_data.cpp | 16 --------- src/main.cpp | 4 --- src/sv_caller.cpp | 75 +++++++++---------------------------------- 7 files changed, 21 insertions(+), 92 deletions(-) diff --git a/include/fasta_query.h b/include/fasta_query.h index 4486bdb0..339a30c7 100644 --- a/include/fasta_query.h +++ b/include/fasta_query.h @@ -24,7 +24,7 @@ class ReferenceGenome { public: ReferenceGenome(std::shared_mutex& shared_mutex) : shared_mutex(shared_mutex) {} - int setFilepath(std::string fasta_filepath); + int read(std::string fasta_filepath); std::string getFilepath() const; std::string_view query(const std::string& chr, uint32_t pos_start, uint32_t pos_end) const; bool compare(const std::string& chr, uint32_t pos_start, uint32_t pos_end, const std::string& compare_seq, float match_threshold) const; diff --git a/include/input_data.h b/include/input_data.h index 32c78ce2..b8c07169 100644 --- a/include/input_data.h +++ b/include/input_data.h @@ -72,11 +72,6 @@ class InputData { void setDBSCAN_MinPtsPct(double min_pts_pct); double getDBSCAN_MinPtsPct() const; - // Set the chromosome to analyze. - void setChromosome(std::string chr); - std::string getChromosome() const; - bool isSingleChr() const; - // Set the output directory where the results will be written. void setOutputDir(std::string dirpath); std::string getOutputDir() const; diff --git a/include/sv_caller.h b/include/sv_caller.h index a0883e6c..f0178519 100644 --- a/include/sv_caller.h +++ b/include/sv_caller.h @@ -91,7 +91,6 @@ class SVCaller { void runSplitReadCopyNumberPredictions(const std::string& chr, std::vector& split_sv_calls, const CNVCaller &cnv_caller, const CHMM &hmm, double mean_chr_cov, const std::vector &pos_depth_map, const InputData &input_data); void saveToVCF(const std::unordered_map> &sv_calls, const InputData &input_data, const ReferenceGenome &ref_genome, const std::unordered_map> &chr_pos_depth_map) const; - // void saveToVCF(const std::unordered_map> &sv_calls, const std::string &output_dir, const ReferenceGenome &ref_genome, const std::unordered_map>& chr_pos_depth_map) const; // Query the read depth (INFO/DP) at a position int getReadDepth(const std::vector& pos_depth_map, uint32_t start) const; diff --git a/src/fasta_query.cpp b/src/fasta_query.cpp index 237db443..12444376 100644 --- a/src/fasta_query.cpp +++ b/src/fasta_query.cpp @@ -15,7 +15,7 @@ #include "utils.h" -int ReferenceGenome::setFilepath(std::string fasta_filepath) +int ReferenceGenome::read(std::string fasta_filepath) { if (fasta_filepath == "") { @@ -45,10 +45,10 @@ int ReferenceGenome::setFilepath(std::string fasta_filepath) // Header line, indicating a new chromosome // Store the previous chromosome and sequence if (current_chr != "") - { - this->chromosomes.push_back(current_chr); // Add the chromosome to the list - this->chr_to_seq[current_chr] = sequence; // Add the sequence to the map - this->chr_to_length[current_chr] = sequence.length(); // Add the sequence length to the map + { + this->chromosomes.push_back(current_chr); // Add the chromosome to the list + this->chr_to_seq[current_chr] = sequence; // Add the sequence to the map + this->chr_to_length[current_chr] = sequence.length(); // Add the sequence length to the map sequence = ""; // Reset the sequence } diff --git a/src/input_data.cpp b/src/input_data.cpp index 936f7e62..c9a73bb7 100644 --- a/src/input_data.cpp +++ b/src/input_data.cpp @@ -192,22 +192,6 @@ double InputData::getDBSCAN_MinPtsPct() const return this->dbscan_min_pts_pct; } -void InputData::setChromosome(std::string chr) -{ - this->chr = chr; - this->single_chr = true; -} - -std::string InputData::getChromosome() const -{ - return this->chr; -} - -bool InputData::isSingleChr() const -{ - return this->single_chr; -} - void InputData::setAlleleFreqFilepaths(std::string filepath) { // Check if empty string diff --git a/src/main.cpp b/src/main.cpp index 45984eb2..dc9b110f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -65,9 +65,6 @@ void runContextSV(const std::unordered_map& args) input_data.setRefGenome(args.at("ref-file")); input_data.setSNPFilepath(args.at("snps-file")); input_data.setOutputDir(args.at("output-dir")); - if (args.find("chr") != args.end()) { - input_data.setChromosome(args.at("chr")); - } if (args.find("thread-count") != args.end()) { input_data.setThreadCount(std::stoi(args.at("thread-count"))); } @@ -130,7 +127,6 @@ void printUsage(const std::string& programName) { << " -r, --ref Reference genome FASTA file (required)\n" << " -s, --snp SNPs VCF file (required)\n" << " -o, --outdir Output directory (required)\n" - << " -c, --chr Chromosome\n" << " -t, --threads Number of threads\n" << " -h, --hmm HMM file\n" << " -n, --sample-size Sample size for HMM predictions\n" diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index 77f27dd1..e6359123 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -109,21 +109,12 @@ void SVCaller::findSplitSVSignatures(std::unordered_map chromosomes; - if (input_data.isSingleChr()) { - // Get the chromosome from the user input argument - chromosomes.push_back(input_data.getChromosome()); - } else { - // Get the chromosomes from the input BAM file - chromosomes = this->getChromosomes(input_data.getLongReadBam()); - } + std::vector chromosomes = this->getChromosomes(input_data.getLongReadBam()); // Read the HMM from the file std::string hmm_filepath = input_data.getHMMFilepath(); @@ -791,13 +775,11 @@ void SVCaller::run(const InputData& input_data) int chr_thread_count = input_data.getThreadCount(); // Initialize the chromosome position depth map and mean coverage map - std::unordered_set invalid_chr; // Track chromosomes not found in the reference genome for (const auto& chr : chromosomes) { uint32_t chr_len = ref_genome.getChromosomeLength(chr); if (chr_len == 0) { - // printError("Chromosome " + chr + " not found in reference genome"); - invalid_chr.insert(chr); - continue; + printError("Chromosome " + chr + " not found in reference genome"); + return; // continue; } chr_pos_depth_map[chr] = std::vector(chr_len+1, 0); // 1-based index @@ -805,38 +787,14 @@ void SVCaller::run(const InputData& input_data) } cnv_caller.calculateMeanChromosomeCoverage(chromosomes, chr_pos_depth_map, chr_mean_cov_map, bam_filepath, chr_thread_count); - // Remove invalid chromosomes that are not found in the reference genome - if (!invalid_chr.empty()) { - printMessage("Removing chromosomes not found in the reference genome..."); - std::vector valid_chr; - for (const auto& chr : chromosomes) { - if (invalid_chr.find(chr) == invalid_chr.end()) { - valid_chr.push_back(chr); - } - } - if (valid_chr.empty()) { - printError("No valid chromosomes found for analysis. Exiting."); - return; - } else { - chromosomes = valid_chr; - } - } - - // Remove chromosomes with no reads (mean coverage is zero) or not found in the reference genome + // Remove chromosomes with no reads (mean coverage is zero) printMessage("Removing chromosomes with no reads..."); std::vector valid_chr; for (const auto& chr : chromosomes) { if (chr_mean_cov_map.find(chr) != chr_mean_cov_map.end()) { valid_chr.push_back(chr); - } else { - printError("Chromosome " + chr + " has no coverage and will be removed from analysis"); - } - } - if (valid_chr.empty()) { - printError("No valid chromosomes found for analysis. Exiting."); - return; - } else { - chromosomes = valid_chr; + } + chromosomes = valid_chr; } std::unordered_map> whole_genome_sv_calls; int current_chr = 0; @@ -845,11 +803,8 @@ void SVCaller::run(const InputData& input_data) if (cigar_svs) { // Use multi-threading across chromosomes. If a single chromosome is // specified, use a single main thread (multi-threading is used for file I/O) - int thread_count = 1; - if (!input_data.isSingleChr()) { - thread_count = input_data.getThreadCount(); - std::cout << "Using " << thread_count << " threads for chr processing..." << std::endl; - } + int thread_count = input_data.getThreadCount(); + std::cout << "Using " << thread_count << " threads for chr processing..." << std::endl; ThreadPool pool(thread_count); auto process_chr = [&](const std::string& chr) { try { From d2168f8197ed16ad939f6ea2924ad24e0a2aab76 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Thu, 26 Feb 2026 07:58:58 -0500 Subject: [PATCH 24/49] remove unused python files --- .gitignore | 8 - python/cluster_params.py | 346 ---------------------------- python/cnv_plots.py | 307 ------------------------- python/environment_merge.yml | 15 -- python/extract_features.py | 150 ------------ python/mendelian_error.py | 135 ----------- python/mendelian_inheritance.py | 78 ------- python/plot_distributions.py | 247 -------------------- python/plot_venn.py | 48 ---- python/predict.py | 78 ------- python/score_vcf.py | 83 ------- python/sv_merger.py | 396 -------------------------------- python/train_model.py | 172 -------------- python/utils.py | 44 ---- 14 files changed, 2107 deletions(-) delete mode 100644 python/cluster_params.py delete mode 100644 python/cnv_plots.py delete mode 100644 python/environment_merge.yml delete mode 100644 python/extract_features.py delete mode 100644 python/mendelian_error.py delete mode 100644 python/mendelian_inheritance.py delete mode 100644 python/plot_distributions.py delete mode 100644 python/plot_venn.py delete mode 100644 python/predict.py delete mode 100644 python/score_vcf.py delete mode 100644 python/sv_merger.py delete mode 100644 python/train_model.py delete mode 100644 python/utils.py diff --git a/.gitignore b/.gitignore index b7478d26..b6394d47 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ CMakeSettings.json # Output folder output/ -python/ # Doxygen docs/html/ @@ -64,8 +63,6 @@ docs/html/ *.sif # Test directories -python/dbscan -python/agglo linktoscripts tests/data tests/cpp_module_out @@ -85,11 +82,6 @@ data/sv_scoring_dataset/ data/hg38ToHg19.over.chain.gz data/hg19ToHg38.over.chain.gz -# Test images -python/dbscan_clustering*.png -python/dist_plots -upset_plot*.png - # Temporary files lib/.nfs* valgrind.log diff --git a/python/cluster_params.py b/python/cluster_params.py deleted file mode 100644 index ff1484ca..00000000 --- a/python/cluster_params.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -test_cluster_params.py: Test the cluster parameters for the cluster + merge -pipeline. - -Usage: - python test_cluster_params.py - - - benchmark_file_path: Path to the benchmark file. - cluster_type: Either 'dbscan' or 'agglo' - output_dir: Directory for saving the output plots. - -""" - -import os -import sys - -import matplotlib.pyplot as plt - -def get_precision_recall(file_path, sv_type='DEL'): - """Parse text file containing epsilon, precision, and recall values.""" - epsilon_values = [] - precision_values = [] - recall_values = [] - fp_counts = [] - fn_counts = [] - comp_counts = [] - base_counts = [] - - with open(file_path, 'r', encoding='utf-8') as file: - lines = file.readlines() - - epsilon = None - sv_section_found = False - for i, line in enumerate(lines): - - if "#EPSILON=" in line: - epsilon = float(line.split('=')[1]) - epsilon_values.append(epsilon) - - # SV type sections - elif "Running truvari" in line: - if sv_type in line: - sv_section_found = True - - # Get the number of SVs in the callset vs. the benchmark - elif "Zipped" in line and "Counter" in line and sv_section_found: - # [INFO] Zipped 269 variants Counter({'comp': 204, 'base': 65}) - - # Split the line by 'Counter' - line = line.split('Counter')[1] - - # Get the value after 'comp': - comp_count = line.split("'comp':")[1] - comp_count = comp_count.split(',')[0] - comp_count = comp_count.split('}')[0] - comp_count = int(comp_count) - - # Get the value after 'base': - base_count = line.split("'base':")[1] - base_count = base_count.split(',')[0] - base_count = base_count.split('}')[0] - base_count = int(base_count) - - # Add the counts to the lists - comp_counts.append(comp_count) - base_counts.append(base_count) - - # Get the number of FPs - elif "FP" in line and sv_section_found: - # Get the value after the ':' - fp = line.split(':')[1] - - # Clean up the string - fp = fp.replace('\n', '') - fp = fp.replace(',', '') - fp = int(fp) - fp_counts.append(fp) - - # Get the number of FNs - elif "FN" in line and sv_section_found: - # Get the value after the ':' - fn = line.split(':')[1] - - # Clean up the string - fn = fn.replace('\n', '') - fn = fn.replace(',', '') - fn = int(fn) - fn_counts.append(fn) - - elif "precision" in line and sv_section_found: - # Get the value after the ':' - p = line.split(':')[1] - - # Clean up the string - p = p.replace('\n', '') - p = p.replace(',', '') - p = float(p) - precision_values.append(p) - - elif "recall" in line and sv_section_found: - # Get the value after the ':' - r = line.split(':')[1] - - # Clean up the string - r = r.replace('\n', '') - r = r.replace(',', '') - r = float(r) - recall_values.append(r) - - # Reset epsilon and sv_section_found - epsilon = None - sv_section_found = False - - print(f'SV Type: {sv_type}') - - ##### Maximizing F1 ##### - # Get the maximum F1 score and the corresponding epsilon, precision, recall - f1_scores = [] - for i, precision in enumerate(precision_values): - recall = recall_values[i] - f1 = 2 * (precision * recall) / (precision + recall) - f1_scores.append(f1) - - max_f1 = max(f1_scores) - max_f1_index = f1_scores.index(max_f1) - - # Print the maximum F1 score - print(f'Maximum F1: {max_f1}') - - # Print the maximum precision and recall values - print(f'Precision at F1: {precision_values[max_f1_index]}') - print(f'Recall at F1: {recall_values[max_f1_index]}') - - # Print the parameter value at the maximum F1 score - print(f'Parameter value : {epsilon_values[max_f1_index]}') - - # Print the FP and FN counts at the maximum F1 score - print(f'FP Count: {fp_counts[max_f1_index]}') - print(f'FN Count: {fn_counts[max_f1_index]}') - - # Print the number of SVs in the callset and benchmark at the maximum F1 - # score - print(f'Number of {sv_type}s in Callset: {comp_counts[max_f1_index]}') - print(f'Number of {sv_type}s in Benchmark: {base_counts[max_f1_index]}') - - ##### Maximizing recall ##### - # # Get the maximum recall value, and then the maximum precision value at that - # # recall value - # max_recall = max(recall_values) - # max_precision = None - # max_index = None # Index of the maximum recall and corresponding precision - # for i, recall in enumerate(recall_values): - # if recall == max_recall: - # if max_precision is None: - # max_precision = precision_values[i] - # max_index = i - # elif precision_values[i] > max_precision: - # max_precision = precision_values[i] - # max_index = i - - # # Print the maximum precision and recall values - # print(f'Maximum Recall: {max_recall}') - # print(f'Maximum Precision at Maximum Recall: {max_precision}') - - # # Print the parameter value at the maximum recall and corresponding precision - # print(f'{parameter_name} at Maximum Recall: {epsilon_values[max_index]}') - - # # Print the FP and FN counts at the maximum recall and corresponding - # # precision - # print(f'FP Count at Maximum Recall: {fp_counts[max_index]}') - # print(f'FN Count at Maximum Recall: {fn_counts[max_index]}') - - # # Print the number of SVs in the callset and benchmark at the maximum recall - # # and corresponding precision - # print(f'Number of {sv_type}s in Callset: {comp_counts[max_index]}') - # print(f'Number of {sv_type}s in Benchmark: {base_counts[max_index]}') - - return epsilon_values, precision_values, recall_values - - -def get_f1_scores(file_path, sv_type='DEL'): - """Parse text file containing epsilon and F1 scores.""" - epsilon_values = [] - f1_values = [] - - with open(file_path, 'r', encoding='utf-8') as file: - lines = file.readlines() - - epsilon = None - sv_section_found = False - for i, line in enumerate(lines): - - if "#EPSILON=" in line: - epsilon = float(line.split('=')[1]) - epsilon_values.append(epsilon) - - # SV type sections - elif "Running truvari" in line: - if sv_type in line: - sv_section_found = True - - elif "f1" in line and sv_section_found: - # Get the value after the ':' - f1 = line.split(':')[1] - - # Clean up the string - f1 = f1.replace('\n', '') - f1 = f1.replace(',', '') - f1 = float(f1) - f1_values.append(f1) - - # Reset epsilon and sv_section_found - epsilon = None - sv_section_found = False - - return epsilon_values, f1_values - - -def plot_precision_recall(epsilon, precision, recall, title="Precision and Recall vs. Epsilon", parameter_name='Epsilon'): - """Plot precision and recall values.""" - # Create figure - plt.figure() - - # Plot precision and recall vs. epsilon on same plot but different axes - ax1 = plt.gca() - ax2 = ax1.twinx() - - # Plot precision vs. epsilon on ax1 - ax1.plot(epsilon, precision, label='Precision', color='black') - - # Plot recall vs. epsilon on ax2 - ax2.plot(epsilon, recall, label='Recall', color='blue') - - # # Show ticks for all epsilon values - # ax1.set_xticks(epsilon) - - # # Make X-ticks vertical - # plt.xticks(rotation=90) - - # # Double the figure width - # plt.gcf().set_size_inches(18.5, 10.5) - - # Add axis labels - ax1.set_xlabel(parameter_name, color='black') - ax1.set_ylabel('Precision', color='black') - ax2.set_xlabel('Epsilon', color='black') - ax2.set_ylabel('Recall', color='blue') - - # Set tick colors - ax1.tick_params(axis='y', colors='black') - ax2.tick_params(axis='y', colors='blue') - - # Add title - plt.title(title) - - return plt - - -def plot_f1(epsilon, f1_scores, title="F1 vs. Epsilon", parameter_name='Epsilon'): - """Plot F1 values.""" - # Create figure - plt.figure() - - # Plot F1 vs. epsilon - plt.plot(epsilon, f1_scores, label='F1') - - # # Show ticks for all epsilon values - # plt.xticks(epsilon) - - # # Make X-ticks vertical - # plt.xticks(rotation=90) - - # # Double the figure width - # plt.gcf().set_size_inches(18.5, 10.5) - - # Add axis labels - plt.xlabel(parameter_name) - plt.ylabel('F1') - - # Add title - plt.title(title) - - # Return figure - return plt - - -if __name__ == '__main__': - # Take in benchmark file path as command line argument - file_path = sys.argv[1] - - print(f'Input file path: {file_path}') - - # Take in cluster type as command line argument - cluster_type = sys.argv[2] - if cluster_type not in ['dbscan', 'agglo']: - print(f"Invalid cluster type: {cluster_type}") - sys.exit(1) - - # Take in output directory name as command line argument - output_dir = sys.argv[3] - - # Create the directory if it doesn't exist - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - # Get the cluster type string - cluster_string = 'DBSCAN' if cluster_type == 'dbscan' else 'Agglomerative' - - # Determine the parameter to test - parameter_name = 'Epsilon' if cluster_type == 'dbscan' else 'Distance Threshold' - - # Create the plot title - plot_title = cluster_string + ' Cluster + Merge' - - # Plot precision and recall values - # Deletions - eps, prec, rec = get_precision_recall(file_path, sv_type='DEL') - fig = plot_precision_recall(eps, prec, rec, title=plot_title + ' (DEL)', parameter_name=parameter_name) - fig.savefig(output_dir + '/Precision_Recall_DEL.png') - - # Duplications - eps, prec, rec = get_precision_recall(file_path, sv_type='DUP') - fig = plot_precision_recall(eps, prec, rec, title=plot_title + ' (DUP)', parameter_name=parameter_name) - fig.savefig(output_dir + '/Precision_Recall_DUP.png') - - # Insertions - eps, prec, rec = get_precision_recall(file_path, sv_type='INS') - fig = plot_precision_recall(eps, prec, rec, title=plot_title + ' (INS)', parameter_name=parameter_name) - fig.savefig(output_dir + '/Precision_Recall_INS.png') - - # Plot F1 scores - # Deletions - eps, f1 = get_f1_scores(file_path, sv_type='DEL') - fig = plot_f1(eps, f1, title=plot_title + ' (DEL)', parameter_name=parameter_name) - fig.savefig(output_dir + '/F1_DEL.png') - - # Duplications - eps, f1 = get_f1_scores(file_path, sv_type='DUP') - fig = plot_f1(eps, f1, title=plot_title + ' (DUP)', parameter_name=parameter_name) - fig.savefig(output_dir + '/F1_DUP.png') - - # Insertions - eps, f1 = get_f1_scores(file_path, sv_type='INS') - fig = plot_f1(eps, f1, title=plot_title + ' (INS)', parameter_name=parameter_name) - fig.savefig(output_dir + '/F1_INS.png') diff --git a/python/cnv_plots.py b/python/cnv_plots.py deleted file mode 100644 index 67c831c6..00000000 --- a/python/cnv_plots.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Plot the copy number variants and their log2_ratio, BAF values.""" - -import os -import sys -import logging as log -import plotly -from plotly.subplots import make_subplots -import pandas as pd - -try: - from .utils import parse_region, get_info_field_column, get_info_field_value -except ImportError: - from utils import parse_region, get_info_field_column, get_info_field_value - -MIN_CNV_LENGTH = 10000 - -# Set up logging. -log.basicConfig( - level=log.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - log.StreamHandler(sys.stdout) - ] -) - -def parse_region(region): - """ - Parses the region string to get the chromosome, start position, and end - position. - - Args: - region (str): The region string in the format "chr:start-end". - - Returns: - tuple: A tuple containing the chromosome, start position, and end - position. - """ - - # Split the region string by ":" and "-". - region_parts = region.split(":") - chromosome = region_parts[0] - region_parts = region_parts[1].split("-") - start_position = int(region_parts[0]) - end_position = int(region_parts[1]) - - return chromosome, start_position, end_position - -def run(cnv_data_file, output_html): - """ - Saves a plot of the CNVs and their log2 ratio and B-allele frequency - values. - - Args: - vcf_path (str): The path to the VCF file. - cnv_data_path (str): The path to the CNV data file. - output_path (str): The path to the output directory. - - Returns: - None - """ - - # Filter the CNV data to the region using pandas, and make the chromosome - # column a string. - log.info("Loading CNV data from %s", cnv_data_file) - - # Read the first 3 lines of the file to get metadata. - # Metadata is formatted as follows: - # "SVTYPE=" - # "POS=" - # "HMM_LOGLH=" - metadata = {} - metadata_row_count = 3 - with open(cnv_data_file, "r", encoding="utf-8") as f: - # Read the first 3 lines of the file. - for _ in range(metadata_row_count): - line = f.readline().strip() - if '=' in line: - key, value = line.split("=") - # log.info("Metadata: %s=%s", key, value) - value = value.strip() - metadata[key] = value - - sv_type = metadata["SVTYPE"] - position = metadata["POS"] - chromosome, start_position, end_position = parse_region(position) - hmm_loglh = float(metadata["HMM_LOGLH"]) - - # Extract information from the metadata. - log.info("SV type: %s, chromosome: %s, start position: %d, end position: %d, HMM log likelihood: %f", sv_type, chromosome, start_position, end_position, hmm_loglh) - - # Read the CNV data from the file. - sv_data = pd.read_csv(cnv_data_file, sep="\t", header=metadata_row_count, dtype={"chromosome": str}) - if len(sv_data) == 0: - log.info("No predictions found in %s", cnv_data_file) - return - else: - log.info("Found %d predictions in %s", len(sv_data), cnv_data_file) - - # Create an output html file where we will append the CNV plots. - if start_position is not None and end_position is not None: - html_filename = f"cnv_plots_{chromosome}_{start_position}_{end_position}.html" - else: - html_filename = f"cnv_plots_{chromosome}.html" - - # Create the output html file. - if os.path.exists(output_html): - os.remove(output_html) - - with open(output_html, "w", encoding="utf-8") as output_html_file: - - # Use absolute value of CNV length (deletions are negative). - # cnv_length = abs(cnv_length) - cnv_length = end_position - start_position + 1 - - # Return if the CNV length is less than the minimum CNV length. - if cnv_length < MIN_CNV_LENGTH: - log.info("Skipping CNV %s:%d-%d due to length < %d.", chromosome, start_position, end_position, MIN_CNV_LENGTH) - return - - # Get the plot range as the minimum and maximum positions in the CNV - # data. - plot_start_position = sv_data["position"].min() - plot_end_position = sv_data["position"].max() - - # Get the CNV state, log2 ratio, and BAF values for all SNPs in the - # plot range. - log.info("Getting SNPs in CNV %s:%d-%d.", chromosome, plot_start_position, plot_end_position) - - # If there are no SNPs in the plot range, skip the CNV. - if len(sv_data) == 0: - log.info("No SNPs found in CNV %s:%d-%d.", chromosome, start_position, end_position) - # continue - else: - log.info("Found %d SNPs in CNV %s:%d-%d.", len(sv_data), chromosome, start_position, end_position) - - # Get the marker colors for the state sequence. - marker_colors = [] - for state in sv_data["cnv_state"]: - if state in [1, 2]: - marker_colors.append("red") - elif state in [3, 4]: - marker_colors.append("black") - elif state in [5, 6]: - marker_colors.append("blue") - - # [TEST] Set the marker colors for the SNPs before and after the CNV to - # gray (no state prediction). - # for i in range(len(sv_data)): - # if sv_data["position"].iloc[i] < start_position or sv_data["position"].iloc[i] > end_position: - # marker_colors[i] = "gray" - - - # Use row['snp'] to get whether SNP or not (0=not SNP, 1=SNP). - marker_symbols = ["circle" if snp == 1 else "circle-open" for snp in sv_data["snp"]] - # marker_symbols = marker_symbols_before + marker_symbols + marker_symbols_after - - # Concatenate the SNP data before, during, and after the CNV. - # sv_data = pd.concat([sv_data_before, sv_data, sv_data_after]) - - # Set all -1 B-allele frequency values to 0. - sv_data.loc[sv_data["b_allele_freq"] == -1, "b_allele_freq"] = 0 - - # Get the hover text for the state sequence markers. - hover_text = [] - for _, row in sv_data.iterrows(): - hover_text.append(f"SNP: {row['snp']}
TYPE: {'NA'}
CHR: {row['chromosome']}
POS: {row['position']}
L2R: {row['log2_ratio']}
BAF: {row['b_allele_freq']}
PFB: {row['population_freq']}
STATE: {row['cnv_state']}") - # hover_text.append(f"TYPE: {cnv_types[row['cnv_state']]}
CHR: {row['chromosome']}
POS: {row['position']}
L2R: {row['log2_ratio']}
BAF: {row['b_allele_freq']}
PFB: {row['population_freq']}") - - # Create the log2 ratio trace. - log2_ratio_trace = plotly.graph_objs.Scatter( - x = sv_data["position"], - y = sv_data["log2_ratio"], - mode = "markers+lines", - name = r'Log2 Ratio', - text = hover_text, - hoverinfo = "text", - marker = dict( - color = marker_colors, - size = 10, - symbol = marker_symbols - ), - line = dict( - color = "black", - width = 0 - ), - showlegend = False - ) - - # Create the B-allele frequency trace. - baf_trace = plotly.graph_objs.Scatter( - x = sv_data["position"], - y = sv_data["b_allele_freq"], - mode = "markers+lines", - name = "B-Allele Frequency", - text = hover_text, - marker = dict( - color = marker_colors, - size = 10, - symbol = marker_symbols - ), - line = dict( - color = "black", - width = 0 - ), - showlegend = False - ) - - # Create a subplot for the CNV plot and the BAF plot. - fig = make_subplots( - rows=2, - cols=1, - shared_xaxes=True, - vertical_spacing=0.05, - subplot_titles=(r"SNP Log2 Ratio", "SNP B-Allele Frequency") - ) - - # Add the traces to the figure. - fig.append_trace(log2_ratio_trace, 1, 1) - fig.append_trace(baf_trace, 2, 1) - - # Set the x-axis title. - fig.update_xaxes( - title_text = "Chromosome Position", - row = 2, - col = 1 - ) - - # Set the y-axis titles. - fig.update_yaxes( - title_text = r"Log2 Ratio", - row = 1, - col = 1 - ) - - fig.update_yaxes( - title_text = "B-Allele Frequency", - row = 2, - col = 1 - ) - - # Set the Y-axis range for the log2 ratio plot. - fig.update_yaxes( - range = [-2.0, 2.0], - row = 1, - col = 1 - ) - - # Set the Y-axis range for the BAF plot. - fig.update_yaxes( - range = [-0.2, 1.2], - row = 2, - col = 1 - ) - - # Set the figure title. - # fig.update_layout( - # title_text = f"{svtype} (SUPPORT={read_support}, LEN={cnv_length}bp) at {chromosome}:{start_position}-{end_position} [ALN={aln}]", - # ) - - # Create a shaded rectangle for the CNV, layering it below the CNV - # trace and labeling it with the CNV type. - fig.add_vrect( - x0 = start_position, - x1 = end_position, - fillcolor = "Black", - layer = "below", - line_width = 0, - opacity = 0.1, - annotation_text = '', - annotation_position = "top left", - annotation_font_size = 20, - annotation_font_color = "black" - ) - - # Add vertical lines at the start and end positions of the CNV. - fig.add_vline( - x = start_position, - line_width = 2, - line_color = "black", - layer = "below" - ) - - fig.add_vline( - x = end_position, - line_width = 2, - line_color = "black", - layer = "below" - ) - - # Append the figure to the output html file. - output_html_file.write(fig.to_html(full_html=False, include_plotlyjs="cdn")) - log.info("Plotted CNV %s %s:%d-%d.", 'SVType', chromosome, start_position, end_position) - - # Increment the CNV count. - # cnv_count += 1 - - # # Break if the maximum number of CNVs has been reached. - # if cnv_count == max_cnvs: - # break - - log.info("Saved CNV plots to %s.", output_html) - -if __name__ == "__main__": - cnv_data_file = sys.argv[1] - output_path = sys.argv[2] - - run(cnv_data_file, output_path) diff --git a/python/environment_merge.yml b/python/environment_merge.yml deleted file mode 100644 index f5b1941c..00000000 --- a/python/environment_merge.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: contextsvmerge -channels: - - bioconda - - anaconda - - conda-forge - - defaults -dependencies: - - python - - numpy - - pytest - - plotly - - pandas - - scikit-learn>=1.3 - -# conda env create --name contextsvmerge --file environment.yml diff --git a/python/extract_features.py b/python/extract_features.py deleted file mode 100644 index 37dbcb24..00000000 --- a/python/extract_features.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -extract_features.py: Extract features from the input VCF file. - -Usage: - extract_features.py - -Arguments: - Path to the input VCF file. - -Output: - A dataframe with a column for each feature. -""" - -import os -import sys -import logging -import numpy as np -import pandas as pd - - -def read_vcf(filepath): - """Read in the VCF file.""" - vcf_df = pd.read_csv(filepath, sep='\t', comment='#', header=None, usecols=[0, 1, 7], \ - names=['CHROM', 'POS', 'INFO'], \ - dtype={'CHROM': str, 'POS': np.int64, 'INFO': str}) - return vcf_df - -def extract_features(input_vcf): - """Extract the features from the VCF file's data.""" - # Read in the VCF file. - vcf_df = read_vcf(input_vcf) - - # Extract the read and clipped base support from the INFO column. - read_support = vcf_df['INFO'].str.extract(r'SUPPORT=(\d+)', expand=False).astype(np.int32) - - # Check if any read depths are missing. - if read_support.isnull().values.any(): - logging.error('Read support is missing.') - sys.exit(1) - - clipped_bases = vcf_df['INFO'].str.extract(r'CLIPSUP=(\d+)', expand=False).astype(np.int32) - - # Check if any clipped bases are missing. - if clipped_bases.isnull().values.any(): - logging.error('Clipped bases is missing.') - sys.exit(1) - - # Get the array of chromosome names. - chrom = vcf_df['CHROM'] - - # Create a key to map the chromosome names to a unique integer. - - # First, get all unique chromosome names. - chrom_unique = chrom.unique() - - # Next, create a dictionary to map the chromosome names to integers. - chrom_dict = {chrom: i for i, chrom in enumerate(chrom_unique)} - - # Finally, map the chromosome names to integers. - chrom = chrom.map(chrom_dict) - - - # Check if any chromosome names are missing. - if chrom.isnull().values.any(): - logging.error('Chromosome name is missing.') - sys.exit(1) - else: - # Print space-separated chromosome names. - logging.info('Chromosomes: ' + ' '.join(chrom.unique().astype(str))) - - # Get the start and end positions. - start = vcf_df['POS'] - - # Check if any start positions are missing. - if start.isnull().values.any(): - logging.error('Start position is missing.') - sys.exit(1) - - # Get the SV length from the INFO column. - sv_length = vcf_df['INFO'].str.extract(r'SVLEN=(-?\d+)', expand=False).astype(np.int32) - - # Check if any SV lengths are missing. - if sv_length.isnull().values.any(): - logging.error('SV length is missing.') - sys.exit(1) - - # Get the SV type from the INFO column. - sv_type = vcf_df['INFO'].str.extract(r'SVTYPE=(\w+)', expand=False) - - # If INFO/REPTYPE=DUP, then the SV type is a duplication. - sv_type[vcf_df['INFO'].str.contains('REPTYPE=DUP')] = 'DUP' - - # Convert the SV type to integers. - sv_type = sv_type.replace('DEL', '0') - sv_type = sv_type.replace('DUP', '1') - sv_type = sv_type.replace('INV', '2') - sv_type = sv_type.replace('INS', '3') - sv_type = sv_type.replace('BND', '4') - sv_type = sv_type.astype(np.int32) - - # Check if any SV types are missing. - if sv_type.isnull().values.any(): - logging.error('SV type is missing.') - sys.exit(1) - - # Loop through the columns and check if any values are missing for all of - # the feature arrays. - for col in [chrom, start, sv_length, sv_type, read_support, clipped_bases]: - if col.isnull().values.all(): - logging.error('All values are missing for a feature.') - logging.error(col) - sys.exit(1) - - # Print the first 4 rows of the features. - logging.info('Features:') - logging.info(pd.DataFrame({'chrom': chrom.head(4), 'start': start.head(4), 'sv_length': sv_length.head(4), \ - 'sv_type': sv_type.head(4), 'read_support': read_support.head(4), \ - 'clipped_bases': clipped_bases.head(4)})) - - # Check that all features have the same length. - if not all(len(col) == len(chrom) for col in [start, sv_length, sv_type, read_support, clipped_bases]): - logging.error('Features do not have the same length.') - - # Print the length of each feature. - logging.error('Chromosomes: ' + str(len(chrom))) - logging.error('Start positions: ' + str(len(start))) - logging.error('SV lengths: ' + str(len(sv_length))) - logging.error('SV types: ' + str(len(sv_type))) - logging.error('Read support: ' + str(len(read_support))) - logging.error('Clipped bases: ' + str(len(clipped_bases))) - - sys.exit(1) - - # Create a dataframe of the features. - features = pd.DataFrame({'chrom': chrom, 'start': start, 'sv_length': sv_length, 'sv_type': sv_type, \ - 'read_support': read_support, 'clipped_bases': clipped_bases}) - - # Check if any features are missing. - if features.isnull().values.any(): - logging.error('Features are missing.') - - # Get the rows with missing features. - missing_features = features[features.isnull().any(axis=1)] - - # Print the rows with missing features. - logging.error(missing_features) - sys.exit(1) - - # Return the features. - return features diff --git a/python/mendelian_error.py b/python/mendelian_error.py deleted file mode 100644 index 2cf69572..00000000 --- a/python/mendelian_error.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -mendelian_error.py: Compute the Mendelian error rate from the VCF files of a -father, mother, and son. - -Usage: - mendelian_error.py - -Arguments: - Path to the father's VCF file. - Path to the mother's VCF file. - Path to the son's VCF file. - -Output: - The Mendelian error rate (proportion of variants with Mendelian errors). - -Example: - mendelian_error.py father.vcf mother.vcf son.vcf -""" - -import sys -import logging -import numpy as np -import pandas as pd - -def get_genotype(sample): - """ - Parse the genotype (GT) field from the SAMPLE column of a VCF file. - """ - genotype = sample.split(':')[0] - - if genotype == './.': - return None - else: - return genotype - - -def compute_mendelian_error_rates(father_file, mother_file, child_file): - """ - Compute the Mendelian error rate from the VCF files of a father, mother, - and child. - """ - # Read the VCF files into pandas dataframes - father_df = pd.read_csv(father_file, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str}, nrows=10000) - - mother_df = pd.read_csv(mother_file, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str}, nrows=10000) - - child_df = pd.read_csv(child_file, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str}, nrows=10000) - - # Parse the genotype (GT) fields and compute the Mendelian error rates - total_variants = len(child_df) - mendelian_errors = 0 - - for i in range(total_variants): - # Loop through the father's variants and compare with the mother's and - # child's variants - - # Get the current variant's location - chrom = child_df['CHROM'][i] - pos = child_df['POS'][i] - svlen = child_df['INFO'][i].split(';')[0].split('=')[1] - - #print(f"Chrom: {chrom}, Pos: {pos}, SVLEN: {svlen}") - - # Find the same variant in the mother's and father's VCF files - mother_df = mother_df[(mother_df['CHROM'] == chrom) & (mother_df['POS'] == pos)] - father_df = father_df[(father_df['CHROM'] == chrom) & (father_df['POS'] == pos)] - - # Check if the variant is present in the mother's and child's VCF files - if mother_df.empty or father_df.empty: - #logging.warning("Variant not found in mother's or child's VCF file") - continue - else: - print("Variant found in mother's and father's VCF file at %s:%d" % (chrom, pos)) - - # Get the samples - child_sample = child_df['SAMPLE'][i] - mother_sample = mother_df['SAMPLE'].values[0] - father_sample = father_df['SAMPLE'].values[0] - - # Get the genotypes - father_genotype = get_genotype(father_sample) - mother_genotype = get_genotype(mother_sample) - child_genotype = get_genotype(child_sample) - - # Skip if any of the genotypes are missing - if father_genotype is None or mother_genotype is None or child_genotype is None: - logging.warning("Missing genotype(s) for variant at %s:%d", chrom, pos) - continue - - # Print the genotypes - print(f"Father: {father_genotype}, Mother: {mother_genotype}, Child: {child_genotype}") - - # Mendelian error: Child's genotype is inconsistent with inheritance of - # exactly one allele from each parent. - # Scenario 1: Father and mother have the same genotype, but the child's - # genotype is different. - # Scenario 2: Father and mother have different genotypes, but the - # child's genotype is the same as one of the parents'. - # See Smolka et al. (2022) for more details (preprint for Sniffles2): - # https://www.biorxiv.org/content/10.1101/2022.04.04.487055v2.full - - # Scenario 1 - if father_genotype == mother_genotype and father_genotype != son_genotype: - mendelian_errors += 1 - - # Scenario 2 - if father_genotype != mother_genotype and (father_genotype == son_genotype or mother_genotype == son_genotype): - mendelian_errors += 1 - - mendelian_error_rate = mendelian_errors / total_variants - - return mendelian_error_rate - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - logging.info("Running mendelian_error.py") - if len(sys.argv) != 4: - logging.error("Incorrect number of arguments") - sys.exit(__doc__) - - father_file = sys.argv[1] - mother_file = sys.argv[2] - child_file = sys.argv[3] - me_rate = compute_mendelian_error_rates(father_file, mother_file, child_file) - logging.info("Mendelian error rate: %.4f", me_rate) diff --git a/python/mendelian_inheritance.py b/python/mendelian_inheritance.py deleted file mode 100644 index 128b1d1a..00000000 --- a/python/mendelian_inheritance.py +++ /dev/null @@ -1,78 +0,0 @@ -import csv -import sys - - -def read_tsv(file_path): - with open(file_path, 'r') as file: - reader = csv.reader(file, delimiter='\t') - return [row for row in reader] - -def calculate_mendelian_error(father_genotype, mother_genotype, child_genotype): - # Generate all possible child genotypes - child_genotypes = set() - for allele1 in father_genotype.split('/'): - for allele2 in mother_genotype.split('/'): - child_genotypes.add('/'.join(sorted([allele1, allele2]))) - - # Print the parent and child genotypes if invalid - if child_genotype not in child_genotypes: - print(f"ME: Father: {father_genotype}, Mother: {mother_genotype}, Child: {child_genotype}") - - # Check if the child genotype is valid - return 0 if child_genotype in child_genotypes else 1 - - -def main(father_file, mother_file, child_file): - father_records = read_tsv(father_file) - mother_records = read_tsv(mother_file) - child_records = read_tsv(child_file) - - if len(father_records) != len(mother_records) or len(father_records) != len(child_records): - raise ValueError("All files must have the same number of records") - - total_records = len(father_records) - error_count = 0 - - sv_type_dict = {} - sv_type_error_dict = {} - - for i in range(total_records): - father_genotype = father_records[i][5] - mother_genotype = mother_records[i][5] - child_genotype = child_records[i][5] - child_sv_type = child_records[i][2] - sv_type_dict[child_sv_type] = sv_type_dict.get(child_sv_type, 0) + 1 - - # Print SV size if error occurs - error_value = calculate_mendelian_error(father_genotype, mother_genotype, child_genotype) - if error_value == 1: - # print(f"SV size: {father_records[i][2]}") - sv_type_error_dict[child_sv_type] = sv_type_error_dict.get(child_sv_type, 0) + 1 - - error_count += error_value - # error_count += calculate_mendelian_error(father_genotype, mother_genotype, child_genotype) - - if total_records == 0: - error_rate = 0 - print("No records found") - else: - error_rate = error_count / total_records - - print(f"Mendelian Inheritance Error Rate: {error_rate:.2%} for {total_records} shared trio SVs") - - print("SV Type Distribution:") - for sv_type, count in sv_type_dict.items(): - error_count = sv_type_error_dict.get(sv_type, 0) - error_rate = error_count / count - print(f"{sv_type}: {error_rate:.2%} ({error_count}/{count})") - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: python mendelian_inheritance.py ") - sys.exit(1) - - father_file = sys.argv[1] - mother_file = sys.argv[2] - child_file = sys.argv[3] - - main(father_file, mother_file, child_file) diff --git a/python/plot_distributions.py b/python/plot_distributions.py deleted file mode 100644 index c2644a8a..00000000 --- a/python/plot_distributions.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -plot_distributions.py: Plot the distributions of SV sizes in the input VCF file -and save the plot as a PNG file. - -Usage: - plot_distributions.py - -Arguments: - Path to the input VCF file. - Path to the output PNG file. - -Output: - A PNG file with the SV size distributions. - -Example: - python plot_distributions.py input.vcf output.png -""" - -import sys -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt - -# import plotly -import plotly.graph_objects as go - -def generate_sv_size_plot(input_vcf, output_png, plot_title="SV Caller"): - # Read VCF file into a pandas DataFrame - try: - vcf_df = pd.read_csv(input_vcf, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str}) - except Exception as e: - try: - print("[DEBUG] Caught TypeError") - # Truvari merged VCF format with different columns - vcf_df = pd.read_csv(input_vcf, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE', 'SAMPLE2'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str, 'SAMPLE2': str}) - except Exception as e: - print("[DEBUG] Caught Exception") - # Platinum pedigree VCF format with different columns - vcf_df = pd.read_csv(input_vcf, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE', 'SAMPLE2', 'SAMPLE3', 'SAMPLE4', 'SAMPLE5', 'SAMPLE6', 'SAMPLE7'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE1': str, 'SAMPLE2': str, 'SAMPLE3': str, 'SAMPLE4': str, \ - 'SAMPLE5': str, 'SAMPLE6': str, 'SAMPLE7': str}) - - # Initialize dictionaries to store SV sizes for each type of SV - sv_sizes = {} - - # Iterate over each record in the VCF file - print("SV CALLER: ", plot_title) - for _, record in vcf_df.iterrows(): - - # Get the POS - pos = record['POS'] - - # Get the SV data by splitting semi-colon separated INFO field and - # extracting SVTYPE and SVLEN - info_fields = record['INFO'].split(';') - sv_type = None - sv_len = None # INFO/SVLEN - sv_span = None # INFO/END - POS - alignment = "NA" - for field in info_fields: - if field.startswith('SVTYPE='): - sv_type = field.split('=')[1] - elif field.startswith('SVLEN='): - sv_len = abs(int(field.split('=')[1])) - elif field.startswith('END='): - sv_span = int(field.split('=')[1]) - pos - elif field.startswith('ALN='): - alignment = field.split('=')[1] - - # Continue if SV type is BND (no SV size) - if sv_type == "BND": - continue - - # If the SV caller is DELLY, then we use the second SV size for non-INS - # (they don't have SVLEN) and the first SV size for INS - sv_size = None - if plot_title == "DELLY" and sv_type != "INS": - sv_size = sv_span - else: - sv_size = sv_len - - # If the plot title is GIAB, then we need to convert INS to DUP if - # INFO/SVTYPE is INS and INFO/REPTYPE is DUP - if "GIAB" in plot_title and sv_type == "INS": - if 'REPTYPE=DUP' in record['INFO']: - sv_type = "DUP" - - # Add the SV type if it's not in the dictionary - if sv_type not in sv_sizes: - sv_sizes[sv_type] = [] - - # Add the SV size to the dictionary - sv_sizes[sv_type].append(sv_size) - - # Create a tiled plot where each tile shows the SV size distribution for a - # different SV type - sv_type_count = len(sv_sizes) - fig, axes = plt.subplots(sv_type_count, 1, figsize=(10, 5 * sv_type_count)) - print(f'Number of SV types: {sv_type_count}') - - # Create a dictionary of SV types and their corresponding colors. - # From: https://davidmathlogic.com/colorblind/ - # WONG colors - sv_colors = {'DEL': '#E69F00', 'DUP': '#56B4E9', 'INV': '#009E73', 'INS': '#F0E442', 'INVDUP': '#D55E00', 'COMPLEX': '#CC79A7'} - - # Create a dictionary of SV types and their corresponding labels - sv_labels = {'DEL': 'Deletion', 'DUP': 'Duplication', 'INV': 'Inversion', 'INS': 'Insertion', 'INVDUP': 'Inverted Duplication', 'COMPLEX': 'Complex'} - - # Get the list of SV types and sort them in the order of the labels - sv_types = sorted(sv_sizes.keys(), key=lambda x: sv_labels[x]) - - # Print the number of SVs for each type, starting with the label - print("SV Caller: ", plot_title) - print("Total number of SVs: ", len(vcf_df)) - - print('Number of SVs for each type:') - total_sv_count = 0 - for sv_type in sv_types: - print(f'{sv_labels[sv_type]}: {len(sv_sizes[sv_type])}') - total_sv_count += len(sv_sizes[sv_type]) - - print(f'Total number of SVs (sum): {total_sv_count}') - - # Print the number of SVs for each type with size > 50kb - print('Number of SVs for each type with size > 50kb:') - for sv_type in sv_types: - print(f'{sv_labels[sv_type]}: {len([x for x in sv_sizes[sv_type] if abs(x) > 50000])}') - - # Summary statistics - all_sv_sizes = [] - for sv_type in sv_types: - all_sv_sizes.extend(sv_sizes[sv_type]) - print('Summary statistics:') - print(f'Minimum SV size: {min(all_sv_sizes)}') - print(f'Maximum SV size: {max(all_sv_sizes)}') - print(f'Mean SV size: {np.mean(all_sv_sizes)}') - print(f'Median SV size: {np.median(all_sv_sizes)}') - print(f'Standard deviation of SV sizes: {np.std(all_sv_sizes)}') - print(f'Number of SVs >10kb: {len([x for x in all_sv_sizes if abs(x) > 10000])}') - print(f'Number of SVs >50kb: {len([x for x in all_sv_sizes if abs(x) > 50000])}') - print(f'Number of SVs >100kb: {len([x for x in all_sv_sizes if abs(x) > 100000])}') - - # Plot the SV size distributions - size_scale = 1000 # Convert SV sizes from bp to kb. Use abs() to handle negative deletion sizes - for i, sv_type in enumerate(sv_types): - sizes = np.array(sv_sizes[sv_type]) - axes[i].hist(np.abs(sizes) / size_scale, bins=100, color=sv_colors[sv_type], alpha=0.7, label=sv_labels[sv_type], edgecolor='black') - axes[i].set_xlabel('SV size (kb)') - axes[i].set_ylabel('Frequency (log scale)') - axes[i].set_title(f'{plot_title}: {sv_labels[sv_type]}') - - # Use a log scale for the y-axis - axes[i].set_yscale('log') - - # In the same axis, plot a known duplication if within the range of the plot - # if sv_type == 'DUP': - # print("TEST: Found DUP") - # cnv_size = 776237 / size_scale - # x_min, x_max = axes[i].get_xlim() - # if cnv_size > x_min and cnv_size < x_max: - # axes[i].axvline(x=cnv_size, color='black', linestyle='--') - # else: - # # Print the values - # print(f'CNV size: {cnv_size}, x_min: {x_min}, x_max: {x_max}') - - # Refresh the plot - plt.draw() - - # Save the plot as a PNG file - plt.tight_layout() - plt.savefig(output_png) - - # Plot an additional plot with suffix _full.png that includes all SV types - # (using plotly to avoid overlapping histograms) - max_size = np.max(np.abs(all_sv_sizes)) - max_bin_edge = np.max([1000000, max_size]) # Set the maximum bin edge to 1Mb or the max size - bin_edges = [0, 1000, 5000, 10000, 50000, 100000, 500000, max_bin_edge] # Bin edges - bin_edges = np.array(bin_edges) / size_scale # Convert to kb - bin_labels = ['0-1kb', '1-5kb', '5-10kb', '10-50kb', '50-100kb', '100-500kb', '500kb+'] - x_values = np.arange(len(bin_edges) - 1) # x values for the histogram - - # Create histograms using the bin edges - fig = go.Figure() - for sv_type in sv_types: - sizes = np.array(np.abs(sv_sizes[sv_type])) / size_scale - - counts, _ = np.histogram(sizes, bins=bin_edges) - fig.add_trace(go.Bar(x=x_values, y=counts, name=sv_labels[sv_type], marker_color=sv_colors[sv_type])) - - - # Update the layout to group the bars side by side - fig.update_layout( - barmode='group', - title=f'{plot_title}: All SV types', - xaxis_title='SV size (kb)', - yaxis_title='Frequency (log scale)', - yaxis_type='log', - bargap=0.3, - ) - - # Add the bin edges to the x-axis ticks as a range - fig.update_xaxes(tickvals=x_values, ticktext=bin_labels) - - # Move the legend to the top right inside the plot - fig.update_layout(legend=dict( - orientation='v', - yanchor='top', - y=0.9, - xanchor='right', - x=0.9, - )) - # # Move the legend to the bottom right outside the plot - # fig.update_layout(legend=dict( - # orientation='v', - # yanchor='top', - # y=1.0, - # xanchor='right', - # x=1.15, - # )) - - # Set a larger font size for all text in the plot - fig.update_layout(font=dict(size=26)) - - # # Save the plot as a high-resolution PNG file for using in posters - fig.write_image(output_png.replace('.png', '_full.png'), width=1200, height=800) - print(f'Saved plot to {output_png.replace(".png", "_full.png")}') - - -if __name__ == '__main__': - # Get the input and output file paths from the command line arguments - input_file = sys.argv[1] - output_file = sys.argv[2] - plot_title = sys.argv[3] - - print(f'Input file: {input_file}') - print(f'Output file: {output_file}') - - # Generate the SV size plot - generate_sv_size_plot(input_file, output_file, plot_title=plot_title) diff --git a/python/plot_venn.py b/python/plot_venn.py deleted file mode 100644 index 757f4408..00000000 --- a/python/plot_venn.py +++ /dev/null @@ -1,48 +0,0 @@ -# from matplotlib_venn import venn3 -from matplotlib_venn import venn2 -import argparse - -import matplotlib.pyplot as plt - -def plot_venn(AB, Ab, aB, output, plot_title, title_Ab, title_aB): - plt.figure(figsize=(8, 8)) - - print('AB:', AB) - print('Ab:', Ab) - print('aB:', aB) - - # Create scaled subsets for the venn diagram - scaling_factor = 1000 - scaled_AB = AB / scaling_factor - scaled_Ab = Ab / scaling_factor - scaled_aB = aB / scaling_factor - - # Create a venn diagram scaled to the number of elements in each set - # venn = venn2(subsets=(AB, Ab, aB), set_labels=(title_Ab, title_aB)) - venn = venn2(subsets=(scaled_Ab, scaled_aB, scaled_AB), set_labels=(title_Ab, title_aB)) - - # Update the labels to reflect the actual counts - venn.get_label_by_id('10').set_text(str(Ab)) - venn.get_label_by_id('01').set_text(str(aB)) - venn.get_label_by_id('11').set_text(str(AB)) - - # Update the title - # plt.title("contextsv and " + title_aB + " venn diagram (all SV types)") - plt.title(plot_title) - plt.savefig(output) - plt.close() - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Generate a Venn diagram.') - parser.add_argument('-a', type=int, required=True, help='Shared count') - parser.add_argument('-b', type=int, required=True, help='False positive count') - parser.add_argument('-c', type=int, required=True, help='False negative count') - parser.add_argument('-o', '--output', type=str, required=True, help='Output file path') - parser.add_argument('-a_title', type=str, required=True, help='Title for set A') - parser.add_argument('-b_title', type=str, required=True, help='Title for set B') - parser.add_argument('-c_title', type=str, required=True, help='Title for set C') - - args = parser.parse_args() - - plot_venn(args.a, args.b, args.c, args.output, args.a_title, args.b_title, args.c_title) - print(f'Venn diagram saved to {args.output}') diff --git a/python/predict.py b/python/predict.py deleted file mode 100644 index 70d186eb..00000000 --- a/python/predict.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -scoring_model.py: Score the structural variants using the binary classification -model. - -Usage: - scoring_model.py - -Arguments: - Path to the input VCF file. - Path to the model file. -""" - -import os -import sys -import logging -import numpy as np -import joblib -import pandas as pd - -import matplotlib.pyplot as plt - -from extract_features import extract_features - - -def score(model, input_vcf, output_vcf): - """Score the structural variants using the binary classification model. - - Args: - model (str): Path to the model file. - input_vcf (str): Path to the input VCF file. - output_vcf (str): Path to the output VCF file. - """ - # Load the model - clf = joblib.load(model) - - # Extract the features from the VCF file - X = extract_features(input_vcf) - - # Predict the labels and get the probabilities - y_pred = clf.predict_proba(X) - - # logging.info('Predicted labels:\n%s', y_pred) - - # Plot a histogram of the probabilities - plt.hist(y_pred[:, 1], bins=20) - plt.xlabel('Probability') - plt.ylabel('Count') - - # # Save the plot to the input VCF file's directory - # output_dir = os.path.dirname(output_vcf) - # output_filepath = os.path.join(output_dir, 'probabilities.png') - # plt.savefig(output_filepath) - # logging.info('Saved the plot of the probabilities to %s.', output_filepath) - - # Save the plot to the working directory - plt.savefig('output/probabilities.png') - - -if __name__ == '__main__': - - # Model file - model = sys.argv[1] - - # Input VCF file to score - input_vcf = sys.argv[2] - - # Output VCF file - output_vcf = sys.argv[3] - - # Set up the logger - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') - - # Score the structural variants - score(model, input_vcf, output_vcf) - \ No newline at end of file diff --git a/python/score_vcf.py b/python/score_vcf.py deleted file mode 100644 index 1e805017..00000000 --- a/python/score_vcf.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -score_vcf.py - Score structural variants in a VCF file using a binary classification model. - -This script prioritizes structural variants in a VCF file by scoring them using -a binary classification model. The model is trained using a VCF file of true -positive structural variants and a VCF file of false positive structural -variants. The model is trained using the following features extracted from the -VCF files: chromosome, start position, structural variant length, structural -variant type, read support, and clipped bases. The model is a logistic -regression model. - -Usage: - python score_vcf.py - -Arguments: - model_path: str - Path to the trained model file. - vcf_filepath: str - Path to the VCF file to score. - -Example: - python score_vcf.py model.pkl structural_variants.vcf - -""" - -import os -import sys -import logging -import numpy as np -import joblib -import pandas as pd -from sklearn.linear_model import LogisticRegression -import matplotlib.pyplot as plt - -from extract_features import extract_features - - -# Set up the logger. -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - -def score(model_path, vcf_filepath, output_vcf): - """Load the model and VCF file and score the structural variants.""" - # Load the VCF file. - logging.info('Extracting features from the VCF file.') - features = extract_features(vcf_filepath) - - # Load the model. - logging.info('Loading the model.') - model = joblib.load(model_path) - - # Score the structural variants. - logging.info('Scoring the structural variants.') - scores = model.predict_proba(features) - - # Plot a histogram of the scores. - logging.info('Plotting the distribution of scores.') - plt.hist(scores) - plt.xlabel('Score') - plt.ylabel('Frequency') - plt.title('Distribution of Scores') - - # Save the plot as a PNG file. - output_png = "scores.png" - plt.tight_layout() - plt.savefig(output_png) - logging.info('Saved the plot as %s.', output_png) - - -if __name__ == '__main__': - # Get the command line arguments. - if len(sys.argv) != 4: - logging.error('Usage: python score_vcf.py \n') - sys.exit(1) - - # Get the model path and VCF file path. - model_path = sys.argv[1] - vcf_filepath = sys.argv[2] - output_vcf = sys.argv[3] - - # Run the program. - score(model_path, vcf_filepath, output_vcf) - logging.info('done.') diff --git a/python/sv_merger.py b/python/sv_merger.py deleted file mode 100644 index 2f5cb94f..00000000 --- a/python/sv_merger.py +++ /dev/null @@ -1,396 +0,0 @@ -""" -sv_merger.py -Use DBSCAN to merge SVs with the same breakpoint. -Mode can be 'dbscan', 'gmm', or 'agglomerative'. -https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html - -Usage: python sv_merger.py -Output: .merged.vcf -""" - -import os, sys -import numpy as np -import pandas as pd - -import logging -logging.basicConfig(level=logging.INFO) - -import matplotlib.pyplot as plt # For plotting merge behavior - -# HDBSCAN clustering algorithm -from sklearn.cluster import HDBSCAN -from sklearn.cluster import DBSCAN - - -def plot_dbscan(breakpoints, chosen_breakpoints, filename='dbscan_clustering.png'): - """ - Plot the DBSCAN clustering behavior for SV breakpoints. - """ - # logging.info the filename - logging.info(f"Plotting DBSCAN clustering behavior to {filename}...") - - # logging.info all breakpoints - logging.info(f"Breakpoints:") - for i in range(breakpoints.shape[0]): - logging.info(f"Row {i+1} - Breakpoints: {breakpoints[i, :]}") - - # Remove the chosen breakpoints from the breakpoints array - breakpoints = np.delete(breakpoints, np.where(breakpoints == chosen_breakpoints), axis=0) - - # Plot the SV breakpoints as individual lines in each row, and the chosen - # SV breakpoint as a red line at the top - # Create a new figure - plt.close() - plt.clf() - plt.cla() - plt.figure(figsize=(10, 10)) - for i in range(breakpoints.shape[0]): - row = i+1 - plt.plot(breakpoints[i, :], [row, row], 'b-') - - plt.plot(chosen_breakpoints, [0, 0], 'r-') - logging.info(f"Chosen breakpoints: {chosen_breakpoints}") - - # Set plot labels - plt.title('DBSCAN Clustering Behavior') - plt.xlabel('Breakpoint Position') - plt.ylabel('SVs') - plt.legend() - - # Save the plot - plt.savefig(filename) - - -def update_support(record, cluster_size): - """ - Set the SUPPORT field in the INFO column of a VCF record to the cluster size. - """ - # Get the INFO column - info = record['INFO'] - - # Parse the INFO columns - info_fields = info.split(';') - - # Loop and update the SUPPORT field, while creating a new INFO string - updated_info = '' - for field in info_fields: - if field.startswith('SUPPORT='): - # Get the current SUPPORT field - previous_support = int(field.split('=')[1]) - - # Add the cluster size to the SUPPORT field - updated_info += f'SUPPORT={previous_support + cluster_size};' - # updated_info += f'SUPPORT={cluster_size};' - else: - updated_info += field + ';' # Append the field to the updated INFO - - # Update the INFO column - record['INFO'] = updated_info - - return record - -def weighted_score(sv_len, hmm_score, weight_hmm): - """ - Calculate a weighted score based on read support and HMM score. - """ - return (1 - weight_hmm) * sv_len + weight_hmm * hmm_score - -def cluster_breakpoints(vcf_df, sv_type, cluster_size_min): - """ - Cluster SV breakpoints using HDBSCAN. - """ - # Set up the output DataFrame - merged_records = pd.DataFrame(columns=['INDEX', 'CHROM', 'POS', 'INFO']) - - # Format the SV breakpoints - breakpoints = None - if sv_type == 'DEL': - sv_start = vcf_df['POS'].values - sv_end = vcf_df['INFO'].str.extract(r'END=(\d+)', expand=False).astype(np.int32) - - # Format the deletion breakpoints - breakpoints = np.column_stack((sv_start, sv_end)) - - elif sv_type == 'INS/DUP': - sv_start = vcf_df['POS'].values - sv_len = vcf_df['INFO'].str.extract(r'SVLEN=(-?\d+)', expand=False).astype(np.int32) - sv_end = sv_start + sv_len - 1 - - # Format the insertion and duplication breakpoints - breakpoints = np.column_stack((sv_start, sv_end)) - else: - logging.error("Invalid SV type: %s", sv_type) - sys.exit(1) - - # Get the combined SV read and clipped base support - sv_support = vcf_df['INFO'].str.extract(r'SUPPORT=(\d+)', expand=False).astype(np.int32) - sv_clipped_base_support = vcf_df['INFO'].str.extract(r'CLIPSUP=(\d+)', expand=False).astype(np.int32) - sv_support = sv_support + sv_clipped_base_support - - # Get the HMM likelihood scores - hmm_scores = vcf_df['INFO'].str.extract(r'HMM=(-?\d+\.?\d*)', expand=False).astype(float) - - # Set all 0 values to a low negative value - hmm_scores[hmm_scores == 0] = -1e-100 - # hmm_scores[hmm_scores == 0] = np.nan - - # Cluster SV breakpoints using HDBSCAN - cluster_labels = [] - - # dbscan = DBSCAN(eps=30000, min_samples=3) - - if len(breakpoints) == 1: - return merged_records - - logging.info("Clustering %d SV breakpoints with parameters: min_cluster_size=%d", len(breakpoints), cluster_size_min) - dbscan = HDBSCAN(min_cluster_size=cluster_size_min, min_samples=2) - if len(breakpoints) > 0: - logging.info("Clustering %d SV breakpoints...", len(breakpoints)) - cluster_labels = dbscan.fit_predict(breakpoints) - - logging.info("Label counts: %d", len(np.unique(cluster_labels))) - - - # Merge SVs with the same label - unique_labels = np.unique(cluster_labels) - #logging.info("Unique labels: %s", unique_labels) - - for label in unique_labels: - - # Skip label -1 (outliers) only if there are no other clusters - if label == -1 and len(unique_labels) > 1: - continue - - # Get the indices of SVs with the same label - idx = cluster_labels == label - - # Get HMM and read support values for the cluster - # max_score_idx = 0 # Default to the first SV in the cluster - cluster_hmm_scores = np.array(hmm_scores[idx]) - # cluster_depth_scores = np.array(sv_support[idx]) - cluster_sv_lengths = np.array(breakpoints[idx][:, 1] - breakpoints[idx][:, 0] + 1) - # max_hmm = None - # max_support = None - # max_hmm_idx = None - # max_support_idx = None - - # Find the maximum HMM score - # if len(np.unique(cluster_hmm_scores)) > 1: - # max_hmm_idx = np.nanargmax(cluster_hmm_scores) - # max_hmm = cluster_hmm_scores[max_hmm_idx] - - # Find the maximum read alignment and clipped base support - # if len(np.unique(cluster_depth_scores)) > 1: - # max_support_idx = np.argmax(cluster_depth_scores) - # max_support = cluster_depth_scores[max_support_idx] - - # Normalize the HMM scores. Since the HMM scores are negative (log lh), we - # normalize them to the range [0, 1] by subtracting the minimum value - cluster_hmm_norm = (cluster_hmm_scores - np.min(cluster_hmm_scores)) / (np.max(cluster_hmm_scores) - np.min(cluster_hmm_scores)) - - # Normalize the SV lengths to the range [0, 1] - cluster_sv_lengths_norm = (cluster_sv_lengths - np.min(cluster_sv_lengths)) / (np.max(cluster_sv_lengths) - np.min(cluster_sv_lengths)) - - # Use a weighted approach to choose the best SV based on HMM and - # support. Deletions have higher priority for HMM scores, while - # insertions and duplications have higher priority for read alignment - # support. - # hmm_weight = 0.7 if sv_type == 'DEL' else 0.3 - hmm_weight = 0.5 - max_score_idx = 0 # Default to the first SV in the cluster - max_score = weighted_score(cluster_hmm_norm[max_score_idx], cluster_sv_lengths_norm[max_score_idx], hmm_weight) - # max_score = weighted_score(cluster_sv_lengths[max_score_idx], cluster_hmm_scores[max_score_idx], hmm_weight) - for k, hmm_norm in enumerate(cluster_hmm_norm): - svlen_norm = cluster_sv_lengths_norm[k] - score = weighted_score(svlen_norm, hmm_norm, hmm_weight) - if score > max_score: - max_score = score - max_score_idx = k - - # Get the VCF record with the highest score - max_record = vcf_df.iloc[idx, :].iloc[max_score_idx, :] - - # # For deletions, choose the SV with the highest HMM score if available - # if sv_type == 'DEL': - # if max_hmm is not None: - # max_score_idx = max_hmm_idx - # elif max_support is not None: - # max_score_idx = max_support_idx - - # # For insertions and duplications, choose the SV with the highest read - # # support if available - # elif sv_type == 'INS/DUP': - # if max_support is not None: - # max_score_idx = max_support_idx - # elif max_hmm is not None: - # max_score_idx = max_hmm_idx - - # Get the VCF record with the highest depth score - # max_record = vcf_df.iloc[idx, :].iloc[max_score_idx, :] - - # Get the number of SVs in this cluster - cluster_size = np.sum(idx) - # logging.info("DEL Cluster size: %s", cluster_size) - - # Update the SUPPORT field in the INFO column - max_record = update_support(max_record, cluster_size) - # pos_values = breakpoints[idx][:, 0] - - # Append the chosen record to the dataframe of records that will - # form the merged VCF file - merged_records.loc[merged_records.shape[0]] = max_record - - return merged_records - -def sv_merger(vcf_file_path, cluster_size_min=3, suffix='.merged'): - """ - Use DBSCAN to merge SVs with the same breakpoint. - Mode can be 'dbscan', 'gmm', or 'agglomerative'. - https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html - """ - - logging.info("Merging SVs in %s using HDBSCAN with minimum cluster size=%d...", vcf_file_path, cluster_size_min) - - # Read VCF file into a pandas DataFrame, using only CHROM, POS, and INFO - # columns - logging.info("Reading VCF file into a pandas DataFrame...") - vcf_df = pd.read_csv(vcf_file_path, sep='\t', comment='#', header=None, usecols=[0, 1, 7], \ - names=['CHROM', 'POS', 'INFO'], \ - dtype={'CHROM': str, 'POS': np.int64, 'INFO': str}) - - # Add a column at the beginning with the index - vcf_df.insert(0, 'INDEX', range(0, len(vcf_df))) - logging.info("Reading complete.") - - # Print total number of records - logging.info("Total number of records: %d", vcf_df.shape[0]) - - # Store a dataframe of records that will form the merged VCF file - merged_records = pd.DataFrame(columns=['INDEX', 'CHROM', 'POS', 'INFO']) - - # Create a set with each chromosome in the VCF file - chromosomes = set(vcf_df['CHROM'].values) - - # [TEST] Use only chromosome 5 - # chromosomes = ['chr5'] - - # Iterate over each chromosome - records_processed = 0 - current_chromosome = 0 - chromosome_count = len(chromosomes) - for chromosome in chromosomes: - - # Cluster deletions - logging.info("Clustering deletions on chromosome %s...", chromosome) - chr_del_df = vcf_df[(vcf_df['CHROM'] == chromosome) & (vcf_df['INFO'].str.contains('SVTYPE=DEL'))] - del_records = cluster_breakpoints(chr_del_df, 'DEL', cluster_size_min) - del chr_del_df - - # Cluster insertions and duplications - logging.info("Clustering all other SVs on chromosome %s...", chromosome) - # chr_ins_dup_df = vcf_df[(vcf_df['CHROM'] == chromosome) & - # ((vcf_df['INFO'].str.contains('SVTYPE=INS')) | - # (vcf_df['INFO'].str.contains('SVTYPE=DUP')))] - chr_non_del_df = vcf_df[(vcf_df['CHROM'] == chromosome) & (~vcf_df['INFO'].str.contains('SVTYPE=DEL'))] - ins_dup_records = cluster_breakpoints(chr_non_del_df, 'INS/DUP', cluster_size_min) - del chr_non_del_df - - # Summarize the number of deletions and insertions/duplications - del_count = del_records.shape[0] - ins_dup_count = ins_dup_records.shape[0] - records_processed += del_count + ins_dup_count - logging.info("Chromosome %s - %d deletions, %d other types merged.", chromosome, del_count, ins_dup_count) - - # Append the deletion and insertion/duplication records to the merged - # records DataFrame - merged_records = pd.concat([merged_records, del_records, ins_dup_records], ignore_index=True) - - current_chromosome += 1 - logging.info("Processed %d of %d chromosomes.", current_chromosome, chromosome_count) - - logging.info("Processed %d records of %d total records.", records_processed, vcf_df.shape[0]) - - # Free up memory - del vcf_df - - # Open a new VCF file for writing - logging.info("Writing merged VCF file...") - merged_vcf = os.path.splitext(vcf_file_path)[0] + suffix + '.vcf' - - total_records = merged_records.shape[0] - logging.info("Writing %d records to merged VCF file...", total_records) - - merge_count = 0 - index_start = 0 - with open(merged_vcf, 'w', encoding='utf-8') as merged_vcf_file: - - # Write the VCF header to the merged VCF file - with open(vcf_file_path, 'r', encoding='utf-8') as vcf_file: - for line in vcf_file: - if line.startswith('#'): - merged_vcf_file.write(line) - else: - break - - # Read the next 1000 records from the original VCF file - logging.info("Reading a chunk of 1000 records from the original VCF file...") - for chunk in pd.read_csv(vcf_file_path, sep='\t', comment='#', header=None, \ - names=['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT', 'SAMPLE'], \ - dtype={'CHROM': str, 'POS': np.int64, 'ID': str, 'REF': str, 'ALT': str, 'QUAL': str, \ - 'FILTER': str, 'INFO': str, 'FORMAT': str, 'SAMPLE': str}, \ - chunksize=1000): - - # Add an INDEX column to the chunk - chunk.insert(0, 'INDEX', range(index_start, index_start + chunk.shape[0])) - index_start += chunk.shape[0] - - # Merge on INDEX, and use all information from the original VCF file - # (chunk) but update the INFO field with the merged INFO field. - # This is done by dropping the INFO column from the chunk so that - # the INFO column from the merged_records dataframe is used. - matching_records = pd.merge(chunk.drop(columns=['INFO']), merged_records[['INDEX', 'INFO']], on=['INDEX'], how='inner') - matching_records = matching_records.drop_duplicates(subset=['INDEX']) # Drop duplicate records - matching_records = matching_records.drop(columns=['INDEX']) # Drop the INDEX column - - # Remove the matching records from the merged records dataframe - merged_records = merged_records[~merged_records.isin(matching_records)].dropna() - - # Write the matching records to the merged VCF file - for _, matching_record in matching_records.iterrows(): - merge_count += 1 - merged_vcf_file.write(f"{matching_record['CHROM']}\t{matching_record['POS']}\t{matching_record['ID']}\t{matching_record['REF']}\t{matching_record['ALT']}\t{matching_record['QUAL']}\t{matching_record['FILTER']}\t{matching_record['INFO']}\t{matching_record['FORMAT']}\t{matching_record['SAMPLE']}\n") - - logging.info("Wrote %d of %d total records to merged VCF file...", merge_count, total_records) - - logging.info("Merged VCF file written to %s", merged_vcf) - - return merged_vcf - -if __name__ == '__main__': - import sys - if len(sys.argv) < 2: - logging.info("Usage: %s ", sys.argv[0]) - sys.exit(1) - - # Get the VCF file path from the command line - vcf_file_path = sys.argv[1] - - # Check if the file exists - if not os.path.exists(vcf_file_path): - logging.error("Error: %s not found.", vcf_file_path) - sys.exit(1) - - # Get the minimum cluster size from the command line - if len(sys.argv) > 2: - cluster_size_min = int(sys.argv[2]) - else: - cluster_size_min = 2 - - # Get the suffix from the command line - suffix = '.merged' - if len(sys.argv) > 3: - suffix += sys.argv[3] - - # DBSCAN - sv_merger(vcf_file_path, cluster_size_min=cluster_size_min, suffix=suffix) - diff --git a/python/train_model.py b/python/train_model.py deleted file mode 100644 index 1e161749..00000000 --- a/python/train_model.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -train_model.py - Train the binary classification model. - -This script trains the binary classification model using the true positive and -false positive data. The true positive data is obtained from a benchmarking -dataset. The false positive data is obtained from running the caller on data -that is known to be negative for SVs. This data can be obtained by running the -caller on a normal sample with known SVs accounted for in the reference genome. - -For example for HG002, the true positive data is obtained from the Genome in a -Bottle benchmarking dataset, and the false positive data is obtained from -running the caller on the HG002 normal sample and extracting the SV calls that -are not in the benchmarking dataset. This can be repeated for other samples such -as HG001 and HG005 as long as the known SVs are accounted for. - -In the HG002 SV v0.6 dataset, there are low-confidence regions which -are excluded from the true positive data. Thus, we must include true SVs from -other publicly available normal samples with information from complex regions, -such as those aligned to CHM13. - -The model is trained using logistic regression. The features are the LRR and -BAF values. The labels are 1 for true positives and 0 for false positives. - -The model is saved to the output directory as a pickle file. - -Usage: - python train_model.py - - - true_positives_filepath: Path to the VCF of true positive SV calls obtained - from a benchmarking dataset. - false_positives_filepath: Path to the VCF of false positive SV calls - obtained from running the caller on data that is known to be negative - for SVs. This data can be obtained by running the caller on a normal - sample with known SVs accounted for in the reference genome. - - output_directory: Path to the output directory. - -Output: - model.pkl: The binary classification model. - -Example: - python train_model.py data/sv_scoring_dataset/true_positives.vcf - sv_scoring_dataset/false_positives.vcf data/sv_scoring_dataset/model -""" - -import os -import sys -import logging -import numpy as np -import joblib -import pandas as pd -from sklearn.linear_model import LogisticRegression -import matplotlib.pyplot as plt - -from extract_features import extract_features - -# Set up the logger. -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - -def train(true_positives_filepath, false_positives_filepath): - """Train the binary classification model.""" - - # Extract the features from the VCF files. - logging.info('Extracting features from the true positive VCF file.') - tp_data = extract_features(true_positives_filepath) - - # Check if any features are missing. - if tp_data.isnull().values.any(): - logging.error('Features are missing.') - - # Get the rows with missing features. - missing_features = tp_data[tp_data.isnull().any(axis=1)] - - # Print the rows with missing features. - logging.error(missing_features) - sys.exit(1) - - logging.info('Extracting features from the false positive VCF file.') - fp_data = extract_features(false_positives_filepath) - - # Check if any features are missing. - if fp_data.isnull().values.any(): - logging.error('Features are missing.') - - # Get the rows with missing features. - missing_features = fp_data[fp_data.isnull().any(axis=1)] - - # Print the rows with missing features. - logging.error(missing_features) - sys.exit(1) - - # Add the labels. - tp_data['label'] = 1 - fp_data['label'] = 0 - - # Print the number of true positives and false positives. - logging.info('Number of true labels: %d', tp_data.shape[0]) - logging.info('Number of false labels: %d', fp_data.shape[0]) - - # Combine the true positive and false positive data. - data = pd.concat([tp_data, fp_data]) - - # Get the features and labels. - features = data[["chrom", "start", "sv_length", "sv_type", "read_support", "clipped_bases"]] - labels = data["label"] - - # Check if any features are missing. - if features.isnull().values.any(): - logging.error('Features are missing.') - - # Get the rows with missing features. - missing_features = features[features.isnull().any(axis=1)] - - # Print the rows with missing features. - logging.error(missing_features) - sys.exit(1) - - # Check if any labels are missing. - if labels.isnull().values.any(): - logging.error('Labels are missing.') - sys.exit(1) - - # Train the model. - model = LogisticRegression() - model.fit(features, labels) - - # Return the model. - return model - -# Run the program. -def run(true_positives_filepath, false_positives_filepath, output_directory): - """Run the program.""" - # Train the model. - model = train(true_positives_filepath, false_positives_filepath) - - # Create the output directory if it does not exist. - if not os.path.exists(output_directory): - os.makedirs(output_directory) - - # Save the model - model_path = os.path.join(output_directory, "model.pkl") - joblib.dump(model, model_path) - - # Print the model. - print(model) - - # Return the model. - # return model - - -if __name__ == '__main__': - # Get the command line arguments. - if len(sys.argv) != 4: - logging.error('Usage: python train_model.py \n') - sys.exit(1) - - # Input VCF of true positive SV calls obtained from a benchmarking dataset. - tp_filepath = sys.argv[1] - - # Input VCF of false positive SV calls obtained from running the caller on - # data that is known to be negative for SVs. This data can be obtained by - # running the caller on a normal sample with known SVs accounted for in the - # reference genome. - fp_filepath = sys.argv[2] - output_dir = sys.argv[3] - - # Run the program. - logging.info('Training the model...') - run(tp_filepath, fp_filepath, output_dir) - logging.info('done.') diff --git a/python/utils.py b/python/utils.py deleted file mode 100644 index 9176dc9f..00000000 --- a/python/utils.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Utility functions for genome data analysis.""" - -def parse_region(region): - """Parse a region string into its chromosome and start and end positions.""" - region_parts = region.split(":") - chromosome = str(region_parts[0]) - - try: - start_position = int(region_parts[1].split("-")[0]) - end_position = int(region_parts[1].split("-")[1]) - except IndexError: - start_position, end_position = None, None - - return chromosome, start_position, end_position - -def get_info_field_column(vcf_data): - """Return the column index of the INFO field in a VCF file.""" - index = vcf_data.apply(lambda col: col.astype(str).str.contains("SVTYPE=").any(), axis=0).idxmax() - return index - -def get_info_field_value(info_field, field_name): - """ - Get the value of a field in the INFO field of a VCF file. - - Args: - info_field (str): The INFO field. - field_name (str): The name of the field to get the value of. - - Returns: - str: The value of the field. - """ - - # Split the INFO field into its parts. - info_field_parts = info_field.split(";") - - # Get the field value. - field_value = "" - for info_field_part in info_field_parts: - if info_field_part.startswith("{}=".format(field_name)): - field_value = info_field_part.split("=")[1] - break - - # Return the field value. - return field_value From 600a83f54130a402752e5fac7561987ad44d5e01 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Tue, 3 Mar 2026 20:11:14 -0500 Subject: [PATCH 25/49] Fix DUP ALT allele issue --- src/sv_caller.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index e6359123..c7767dd2 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -1005,7 +1005,7 @@ void SVCaller::runSplitReadCopyNumberPredictions(const std::string& chr, std::ve // For insertions predicted as duplications, update all information } else if (sv_candidate.sv_type == SVType::INS && supp_type == SVType::DUP) { sv_candidate.sv_type = supp_type; - sv_candidate.alt_allele = getSVTypeSymbol(supp_type); // Update the ALT allele format + sv_candidate.alt_allele = ""; // Explicitly set to sv_candidate.aln_type.set(static_cast(SVDataType::HMM)); sv_candidate.hmm_likelihood = supp_lh; sv_candidate.genotype = genotype; @@ -1269,6 +1269,10 @@ void SVCaller::saveToVCF(const std::unordered_map Date: Thu, 5 Mar 2026 10:30:20 -0500 Subject: [PATCH 26/49] Fix multithreading and type errors --- src/cnv_caller.cpp | 14 +++++++------- src/input_data.cpp | 35 +++++++++++++++++++++++++++++++++++ src/sv_caller.cpp | 8 +++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index c91d1d31..b2568e3c 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -75,6 +75,8 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end // Loop through evenly spaced positions in the region and get the log2 ratio double pos_step = static_cast(end_pos - start_pos + 1) / static_cast(sample_size); std::unordered_map window_log2_map; + size_t depth_map_size = pos_depth_map.size(); // Cache size for bounds checking + for (int i = 0; i < sample_size; i++) { uint32_t window_start = (uint32_t) (start_pos + i * pos_step); @@ -83,18 +85,16 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end // Calculate the mean depth for the window double cov_sum = 0.0; int pos_count = 0; - for (int j = 0; j < pos_step; j++) + int max_steps = (int)pos_step + 1; // Convert double to int with safety margin + for (int j = 0; j < max_steps; j++) { uint32_t pos = (uint32_t) (start_pos + i * pos_step + j); - if (pos > end_pos) + if (pos > end_pos || pos >= depth_map_size) { break; } - if (pos < pos_depth_map.size()) { - cov_sum += pos_depth_map[pos]; - pos_count++; - } - + cov_sum += pos_depth_map[pos]; + pos_count++; } double log2_cov = 0.0; if (pos_count > 0) diff --git a/src/input_data.cpp b/src/input_data.cpp index c9a73bb7..15a76908 100644 --- a/src/input_data.cpp +++ b/src/input_data.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "utils.h" #include "debug.h" // For DEBUG_PRINT @@ -72,6 +73,40 @@ void InputData::setLongReadBam(std::string filepath) } else { fclose(fp); } + + // Check if pgbam file is being used and warn user + if (filepath.find(".pgbam") != std::string::npos) + { + std::cerr << "================================================================================\n" + << "WARNING: Using PetaGene-compressed BAM file (.pgbam)\n" + << " This format does NOT support safe concurrent decompression.\n" + << " Multi-threaded access may cause CRC32 checksum errors.\n" + << "\n" + << "RECOMMENDED: Decompress the pgbam file to standard BAM format using:\n" + << " petasuite --decompress input.pgbam\n" + << "================================================================================\n"; + } + + // Check if BAM index file exists and is newer than BAM file + std::string index_filepath = filepath + ".bai"; + struct stat bam_stat, index_stat; + if (stat(filepath.c_str(), &bam_stat) == 0) + { + if (stat(index_filepath.c_str(), &index_stat) == 0) + { + if (index_stat.st_mtime < bam_stat.st_mtime) + { + std::cerr << "================================================================================\n" + << "WARNING: BAM index file is older than BAM file\n" + << " BAM: " << filepath << "\n" + << " Index: " << index_filepath << "\n" + << "\n" + << "RECOMMENDED: Rebuild the BAM index using:\n" + << " samtools index " << filepath << "\n" + << "================================================================================\n"; + } + } + } } } diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index c7767dd2..52160656 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -689,7 +689,6 @@ void SVCaller::processChromosome(const std::string& chr, std::vector& ch printError("ERROR: failed to open " + bam_filepath); return; } - hts_set_threads(fp_in, 1); // Load the header bam_hdr_t *bamHdr = sam_hdr_read(fp_in); @@ -699,6 +698,10 @@ void SVCaller::processChromosome(const std::string& chr, std::vector& ch return; } + // Single-threaded I/O in worker threads to prevent index contention + // (ThreadPool already provides parallelism across chromosomes) + hts_set_threads(fp_in, 1); + // Load the index hts_idx_t *idx = sam_index_load(fp_in, bam_filepath.c_str()); if (!idx) { @@ -1261,8 +1264,7 @@ void SVCaller::saveToVCF(const std::unordered_map Date: Fri, 6 Mar 2026 22:59:18 -0500 Subject: [PATCH 27/49] remove swig and fix mid-range recall issues --- .gitignore | 4 ---- Doxyfile | 4 ++-- Makefile | 2 +- README.md | 2 -- include/input_data.h | 10 -------- include/swig_interface.h | 17 -------------- src/cnv_caller.cpp | 20 +++++++++------- src/input_data.cpp | 25 -------------------- src/main.cpp | 50 +++++++++++++++++++++++++++++----------- src/sv_caller.cpp | 10 +++++--- src/swig_interface.cpp | 26 --------------------- src/swig_wrapper.i | 22 ------------------ tests/test_general.py | 2 -- 13 files changed, 58 insertions(+), 136 deletions(-) delete mode 100644 include/swig_interface.h delete mode 100644 src/swig_interface.cpp delete mode 100644 src/swig_wrapper.i diff --git a/.gitignore b/.gitignore index b6394d47..2dcbd060 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,6 @@ *.o *.obj -# SWIG files -src/swig_wrapper.cpp -lib/contextsv.py - # Pycache __pycache__/ diff --git a/Doxyfile b/Doxyfile index a11d2045..2faa3e0e 100644 --- a/Doxyfile +++ b/Doxyfile @@ -1063,7 +1063,7 @@ EXCLUDE_SYMLINKS = NO # Note that the wildcards are matched against the file with absolute path, so to # exclude all test directories for example use the pattern */test/* -EXCLUDE_PATTERNS = *test* *swig* khmm.cpp kc.cpp khmm.h kc.h +EXCLUDE_PATTERNS = *test* khmm.cpp kc.cpp khmm.h kc.h # The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names # (namespaces, classes, functions, etc.) that should be excluded from the @@ -1071,7 +1071,7 @@ EXCLUDE_PATTERNS = *test* *swig* khmm.cpp kc.cpp khmm.h kc.h # wildcard * is used, a substring. Examples: ANamespace, AClass, # ANamespace::AClass, ANamespace::*Test -EXCLUDE_SYMBOLS = *SWIG* +EXCLUDE_SYMBOLS = # The EXAMPLE_PATH tag can be used to specify one or more files or directories # that contain example code fragments that are included (see the \include diff --git a/Makefile b/Makefile index ae39b32b..a11470a0 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ LDFLAGS := -L$(LIB_DIR) -L$(CONDA_LIB_DIR) -Wl,-rpath=$(CONDA_LIB_DIR) # Add rp LDLIBS := -lhts # Link with libhts.a or libhts.so # Sources and Output -SOURCES := $(filter-out $(SRC_DIR)/swig_wrapper.cpp, $(wildcard $(SRC_DIR)/*.cpp)) # Filter out the SWIG wrapper from the sources +SOURCES := $(wildcard $(SRC_DIR)/*.cpp) OBJECTS := $(patsubst $(SRC_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(SOURCES)) TARGET := $(BUILD_DIR)/contextsv diff --git a/README.md b/README.md index 3a7e0cda..32e7bd3b 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,6 @@ Options: -c, --chr Chromosome -t, --threads Number of threads -h, --hmm HMM file - -n, --sample-size Sample size for HMM predictions - --min-cnv Minimum CNV length --eps DBSCAN epsilon --min-pts-pct Percentage of mean chr. coverage to use for DBSCAN minimum points -e, --eth ETH file diff --git a/include/input_data.h b/include/input_data.h index b8c07169..6a028d53 100644 --- a/include/input_data.h +++ b/include/input_data.h @@ -55,14 +55,6 @@ class InputData { void setAssemblyGaps(std::string filepath); std::string getAssemblyGaps() const; - // Set the sample size for HMM predictions. - void setSampleSize(int sample_size); - int getSampleSize() const; - - // Set the minimum CNV length to use for copy number predictions. - void setMinCNVLength(int min_cnv_length); - uint32_t getMinCNVLength() const; - // Set the epsilon parameter for DBSCAN clustering. void setDBSCAN_Epsilon(double epsilon); double getDBSCAN_Epsilon() const; @@ -99,8 +91,6 @@ class InputData { std::string ethnicity; std::unordered_map pfb_filepaths; // Map of population frequency VCF filepaths by chromosome std::string output_dir; - int sample_size; - uint32_t min_cnv_length; int min_reads; double dbscan_epsilon; double dbscan_min_pts_pct; diff --git a/include/swig_interface.h b/include/swig_interface.h deleted file mode 100644 index 578f4653..00000000 --- a/include/swig_interface.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// swig_interface.h: -// Declare the C++ functions that will be wrapped by SWIG -// - -#ifndef SWIG_INTERFACE_H -#define SWIG_INTERFACE_H - -#include "input_data.h" - -/// @cond -#include -/// @endcond - -int run(const InputData& input_data); - -#endif // SWIG_INTERFACE_H diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index b2568e3c..45e31a0a 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -52,8 +52,8 @@ void CNVCaller::runViterbi(const CHMM& hmm, SNPData& snp_data, std::pair& pos_depth_map, double mean_chr_cov, SNPData& snp_data, const InputData& input_data) const { - // Initialize the SNP data with default values and sample size length - int sample_size = input_data.getSampleSize(); + // Initialize SNP sampling using recommended fixed sample size + int sample_size = 20; std::vector snp_pos; std::unordered_map snp_baf_map; std::unordered_map snp_pfb_map; @@ -61,8 +61,10 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end this->readSNPAlleleFrequencies(chr, start_pos, end_pos, snp_pos, snp_baf_map, snp_pfb_map, input_data); // Get the log2 ratio for evenly spaced positions in the - // region - sample_size = std::max((int) snp_pos.size(), sample_size); + // region. Scale sample size with region length to ensure sufficient + // observations for large SVs (minimum 1 observation per 1kb for better resolution) + int region_based_samples = (int)((end_pos - start_pos + 1) / 1000); + sample_size = std::max({(int) snp_pos.size(), sample_size, region_based_samples}); // Print an error if the end position is less than or equal to the start // position @@ -226,7 +228,9 @@ std::tuple CNVCaller::runCopyNumberPrediction(std } // Use the state exceeding the threshold if non-neutral - double pct_threshold = 0.3; + // Adaptive threshold: regions >5kb are noisier due to coverage fragmentation + uint32_t region_length = end_pos - start_pos; + double pct_threshold = (region_length > 5000) ? 0.25 : 0.3; int max_state = 0; // Unknown state if (largest_non_neutral_pct > pct_threshold) { @@ -242,7 +246,7 @@ std::tuple CNVCaller::runCopyNumberPrediction(std SVType predicted_cnv_type = getSVTypeFromCNState(max_state); // Save the SV calls if enabled - uint32_t min_length = 30000; + uint32_t min_length = 10000; // Lowered from 30kb to include 10-30kb SVs bool copy_number_change = (predicted_cnv_type != SVType::UNKNOWN && predicted_cnv_type != SVType::NEUTRAL); if (input_data.getSaveCNVData() && copy_number_change && (end_pos - start_pos) >= min_length) { @@ -311,8 +315,8 @@ void CNVCaller::runCIGARCopyNumberPrediction(std::string chr, std::vectorsnp_vcf_filepath = ""; this->chr = ""; this->output_dir = ""; - this->sample_size = 20; - this->min_cnv_length = 2000; // Default minimum CNV length this->min_reads = 5; this->dbscan_epsilon = 0.1; this->dbscan_min_pts_pct = 0.1; @@ -44,8 +42,6 @@ void InputData::printParameters() const DEBUG_PRINT("Reference genome: " << this->ref_filepath); DEBUG_PRINT("SNP VCF: " << this->snp_vcf_filepath); DEBUG_PRINT("Output directory: " << this->output_dir); - DEBUG_PRINT("Sample size: " << this->sample_size); - DEBUG_PRINT("Minimum CNV length: " << this->min_cnv_length); DEBUG_PRINT("DBSCAN epsilon: " << this->dbscan_epsilon); DEBUG_PRINT("DBSCAN minimum points percentage: " << this->dbscan_min_pts_pct * 100.0f << "%"); } @@ -139,16 +135,6 @@ void InputData::setOutputDir(std::string dirpath) } } -int InputData::getSampleSize() const -{ - return this->sample_size; -} - -void InputData::setSampleSize(int sample_size) -{ - this->sample_size = sample_size; -} - std::string InputData::getSNPFilepath() const { return this->snp_vcf_filepath; @@ -197,16 +183,6 @@ std::string InputData::getAssemblyGaps() const return this->assembly_gaps; } -uint32_t InputData::getMinCNVLength() const -{ - return this->min_cnv_length; -} - -void InputData::setMinCNVLength(int min_cnv_length) -{ - this->min_cnv_length = (uint32_t) min_cnv_length; -} - void InputData::setDBSCAN_Epsilon(double epsilon) { this->dbscan_epsilon = epsilon; @@ -360,7 +336,6 @@ void InputData::setHMMFilepath(std::string filepath) exit(1); } else { this->hmm_filepath = filepath; - std::cout << "Using HMM file: " << this->hmm_filepath << std::endl; } } } diff --git a/src/main.cpp b/src/main.cpp index dc9b110f..bd37ce54 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,5 @@ -#include "swig_interface.h" +#include "contextsv.h" /// @cond DOXYGEN_IGNORE #include @@ -71,12 +71,6 @@ void runContextSV(const std::unordered_map& args) if (args.find("hmm-file") != args.end()) { input_data.setHMMFilepath(args.at("hmm-file")); } - if (args.find("sample-size") != args.end()) { - input_data.setSampleSize(std::stoi(args.at("sample-size"))); - } - if (args.find("min-cnv") != args.end()) { - input_data.setMinCNVLength(std::stoi(args.at("min-cnv"))); - } if (args.find("eth") != args.end()) { input_data.setEthnicity(args.at("eth")); } @@ -115,8 +109,42 @@ void runContextSV(const std::unordered_map& args) std::cout << "Saving CNV data to: " << json_filepath << std::endl; } + // Print all parameters being used + std::cout << "\n========================================" << std::endl; + std::cout << "ContextSV Parameters:" << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Input BAM: " << input_data.getLongReadBam() << std::endl; + std::cout << "Reference genome: " << input_data.getRefGenome() << std::endl; + std::cout << "SNP VCF file: " << input_data.getSNPFilepath() << std::endl; + std::cout << "Output directory: " << input_data.getOutputDir() << std::endl; + std::cout << "HMM file: " << input_data.getHMMFilepath() << std::endl; + std::cout << "Thread count: " << input_data.getThreadCount() << std::endl; + std::cout << "DBSCAN epsilon: " << input_data.getDBSCAN_Epsilon() << std::endl; + std::cout << "DBSCAN min pts pct: " << input_data.getDBSCAN_MinPtsPct() << std::endl; + if (!input_data.getEthnicity().empty()) { + std::cout << "Ethnicity: " << input_data.getEthnicity() << std::endl; + } + if (args.find("pfb-file") != args.end()) { + std::cout << "PFB file: " << args.at("pfb-file") << std::endl; + } + if (!input_data.getAssemblyGaps().empty()) { + std::cout << "Assembly gaps: " << input_data.getAssemblyGaps() << std::endl; + } + std::cout << "Save CNV data: " << (input_data.getSaveCNVData() ? "true" : "false") << std::endl; + std::cout << "Verbose mode: " << (input_data.getVerbose() ? "true" : "false") << std::endl; + std::cout << "========================================\n" << std::endl; + // Run ContextSV - run(input_data); + ContextSV contextsv; + try + { + contextsv.run(input_data); + } + catch (std::exception& e) + { + std::cerr << e.what() << std::endl; + exit(1); + } } void printUsage(const std::string& programName) { @@ -129,8 +157,6 @@ void printUsage(const std::string& programName) { << " -o, --outdir Output directory (required)\n" << " -t, --threads Number of threads\n" << " -h, --hmm HMM file\n" - << " -n, --sample-size Sample size for HMM predictions\n" - << " --min-cnv Minimum CNV length\n" << " --eps DBSCAN epsilon\n" << " --min-pts-pct Percentage of mean chr. coverage to use for DBSCAN minimum points\n" << " -e, --eth ETH file\n" @@ -164,10 +190,6 @@ std::unordered_map parseArguments(int argc, char* argv args["thread-count"] = argv[++i]; } else if ((arg == "-h" || arg == "--hmm") && i + 1 < argc) { args["hmm-file"] = argv[++i]; - } else if ((arg == "-n" || arg == "--sample-size") && i + 1 < argc) { - args["sample-size"] = argv[++i]; - } else if (arg == "--min-cnv" && i + 1 < argc) { - args["min-cnv"] = argv[++i]; } else if (arg == "--min-reads" && i + 1 < argc) { args["min-reads"] = argv[++i]; } else if (arg == "--eps" && i + 1 < argc) { diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index 52160656..bac11b4b 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -231,7 +231,7 @@ void SVCaller::findSplitSVSignatures(std::unordered_map= min_length && sv_length <= max_length) { + // Require higher cluster size for large SVs (>100kb) to reduce false positives + if (sv_length > 100000 && cluster_size < 10) { + continue; + } SVEvidenceFlags aln_type; aln_type.set(static_cast(SVDataType::SPLIT)); SVCall sv_candidate(sv_start, sv_end, sv_type, alt, aln_type, Genotype::UNKNOWN, 0.0, 0, 0, cluster_size); @@ -587,7 +591,7 @@ void SVCaller::processCIGARRecord(bam_hdr_t *header, bam1_t *alignment, std::vec cigar_sv_calls.emplace_back(sv_call); // Process clipped bases as potential insertions - } else if (op == BAM_CSOFT_CLIP) { + } else if (op == BAM_CSOFT_CLIP && op_len >= 100) { // Increased from 50 to reduce false positives // Soft-clipped bases are considered as potential insertions // Skip if the position exceeds the reference genome length if (pos + 1 >= pos_depth_map.size()) { @@ -797,8 +801,8 @@ void SVCaller::run(const InputData& input_data) if (chr_mean_cov_map.find(chr) != chr_mean_cov_map.end()) { valid_chr.push_back(chr); } - chromosomes = valid_chr; } + chromosomes = valid_chr; std::unordered_map> whole_genome_sv_calls; int current_chr = 0; int total_chr_count = chromosomes.size(); diff --git a/src/swig_interface.cpp b/src/swig_interface.cpp deleted file mode 100644 index 76eb2151..00000000 --- a/src/swig_interface.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#include "swig_interface.h" -#include "contextsv.h" - -/// @cond -#include -/// @endcond - - -// Run the CLI with the given parameters -int run(const InputData& input_data) -{ - // Run ContextSV - ContextSV contextsv; - try - { - contextsv.run(input_data); - } - - catch (std::exception& e) - { - std::cerr << e.what() << std::endl; - return -1; - } - - return 0; -} diff --git a/src/swig_wrapper.i b/src/swig_wrapper.i deleted file mode 100644 index 62903afe..00000000 --- a/src/swig_wrapper.i +++ /dev/null @@ -1,22 +0,0 @@ -/* -SWIG wrapper for C++ code. -*/ - -%module contextsv - -// Include header -%{ -#include "swig_interface.h" -#include "input_data.h" -%} - -// Set up types -%include "std_string.i" -%include "stdint.i" - -// Set up the namespace -%include "input_data.h" - -// Include functions -int run(InputData input_data); - diff --git a/tests/test_general.py b/tests/test_general.py index c1c59da7..525e6dd2 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -90,8 +90,6 @@ def test_run_basic(): "--hmm", HMM_FILE, "--eth", "nfe", "--pfb", PFB_FILE, - "--sample-size", "20", - "--min-cnv", "2000", "--eps", "0.1", "--min-pts-pct", "0.1", "--assembly-gaps", GAP_FILE, From 3bae2d84ce189604ddf1fa06bd5601498ea9a624 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Sat, 7 Mar 2026 00:16:39 -0500 Subject: [PATCH 28/49] removed chr param from unit test --- tests/test_general.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_general.py b/tests/test_general.py index 525e6dd2..9efc37b1 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -93,7 +93,6 @@ def test_run_basic(): "--eps", "0.1", "--min-pts-pct", "0.1", "--assembly-gaps", GAP_FILE, - "--chr", "chr3", "--save-cnv", "--debug" ], From b011222d99a32219f89125ecd1743a6588a1a41e Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Sat, 7 Mar 2026 00:29:29 -0500 Subject: [PATCH 29/49] performance improvements --- Makefile | 4 +- include/input_data.h | 4 + src/cnv_caller.cpp | 317 ++++++++++++++++++++++++++++++------------- src/input_data.cpp | 10 ++ src/main.cpp | 3 + src/sv_caller.cpp | 207 ++++++++++++++++------------ src/sv_object.cpp | 5 +- 7 files changed, 357 insertions(+), 193 deletions(-) diff --git a/Makefile b/Makefile index a11470a0..0dd1e883 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ CONDA_LIB_DIR := $(CONDA_PREFIX)/lib # Compiler and Flags CXX := g++ -CXXFLAGS := -std=c++17 -g -I$(INCL_DIR) -I$(CONDA_INCL_DIR) -Wall -Wextra -pedantic +CXXFLAGS := -std=c++17 -O3 -DNDEBUG -I$(INCL_DIR) -I$(CONDA_INCL_DIR) -Wall -Wextra -pedantic # Linker Flags # Ensure that the library paths are set correctly for linking @@ -27,7 +27,7 @@ TARGET := $(BUILD_DIR)/contextsv all: $(TARGET) # Debug target -debug: CXXFLAGS += -DDEBUG +debug: CXXFLAGS := -std=c++17 -g -O0 -DDEBUG -I$(INCL_DIR) -I$(CONDA_INCL_DIR) -Wall -Wextra -pedantic debug: all # Link the executable diff --git a/include/input_data.h b/include/input_data.h index 6a028d53..bc300208 100644 --- a/include/input_data.h +++ b/include/input_data.h @@ -55,6 +55,10 @@ class InputData { void setAssemblyGaps(std::string filepath); std::string getAssemblyGaps() const; + // Set/get a target chromosome for single-chromosome analysis. + void setChromosome(std::string chr); + std::string getChromosome() const; + // Set the epsilon parameter for DBSCAN clustering. void setDBSCAN_Epsilon(double epsilon); double getDBSCAN_Epsilon() const; diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index 45e31a0a..9272e1b9 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -19,6 +19,7 @@ #include #include // Progress bar #include // std::iota +#include #include #include #include @@ -76,28 +77,47 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end // Loop through evenly spaced positions in the region and get the log2 ratio double pos_step = static_cast(end_pos - start_pos + 1) / static_cast(sample_size); - std::unordered_map window_log2_map; size_t depth_map_size = pos_depth_map.size(); // Cache size for bounds checking - + + // Keep windows in deterministic genomic order and avoid string key conversions + std::vector window_starts; + std::vector window_ends; + std::vector window_log2; + window_starts.reserve(sample_size); + window_ends.reserve(sample_size); + window_log2.reserve(sample_size); + for (int i = 0; i < sample_size; i++) { - uint32_t window_start = (uint32_t) (start_pos + i * pos_step); - uint32_t window_end = (uint32_t) (start_pos + (i + 1) * pos_step); + uint32_t window_start = static_cast(start_pos + i * pos_step); + uint32_t window_end = static_cast(start_pos + (i + 1) * pos_step); + + if (window_start > end_pos) + { + window_start = end_pos; + } + if (window_end > end_pos) + { + window_end = end_pos; + } + if (window_end < window_start) + { + window_end = window_start; + } // Calculate the mean depth for the window double cov_sum = 0.0; int pos_count = 0; - int max_steps = (int)pos_step + 1; // Convert double to int with safety margin - for (int j = 0; j < max_steps; j++) + if (depth_map_size > 0 && window_start < depth_map_size) { - uint32_t pos = (uint32_t) (start_pos + i * pos_step + j); - if (pos > end_pos || pos >= depth_map_size) + uint32_t bounded_end = std::min(window_end, static_cast(depth_map_size - 1)); + for (uint32_t pos = window_start; pos <= bounded_end; pos++) { - break; + cov_sum += pos_depth_map[pos]; + pos_count++; } - cov_sum += pos_depth_map[pos]; - pos_count++; } + double log2_cov = 0.0; if (pos_count > 0) { @@ -106,12 +126,12 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end // Use a small value to avoid division by zero cov_sum = 1e-9; } - log2_cov = log2((cov_sum / (double) pos_count) / mean_chr_cov); + log2_cov = log2((cov_sum / static_cast(pos_count)) / mean_chr_cov); } - // Store the log2 ratio for the window - std::string window_key = std::to_string(window_start) + "-" + std::to_string(window_end); - window_log2_map[window_key] = log2_cov; + window_starts.push_back(window_start); + window_ends.push_back(window_end); + window_log2.push_back(log2_cov); } // Create new vectors for the SNP data @@ -121,28 +141,62 @@ void CNVCaller::querySNPRegion(std::string chr, uint32_t start_pos, uint32_t end std::vector snp_log2_hmm; std::vector is_snp_hmm; - // Loop through the window ranges and append all SNPs in the range, using - // the log2 ratio for the window - for (const auto& window : window_log2_map) + size_t reserve_hint = std::max(static_cast(sample_size), snp_pos.size()); + snp_pos_hmm.reserve(reserve_hint); + snp_baf_hmm.reserve(reserve_hint); + snp_pfb_hmm.reserve(reserve_hint); + snp_log2_hmm.reserve(reserve_hint); + is_snp_hmm.reserve(reserve_hint); + + // Loop through the window ranges and append SNPs in each range, using + // the log2 ratio for the window. Use a two-pointer scan to avoid + // O(num_windows * num_snps) behavior. + size_t snp_idx = 0; + for (size_t w = 0; w < window_starts.size(); w++) { - uint32_t window_start = std::stoi(window.first.substr(0, window.first.find('-'))); - uint32_t window_end = std::stoi(window.first.substr(window.first.find('-') + 1)); - double log2_cov = window.second; + uint32_t window_start = window_starts[w]; + uint32_t window_end = window_ends[w]; + double log2_cov = window_log2[w]; + + while (snp_idx < snp_pos.size() && snp_pos[snp_idx] < window_start) + { + snp_idx++; + } - // Loop through the SNP positions and add them to the SNP data bool snp_found = false; - for (uint32_t pos : snp_pos) + size_t local_idx = snp_idx; + while (local_idx < snp_pos.size() && snp_pos[local_idx] <= window_end) { - if (pos >= window_start && pos <= window_end) + uint32_t pos = snp_pos[local_idx]; + double baf = -1.0; + double pfb = 0.5; + + auto baf_it = snp_baf_map.find(pos); + if (baf_it != snp_baf_map.end()) + { + baf = baf_it->second; + } + + auto pfb_it = snp_pfb_map.find(pos); + if (pfb_it != snp_pfb_map.end()) { - snp_pos_hmm.push_back(pos); - snp_baf_hmm.push_back(snp_baf_map[pos]); - snp_pfb_hmm.push_back(snp_pfb_map[pos]); - snp_log2_hmm.push_back(log2_cov); - is_snp_hmm.push_back(true); - snp_found = true; + pfb = pfb_it->second; } + + snp_pos_hmm.push_back(pos); + snp_baf_hmm.push_back(baf); + snp_pfb_hmm.push_back(pfb); + snp_log2_hmm.push_back(log2_cov); + is_snp_hmm.push_back(true); + snp_found = true; + local_idx++; } + + if (snp_found) + { + snp_idx = local_idx; + } + if (!snp_found) { // If no SNPs were found in the window, add a dummy SNP with the @@ -212,14 +266,23 @@ std::tuple CNVCaller::runCopyNumberPrediction(std std::vector& state_sequence = prediction.first; double likelihood = prediction.second; - // Get state percentages + // Get state percentages (single pass) + std::array state_counts = {0, 0, 0, 0, 0, 0, 0}; + for (int state : state_sequence) + { + if (state >= 1 && state <= 6) + { + state_counts[state]++; + } + } + std::unordered_map state_pct; - double state_count = (double) state_sequence.size(); + double state_count = static_cast(state_sequence.size()); double largest_non_neutral_pct = 0.0; int non_neutral_state = 0; for (int i = 0; i < 6; i++) { - state_pct[i+1] = (double)std::count(state_sequence.begin(), state_sequence.end(), i+1) / state_count; + state_pct[i+1] = static_cast(state_counts[i+1]) / state_count; if (i+1 != 3 && state_pct[i+1] > largest_non_neutral_pct) { largest_non_neutral_pct = state_pct[i+1]; @@ -337,33 +400,43 @@ void CNVCaller::runCIGARCopyNumberPrediction(std::string chr, std::vector& state_sequence = prediction.first; double likelihood = prediction.second; - // Get all the states in the SV region - std::vector sv_states; + // Get state counts in the SV region (single pass) + std::array sv_state_counts = {0, 0, 0, 0, 0, 0, 0}; + int state_count = 0; for (size_t i = 0; i < state_sequence.size(); i++) { if (snp_data.pos[i] >= start_pos && snp_data.pos[i] <= end_pos) { - sv_states.push_back(state_sequence[i]); + int state = state_sequence[i]; + if (state >= 1 && state <= 6) + { + sv_state_counts[state]++; + state_count++; + } } } + if (state_count == 0) + { + continue; + } + // Determine if there is a majority state within the SV region and if it // is greater than 50% int max_state = 0; int max_count = 0; for (int i = 0; i < 6; i++) { - int state_count = std::count(sv_states.begin(), sv_states.end(), i+1); - if (state_count > max_count) + int count_i = sv_state_counts[i+1]; + if (count_i > max_count) { max_state = i+1; - max_count = state_count; + max_count = count_i; } } // If there is no majority state, then set the state to unknown double pct_threshold = 0.50; - int state_count = (int) sv_states.size(); if ((double) max_count / (double) state_count < pct_threshold) { max_state = 0; @@ -489,6 +562,11 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& printError("ERROR: Chromosome length mismatch for " + chr + ": expected " + std::to_string(chr_length) + ", found " + std::to_string(pos_depth_map.size()) + ", resizing to " + std::to_string(chr_length)); pos_depth_map.resize(chr_length, 0); } + + // Difference-array depth accumulation: O(#CIGAR ops + chr_len) + // instead of O(total aligned bases) + std::vector depth_delta(pos_depth_map.size() + 1, 0); + while (sam_itr_next(bam_file, bam_iter, bam_record) >= 0) { // Ignore UNMAP, SECONDARY, QCFAIL, and DUP reads @@ -510,15 +588,17 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& uint32_t op_len = bam_cigar_oplen(cigar[i]); if (op == BAM_CMATCH || op == BAM_CEQUAL || op == BAM_CDIFF) { - // Update the depth for each position in the alignment - for (uint32_t j = 0; j < op_len; j++) + if (ref_pos < pos_depth_map.size()) { - if (ref_pos + j >= pos_depth_map.size()) + uint32_t start_cov = ref_pos; + uint64_t end_cov_u64 = static_cast(ref_pos) + static_cast(op_len) - 1; + uint32_t end_cov = static_cast(std::min(end_cov_u64, static_cast(pos_depth_map.size() - 1))); + + depth_delta[start_cov] += 1; + if (static_cast(end_cov + 1) < depth_delta.size()) { - printError("ERROR: Reference position out of range for " + chr + ":" + std::to_string(ref_pos+j)); - continue; + depth_delta[end_cov + 1] -= 1; } - pos_depth_map[ref_pos + j]++; } } @@ -535,8 +615,29 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& } hts_itr_destroy(bam_iter); - uint64_t cum_depth = std::accumulate(pos_depth_map.begin(), pos_depth_map.end(), 0ULL); - uint32_t pos_count = std::count_if(pos_depth_map.begin(), pos_depth_map.end(), [](uint32_t depth) { return depth > 0; }); + uint64_t cum_depth = 0; + uint32_t pos_count = 0; + int64_t running_depth = 0; + if (!pos_depth_map.empty()) + { + pos_depth_map[0] = 0; + } + for (size_t pos = 1; pos < pos_depth_map.size(); pos++) + { + running_depth += depth_delta[pos]; + if (running_depth < 0) + { + running_depth = 0; + } + + uint32_t depth = static_cast(running_depth); + pos_depth_map[pos] = depth; + cum_depth += depth; + if (depth > 0) + { + pos_count++; + } + } // Calculate the mean coverage for the chromosome double mean_chr_cov = (pos_count > 0) ? static_cast(cum_depth) / static_cast(pos_count) : 0.0; @@ -561,8 +662,20 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, uint32_t end_pos, std::vector& snp_pos, std::unordered_map& snp_baf, std::unordered_map& snp_pfb, const InputData& input_data) const { - // Lock during reading - std::shared_lock lock(this->shared_mutex); + struct ReaderCache { + bcf_srs_t* reader = nullptr; + std::string filepath; + int thread_count = -1; + + ~ReaderCache() { + if (reader) { + bcf_sr_destroy(reader); + } + } + }; + + thread_local ReaderCache snp_cache; + thread_local ReaderCache pfb_cache; // --------- SNP file --------- const std::string snp_filepath = input_data.getSNPFilepath(); @@ -572,24 +685,52 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui return; } - // Initialize the SNP file reader - bcf_srs_t *snp_reader = bcf_sr_init(); - if (!snp_reader) - { - printError("ERROR: Could not initialize SNP reader."); - return; - } - snp_reader->require_index = 1; - - // Use multi-threading if not threading by chromosome int thread_count = input_data.getThreadCount(); - bcf_sr_set_threads(snp_reader, thread_count); + auto get_cached_reader = [&](ReaderCache& cache, const std::string& filepath, const std::string& label) -> bcf_srs_t* { + if (filepath.empty()) { + return nullptr; + } - // Add the SNP file to the reader - if (bcf_sr_add_reader(snp_reader, snp_filepath.c_str()) < 0) + bool needs_reload = (cache.reader == nullptr) || (cache.filepath != filepath) || (cache.thread_count != thread_count); + if (needs_reload) + { + if (cache.reader) + { + bcf_sr_destroy(cache.reader); + cache.reader = nullptr; + } + + cache.reader = bcf_sr_init(); + if (!cache.reader) + { + printError("ERROR: Could not initialize " + label + " reader."); + return nullptr; + } + cache.reader->require_index = 1; + + // Add the file to the reader + if (bcf_sr_add_reader(cache.reader, filepath.c_str()) < 0) + { + printError("ERROR: Could not add " + label + " file to reader: " + filepath); + bcf_sr_destroy(cache.reader); + cache.reader = nullptr; + cache.filepath.clear(); + cache.thread_count = -1; + return nullptr; + } + + bcf_sr_set_threads(cache.reader, thread_count); + cache.filepath = filepath; + cache.thread_count = thread_count; + } + + return cache.reader; + }; + + // Initialize/reuse the SNP file reader + bcf_srs_t *snp_reader = get_cached_reader(snp_cache, snp_filepath, "SNP"); + if (!snp_reader) { - bcf_sr_destroy(snp_reader); - printError("ERROR: Could not add SNP file to reader: " + snp_filepath); return; } @@ -612,7 +753,7 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui } pfb_file.close(); - bcf_srs_t *pfb_reader = bcf_sr_init(); + bcf_srs_t *pfb_reader = nullptr; std::string chr_gnomad = chr; std::string AF_key; if (use_pfb) @@ -643,31 +784,12 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui } } - // Initialize the population allele frequency reader + // Initialize/reuse the population allele frequency reader + pfb_reader = get_cached_reader(pfb_cache, pfb_filepath, "population allele frequency"); if (!pfb_reader) { - printError("ERROR: Could not initialize population allele frequency reader."); - - // Clean up - bcf_sr_destroy(snp_reader); - return; - } - pfb_reader->require_index = 1; - - // Add the population allele frequency file to the reader - if (bcf_sr_add_reader(pfb_reader, pfb_filepath.c_str()) < 0) - { - printError("ERROR: Could not add population allele frequency file to reader: " + pfb_filepath); - - // Clean up - bcf_sr_destroy(pfb_reader); - bcf_sr_destroy(snp_reader); - return; + use_pfb = false; } - - // Use multi-threading if not threading by chromosome - int thread_count = input_data.getThreadCount(); - bcf_sr_set_threads(pfb_reader, thread_count); } // Read the SNP data @@ -677,8 +799,6 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui if (bcf_sr_set_regions(snp_reader, region_str.c_str(), 0) < 0) //chr.c_str(), 0) < 0) { printError("ERROR: Could not set region for SNP reader: " + chr); - bcf_sr_destroy(snp_reader); - bcf_sr_destroy(pfb_reader); return; } @@ -710,8 +830,12 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui int32_t *dp = 0; int dp_count = 0; int dp_ret = bcf_get_format_int32(snp_reader->readers[0].header, snp_record, "DP", &dp, &dp_count); - if (dp_ret < 0 || dp[0] <= 10) + if (dp_ret < 0 || dp_count == 0 || dp[0] <= 10) { + if (dp) + { + free(dp); + } continue; } free(dp); @@ -728,6 +852,10 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui int ad_ret = bcf_get_format_int32(snp_reader->readers[0].header, snp_record, "AD", &ad, &ad_count); if (ad_ret < 0 || ad_count < 2) { + if (ad) + { + free(ad); + } continue; } @@ -750,8 +878,6 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui // Continue if no SNP was found in the region if (!snp_found) { - bcf_sr_destroy(snp_reader); - bcf_sr_destroy(pfb_reader); return; } @@ -802,14 +928,9 @@ void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, ui continue; } snp_pfb[pfb_pos] = pfb; - break; } free(pfb_f); } - - // Clean up - bcf_sr_destroy(snp_reader); - bcf_sr_destroy(pfb_reader); } void CNVCaller::saveSVCopyNumberToJSON(SNPData &before_sv, SNPData &after_sv, SNPData &snp_data, std::string chr, uint32_t start, uint32_t end, std::string sv_type, double likelihood, const std::string& filepath) const diff --git a/src/input_data.cpp b/src/input_data.cpp index de126619..51cbfe57 100644 --- a/src/input_data.cpp +++ b/src/input_data.cpp @@ -183,6 +183,16 @@ std::string InputData::getAssemblyGaps() const return this->assembly_gaps; } +void InputData::setChromosome(std::string chr) +{ + this->chr = chr; +} + +std::string InputData::getChromosome() const +{ + return this->chr; +} + void InputData::setDBSCAN_Epsilon(double epsilon) { this->dbscan_epsilon = epsilon; diff --git a/src/main.cpp b/src/main.cpp index bd37ce54..9d63919a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -80,6 +80,9 @@ void runContextSV(const std::unordered_map& args) if (args.find("assembly-gaps") != args.end()) { input_data.setAssemblyGaps(args.at("assembly-gaps")); } + if (args.find("chr") != args.end()) { + input_data.setChromosome(args.at("chr")); + } if (args.find("save-cnv") != args.end()) { input_data.saveCNVData(true); } diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index bac11b4b..d6fcdcb7 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -37,9 +37,7 @@ int SVCaller::readNextAlignment(samFile *fp_in, hts_itr_t *itr, bam1_t *bam1) { - std::shared_lock lock(this->shared_mutex); - int ret = sam_itr_next(fp_in, itr, bam1); - return ret; + return sam_itr_next(fp_in, itr, bam1); } std::vector SVCaller::getChromosomes(const std::string &bam_filepath) @@ -97,110 +95,108 @@ void SVCaller::findSplitSVSignatures(std::unordered_map> primary_map; // TID-> qname -> primary alignment - std::unordered_map> supp_map; // qname -> supplementary alignment - bam1_t *bam1 = bam_init1(); if (!bam1) { printError("ERROR: failed to initialize BAM record"); + bam_hdr_destroy(bamHdr); + hts_idx_destroy(idx); + sam_close(fp_in); return; } - - // Set the region to the whole genome, or a user-specified chromosome - hts_itr_t *itr = nullptr; - - itr = sam_itr_queryi(idx, HTS_IDX_START, 0, 0); - if (!itr) { - bam_destroy1(bam1); - printError("ERROR: failed to create iterator for the whole genome"); - return; + // Build chromosome list (single chromosome if requested) + std::vector chromosomes; + const std::string target_chr = input_data.getChromosome(); + if (!target_chr.empty()) { + if (bam_name2id(bamHdr, target_chr.c_str()) < 0) { + printError("ERROR: Requested chromosome " + target_chr + " not found in BAM header"); + bam_destroy1(bam1); + bam_hdr_destroy(bamHdr); + hts_idx_destroy(idx); + sam_close(fp_in); + return; + } + chromosomes.push_back(target_chr); + } else { + chromosomes.reserve(static_cast(bamHdr->n_targets)); + for (int i = 0; i < bamHdr->n_targets; i++) { + chromosomes.push_back(bamHdr->target_name[i]); + } } - uint32_t primary_count = 0; - uint32_t supplementary_count = 0; + printMessage("Processing split-read alignments from " + bam_filepath); + int current_chr = 0; + int total_chr = static_cast(chromosomes.size()); - // Main loop to process the alignments - printMessage("Processing alignments from " + bam_filepath); - uint32_t num_alignments = 0; - std::unordered_set alignment_tids; // All unique chromosome IDs - std::unordered_set supp_qnames; // All unique query names - while (readNextAlignment(fp_in, itr, bam1) >= 0) { + for (const auto& chr_name : chromosomes) { + current_chr++; + int primary_tid = bam_name2id(bamHdr, chr_name.c_str()); + if (primary_tid < 0) { + printError("ERROR: Chromosome " + chr_name + " not found in BAM header"); + continue; + } + + // Per-chromosome maps to avoid whole-genome materialization + std::unordered_map chr_primary_map; + std::unordered_map> supp_map; + std::unordered_set supp_qnames; - // Skip secondary and unmapped alignments, duplicates, QC failures, and low mapping quality - if (bam1->core.flag & BAM_FSECONDARY || bam1->core.flag & BAM_FUNMAP || bam1->core.flag & BAM_FDUP || bam1->core.flag & BAM_FQCFAIL || bam1->core.qual < this->min_mapq) { + hts_itr_t* itr = sam_itr_querys(idx, bamHdr, chr_name.c_str()); + if (!itr) { + printError("ERROR: failed to query chromosome " + chr_name); continue; } - const std::string qname = bam_get_qname(bam1); // Query template name - // Process primary alignments - if (!(bam1->core.flag & BAM_FSUPPLEMENTARY)) { - // Store chromosome (TID), start, and end positions (1-based) of the - // primary alignment, and the strand (true for forward, false for - // reverse) - std::pair qpos = getAlignmentReadPositions(bam1); + uint32_t num_alignments = 0; + while (readNextAlignment(fp_in, itr, bam1) >= 0) { - primary_map[bam1->core.tid][qname] = PrimaryAlignment{static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE), 0}; - alignment_tids.insert(bam1->core.tid); - primary_count++; + // Skip secondary and unmapped alignments, duplicates, QC failures, and low mapping quality + if (bam1->core.flag & BAM_FSECONDARY || bam1->core.flag & BAM_FUNMAP || bam1->core.flag & BAM_FDUP || bam1->core.flag & BAM_FQCFAIL || bam1->core.qual < this->min_mapq) { + continue; + } + const std::string qname = bam_get_qname(bam1); // Query template name - // Process supplementary alignments - } else if (bam1->core.flag & BAM_FSUPPLEMENTARY) { - // Store chromosome (TID), start, and end positions (1-based) of the - // supplementary alignment, and the strand (true for forward, false - // for reverse) std::pair qpos = getAlignmentReadPositions(bam1); - supp_map[qname].push_back(SuppAlignment{bam1->core.tid, static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE)}); - alignment_tids.insert(bam1->core.tid); - supp_qnames.insert(qname); - supplementary_count++; - } - num_alignments++; - if (num_alignments % 1000000 == 0) { - printMessage("Processed " + std::to_string(num_alignments) + " alignments"); - } - } + // Process primary alignments + if (!(bam1->core.flag & BAM_FSUPPLEMENTARY)) { + chr_primary_map[qname] = PrimaryAlignment{static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE), 0}; - // Clean up the iterator and alignment - hts_itr_destroy(itr); - bam_destroy1(bam1); + // Process supplementary alignments + } else { + supp_map[qname].push_back(SuppAlignment{bam1->core.tid, static_cast(bam1->core.pos + 1), static_cast(bam_endpos(bam1)), static_cast(qpos.first), static_cast(qpos.second), !(bam1->core.flag & BAM_FREVERSE)}); + supp_qnames.insert(qname); + } + num_alignments++; - // Clean up the BAM file and index - sam_close(fp_in); - hts_idx_destroy(idx); - // bam_hdr_destroy(bamHdr); - - // Remove primary alignments without supplementary alignments - std::unordered_map> to_remove; - for (auto& chr_primary : primary_map) { - std::unordered_set qnames; - for (const auto& entry : chr_primary.second) { - if (supp_qnames.find(entry.first) == supp_qnames.end()) { - to_remove[chr_primary.first].insert(entry.first); + if (num_alignments % 1000000 == 0) { + printMessage("(" + std::to_string(current_chr) + "/" + std::to_string(total_chr) + ") " + chr_name + ": Processed " + std::to_string(num_alignments) + " alignments"); + } + } + hts_itr_destroy(itr); + + // Remove primary alignments without supplementary alignments + int removed = 0; + for (auto it = chr_primary_map.begin(); it != chr_primary_map.end();) { + if (supp_qnames.find(it->first) == supp_qnames.end()) { + it = chr_primary_map.erase(it); + removed++; + } else { + ++it; } } - } - int total_removed = 0; - for (auto& chr_primary : primary_map) { - // Remove the qnames from the primary map - total_removed += to_remove[chr_primary.first].size(); - for (const auto& qname : to_remove[chr_primary.first]) { - chr_primary.second.erase(qname); + if (removed > 0) { + printMessage(chr_name + ": Removed " + std::to_string(removed) + " primary alignments without supplementary alignments"); } - } - printMessage("Removed " + std::to_string(total_removed) + " primary alignments without supplementary alignments"); - // Process the primary alignments and find SVs - for (const auto& chr_primary : primary_map) { - int primary_tid = chr_primary.first; - std::string chr_name = bamHdr->target_name[primary_tid]; - printMessage("Processing chromosome " + chr_name + " with " + std::to_string(chr_primary.second.size()) + " primary alignments"); + printMessage("(" + std::to_string(current_chr) + "/" + std::to_string(total_chr) + ") Processing chromosome " + chr_name + " with " + std::to_string(chr_primary_map.size()) + " primary alignments"); + + if (chr_primary_map.empty()) { + continue; + } std::vector chr_sv_calls; chr_sv_calls.reserve(1000); - const std::unordered_map& chr_primary_map = chr_primary.second; // Identify overlapping primary alignments and cluster endpoints std::unique_ptr root = nullptr; @@ -240,7 +236,11 @@ void SVCaller::findSplitSVSignatures(std::unordered_map& supp_alns = supp_map[qname]; + auto supp_it = supp_map.find(qname); + if (supp_it == supp_map.end()) { + continue; + } + const std::vector& supp_alns = supp_it->second; bool primary_strand = chr_primary_map.at(qname).strand; bool has_opposite_strand = false; for (const SuppAlignment& supp_aln : supp_alns) { @@ -292,7 +292,11 @@ void SVCaller::findSplitSVSignatures(std::unordered_map ref_distances; for (const std::string& qname : primary_cluster) { const PrimaryAlignment& primary_aln = chr_primary_map.at(qname); - const std::vector& supp_alns = supp_map.at(qname); + auto supp_it = supp_map.find(qname); + if (supp_it == supp_map.end()) { + continue; + } + const std::vector& supp_alns = supp_it->second; for (const SuppAlignment& supp_aln : supp_alns) { if (supp_aln.tid == primary_tid) { // Same chromosome @@ -494,8 +498,11 @@ void SVCaller::findSplitSVSignatures(std::unordered_map& sv_calls, const std::vector& pos_depth_map) @@ -762,6 +769,17 @@ void SVCaller::run(const InputData& input_data) // Get the chromosomes std::vector chromosomes = this->getChromosomes(input_data.getLongReadBam()); + + // Restrict to a single chromosome if requested + const std::string target_chr = input_data.getChromosome(); + if (!target_chr.empty()) { + auto chr_it = std::find(chromosomes.begin(), chromosomes.end(), target_chr); + if (chr_it == chromosomes.end()) { + printError("Requested chromosome " + target_chr + " not found in BAM header"); + return; + } + chromosomes = {target_chr}; + } // Read the HMM from the file std::string hmm_filepath = input_data.getHMMFilepath(); @@ -782,15 +800,23 @@ void SVCaller::run(const InputData& input_data) int chr_thread_count = input_data.getThreadCount(); // Initialize the chromosome position depth map and mean coverage map + // (skip chromosomes missing from the reference instead of aborting) + std::vector ref_valid_chromosomes; for (const auto& chr : chromosomes) { uint32_t chr_len = ref_genome.getChromosomeLength(chr); if (chr_len == 0) { - printError("Chromosome " + chr + " not found in reference genome"); - return; - // continue; + printError("Chromosome " + chr + " not found in reference genome, skipping"); + continue; } chr_pos_depth_map[chr] = std::vector(chr_len+1, 0); // 1-based index chr_mean_cov_map[chr] = 0.0; + ref_valid_chromosomes.push_back(chr); + } + + chromosomes = std::move(ref_valid_chromosomes); + if (chromosomes.empty()) { + printError("No chromosomes with reference sequence were available for processing"); + return; } cnv_caller.calculateMeanChromosomeCoverage(chromosomes, chr_pos_depth_map, chr_mean_cov_map, bam_filepath, chr_thread_count); @@ -820,7 +846,7 @@ void SVCaller::run(const InputData& input_data) InputData chr_input_data = input_data; // Use a thread-local copy this->processChromosome(chr, sv_calls, chr_input_data, chr_pos_depth_map[chr], chr_mean_cov_map[chr]); { - std::shared_lock lock(this->shared_mutex); + std::unique_lock lock(this->shared_mutex); whole_genome_sv_calls[chr] = std::move(sv_calls); } } catch (const std::exception& e) { @@ -945,8 +971,9 @@ void SVCaller::findOverlaps(const std::unique_ptr &root, const Pri if (root->left && root->left->max_end >= query.start) findOverlaps(root->left, query, result); - // Always check the right subtree - findOverlaps(root->right, query, result); + // Check right subtree only when the query can overlap intervals there + if (root->right && query.end >= root->region.start) + findOverlaps(root->right, query, result); } void SVCaller::insert(std::unique_ptr &root, const PrimaryAlignment ®ion, std::string qname) diff --git a/src/sv_object.cpp b/src/sv_object.cpp index b68ef3c7..8016c35b 100644 --- a/src/sv_object.cpp +++ b/src/sv_object.cpp @@ -27,9 +27,8 @@ void addSVCall(std::vector& sv_calls, SVCall& sv_call) return; } - // Insert the SV call in sorted order - auto it = std::lower_bound(sv_calls.begin(), sv_calls.end(), sv_call); - sv_calls.insert(it, sv_call); + // Append and defer sorting/merging to downstream steps + sv_calls.push_back(sv_call); } uint32_t getSVCount(const std::vector& sv_calls) From 855dcb59801139df64908e438bec6dc20467da6d Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Sun, 8 Mar 2026 14:27:16 -0400 Subject: [PATCH 30/49] improve cnv detection, remove debug code --- src/cnv_caller.cpp | 26 +++++++-- src/sv_caller.cpp | 134 ++++++++++++++++++++++++++++++--------------- src/sv_object.cpp | 85 +--------------------------- 3 files changed, 111 insertions(+), 134 deletions(-) diff --git a/src/cnv_caller.cpp b/src/cnv_caller.cpp index 9272e1b9..35bdbb73 100644 --- a/src/cnv_caller.cpp +++ b/src/cnv_caller.cpp @@ -370,6 +370,7 @@ void CNVCaller::runCIGARCopyNumberPrediction(std::string chr, std::vector end if (start_pos > end_pos) @@ -435,8 +436,16 @@ void CNVCaller::runCIGARCopyNumberPrediction(std::string chr, std::vectorDUP conversion in 10-50kb, + // where depth-driven relabeling is noisier. double pct_threshold = 0.50; + if (sv_call.sv_type == SVType::INS && + (max_state == 5 || max_state == 6) && + sv_length >= 10000 && sv_length <= 50000) + { + pct_threshold = 0.65; + } if ((double) max_count / (double) state_count < pct_threshold) { max_state = 0; @@ -451,6 +460,14 @@ void CNVCaller::runCIGARCopyNumberPrediction(std::string chr, std::vector CNVCaller::splitRegionIntoChunks(std::string chr, uint3 void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& chromosomes, std::unordered_map>& chr_pos_depth_map, std::unordered_map& chr_mean_cov_map, const std::string& bam_filepath, int thread_count) const { // Open the BAM file - printMessage("Opening BAM file: " + bam_filepath); samFile *bam_file = sam_open(bam_filepath.c_str(), "r"); if (!bam_file) { @@ -535,6 +551,7 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& } // Iterate through each chromosome and update the depth map + printMessage("Calculating mean chromosome coverage for copy number prediction..."); int current_chr = 0; int total_chr_count = chromosomes.size(); for (const std::string& chr : chromosomes) @@ -547,7 +564,7 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& continue; } - printMessage("(" + std::to_string(++current_chr) + "/" + std::to_string(total_chr_count) + ") Reading BAM file for chromosome: " + chr); + printMessage("(" + std::to_string(++current_chr) + "/" + std::to_string(total_chr_count) + ") Processing chromosome: " + chr); std::vector& pos_depth_map = chr_pos_depth_map[chr]; int tid = bam_name2id(bam_header, chr.c_str()); if (tid < 0) @@ -641,14 +658,12 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& // Calculate the mean coverage for the chromosome double mean_chr_cov = (pos_count > 0) ? static_cast(cum_depth) / static_cast(pos_count) : 0.0; - printMessage("Mean coverage for chromosome " + chr + ": " + std::to_string(mean_chr_cov)); if (mean_chr_cov != 0.0) { chr_mean_cov_map[chr] = mean_chr_cov; } } // Clean up the BAM file and index - printMessage("Closing BAM file " + bam_filepath); bam_destroy1(bam_record); hts_idx_destroy(bam_index); bam_hdr_destroy(bam_header); @@ -657,7 +672,6 @@ void CNVCaller::calculateMeanChromosomeCoverage(const std::vector& bam_index = nullptr; bam_header = nullptr; bam_file = nullptr; - printMessage("BAM file closed."); } void CNVCaller::readSNPAlleleFrequencies(std::string chr, uint32_t start_pos, uint32_t end_pos, std::vector& snp_pos, std::unordered_map& snp_baf, std::unordered_map& snp_pfb, const InputData& input_data) const diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index d6fcdcb7..c3eba00d 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -76,7 +76,6 @@ void SVCaller::findSplitSVSignatures(std::unordered_map(chromosomes.size()); @@ -167,10 +165,6 @@ void SVCaller::findSplitSVSignatures(std::unordered_map 0) { - printMessage(chr_name + ": Removed " + std::to_string(removed) + " primary alignments without supplementary alignments"); - } - - printMessage("(" + std::to_string(current_chr) + "/" + std::to_string(total_chr) + ") Processing chromosome " + chr_name + " with " + std::to_string(chr_primary_map.size()) + " primary alignments"); + printMessage("(" + std::to_string(current_chr) + "/" + std::to_string(total_chr) + ") Processing " + chr_name + " (" + std::to_string(chr_primary_map.size()) + " primary alignments)"); if (chr_primary_map.empty()) { continue; @@ -253,7 +243,13 @@ void SVCaller::findSplitSVSignatures(std::unordered_map(num_supp_opposite_strand) / static_cast(num_primary) > 0.5) { + double opposite_strand_ratio = (num_primary > 0) + ? static_cast(num_supp_opposite_strand) / static_cast(num_primary) + : 0.0; + + // Classify inversion when opposite-strand support is moderate-to-strong. + // This avoids missing true mid-size inversions that can have mixed strand evidence. + if (num_primary >= 3 && num_supp_opposite_strand >= 2 && opposite_strand_ratio >= 0.5) { inversion = true; } @@ -299,6 +295,14 @@ void SVCaller::findSplitSVSignatures(std::unordered_map& supp_alns = supp_it->second; for (const SuppAlignment& supp_aln : supp_alns) { if (supp_aln.tid == primary_tid) { + bool is_opposite_strand = supp_aln.strand != primary_aln.strand; + + // For inversion clusters, only keep opposite-strand supplementary + // alignments to avoid contaminating inversion breakpoint evidence. + if (inversion && !is_opposite_strand) { + continue; + } + // Same chromosome int read_distance = 0; int ref_distance = 0; @@ -445,12 +449,13 @@ void SVCaller::findSplitSVSignatures(std::unordered_map(SVDataType::SPLITDIST1)); if (split_candidate_sv) { int aln_offset = static_cast(ref_distance - read_distance); - if (read_distance > ref_distance && read_distance >= min_length && read_distance <= max_length) { + + if (read_distance > ref_distance && read_distance >= min_length && read_distance <= max_length) { // Add an insertion SV call at the 5'-most primary position SVType sv_type = SVType::INS; SVCall sv_candidate(sv_start, sv_start + (read_distance-1), sv_type, getSVTypeSymbol(sv_type), aln_type, Genotype::UNKNOWN, 0.0, 0, aln_offset, primary_cluster_size); addSVCall(chr_sv_calls, sv_candidate); - // } + // } } else if (ref_distance > read_distance && ref_distance >= min_length && ref_distance <= max_length) { // Set it to unknown, SV type will be determined by the @@ -472,12 +477,26 @@ void SVCaller::findSplitSVSignatures(std::unordered_map= min_length && sv_length <= max_length) { - // Require higher cluster size for large SVs (>100kb) to reduce false positives - if (sv_length > 100000 && cluster_size < 10) { - continue; + // Use balanced support for inversions. + // For non-inversions, keep large events even with sparse + // split-read support because >100kb SVs often have few + // spanning split reads. + int balanced_cluster_size = std::min(primary_cluster_size, supp_cluster_size); + if (sv_type == SVType::INV) { + const int INV_MIN_LENGTH = 500; + // Size-dependent cluster threshold: large inversions (>50kb) + // may have sparse split-read support, similar to other SV types + int min_cluster = (sv_length > 50000) ? 3 : 5; + if (sv_length < INV_MIN_LENGTH || balanced_cluster_size < min_cluster) { + continue; + } } SVEvidenceFlags aln_type; - aln_type.set(static_cast(SVDataType::SPLIT)); + if (sv_type == SVType::INV) { + aln_type.set(static_cast(SVDataType::SPLITINV)); + } else { + aln_type.set(static_cast(SVDataType::SPLIT)); + } SVCall sv_candidate(sv_start, sv_end, sv_type, alt, aln_type, Genotype::UNKNOWN, 0.0, 0, 0, cluster_size); addSVCall(chr_sv_calls, sv_candidate); } @@ -598,7 +617,7 @@ void SVCaller::processCIGARRecord(bam_hdr_t *header, bam1_t *alignment, std::vec cigar_sv_calls.emplace_back(sv_call); // Process clipped bases as potential insertions - } else if (op == BAM_CSOFT_CLIP && op_len >= 100) { // Increased from 50 to reduce false positives + } else if (op == BAM_CSOFT_CLIP && op_len >= 200) { // Increased from 100bp to reduce adapter/error artifacts // Soft-clipped bases are considered as potential insertions // Skip if the position exceeds the reference genome length if (pos + 1 >= pos_depth_map.size()) { @@ -729,12 +748,10 @@ void SVCaller::processChromosome(const std::string& chr, std::vector& ch double dbscan_min_pts_pct = input_data.getDBSCAN_MinPtsPct(); if (dbscan_min_pts_pct > 0.0) { dbscan_min_pts = (int)std::ceil(mean_chr_cov * dbscan_min_pts_pct); - printMessage(chr + ": Mean chr. cov.: " + std::to_string(mean_chr_cov) + " (DBSCAN min. pts.= " + std::to_string(dbscan_min_pts) + ", min. pts. pct.= " + std::to_string(dbscan_min_pts_pct) + ")"); - } + } // ----------------------------------------------------------------------- // Detect SVs from the CIGAR strings - printMessage(chr + ": CIGAR SVs..."); this->findCIGARSVs(fp_in, idx, bamHdr, chr, chr_sv_calls, chr_pos_depth_map); // Clean up the BAM file and index @@ -742,11 +759,10 @@ void SVCaller::processChromosome(const std::string& chr, std::vector& ch hts_idx_destroy(idx); bam_hdr_destroy(bamHdr); - printMessage(chr + ": Merging CIGAR..."); mergeSVs(chr_sv_calls, dbscan_epsilon, dbscan_min_pts, false); int region_sv_count = getSVCount(chr_sv_calls); - printMessage(chr + ": Found " + std::to_string(region_sv_count) + " SV candidates in the CIGAR string"); + printMessage(chr + ": Found " + std::to_string(region_sv_count) + " SV candidates"); } void SVCaller::run(const InputData& input_data) @@ -761,7 +777,6 @@ void SVCaller::run(const InputData& input_data) input_data.printParameters(); // Set up the reference genome - printMessage("Loading the reference genome..."); const std::string ref_filepath = input_data.getRefGenome(); std::shared_mutex ref_mutex; // Dummy mutex (remove later) ReferenceGenome ref_genome(ref_mutex); @@ -783,7 +798,6 @@ void SVCaller::run(const InputData& input_data) // Read the HMM from the file std::string hmm_filepath = input_data.getHMMFilepath(); - std::cout << "Reading HMM from file: " << hmm_filepath << std::endl; const CHMM& hmm = ReadCHMM(hmm_filepath.c_str()); // Set up the JSON output file for CNV data @@ -918,7 +932,7 @@ void SVCaller::run(const InputData& input_data) DEBUG_PRINT("Merging split-read SVs..."); for (auto& entry : whole_genome_split_sv_calls) { std::vector& sv_calls = entry.second; - mergeSVs(sv_calls, 0.1, 2, true); + mergeSVs(sv_calls, 0.05, 3, true); // Tightened epsilon/min_pts, keep singletons } } @@ -936,7 +950,7 @@ void SVCaller::run(const InputData& input_data) DEBUG_PRINT("Merging CIGAR and split read SV calls..."); for (auto& entry : whole_genome_sv_calls) { std::vector& sv_calls = entry.second; - mergeSVs(sv_calls, 0.1, 2, true); + mergeSVs(sv_calls, 0.05, 3, true); // Tightened epsilon/min_pts, keep singletons } } @@ -950,7 +964,6 @@ void SVCaller::run(const InputData& input_data) std::string chr = entry.first; int sv_count = getSVCount(entry.second); total_sv_count += sv_count; - printMessage("Total SVs detected for " + chr + ": " + std::to_string(sv_count)); } printMessage("Total SVs detected: " + std::to_string(total_sv_count)); @@ -1038,12 +1051,21 @@ void SVCaller::runSplitReadCopyNumberPredictions(const std::string& chr, std::ve sv_candidate.cn_state = cn_state; // For insertions predicted as duplications, update all information } else if (sv_candidate.sv_type == SVType::INS && supp_type == SVType::DUP) { - sv_candidate.sv_type = supp_type; - sv_candidate.alt_allele = ""; // Explicitly set to - sv_candidate.aln_type.set(static_cast(SVDataType::HMM)); - sv_candidate.hmm_likelihood = supp_lh; - sv_candidate.genotype = genotype; - sv_candidate.cn_state = cn_state; + // Only reclassify INS to DUP if it's larger than the minimum DUP threshold + // This reduces false positives from small/mid-sized insertions being + // misclassified as duplications in the 10-50kb range where depth signal is weak + const uint32_t DUP_MIN_SIZE = 10000; // 10kb minimum for DUP reclassification + uint32_t sv_size = sv_candidate.end - sv_candidate.start + 1; + + if (sv_size >= DUP_MIN_SIZE) { + sv_candidate.sv_type = supp_type; + sv_candidate.alt_allele = ""; // Explicitly set to + sv_candidate.aln_type.set(static_cast(SVDataType::HMM)); + sv_candidate.hmm_likelihood = supp_lh; + sv_candidate.genotype = genotype; + sv_candidate.cn_state = cn_state; + } + // Otherwise, keep as INS } else { // Add a new SV call with the conflicting type SVCall new_sv_call = sv_candidate; // Copy the original SV call @@ -1114,7 +1136,6 @@ void SVCaller::saveToVCF(const std::unordered_map header_lines = { std::string("##reference=") + ref_genome.getFilepath(), contig_header, @@ -1160,8 +1177,6 @@ void SVCaller::saveToVCF(const std::unordered_map", }; - std::cout << "Writing VCF header..." << std::endl; - // Add the file format std::string file_format = "##fileformat=VCFv4.2"; vcf_stream << file_format << std::endl; @@ -1188,7 +1203,6 @@ void SVCaller::saveToVCF(const std::unordered_map& sv_calls = pair.second; - std::cout << "Saving SV calls for " << chr << "..." << std::endl; for (const auto& sv_call : sv_calls) { uint32_t start = sv_call.start; uint32_t end = sv_call.end; @@ -1253,6 +1266,37 @@ void SVCaller::saveToVCF(const std::unordered_mapDUP conversion) + bool has_conflict = false; + if (sv_type != SVType::UNKNOWN && cnv_type != SVType::UNKNOWN && cnv_type != SVType::NEUTRAL) { + if ((sv_type == SVType::INS && cnv_type == SVType::DEL) || + (sv_type == SVType::DEL && cnv_type == SVType::DUP) || + (sv_type == SVType::DUP && cnv_type == SVType::DEL)) { + has_conflict = true; + } + } + + // Check cluster support for inversions + bool low_cluster_support = (sv_type == SVType::INV && cluster_size < 5); + + if (has_conflict || low_cluster_support) { + filter = "LowQual"; + filtered_svs += 1; + } + } + // Deletion if (sv_type == SVType::DEL) { // Get the deleted sequence from the reference genome, also including the preceding base @@ -1341,7 +1385,7 @@ void SVCaller::saveToVCF(const std::unordered_map 0) { - std::cout << "Total unclassified SVs: " << unclassified_svs << std::endl; + std::cout << " Unclassified SVs: " << unclassified_svs << std::endl; } printMessage("Total PASS filtered SVs: " + std::to_string(filtered_svs)); printMessage("Total filtered assembly gaps: " + std::to_string(assembly_gap_filtered_svs)); diff --git a/src/sv_object.cpp b/src/sv_object.cpp index 8016c35b..79ccea85 100644 --- a/src/sv_object.cpp +++ b/src/sv_object.cpp @@ -42,18 +42,11 @@ void concatenateSVCalls(std::vector &target, const std::vector& } void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool keep_noise, const std::string& json_filepath) -{ - printMessage("Merging SVs with DBSCAN, eps=" + std::to_string(epsilon) + ", min_pts=" + std::to_string(min_pts)); - +{ if (sv_calls.size() < 2) { return; } - // Set this to print cluster information for a specific SV call for debugging - // This is useful for debugging purposes to see how the SVs are merged - bool debug_mode = false; - SVType debug_sv_type = SVType::INV; - // Cluster SVs using DBSCAN for each SV type int initial_size = sv_calls.size(); std::vector merged_sv_calls; @@ -66,12 +59,6 @@ void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool k SVType::BND, }) { - // Skip if not the debug SV type - if (debug_mode && (sv_type != debug_sv_type)) { - DEBUG_PRINT("DEBUG: Skipping SV type " + getSVTypeString(sv_type) + " for debug mode"); - continue; - } - std::vector merged_sv_type_calls; // Create a vector of SV calls for the current SV type and size interval @@ -133,15 +120,6 @@ void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool k for (const auto& sv_call : cluster_sv_calls) { SVCall noise_sv_call = sv_call; merged_sv_type_calls.push_back(noise_sv_call); - - // Print the added SV calls if >10 kb and the debug SV type - if (debug_mode && noise_sv_call.sv_type == debug_sv_type && (noise_sv_call.end - noise_sv_call.start) > 10000) { - DEBUG_PRINT("DEBUG: Adding noise SV call at " + std::to_string(noise_sv_call.start) + "-" + std::to_string(noise_sv_call.end) + - ", type: " + getSVTypeString(noise_sv_call.sv_type) + - ", length: " + std::to_string(noise_sv_call.end - noise_sv_call.start) + - ", cluster size: " + std::to_string(noise_sv_call.cluster_size) + - ", likelihood: " + std::to_string(noise_sv_call.hmm_likelihood)); - } } // Merge clustered SV calls @@ -190,53 +168,15 @@ void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool k return (a.end - a.start) > (b.end - b.start); }); - // Print the added SV calls if >10 kb and the debug SV type - if (debug_mode && sv_type == debug_sv_type) { - DEBUG_PRINT("DEBUG: Cluster " + std::to_string(cluster_id) + " with " + std::to_string(cluster_sv_calls.size()) + " SV calls (length sorted):"); - for (const auto& sv_call : cluster_sv_calls) { - if ((sv_call.end - sv_call.start) > 10000) { - DEBUG_PRINT("DEBUG: SV call at " + std::to_string(sv_call.start) + "-" + std::to_string(sv_call.end) + - ", type: " + getSVTypeString(sv_call.sv_type) + - ", length: " + std::to_string(sv_call.end - sv_call.start) + - ", cluster size: " + std::to_string(sv_call.cluster_size) + - ", likelihood: " + std::to_string(sv_call.hmm_likelihood)); - } - } - } - // Get the top % of the cluster double top_pct = 0.2; size_t top_pct_size = std::max(1, (int) (cluster_sv_calls.size() * top_pct)); std::vector top_pct_calls(cluster_sv_calls.begin(), cluster_sv_calls.begin() + top_pct_size); - // Print the added SV calls if >10 kb and the debug SV type - if (debug_mode && sv_type == debug_sv_type) { - DEBUG_PRINT("DEBUG: Top " + std::to_string((int)(top_pct * 100)) + "% of cluster " + std::to_string(cluster_id) + " with " + - std::to_string(top_pct_calls.size()) + " SV calls (length sorted):"); - for (const auto& sv_call : top_pct_calls) { - if ((sv_call.end - sv_call.start) > 10000) { - DEBUG_PRINT("DEBUG: SV call at " + std::to_string(sv_call.start) + "-" + std::to_string(sv_call.end) + - ", type: " + getSVTypeString(sv_call.sv_type) + - ", length: " + std::to_string(sv_call.end - sv_call.start) + - ", cluster size: " + std::to_string(sv_call.cluster_size) + - ", likelihood: " + std::to_string(sv_call.hmm_likelihood)); - } - } - } - // Get the median SV for the top % of the cluster size_t median_index = top_pct_calls.size() / 2; merged_sv_call = top_pct_calls[median_index]; - // Print the merged SV call - if (debug_mode && sv_type == debug_sv_type) { - DEBUG_PRINT("DEBUG: Merged SV call at " + std::to_string(merged_sv_call.start) + "-" + std::to_string(merged_sv_call.end) + - ", type: " + getSVTypeString(merged_sv_call.sv_type) + - ", length: " + std::to_string(merged_sv_call.end - merged_sv_call.start) + - ", cluster size: " + std::to_string(merged_sv_call.cluster_size) + - ", likelihood: " + std::to_string(merged_sv_call.hmm_likelihood)); - } - // Add SV call merged_sv_call.cluster_size = (int) cluster_sv_calls.size(); merged_sv_type_calls.push_back(merged_sv_call); @@ -246,25 +186,10 @@ void mergeSVs(std::vector& sv_calls, double epsilon, int min_pts, bool k } DEBUG_PRINT("Merged " + std::to_string(cluster_count) + " clusters of " + getSVTypeString(sv_type) + ", found " + std::to_string(merged_sv_type_calls.size()) + " merged SV calls"); - // Print SV call start, end, type, and length for debugging if > 10 kb - if (debug_mode && sv_type == debug_sv_type) { - DEBUG_PRINT("DEBUG: Merged SV calls for " + getSVTypeString(sv_type) + ":"); - for (const auto& sv_call : merged_sv_type_calls) { - if ((sv_call.end - sv_call.start) > 10000) { - DEBUG_PRINT("DEBUG: SV call at " + std::to_string(sv_call.start) + "-" + std::to_string(sv_call.end) + - ", type: " + getSVTypeString(sv_call.sv_type) + - ", length: " + std::to_string(sv_call.end - sv_call.start) + - ", cluster size: " + std::to_string(sv_call.cluster_size) + - ", likelihood: " + std::to_string(sv_call.hmm_likelihood)); - } - } - } merged_sv_calls.insert(merged_sv_calls.end(), merged_sv_type_calls.begin(), merged_sv_type_calls.end()); } sv_calls = std::move(merged_sv_calls); // Replace with filtered list - int updated_size = sv_calls.size(); - printMessage("Merged " + std::to_string(initial_size) + " SV calls into " + std::to_string(updated_size) + " SV calls"); } void saveClustersToJSON(const std::string &filename, const std::map> &clusters) @@ -311,18 +236,15 @@ void saveClustersToJSON(const std::string &filename, const std::map &sv_calls) { - int initial_size = sv_calls.size(); std::vector combined_sv_calls; // Sort first by start position, then by SV type @@ -341,9 +263,6 @@ void mergeDuplicateSVs(std::vector &sv_calls) combined_sv_calls.push_back(sv_call); } } - int merge_count = initial_size - combined_sv_calls.size(); sv_calls = std::move(combined_sv_calls); // Replace with filtered list - if (merge_count > 0) { - printMessage("Merged " + std::to_string(merge_count) + " SV candidates with identical start and end positions"); - } + } From eddb43ca80742c72e380a35e617d45148e7827d1 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Thu, 19 Mar 2026 09:55:35 -0400 Subject: [PATCH 31/49] remove large inversion max length --- src/sv_caller.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index c3eba00d..fd49fdfc 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -218,7 +218,8 @@ void SVCaller::findSplitSVSignatures(std::unordered_map(ref_distance - read_distance); - if (read_distance > ref_distance && read_distance >= min_length && read_distance <= max_length) { + if (read_distance > ref_distance && read_distance >= min_length && read_distance <= max_length_noninv) { // Add an insertion SV call at the 5'-most primary position SVType sv_type = SVType::INS; SVCall sv_candidate(sv_start, sv_start + (read_distance-1), sv_type, getSVTypeSymbol(sv_type), aln_type, Genotype::UNKNOWN, 0.0, 0, aln_offset, primary_cluster_size); addSVCall(chr_sv_calls, sv_candidate); // } - } else if (ref_distance > read_distance && ref_distance >= min_length && ref_distance <= max_length) { + } else if (ref_distance > read_distance && ref_distance >= min_length && ref_distance <= max_length_noninv) { // Set it to unknown, SV type will be determined by the // HMM prediction @@ -476,7 +477,8 @@ void SVCaller::findSplitSVSignatures(std::unordered_map= min_length && sv_length <= max_length) { + int max_allowed_length = (sv_type == SVType::INV) ? max_length_inv : max_length_noninv; + if (sv_length >= min_length && sv_length <= max_allowed_length) { // Use balanced support for inversions. // For non-inversions, keep large events even with sparse // split-read support because >100kb SVs often have few @@ -1012,6 +1014,12 @@ void SVCaller::runSplitReadCopyNumberPredictions(const std::string& chr, std::ve { std::vector additional_calls; for (auto& sv_candidate : split_sv_calls) { + const uint32_t MAX_INV_HMM_LENGTH = 1000000; // Avoid expensive CNV/HMM over very large inversion spans + uint32_t sv_length = sv_candidate.end - sv_candidate.start + 1; + if (sv_candidate.sv_type == SVType::INV && sv_length > MAX_INV_HMM_LENGTH) { + // Keep split-read inversion call as-is; skip CNV/HMM refinement for very large regions. + continue; + } std::tuple result = cnv_caller.runCopyNumberPrediction(chr, hmm, sv_candidate.start, sv_candidate.end, mean_chr_cov, pos_depth_map, input_data); double supp_lh = std::get<0>(result); From 7a644dd20854c43a04c4f9ff48a7aed7646e1e66 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Fri, 20 Mar 2026 21:15:28 -0400 Subject: [PATCH 32/49] large inversion improvement --- src/sv_caller.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sv_caller.cpp b/src/sv_caller.cpp index fd49fdfc..e9fbebeb 100644 --- a/src/sv_caller.cpp +++ b/src/sv_caller.cpp @@ -1296,8 +1296,8 @@ void SVCaller::saveToVCF(const std::unordered_map Date: Thu, 26 Mar 2026 23:29:07 -0400 Subject: [PATCH 33/49] update minimum length for plots --- python/cnv_plots_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cnv_plots_json.py b/python/cnv_plots_json.py index 9f2c9458..6ed018f3 100644 --- a/python/cnv_plots_json.py +++ b/python/cnv_plots_json.py @@ -5,7 +5,7 @@ import plotly from plotly.subplots import make_subplots -min_sv_length = 60000 # Minimum SV length in base pairs +min_sv_length = 50000 # Minimum SV length in base pairs # Set up argument parser parser = argparse.ArgumentParser(description='Generate CNV plots from JSON data.') From 1f0c44db77b66721fe6ce6037b1c599bada180b3 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Tue, 21 Apr 2026 00:06:16 -0400 Subject: [PATCH 34/49] save manuscript svg --- conda/meta.yaml | 2 ++ python/cnv_plots_json.py | 60 ++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/conda/meta.yaml b/conda/meta.yaml index d217ee43..51ab7b17 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -10,6 +10,7 @@ source: git_lfs: false channels: + - wglab - conda-forge - bioconda - defaults @@ -25,6 +26,7 @@ requirements: - htslib=1.20 run: - htslib=1.20 + - contextscore test: commands: diff --git a/python/cnv_plots_json.py b/python/cnv_plots_json.py index 6ed018f3..949cbb2a 100644 --- a/python/cnv_plots_json.py +++ b/python/cnv_plots_json.py @@ -1,3 +1,6 @@ +""" +pip install plotly kaleido==0.2.1 +""" import os import argparse import json @@ -11,8 +14,18 @@ parser = argparse.ArgumentParser(description='Generate CNV plots from JSON data.') parser.add_argument('json_file', type=str, help='Path to the JSON file containing SV data') parser.add_argument('chromosome', type=str, help='Chromosome to filter the SVs by (e.g., "chr3")', nargs='?', default=None) +parser.add_argument('--formats', type=str, default='html,svg', help='Comma-separated output formats (e.g., html,svg,pdf,png)') +parser.add_argument('--width', type=int, default=1800, help='Figure width in pixels for static exports') +parser.add_argument('--height', type=int, default=1200, help='Figure height in pixels for static exports') +parser.add_argument('--scale', type=float, default=2.0, help='Scale factor for raster exports (png,jpg,webp)') args = parser.parse_args() +output_formats = [fmt.strip().lower() for fmt in args.formats.split(',') if fmt.strip()] + +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +output_dir = os.path.join(repo_root, 'linktoscripts', 'CNV_Plots') +os.makedirs(output_dir, exist_ok=True) + # Load your JSON data with open(args.json_file) as f: sv_data = json.load(f) @@ -35,16 +48,19 @@ } # Loop through each SV (assuming your JSON contains multiple SVs) +skip_count_chrom = 0 +skip_count_length = 0 +save_count = 0 for sv in sv_data: # If a chromosome is specified, filter the SVs by that chromosome if args.chromosome and sv['chromosome'] != args.chromosome: - print(f"Skipping SV {sv['chromosome']}:{sv['start']}-{sv['end']} of type {sv['sv_type']} (not on chromosome {args.chromosome})") + skip_count_chrom += 1 continue # Filter out SVs that are smaller than the minimum length if np.abs(sv['size']) < min_sv_length: - print(f"Skipping SV {sv['chromosome']}:{sv['start']}-{sv['end']} of type {sv['sv_type']} with size {sv['size']} bp (smaller than {min_sv_length} bp)") + skip_count_length += 1 continue # Extract data for plotting @@ -228,15 +244,41 @@ title_text = f"{sv_type_dict[sv_type]} at {chromosome}:{start}-{end} ({sv_length} bp)", title_x = 0.5, showlegend = False, + template = 'simple_white', + font = dict(family='Arial', size=20, color='black'), + width = args.width, + height = args.height, + margin = dict(l=100, r=30, t=120, b=90) ) + + fig.update_xaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') + fig.update_yaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') # height = 800, # width = 800 # ) - # Save the plot to an HTML file (use a unique filename per SV) - # Use the input filepath directory as the output directory - output_dir = os.path.dirname(args.json_file) + # Save plots into a dedicated repository output directory. svlen_kb = sv_length // 1000 - file_name = f"SV_{chromosome}_{start}_{end}_{sv_type}_{svlen_kb}kb.html" - file_path = os.path.join(output_dir, file_name) - fig.write_html(file_path) - print(f"Plot saved as {file_path}") + base_name = f"SV_{chromosome}_{start}_{end}_{sv_type}_{svlen_kb}kb" + + if 'html' in output_formats: + html_path = os.path.join(output_dir, f"{base_name}.html") + fig.write_html(html_path) + print(f"Plot saved as {html_path}") + + static_formats = {'svg', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'eps'} + requested_static_formats = [fmt for fmt in output_formats if fmt in static_formats] + + if requested_static_formats: + try: + for fmt in requested_static_formats: + out_path = os.path.join(output_dir, f"{base_name}.{fmt}") + fig.write_image(out_path, format=fmt, width=args.width, height=args.height, scale=args.scale) + print(f"Plot saved as {out_path}") + except ValueError as e: + print("Static image export requires Kaleido. Install with: pip install -U kaleido") + raise e + + save_count += 1 + +print(f"Finished processing {save_count} SVs. Skipped {skip_count_chrom} SVs due to chromosome filter and {skip_count_length} SVs due to length filter.") + From cc2d379c3f6b4811db87e7b28a24e441b39541d8 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 4 May 2026 20:31:51 -0400 Subject: [PATCH 35/49] cnv plots output dir parameter --- python/cnv_plots_json.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/python/cnv_plots_json.py b/python/cnv_plots_json.py index 949cbb2a..6a28ae4f 100644 --- a/python/cnv_plots_json.py +++ b/python/cnv_plots_json.py @@ -9,21 +9,23 @@ from plotly.subplots import make_subplots min_sv_length = 50000 # Minimum SV length in base pairs +marker_size = 8 # Set up argument parser parser = argparse.ArgumentParser(description='Generate CNV plots from JSON data.') parser.add_argument('json_file', type=str, help='Path to the JSON file containing SV data') parser.add_argument('chromosome', type=str, help='Chromosome to filter the SVs by (e.g., "chr3")', nargs='?', default=None) parser.add_argument('--formats', type=str, default='html,svg', help='Comma-separated output formats (e.g., html,svg,pdf,png)') -parser.add_argument('--width', type=int, default=1800, help='Figure width in pixels for static exports') -parser.add_argument('--height', type=int, default=1200, help='Figure height in pixels for static exports') +parser.add_argument('--width', type=int, default=1200, help='Figure width in pixels for static exports') +parser.add_argument('--height', type=int, default=800, help='Figure height in pixels for static exports') parser.add_argument('--scale', type=float, default=2.0, help='Scale factor for raster exports (png,jpg,webp)') +parser.add_argument('--output-dir', type=str, default=None, help='Directory to save the output plots (default: linktoscripts/CNV_Plots)') args = parser.parse_args() output_formats = [fmt.strip().lower() for fmt in args.formats.split(',') if fmt.strip()] repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -output_dir = os.path.join(repo_root, 'linktoscripts', 'CNV_Plots') +output_dir = args.output_dir if args.output_dir else os.path.join(repo_root, 'linktoscripts', 'CNV_Plots') os.makedirs(output_dir, exist_ok=True) # Load your JSON data @@ -140,7 +142,7 @@ hoverinfo='text', marker=dict( color=state_colors, - size=5, + size=marker_size, symbol=marker_symbols, ), line=dict( @@ -160,7 +162,7 @@ hoverinfo='text', marker=dict( color=state_colors, - size=5, + size=marker_size, symbol=marker_symbols, ), line=dict( @@ -253,9 +255,7 @@ fig.update_xaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') fig.update_yaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') - # height = 800, - # width = 800 - # ) + # Save plots into a dedicated repository output directory. svlen_kb = sv_length // 1000 base_name = f"SV_{chromosome}_{start}_{end}_{sv_type}_{svlen_kb}kb" From 34f289d6b5b8a290f7d1d1bac3a5c319235a4d2d Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 00:40:11 -0400 Subject: [PATCH 36/49] cnv plot installation Co-authored-by: Copilot --- Makefile | 12 + README.md | 13 + conda/meta.yaml | 8 + python/cnv_plots_json.py | 503 ++++++++++++++++++++------------------- 4 files changed, 293 insertions(+), 243 deletions(-) diff --git a/Makefile b/Makefile index 0dd1e883..ee5442f2 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ LDLIBS := -lhts # Link with libhts.a or libhts.so SOURCES := $(wildcard $(SRC_DIR)/*.cpp) OBJECTS := $(patsubst $(SRC_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(SOURCES)) TARGET := $(BUILD_DIR)/contextsv +PREFIX ?= $(CONDA_PREFIX) +BINDIR ?= $(PREFIX)/bin # Default target all: $(TARGET) @@ -43,3 +45,13 @@ $(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp # Clean the build directory clean: rm -rf $(BUILD_DIR) + +# Install binaries and helper scripts +install: $(TARGET) + @if [ -z "$(PREFIX)" ]; then \ + echo "Error: PREFIX is empty. Activate a conda env or run 'make install PREFIX=/your/prefix'."; \ + exit 1; \ + fi + install -d $(BINDIR) + install -m 755 $(TARGET) $(BINDIR)/contextsv + install -m 755 python/cnv_plots_json.py $(BINDIR)/contextsv-cnv-plot diff --git a/README.md b/README.md index 32e7bd3b..508ec26d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ ContextSV and its dependencies can then be installed using the following command conda install -c wglab -c conda-forge -c bioconda contextsv ``` +After installation, two commands are available: + +- `contextsv`: the main SV caller +- `contextsv-cnv-plot`: utility to generate CNV plots from ContextSV JSON output + +Example plotting usage: + +``` +contextsv-cnv-plot ./output/sv_calls.json chr3 --formats html,svg --output-dir ./CNV_Plots +``` + +You can run `contextsv-cnv-plot --help` to see all plotting options. + ### Docker First, install [Docker](https://docs.docker.com/engine/install/). Pull the latest image from Docker hub, which contains the latest release and its dependencies. diff --git a/conda/meta.yaml b/conda/meta.yaml index 51ab7b17..4e31b75d 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -17,6 +17,9 @@ channels: build: number: 0 + script: + - make + - make install PREFIX=$PREFIX requirements: build: @@ -27,11 +30,16 @@ requirements: run: - htslib=1.20 - contextscore + - python >=3.9 + - plotly + - python-kaleido test: commands: - contextsv --help - test -f $PREFIX/bin/contextsv + - contextsv-cnv-plot --help + - test -f $PREFIX/bin/contextsv-cnv-plot - contextsv --version about: home: https://github.com/WGLab/ContextSV diff --git a/python/cnv_plots_json.py b/python/cnv_plots_json.py index 6a28ae4f..b9fd6849 100644 --- a/python/cnv_plots_json.py +++ b/python/cnv_plots_json.py @@ -1,284 +1,301 @@ -""" -pip install plotly kaleido==0.2.1 -""" -import os +#!/usr/bin/env python3 import argparse import json -import numpy as np -import plotly -from plotly.subplots import make_subplots - -min_sv_length = 50000 # Minimum SV length in base pairs -marker_size = 8 - -# Set up argument parser -parser = argparse.ArgumentParser(description='Generate CNV plots from JSON data.') -parser.add_argument('json_file', type=str, help='Path to the JSON file containing SV data') -parser.add_argument('chromosome', type=str, help='Chromosome to filter the SVs by (e.g., "chr3")', nargs='?', default=None) -parser.add_argument('--formats', type=str, default='html,svg', help='Comma-separated output formats (e.g., html,svg,pdf,png)') -parser.add_argument('--width', type=int, default=1200, help='Figure width in pixels for static exports') -parser.add_argument('--height', type=int, default=800, help='Figure height in pixels for static exports') -parser.add_argument('--scale', type=float, default=2.0, help='Scale factor for raster exports (png,jpg,webp)') -parser.add_argument('--output-dir', type=str, default=None, help='Directory to save the output plots (default: linktoscripts/CNV_Plots)') -args = parser.parse_args() - -output_formats = [fmt.strip().lower() for fmt in args.formats.split(',') if fmt.strip()] - -repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -output_dir = args.output_dir if args.output_dir else os.path.join(repo_root, 'linktoscripts', 'CNV_Plots') -os.makedirs(output_dir, exist_ok=True) - -# Load your JSON data -with open(args.json_file) as f: - sv_data = json.load(f) - -# State marker colors -# https://community.plotly.com/t/plotly-colours-list/11730/6 -state_colors_dict = { - '1': 'darkred', - '2': 'red', - '3': 'gray', - '4': 'green', - '5': 'blue', - '6': 'darkblue', +import os + +DEFAULT_MIN_SV_LENGTH = 50000 +MARKER_SIZE = 8 +STATIC_FORMATS = {"svg", "pdf", "png", "jpg", "jpeg", "webp", "eps"} +ALLOWED_FORMATS = STATIC_FORMATS.union({"html"}) + +STATE_COLORS = { + "1": "darkred", + "2": "red", + "3": "gray", + "4": "green", + "5": "blue", + "6": "darkblue", } -sv_type_dict = { - 'DEL': 'Deletion', - 'DUP': 'Duplication', - 'INV': 'Inversion' +SV_TYPE_LABELS = { + "DEL": "Deletion", + "DUP": "Duplication", + "INV": "Inversion", } -# Loop through each SV (assuming your JSON contains multiple SVs) -skip_count_chrom = 0 -skip_count_length = 0 -save_count = 0 -for sv in sv_data: - - # If a chromosome is specified, filter the SVs by that chromosome - if args.chromosome and sv['chromosome'] != args.chromosome: - skip_count_chrom += 1 - continue - - # Filter out SVs that are smaller than the minimum length - if np.abs(sv['size']) < min_sv_length: - skip_count_length += 1 - continue - - # Extract data for plotting - positions_before = sv['before_sv']['positions'] - b_allele_freq_before = sv['before_sv']['b_allele_freq'] - positions_after = sv['after_sv']['positions'] - b_allele_freq_after = sv['after_sv']['b_allele_freq'] - - # Create a subplot for the CNV plot and the BAF plot. - fig = make_subplots( - rows=2, - cols=1, - shared_xaxes=True, - vertical_spacing=0.05, - subplot_titles=(r"SNP Log2 Ratio", "SNP B-Allele Frequency") - ) +REQUIRED_SECTION_KEYS = { + "positions", + "b_allele_freq", + "population_freq", + "log2_ratio", + "is_snp", +} + +REQUIRED_SV_KEYS = { + "chromosome", + "start", + "end", + "sv_type", + "size", + "before_sv", + "sv", + "after_sv", +} - # Get the chromosome, start, end, and sv_type from the SV data - chromosome = sv['chromosome'] - start = sv['start'] - end = sv['end'] - sv_type = sv['sv_type'] - likelihood = sv['likelihood'] - sv_length = sv['size'] - # Plot the data for 'before_sv', 'sv', and 'after_sv' +def parse_args(): + parser = argparse.ArgumentParser(description="Generate CNV plots from JSON data.") + parser.add_argument("json_file", type=str, help="Path to the JSON file containing SV data") + parser.add_argument( + "chromosome", + type=str, + nargs="?", + default=None, + help="Chromosome to filter SVs by (e.g., chr3)", + ) + parser.add_argument( + "--formats", + type=str, + default="html", + help="Comma-separated output formats (e.g., html,svg,pdf,png)", + ) + parser.add_argument("--width", type=int, default=1200, help="Figure width in pixels for static exports") + parser.add_argument("--height", type=int, default=800, help="Figure height in pixels for static exports") + parser.add_argument("--scale", type=float, default=2.0, help="Scale factor for raster exports") + parser.add_argument( + "--min-sv-length", + type=int, + default=DEFAULT_MIN_SV_LENGTH, + help="Minimum SV length in base pairs to plot", + ) + parser.add_argument( + "--output-dir", + type=str, + default=None, + help="Directory to save output plots (default: ./CNV_Plots)", + ) + return parser.parse_args() + + +def parse_formats(formats_arg): + formats = [fmt.strip().lower() for fmt in formats_arg.split(",") if fmt.strip()] + invalid = [fmt for fmt in formats if fmt not in ALLOWED_FORMATS] + if invalid: + allowed = ", ".join(sorted(ALLOWED_FORMATS)) + bad = ", ".join(invalid) + raise ValueError(f"Unsupported format(s): {bad}. Allowed formats are: {allowed}") + return formats + + +def load_json_records(path): + if not os.path.isfile(path): + raise FileNotFoundError(f"Input JSON file not found: {path}") + + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + + if not isinstance(data, list): + raise ValueError("Input JSON must contain a top-level list of SV records.") + + return data + + +def validate_record(record, idx): + missing_sv_keys = REQUIRED_SV_KEYS - set(record.keys()) + if missing_sv_keys: + missing = ", ".join(sorted(missing_sv_keys)) + raise ValueError(f"Record {idx} missing required top-level key(s): {missing}") + for section in ["before_sv", "sv", "after_sv"]: - positions = sv[section]['positions'] - b_allele_freq = sv[section]['b_allele_freq'] - population_freq = sv[section]['population_freq'] - log2_ratio = sv[section]['log2_ratio'] - is_snp = sv[section]['is_snp'] + missing_section_keys = REQUIRED_SECTION_KEYS - set(record[section].keys()) + if missing_section_keys: + missing = ", ".join(sorted(missing_section_keys)) + raise ValueError(f"Record {idx}, section {section} missing key(s): {missing}") - # Set all b-allele frequencies to NaN if not SNPs - b_allele_freq = [freq if is_snp_val else float('nan') for freq, is_snp_val in zip(b_allele_freq, is_snp)] +def build_hover_text(section, positions, states, log2_ratio, is_snp, b_allele_freq, population_freq): + hover_text = [] + for i, position in enumerate(positions): if section == "sv": - # is_snp = sv[section]['is_snp'] - states = sv[section]['states'] - state_colors = [state_colors_dict[str(state)] for state in states] - marker_symbols = ['circle' if is_snp_val else 'circle-open' for is_snp_val in is_snp] - - # Set the hover text - hover_text = [] - for i, position in enumerate(positions): - # Add hover text for each point - hover_text.append( - f"Position: {position}
" - f"State: {states[i]}
" - f"Log2 Ratio: {log2_ratio[i]}
" - f"SNP: {is_snp[i]}
" - f"BAF: {b_allele_freq[i]}
" - f"Population Frequency: {population_freq[i]}
" - ) + hover_text.append( + f"Position: {position}
" + f"State: {states[i]}
" + f"Log2 Ratio: {log2_ratio[i]}
" + f"SNP: {is_snp[i]}
" + f"BAF: {b_allele_freq[i]}
" + f"Population Frequency: {population_freq[i]}
" + ) else: - # is_snp = sv[section]['is_snp'] - state_colors = ['black'] * len(positions) - # marker_symbols = ['circle-open'] * len(positions) - marker_symbols = ['circle' if is_snp_val else 'circle-open' for is_snp_val in is_snp] - hover_text = [] - for i, position in enumerate(positions): - # Add hover text for each point - hover_text.append( - f"Position: {position}
" - f"Log2 Ratio: {log2_ratio[i]}
" - f"BAF: {b_allele_freq[i]}
" - f"Population Frequency: {population_freq[i]}
" - ) - - # Create the log2 trace - log2_trace = plotly.graph_objs.Scatter( - x=positions, - y=log2_ratio, - mode='markers+lines', - name=r'Log2 Ratio', - text=hover_text, - hoverinfo='text', - marker=dict( - color=state_colors, - size=marker_size, - symbol=marker_symbols, - ), - line=dict( - color='black', - width=0 - ), - showlegend=False - ) + hover_text.append( + f"Position: {position}
" + f"Log2 Ratio: {log2_ratio[i]}
" + f"BAF: {b_allele_freq[i]}
" + f"Population Frequency: {population_freq[i]}
" + ) + return hover_text + + +def add_section_traces(fig, record, section, start, end): + positions = record[section]["positions"] + b_allele_freq = record[section]["b_allele_freq"] + population_freq = record[section]["population_freq"] + log2_ratio = record[section]["log2_ratio"] + is_snp = record[section]["is_snp"] + + b_allele_freq = [freq if snp_flag else float("nan") for freq, snp_flag in zip(b_allele_freq, is_snp)] + + if section == "sv": + states = record[section].get("states", ["NA"] * len(positions)) + state_colors = [STATE_COLORS.get(str(state), "black") for state in states] + marker_symbols = ["circle" if snp_flag else "circle-open" for snp_flag in is_snp] + else: + states = ["NA"] * len(positions) + state_colors = ["black"] * len(positions) + marker_symbols = ["circle" if snp_flag else "circle-open" for snp_flag in is_snp] + + hover_text = build_hover_text(section, positions, states, log2_ratio, is_snp, b_allele_freq, population_freq) + + import plotly + + log2_trace = plotly.graph_objs.Scatter( + x=positions, + y=log2_ratio, + mode="markers+lines", + name="Log2 Ratio", + text=hover_text, + hoverinfo="text", + marker={"color": state_colors, "size": MARKER_SIZE, "symbol": marker_symbols}, + line={"color": "black", "width": 0}, + showlegend=False, + ) - # Create the BAF trace - baf_trace = plotly.graph_objs.Scatter( - x=positions, - y=b_allele_freq, - mode='markers+lines', - name='B-Allele Frequency', - text=hover_text, - hoverinfo='text', - marker=dict( - color=state_colors, - size=marker_size, - symbol=marker_symbols, - ), - line=dict( - color='black', - width=0 - ), - showlegend=False - ) + baf_trace = plotly.graph_objs.Scatter( + x=positions, + y=b_allele_freq, + mode="markers+lines", + name="B-Allele Frequency", + text=hover_text, + hoverinfo="text", + marker={"color": state_colors, "size": MARKER_SIZE, "symbol": marker_symbols}, + line={"color": "black", "width": 0}, + showlegend=False, + ) - if section == "sv": - # Create a shaded rectangle for the CNV, layering it below the CNV - # trace and labeling it with the CNV type. - fig.add_vrect( - x0 = start, - x1 = end, - fillcolor = "Black", - layer = "below", - line_width = 0, - opacity = 0.1, - annotation_text = '', - annotation_position = "top left", - annotation_font_size = 20, - annotation_font_color = "black" - ) + if section == "sv": + fig.add_vrect(x0=start, x1=end, fillcolor="black", layer="below", line_width=0, opacity=0.1) + fig.add_vline(x=start, line_width=2, line_color="black", layer="below") + fig.add_vline(x=end, line_width=2, line_color="black", layer="below") - # Add vertical lines at the start and end positions of the CNV. - fig.add_vline( - x = start, - line_width = 2, - line_color = "black", - layer = "below" - ) + fig.append_trace(log2_trace, row=1, col=1) + fig.append_trace(baf_trace, row=2, col=1) - fig.add_vline( - x = end, - line_width = 2, - line_color = "black", - layer = "below" - ) - # Add traces to the figure - fig.append_trace(log2_trace, row=1, col=1) - fig.append_trace(baf_trace, row=2, col=1) - - # Set the x-axis title. - fig.update_xaxes( - title_text = "Chromosome Position", - row = 2, - col = 1 - ) +def build_figure(record, width, height): + from plotly.subplots import make_subplots - # Set the y-axis titles. - fig.update_yaxes( - title_text = r"Log2 Ratio", - row = 1, - col = 1 - ) + chromosome = record["chromosome"] + start = record["start"] + end = record["end"] + sv_type = record["sv_type"] + sv_length = record["size"] - fig.update_yaxes( - title_text = "B-Allele Frequency", - row = 2, - col = 1 + fig = make_subplots( + rows=2, + cols=1, + shared_xaxes=True, + vertical_spacing=0.05, + subplot_titles=("SNP Log2 Ratio", "SNP B-Allele Frequency"), ) - # Set the Y-axis range for the log2 ratio plot. - fig.update_yaxes( - range = [-2.0, 2.0], - row = 1, - col = 1 - ) + for section in ["before_sv", "sv", "after_sv"]: + add_section_traces(fig, record, section, start, end) - # Set the Y-axis range for the BAF plot. - fig.update_yaxes( - range = [-0.2, 1.2], - row = 2, - col = 1 - ) + fig.update_xaxes(title_text="Chromosome Position", row=2, col=1) + fig.update_yaxes(title_text="Log2 Ratio", range=[-2.0, 2.0], row=1, col=1) + fig.update_yaxes(title_text="B-Allele Frequency", range=[-0.2, 1.2], row=2, col=1) - # Set the title of the plot. + title_label = SV_TYPE_LABELS.get(sv_type, sv_type) fig.update_layout( - title_text = f"{sv_type_dict[sv_type]} at {chromosome}:{start}-{end} ({sv_length} bp)", - title_x = 0.5, - showlegend = False, - template = 'simple_white', - font = dict(family='Arial', size=20, color='black'), - width = args.width, - height = args.height, - margin = dict(l=100, r=30, t=120, b=90) + title_text=f"{title_label} at {chromosome}:{start}-{end} ({sv_length} bp)", + title_x=0.5, + showlegend=False, + template="simple_white", + font={"family": "Arial", "size": 20, "color": "black"}, + width=width, + height=height, + margin={"l": 100, "r": 30, "t": 120, "b": 90}, ) + fig.update_xaxes(showline=True, linewidth=2, linecolor="black", mirror=True, ticks="outside") + fig.update_yaxes(showline=True, linewidth=2, linecolor="black", mirror=True, ticks="outside") + return fig - fig.update_xaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') - fig.update_yaxes(showline=True, linewidth=2, linecolor='black', mirror=True, ticks='outside') - # Save plots into a dedicated repository output directory. - svlen_kb = sv_length // 1000 - base_name = f"SV_{chromosome}_{start}_{end}_{sv_type}_{svlen_kb}kb" - - if 'html' in output_formats: +def write_outputs(fig, base_name, output_dir, formats, width, height, scale): + if "html" in formats: html_path = os.path.join(output_dir, f"{base_name}.html") fig.write_html(html_path) print(f"Plot saved as {html_path}") - static_formats = {'svg', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'eps'} - requested_static_formats = [fmt for fmt in output_formats if fmt in static_formats] - + requested_static_formats = [fmt for fmt in formats if fmt in STATIC_FORMATS] if requested_static_formats: try: for fmt in requested_static_formats: out_path = os.path.join(output_dir, f"{base_name}.{fmt}") - fig.write_image(out_path, format=fmt, width=args.width, height=args.height, scale=args.scale) + fig.write_image(out_path, format=fmt, width=width, height=height, scale=scale) print(f"Plot saved as {out_path}") - except ValueError as e: + except ValueError as err: print("Static image export requires Kaleido. Install with: pip install -U kaleido") - raise e + raise err + + +def main(): + args = parse_args() + try: + import plotly # noqa: F401 + except ImportError as err: + raise ImportError( + "Missing required dependency 'plotly'. Install with: conda install -c conda-forge plotly" + ) from err + + formats = parse_formats(args.formats) + records = load_json_records(args.json_file) + + output_dir = args.output_dir if args.output_dir else os.path.join(os.getcwd(), "CNV_Plots") + os.makedirs(output_dir, exist_ok=True) + + skip_count_chrom = 0 + skip_count_length = 0 + save_count = 0 + + for idx, record in enumerate(records, start=1): + validate_record(record, idx) - save_count += 1 + if args.chromosome and record["chromosome"] != args.chromosome: + skip_count_chrom += 1 + continue + + if abs(record["size"]) < args.min_sv_length: + skip_count_length += 1 + continue + + fig = build_figure(record, args.width, args.height) + + sv_length = record["size"] + svlen_kb = sv_length // 1000 + base_name = ( + f"SV_{record['chromosome']}_{record['start']}_{record['end']}_" + f"{record['sv_type']}_{svlen_kb}kb" + ) + + write_outputs(fig, base_name, output_dir, formats, args.width, args.height, args.scale) + save_count += 1 + + print( + f"Finished processing {save_count} SVs. " + f"Skipped {skip_count_chrom} SVs due to chromosome filter and " + f"{skip_count_length} SVs due to length filter." + ) -print(f"Finished processing {save_count} SVs. Skipped {skip_count_chrom} SVs due to chromosome filter and {skip_count_length} SVs due to length filter.") +if __name__ == "__main__": + main() From 69f96a03401a8b0dbad68590275e5a777f9f4bd0 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 00:53:07 -0400 Subject: [PATCH 37/49] update conda build Co-authored-by: Copilot --- conda/meta.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/conda/meta.yaml b/conda/meta.yaml index 4e31b75d..4550884e 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -17,9 +17,6 @@ channels: build: number: 0 - script: - - make - - make install PREFIX=$PREFIX requirements: build: From 360edbaba56e8da2e8961ee10b86fc4f0a41e64f Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 01:13:32 -0400 Subject: [PATCH 38/49] add build script --- .gitignore | 3 --- conda/build.sh | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 conda/build.sh diff --git a/.gitignore b/.gitignore index 2dcbd060..ece555be 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,6 @@ __pycache__/ *.code-workspace CMakeSettings.json -# Shell scripts -*.sh - # Output folder output/ diff --git a/conda/build.sh b/conda/build.sh new file mode 100644 index 00000000..75abb188 --- /dev/null +++ b/conda/build.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +echo "Building ContextSV..." +export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:${PREFIX}/lib +export CONDA_PREFIX=$PREFIX +export CXXFLAGS="-I$PREFIX/include $CXXFLAGS" +export LDFLAGS="-L$PREFIX/lib $LDFLAGS" + +echo "Checking for HTSLib..." +ls -la $PREFIX/include/htslib/ || echo "HTSLib headers not found" +pkg-config --exists htslib && echo "✓ HTSLib found" || echo "⚠ HTSLib not via pkg-config" + +echo "Compiling ContextSV..." +make + +echo "Installing ContextSV..." +mkdir -p ${PREFIX}/bin +cp build/contextsv ${PREFIX}/bin/ +chmod +x ${PREFIX}/bin/contextsv + +echo "Verifying ContextSV installation..." +$PREFIX/bin/contextsv --help +$PREFIX/bin/contextsv --version From 82ced396f4b97bff768e9b37107f03c448f39a36 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 01:14:40 -0400 Subject: [PATCH 39/49] cnv plot install Co-authored-by: Copilot --- conda/build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/conda/build.sh b/conda/build.sh index 75abb188..8f0aff51 100644 --- a/conda/build.sh +++ b/conda/build.sh @@ -19,6 +19,8 @@ echo "Installing ContextSV..." mkdir -p ${PREFIX}/bin cp build/contextsv ${PREFIX}/bin/ chmod +x ${PREFIX}/bin/contextsv +cp python/cnv_plots_json.py ${PREFIX}/bin/contextsv-cnv-plot +chmod +x ${PREFIX}/bin/contextsv-cnv-plot echo "Verifying ContextSV installation..." $PREFIX/bin/contextsv --help From f7236225223566dcd29908e5e4a7c2aa421fde17 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 01:37:41 -0400 Subject: [PATCH 40/49] cnv plot install Co-authored-by: Copilot --- conda/build.sh | 4 ++++ conda/meta.yaml | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/conda/build.sh b/conda/build.sh index 8f0aff51..265df296 100644 --- a/conda/build.sh +++ b/conda/build.sh @@ -25,3 +25,7 @@ chmod +x ${PREFIX}/bin/contextsv-cnv-plot echo "Verifying ContextSV installation..." $PREFIX/bin/contextsv --help $PREFIX/bin/contextsv --version + +echo "Verifying CNV plotting command installation..." +test -x ${PREFIX}/bin/contextsv-cnv-plot +${PREFIX}/bin/contextsv-cnv-plot --help diff --git a/conda/meta.yaml b/conda/meta.yaml index 4550884e..b088db9d 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -33,11 +33,11 @@ requirements: test: commands: - - contextsv --help - - test -f $PREFIX/bin/contextsv - - contextsv-cnv-plot --help - - test -f $PREFIX/bin/contextsv-cnv-plot - - contextsv --version + - test -x $PREFIX/bin/contextsv + - $PREFIX/bin/contextsv --help + - $PREFIX/bin/contextsv --version + - test -x $PREFIX/bin/contextsv-cnv-plot + - $PREFIX/bin/contextsv-cnv-plot --help about: home: https://github.com/WGLab/ContextSV license: MIT From eaf6c7a989832c6bee0aabe8bed1f052c6bb712c Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 02:21:08 -0400 Subject: [PATCH 41/49] remove user cluster params --- src/main.cpp | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 9d63919a..38b53dc7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -90,15 +90,6 @@ void runContextSV(const std::unordered_map& args) input_data.setVerbose(true); } - // DBSCAN parameters - if (args.find("epsilon") != args.end()) { - input_data.setDBSCAN_Epsilon(std::stod(args.at("epsilon"))); - } - - if (args.find("min-pts-pct") != args.end()) { - input_data.setDBSCAN_MinPtsPct(std::stod(args.at("min-pts-pct"))); - } - // Set up the CNV JSON file if enabled if (input_data.getSaveCNVData()) { const std::string output_dir = input_data.getOutputDir(); @@ -160,8 +151,6 @@ void printUsage(const std::string& programName) { << " -o, --outdir Output directory (required)\n" << " -t, --threads Number of threads\n" << " -h, --hmm HMM file\n" - << " --eps DBSCAN epsilon\n" - << " --min-pts-pct Percentage of mean chr. coverage to use for DBSCAN minimum points\n" << " -e, --eth ETH file\n" << " -p, --pfb PFB file\n" << " --assembly-gaps Assembly gaps file\n" @@ -195,10 +184,6 @@ std::unordered_map parseArguments(int argc, char* argv args["hmm-file"] = argv[++i]; } else if (arg == "--min-reads" && i + 1 < argc) { args["min-reads"] = argv[++i]; - } else if (arg == "--eps" && i + 1 < argc) { - args["epsilon"] = argv[++i]; - } else if (arg == "--min-pts-pct" && i + 1 < argc) { - args["min-pts-pct"] = argv[++i]; } else if ((arg == "-e" || arg == "--eth") && i + 1 < argc) { args["eth"] = argv[++i]; } else if ((arg == "-p" || arg == "--pfb") && i + 1 < argc) { From f85e194b4ab087957abf6dccce4d90f93cdb0ab8 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 02:25:14 -0400 Subject: [PATCH 42/49] clean up arg Co-authored-by: Copilot --- src/main.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 38b53dc7..a0f670e8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -182,8 +182,6 @@ std::unordered_map parseArguments(int argc, char* argv args["thread-count"] = argv[++i]; } else if ((arg == "-h" || arg == "--hmm") && i + 1 < argc) { args["hmm-file"] = argv[++i]; - } else if (arg == "--min-reads" && i + 1 < argc) { - args["min-reads"] = argv[++i]; } else if ((arg == "-e" || arg == "--eth") && i + 1 < argc) { args["eth"] = argv[++i]; } else if ((arg == "-p" || arg == "--pfb") && i + 1 < argc) { From 2d6bccee92f3b4863e5aea5c56f6687cdf3a2797 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 02:23:43 -0400 Subject: [PATCH 43/49] update readme --- README.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 508ec26d..2aacb307 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Class documentation is available at
File containing per-chromosome population allele frequency filepaths as described in this documentation + --assembly-gaps Assembly gaps file in BED format available from UCSC Genome Browser (https://hgdownload.soe.ucsc.edu/goldenPath/hg38/database/gap.txt.gz for GRCh38) + --save-cnv Save CNV data in JSON for downstream plotting with contextsv-cnv-plot --debug Debug mode with verbose logging --version Print version and exit -h, --help Print usage and exit From 24d3421d1639674f756d52a02b1c8393fe87bb46 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 02:38:50 -0400 Subject: [PATCH 44/49] update readme --- README.md | 48 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2aacb307..011682c3 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ First, install [Anaconda](https://www.anaconda.com/). Next, create a new environment. This installation has been tested with Python 3.10, Linux 64-bit. -``` +```bash conda create -n contextsv python=3.10 conda activate contextsv ``` ContextSV and its dependencies can then be installed using the following command: -``` +```bash conda install -c wglab -c conda-forge -c bioconda contextsv # Or using mamba (faster dependency resolution): @@ -38,19 +38,41 @@ After installation, you should have access to the following commands in your ter - `contextsv-cnv-plot`: utility to generate CNV plots from ContextSV JSON output - `contextscore`: [ContextScore](https://github.com/WGLab/ContextScore) utility for post-filtering of low-confidence SV calls -Example plotting usage: - -``` +Example usage: + +```bash +# SV calling example: +contextsv \ + --bam sample.bam \ + --ref hg38.fa \ + --outdir output/ \ + --threads 4 \ + --snp snps.vcf \ + --eth nfe \ + --pfb gnomadv4_filepaths.txt \ + --assembly-gaps hg38-gaps.bed \ # optional: assembly gaps file + --save-cnv # optional: save CNV calls in JSON + +# SV post-filtering example: +contextscore \ + --input input.vcf \ + --output scored.vcf \ + --sample-coverage 30 \ + --buildver hg38 \ + --threshold 0.2 \ + --annovar /path/to/annovar \ + --annovar-db /path/to/humandb + + +# CNV plotting example: contextsv-cnv-plot ./output/sv_calls.json chr3 --formats html,svg --output-dir ./CNV_Plots ``` -You can run `contextsv-cnv-plot --help` to see all plotting options. - ### Docker First, install [Docker](https://docs.docker.com/engine/install/). Pull the latest image from Docker hub, which contains the latest release and its dependencies. -``` +```bash docker pull genomicslab/contextsv ``` @@ -59,21 +81,21 @@ docker pull genomicslab/contextsv ContextSV requires HTSLib as a dependency that can be installed using [Anaconda](https://www.anaconda.com/). Create an environment containing HTSLib: -``` +```bash conda create -n htsenv -c bioconda -c conda-forge htslib conda activate htsenv ``` Then follow the instructions below to build ContextSV: -``` +```bash git clone https://github.com/WGLab/ContextSV cd ContextSV make ``` ContextSV can then be run: -``` +```bash ./build/contextsv --help Options: @@ -107,7 +129,7 @@ Download links for genome VCF files are located here (last updated April 3, ### Script for downloading gnomAD VCFs -``` +```bash download_dir="~/data/gnomad/v4.0.0/" chr_list=("1" "2" "3" "4" "5" "6" "7" "8" "9" "10" "11" "12" "13" "14" "15" "16" "17" "18" "19" "20" "21" "22" "X" "Y") @@ -122,7 +144,7 @@ Finally, create a text file that specifies the chromosome and its corresponding gnomAD filepath. This file will be passed in as an argument: **gnomadv4_filepaths.txt** -``` +```bash 1=~/data/gnomad/v4.0.0/gnomad.genomes.v4.0.sites.chr1.vcf.bgz 2=~/data/gnomad/v4.0.0/gnomad.genomes.v4.0.sites.chr2.vcf.bgz 3=~/data/gnomad/v4.0.0/gnomad.genomes.v4.0.sites.chr3.vcf.bgz From 9cf06bfd642edb77e332ab2e3439eb31ab2982a7 Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 02:59:44 -0400 Subject: [PATCH 45/49] update Dockerfile --- Dockerfile | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 95933f28..a5c3e7ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,16 +6,24 @@ ARG CONTEXTSV_VERSION WORKDIR /app -RUN apt-get update -RUN conda update conda +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +RUN conda update -y conda + +# Install ContextSV and plotting dependencies. +RUN conda config --add channels wglab \ + && conda config --add channels conda-forge \ + && conda config --add channels bioconda \ + && conda create -y -n contextsv python=3.10 \ + && conda install -y -n contextsv -c wglab -c conda-forge -c bioconda \ + contextsv=${CONTEXTSV_VERSION} plotly python-kaleido \ + && conda clean -afy + +# Smoke test both commands at build time. +RUN conda run -n contextsv contextsv --help \ + && conda run -n contextsv contextsv-cnv-plot --help -# Install ContextSV -RUN conda config --add channels wglab -RUN conda config --add channels conda-forge -RUN conda config --add channels bioconda -RUN conda create -n contextsv python=3.9 -RUN echo "conda activate contextsv" >> ~/.bashrc SHELL ["/bin/bash", "--login", "-c"] -RUN conda install -n contextsv -c wglab -c conda-forge -c bioconda contextsv=${CONTEXTSV_VERSION} && conda clean -afy -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "contextsv", "contextsv"] +# Default command remains contextsv, but this allows overriding with contextsv-cnv-plot. +ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "contextsv"] +CMD ["contextsv"] From c1e453ecd725cc567cf7f0db7ff777df7afedcce Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 03:34:46 -0400 Subject: [PATCH 46/49] update docker readme --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 011682c3..ce0e5cfd 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,27 @@ Pull the latest image from Docker hub, which contains the latest release and its docker pull genomicslab/contextsv ``` +Example usage: + +```bash +# SV calling: +docker run --rm genomicslab/contextsv --help + +# SV post-filtering: +docker run --rm \ + -v /path/to/data:/mnt \ + genomicslab/contextsv \ + contextscore \ + --help + +# CNV plotting: +docker run --rm \ + -v /path/to/data:/mnt \ + genomicslab/contextsv \ + contextsv-cnv-plot \ + --help +``` + ## Building from source (for testing/development) ContextSV requires HTSLib as a dependency that can be installed using [Anaconda](https://www.anaconda.com/). Create an environment From d09b759f1f5ffc787ba48f5292a72934f03a629a Mon Sep 17 00:00:00 2001 From: jonperdomo Date: Mon, 11 May 2026 03:48:10 -0400 Subject: [PATCH 47/49] update test Co-authored-by: Copilot --- tests/test_general.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_general.py b/tests/test_general.py index 9efc37b1..8f8a1223 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -90,8 +90,6 @@ def test_run_basic(): "--hmm", HMM_FILE, "--eth", "nfe", "--pfb", PFB_FILE, - "--eps", "0.1", - "--min-pts-pct", "0.1", "--assembly-gaps", GAP_FILE, "--save-cnv", "--debug" From cd9e249b146e680c4e6ffbd7db92154c100dfd5f Mon Sep 17 00:00:00 2001 From: Jon Perdomo Date: Mon, 11 May 2026 03:49:20 -0400 Subject: [PATCH 48/49] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index a0f670e8..d60cbe16 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -151,7 +151,7 @@ void printUsage(const std::string& programName) { << " -o, --outdir Output directory (required)\n" << " -t, --threads Number of threads\n" << " -h, --hmm HMM file\n" - << " -e, --eth ETH file\n" + << " -e, --eth Ethnicity identifier (e.g. nfe, asj)\n" << " -p, --pfb PFB file\n" << " --assembly-gaps Assembly gaps file\n" << " --save-cnv Save CNV data\n" From c9d2933e089cd5d4a8d75551f7cdecb9ba07e42c Mon Sep 17 00:00:00 2001 From: Jon Perdomo Date: Mon, 11 May 2026 03:53:08 -0400 Subject: [PATCH 49/49] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce0e5cfd..24476fdb 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ contextscore \ # CNV plotting example: -contextsv-cnv-plot ./output/sv_calls.json chr3 --formats html,svg --output-dir ./CNV_Plots +contextsv-cnv-plot ./output/CNVCalls.json chr3 --formats html,svg --output-dir ./CNV_Plots ``` ### Docker