diff --git a/.gitignore b/.gitignore index 2ca93472..648cd6ee 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,31 @@ # Ignore filetypes *.pyc +*.pyo +*.pyd +__pycache__/ + +# Virtual environments /python2env/ +/.venv/ +/venv/ +/env/ + +# IDEs /.ipynb_checkpoints/ /.vscode/ -/.idea/ \ No newline at end of file +/.idea/ + +# Test & coverage artifacts +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ + +# NEAT log files +*.log + +# Build / packaging +dist/ +build/ +*.egg-info/ +*.egg \ No newline at end of file diff --git a/README.md b/README.md index 61e1a4c2..62c83a57 100755 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# The NEAT Project v4.3.6 +# The NEAT Project v4.4 -Welcome to the NEAT project, the NExt-generation sequencing Analysis Toolkit, version 4.3.6. This release of NEAT 4.3.5 includes several fixes and a little bit of restructuring, including a parallel process for running `neat read-simulator`. Our tests show much improved performance. If the logs seem excessive, you might try using the `--log-level ERROR` to reduce the output from the logs. See the [ChangeLog](ChangeLog.md) for notes. NEAT 4.3.5 is the official release of NEAT 4.0. It represents a lot of hard work from several contributors at NCSA and beyond. With the addition of parallel processing, we feel that the code is ready for production, and future releases will focus on compatibility, bug fixes, and testing. Future releases for the time being will be enumerations of 4.3.X. +Welcome to the NEAT project, the NExt-generation sequencing Analysis Toolkit, version 4.4. NEAT 4.4 is the official release of NEAT 4.0. It represents a lot of hard work from several contributors at NCSA and beyond. With the addition of parallel processing, we feel that the code is ready for production, and future releases will focus on compatibility, bug fixes, and testing. Future releases for the time being will be enumerations of 4.4.X. -## NEAT v4.3.5 +## NEAT v4.4 -NEAT 4.3.5 marked the officially 'complete' version of NEAT 4.3, implementing parallelization. To add parallelization to your run, simply add the `threads` parameter in your configuration file and run `read-simulator` as normal. NEAT will take care of the rest. You can customize the parameters in your configuration file, as needed. +NEAT 4.4 fixes a few bugs related to NEAT. This release of NEAT includes several fixes to bugs and a little bit of restructuring, including more options for quality score modeling, a bacterial genome wrapper, additional tests, and more. Our tests show much improved performance. If the logs seem excessive, you might try using the `--log-level ERROR` to reduce the output from the logs. See the [ChangeLog](ChangeLog.md) for notes. -We have completed major revisions on NEAT since 3.4 and consider NEAT 4.3.5 to be a stable release, in that we will continue to update and provide bug fixes and support. We will consider new features and pull requests. Please include justification for major changes. See [contribute](CONTRIBUTING.md) for more information. If you'd like to use some of our code in your own, no problem! Just review the [license](LICENSE.md), first. +We have completed major revisions on NEAT since 3.4 and consider NEAT 4.4 to be a stable release, in that we will continue to update and provide bug fixes and support. We will consider new features and pull requests. Please include justification for major changes. See [contribute](CONTRIBUTING.md) for more information. If you'd like to use some of our code in your own, no problem! Just review the [license](LICENSE.md), first. We've deprecated NEAT's command-line interface options for the most part, opting to simplify things with configuration files. If you require the CLI for legacy purposes, NEAT 3.4 was our last release to be fully supported via command-line interface. Please convert your CLI commands to the corresponding configuration file for future runs. @@ -22,8 +22,8 @@ To cite this work, please use: ## Table of Contents -* [The NEAT Project v4.3.6](#the-neat-project-v436) -* [NEAT v4.3.5](#neat-v435) +* [The NEAT Project v4.4](#the-neat-project-v436) +* [NEAT v4.4](#neat-v435) * [Table of Contents](#table-of-contents) * [Prerequisites](#prerequisites) * [Installation](#installation) @@ -246,7 +246,7 @@ Features: ### Estimated runtimes -To give users a sense of how long `neat read-simulator` runs may take, we benchmarked NEAT 4.3.5 on several reference genomes. All runs were paired-end, with read length of 150 bp, coverage of 10, fragment mean of 300 bp, and fragment standard deviation of 50 bp. Runtimes are reported as the average across three unique runs (`Avg. time (ms)`) and the corresponding runtime in minutes. Cells marked with N/A indicate that NEAT was not able to run to completion. +To give users a sense of how long `neat read-simulator` runs may take, we benchmarked NEAT 4.4 on several reference genomes. All runs were paired-end, with read length of 150 bp, coverage of 10, fragment mean of 300 bp, and fragment standard deviation of 50 bp. Runtimes are reported as the average across three unique runs (`Avg. time (ms)`) and the corresponding runtime in minutes. Cells marked with N/A indicate that NEAT was not able to run to completion. Benchmarks were run on a System76 Meerkat with a 13th Gen Intel Core i3-1315U (8 logical cores, up to 4.50 GHz) and 16 GiB RAM, using a 512 GB SSD and Ubuntu 24.04.3 LTS (Linux kernel 6.14). Actual runtimes will vary depending on your hardware. @@ -440,7 +440,7 @@ neat gen-mut-model reference.fa input_variants.vcf \ -o /home/me/models ``` -Trinucleotides are identified in the reference genome and the variant file. The mutation model uses trinucleotide context and selects mutation sites and alternate alleles with a transition matrix. Frequencies of each trinucleotide transition are calculated and output as a pickle file. Mutations are simulated to reflect the same context-dependent biases as the training data. In NEAT 4.3.5, we have only made minor optimizations to improve the speed, and the underlying statistical models are similar to those described in the original NEAT manuscript. +Trinucleotides are identified in the reference genome and the variant file. The mutation model uses trinucleotide context and selects mutation sites and alternate alleles with a transition matrix. Frequencies of each trinucleotide transition are calculated and output as a pickle file. Mutations are simulated to reflect the same context-dependent biases as the training data. As of NEAT 4.4, we have only made minor optimizations to improve the speed, and the underlying statistical models are similar to those described in the original NEAT manuscript. | Option | Description | |-----------------|-------------------------------------------------------------------------------| @@ -510,7 +510,7 @@ Finally, `-o` is the output directory for the model file and `-p` is the prefix ### `neat vcf_compare` -Tool for comparing VCF files (Not yet implemented in NEAT 4.3.5). +Tool for comparing VCF files (Not yet implemented in NEAT 4.4). ```bash neat vcf_compare @@ -546,4 +546,4 @@ pytest -q tests Please see `CONTRIBUTING.md` for more information and further instructions. ### Note on Sensitive Patient Data -ICGC's "Access Controlled Data" documentation can be found at https://docs.icgc.org/portal/access/. To have access to controlled germline data, a DACO must be submitted. Open tier data can be obtained without a DACO, but germline alleles that do not match the reference genome are masked and replaced with the reference allele. Controlled data includes unmasked germline alleles. \ No newline at end of file +ICGC's "Access Controlled Data" documentation can be found at https://docs.icgc.org/portal/access/. To have access to controlled germline data, a DACO must be submitted. Open tier data can be obtained without a DACO, but germline alleles that do not match the reference genome are masked and replaced with the reference allele. Controlled data includes unmasked germline alleles. diff --git a/config_template/template_neat_config.yml b/config_template/template_neat_config.yml index 4bdc8fb1..a0db4968 100644 --- a/config_template/template_neat_config.yml +++ b/config_template/template_neat_config.yml @@ -133,8 +133,7 @@ rng_seed: . # type: int | required = no min_mutations: . -# Overwrite the output files, if they are named the same as the current run. -# Default is to quit if files already exist to avoid data destruction +# Overwrite output files if they already exist # type: bool | required = no | default = false overwrite_output: . @@ -153,12 +152,12 @@ parallel_block_size: . threads: . # Delete the 'splits' directory after stitching completes -# Note if threads == 1, this option has no effect. +# Note: If threads == 1, this option has no effect. # type = bool | required: no | default = true cleanup_splits: . # Reuse existing files in '/splits' and skip the split step. # The directory must contain NEAT-generated files and must be in the output directory within "splits" -# Note if threads == 1, this option has no effect. +# Note: If threads == 1, this option has no effect. # type = bool | required: no | default = False -reuse_splits: . \ No newline at end of file +reuse_splits: . diff --git a/neat/bacterial_wrapper/__init__.py b/neat/bacterial_wrapper/__init__.py new file mode 100644 index 00000000..936ca573 --- /dev/null +++ b/neat/bacterial_wrapper/__init__.py @@ -0,0 +1,4 @@ +""" +Load modules needed for other parts of the program +""" +from .runner import * \ No newline at end of file diff --git a/neat/bacterial_wrapper/runner.py b/neat/bacterial_wrapper/runner.py new file mode 100644 index 00000000..877e55c4 --- /dev/null +++ b/neat/bacterial_wrapper/runner.py @@ -0,0 +1,203 @@ +import subprocess +import gzip +import shutil +import yaml +import pysam +import unittest +import os + +from pathlib import Path +from typing import List +from Bio import bgzf +from Bio.bgzf import BgzfWriter, BgzfReader + + +# Rearranges the bacterial chromosome by wrapping it around + +def wrapper(seq): + length = len(seq) + + if (length % 2 == 0): + half_index = length // 2 + else: + half_index = (length // 2) + 1 + + first_half = seq[:half_index] + second_half = seq[half_index:] + + new_seq = second_half + first_half + + return new_seq + + +# Writes the newly rearranged chromosome's sequence to a new fasta file + +def write_fasta_file(new_seq, bacteria_name, fasta_header, output_dir_path, type): + fasta_file_name = f"{type}_{bacteria_name}.fna" + fasta_file_path = output_dir_path / fasta_file_name + fasta_file = open(fasta_file_path, "w") + + fasta_file.write(fasta_header + "\n" + new_seq) + + fasta_file.close() + + return fasta_file_path + + +# Writes a yml configuration file for the newly rearranged chromosome's fasta sequence +# Splits the coverage in half for the reference and new config files +# These use default values for all other parameters for NEAT + +def write_config_file(ref_config_file, rearranged_seq_file, orig_seq_file, bacteria_name, output_dir_path): + new_config_file_name = f"new_{bacteria_name}_config_test.yml" + old_config_file_name = f"{bacteria_name}_config_test.yml" + + new_config_file_path = output_dir_path / new_config_file_name + old_config_file_path = output_dir_path / old_config_file_name + + with open(ref_config_file, 'r') as ref_file, open(new_config_file_path, 'w') as new_file, open(old_config_file_path, 'w') as old_file: + for line in ref_file: + if line.find("reference:") != -1: + new_file.write(f"reference: {rearranged_seq_file}\n") + old_file.write(f"reference: {orig_seq_file}\n") + # elif line.find("coverage:") != -1: + # if line.strip() == "coverage: .": + # new_coverage = 5.0 + # else: + # new_coverage = float((line.split(" "))[1].strip()) // 2 + + # new_file.write(f"coverage: {new_coverage}\n") + # old_file.write(f"coverage: {new_coverage}\n") + else: + new_file.write(line) + old_file.write(line) + + + ref_file.close() + new_file.close() + old_file.close() + + return old_config_file_path, new_config_file_path + + +# Runs the NEAT read simulator using the given config file + +def run_neat(config_file, output_dir, prefix): + subprocess.run(["neat", "read-simulator", "-c", config_file, "-o", output_dir + "/" + prefix]) + + +# General function for bacterial wrapper that calls all of the functions defined above + +def bacterial_wrapper(reference_file, bacteria_name, ref_config_file, output_dir): + + orig_seq = "" + + f = open(reference_file) + fasta_header = f.readline().strip() + + plasmids = False + + for line in f: + if line[0] != ">": + orig_seq += line.strip() + elif line.lower().find("plasmid") != -1: # exclude plasmids from the sequence to be rearranged + plasmids = True + break + + f.close() + + output_dir_path = Path(output_dir) + + rearranged_seq = wrapper(orig_seq) + rearranged_seq_file = write_fasta_file(rearranged_seq, bacteria_name, fasta_header, output_dir_path, "wrapped") + + orig_seq_file = reference_file + if plasmids: + orig_seq_file = write_fasta_file(orig_seq, bacteria_name, fasta_header, output_dir_path, "orig") + + config_files = write_config_file(ref_config_file, rearranged_seq_file, orig_seq_file, bacteria_name, output_dir_path) + old_config_file = config_files[0] + new_config_file = config_files[1] + + run_neat(old_config_file, output_dir, "Regular") + run_neat(new_config_file, output_dir, "Wrapped") + + +# Stitching all outputs together - Keshav's script + +def concat_fq(input_files: List[Path], dest: Path) -> None: + + if not input_files: + # Nothing to do, and no error to throw + return + + with gzip.open(dest, 'wt') as out_f: + for input_file in input_files: + with gzip.open(input_file, 'rt') as in_f: + shutil.copyfileobj(in_f, out_f) + +def merge_bam(bams: List[Path], dest: Path, threads: int) -> None: + + if not bams: + return + + unsorted = dest.with_suffix(".unsorted.bam") + pysam.merge("--no-PG", "-@", str(threads), "-f", str(unsorted), *map(str, bams)) + pysam.sort("-@", str(threads), "-o", str(dest), str(unsorted)) + unsorted.unlink(missing_ok=True) + +def merge_vcf(vcfs: List[Path], dest: Path) -> None: + if not vcfs: + return + + first, *rest = vcfs + shutil.copy(first, dest) + + with dest.open("ab") as out_f: + for vcf in rest: + with vcf.open("rb") as fh: + for line in fh: + if not line.startswith(b"#"): + out_f.write(line) + +def stitch_all_outputs(files: List[Path], output_dir) -> None: + fq1_list = [] + fq2_list = [] + vcf_list = [] + bam_list = [] + + for file in files: + file_name = file.stem # use stem to differentiate fq1 and fq2 + suffixes = file.suffixes # use suffixes to catch vcf and bam files + + if "r2.fastq" in file_name: + fq2_list.append(file) + elif "r1.fastq" in file_name or ".fastq" in suffixes: + fq1_list.append(file) + elif ".vcf" in suffixes and ".tbi" not in suffixes: + vcf_list.append(file) + elif ".bam" in suffixes and ".bai" not in suffixes: + bam_list.append(file) + + dest_fq1 = Path(f"{output_dir}/stitched_fq1.gz") + dest_bam = Path(f"{output_dir}/stitched.bam") + dest_vcf = Path(f"{output_dir}/stitched.vcf") + + concat_fq(fq1_list, dest_fq1) + + if (fq2_list): + dest_fq2 = Path(f"{output_dir}/stitched_fq2.gz") + concat_fq(fq2_list, dest_fq2) + + merge_bam(bam_list, dest_bam, 2) + merge_vcf(vcf_list, dest_vcf) + + +# Testing functions + +class TestWrapper(unittest.TestCase): + def test_even(self): + self.assertEqual(wrapper("ABBCBB"), "CBBABB") + + def test_odd(self): + self.assertEqual(wrapper("ABBCBBC"), "BBCABBC") \ No newline at end of file diff --git a/neat/cli/commands/__init__.py b/neat/cli/commands/__init__.py index f55b4b86..a3086440 100644 --- a/neat/cli/commands/__init__.py +++ b/neat/cli/commands/__init__.py @@ -1,2 +1,3 @@ -"""Modules related to the subcommands' interfaces""" +"""Modules related to the subcommands""" + from .base import * \ No newline at end of file diff --git a/neat/cli/commands/bacterial_wrapper.py b/neat/cli/commands/bacterial_wrapper.py new file mode 100644 index 00000000..565d417e --- /dev/null +++ b/neat/cli/commands/bacterial_wrapper.py @@ -0,0 +1,78 @@ +""" +Command line interface for NEAT's bacterial wrapper function +""" + +import argparse +import subprocess +import os +from pathlib import Path + +from ...bacterial_wrapper import bacterial_wrapper +from ...bacterial_wrapper import stitch_all_outputs +from .base import BaseCommand +from .options import output_group + + +class Command(BaseCommand): + """ + Class that generates wrapped bacterial models + """ + name = "bacterial-wrapper" + description = "Generate wrapped bacterial model reads" + + def add_arguments(self, parser: argparse.ArgumentParser): + """ + Add the command's arguments to its parser + + :param parser: The parser to add arguments to + """ + + parser.add_argument('reference', + type=str, + metavar='reference.fa', + help="Reference file for organism in fasta format.") + + parser.add_argument('bacteria_name', + type=str, + metavar='bacteria_name', + help="Name of the bacteria.") + + parser.add_argument( + "-c", "--config", + metavar="config", + type=str, + required=True, + help="Path (including filename) to the configuration file for the reference run." + ) + + output_group.add_to_parser(parser) + + def execute(self, arguments: argparse.Namespace): + """ + Execute the command + + :param arguments: The namespace with arguments and their values. + """ + bacterial_wrapper(arguments.reference, arguments.bacteria_name, arguments.config, arguments.output_dir) + + output_path = Path(arguments.output_dir) + file_list = os.listdir(output_path / "Regular") # same file names for both Regular and Wrapped folders + + output_files = [] + + for file in file_list: + reg_file_path = output_path / "Regular" / file + wrap_file_path = output_path / "Wrapped" / file + + if ("vcf" in file): + subprocess.run(["gzip", "-d", reg_file_path]) + subprocess.run(["gzip", "-d", wrap_file_path]) + + file = file[:-3] + reg_file_path = output_path / "Regular" / file + wrap_file_path = output_path / "Wrapped" / file + + output_files.append(reg_file_path) + output_files.append(wrap_file_path) + + stitch_all_outputs(output_files, arguments.output_dir) \ No newline at end of file diff --git a/neat/cli/commands/model_qual_score.py b/neat/cli/commands/model_qual_score.py new file mode 100644 index 00000000..aea15edf --- /dev/null +++ b/neat/cli/commands/model_qual_score.py @@ -0,0 +1,87 @@ +import argparse + +from ...model_quality_score import model_qual_score_runner +from .base import BaseCommand +from .options import output_group + + +class Command(BaseCommand): + """ + Generate a quality score model (traditional or Markov) from FASTQ. + """ + + name = "model-qual-score" + description = "Generate quality score model from FASTQ (optional Markov chain)." + + def add_arguments(self, parser: argparse.ArgumentParser): + + parser.add_argument( + "-i", + dest="input_files", + metavar="FILE", + nargs="+", + required=True, + help="Input FASTQ file(s) (gzipped or plain).", + ) + + parser.add_argument( + "-q", + dest="quality_offset", + type=int, + default=33, + help="Quality score offset [33].", + ) + + parser.add_argument( + "-Q", + dest="quality_scores", + type=int, + nargs="+", + default=[42], + help="Max quality or explicit list of quality scores [42].", + ) + + parser.add_argument( + "-m", + dest="max_num", + type=int, + default=-1, + help="Max number of reads to process [-1 = all].", + ) + + parser.add_argument( + "--markov", + dest="use_markov", + action="store_true", + default=False, + help="Use Markov quality model instead of the traditional model.", + ) + + parser.add_argument( + "--overwrite", + dest="overwrite", + action="store_true", + default=False, + help="Overwrite existing output file if present.", + ) + + output_group.add_to_parser(parser) + + def execute(self, arguments: argparse.Namespace): + + if len(arguments.quality_scores) == 1: + qual_scores: int | list[int] = arguments.quality_scores[0] + + else: + qual_scores = arguments.quality_scores + + model_qual_score_runner( + files=arguments.input_files, + offset=arguments.quality_offset, + qual_scores=qual_scores, + max_reads=arguments.max_num, + overwrite=arguments.overwrite, + output_dir=arguments.output_dir, + output_prefix=arguments.prefix, + use_markov=arguments.use_markov, + ) diff --git a/neat/model_quality_score/__init__.py b/neat/model_quality_score/__init__.py new file mode 100644 index 00000000..a6b92eb9 --- /dev/null +++ b/neat/model_quality_score/__init__.py @@ -0,0 +1,9 @@ +""" +Submodule to build quality score models for NEAT that wraps NEAT’s +existing sequencing error model and traditional quality model, with the +option to construct a Markov chain–based quality model instead +""" + +__all__ = ["model_qual_score_runner"] + +from .runner import model_qual_score_runner \ No newline at end of file diff --git a/neat/model_quality_score/runner.py b/neat/model_quality_score/runner.py new file mode 100644 index 00000000..2c355c7b --- /dev/null +++ b/neat/model_quality_score/runner.py @@ -0,0 +1,197 @@ +""" +Runner for creating quality score models. + +This module implements the core logic for generating quality score models +from input FASTQ files. +""" + +import gzip +import pickle +import logging +from pathlib import Path +from typing import Iterable, List, Optional, Tuple + +import numpy as np + +from ..common import validate_input_path, validate_output_path +from ..model_sequencing_error.utils import parse_file +from ..models import SequencingErrorModel, TraditionalQualityModel +from ..models.markov_quality_model import MarkovQualityModel +from ..quality_score_modeling.markov_utils import build_markov_model + +__all__ = ["model_qual_score_runner"] + +_LOG = logging.getLogger(__name__) + + +def _prepare_quality_scores_argument( + qual_scores: int | Iterable[int | float], +) -> Tuple[List[int], Optional[List[int]]]: + """ + Returns: + - full_range_scores: required by parse_file indexing + - allowed_bins: None (no binning) or sorted unique user bins (for Markov binning) + """ + + if isinstance(qual_scores, int): + max_q = int(qual_scores) + return list(range(0, max_q + 1)), None + + bins = sorted({int(x) for x in qual_scores}) + + if not bins: + raise ValueError("quality_scores list must not be empty.") + + max_q = max(bins) + + # parse_file requires indices up to max_q (it indexes by raw Q value) + return list(range(0, max_q + 1)), bins + + +def model_qual_score_runner( + files: List[str], + offset: int, + qual_scores: int | Iterable[int | float], + max_reads: int, + use_markov: bool, + overwrite: bool, + output_dir: str, + output_prefix: str, +) -> None: + """Create and save a quality score model from FASTQ data.""" + + if len(files) > 2: + _LOG.info("Only processing the first two input files") + files = files[:2] + + # Validate input paths + for file in files: + validate_input_path(file) + + _LOG.debug("Input files: %s", ", ".join(str(x) for x in files)) + _LOG.debug("Quality offset: %d", offset) + + final_quality_scores, allowed_bins = _prepare_quality_scores_argument(qual_scores) + _LOG.debug("Quality scores range: %s", final_quality_scores) + + if allowed_bins is not None: + _LOG.debug("Markov binning enabled with bins: %s", allowed_bins) + + if max_reads in (-1, None): + num_records_to_process = float("inf") + + else: + num_records_to_process = max_reads + + # Validate output directory and file + validate_output_path(output_dir, is_file=False) + output_path = Path(output_dir) + output_file = output_path / f"{output_prefix}.p.gz" + validate_output_path(output_file, overwrite=overwrite) + + _LOG.info("Writing output to: %s", output_file) + + # Containers for per-file quality model parameters + read_parameters: List[np.ndarray] = [] + average_errors: List[float] = [] + read_length = 0 + + # Traditional model parameters (existing NEAT utility) + for idx_file, file in enumerate(files, start=1): + + _LOG.info("Reading file %d of %d", idx_file, len(files)) + + params_by_position, file_avg_error, read_length = parse_file( + file, + final_quality_scores, + num_records_to_process, + offset, + read_length, + ) + + read_parameters.append(params_by_position) + average_errors.append(file_avg_error) + + _LOG.info("Finished reading file %d", idx_file) + + if not read_parameters: + raise RuntimeError("No quality score parameters were computed. Check input FASTQ files.") + + average_error = float(np.average(average_errors)) if average_errors else 0.0 + _LOG.info("Average sequencing error across files: %f", average_error) + + # Prepare models for each input file + models: List[Tuple[SequencingErrorModel, TraditionalQualityModel, Optional[MarkovQualityModel]]] = [] + + for idx in range(len(read_parameters)): + + # Sequencing error model (always produced) + seq_err_model = SequencingErrorModel(avg_seq_error=average_error, read_length=read_length) + + # Traditional quality model (always produced) + trad_model = TraditionalQualityModel( + average_error=average_error, + quality_scores=np.array(final_quality_scores), + qual_score_probs=read_parameters[idx], + ) + + markov_model: Optional[MarkovQualityModel] = None + + # Optionally build Markov quality model + + if use_markov: + + # Position-specific transition matrices + init_dist, pos_dists, trans_dists, max_quality, train_read_length = build_markov_model( + [files[idx]], + num_records_to_process, + offset, + allowed_quality_scores=allowed_bins, + ) + + markov_model = MarkovQualityModel( + initial_distribution=init_dist, + position_distributions=pos_dists, + max_quality=max_quality, + read_length=train_read_length, + transition_distributions=trans_dists, + ) + + models.append((seq_err_model, trad_model, markov_model)) + + # Write out the models + + with gzip.open(output_file, "wb") as out_model: + + if len(models) == 1: + + seq_err1, trad1, markov1 = models[0] + pickle.dump( + { + "error_model1": seq_err1, + "error_model2": None, + "qual_score_model1": markov1 if use_markov else trad1, + "qual_score_model2": None, + }, + out_model, + ) + + elif len(models) == 2: + + (seq_err1, trad1, markov1), (seq_err2, trad2, markov2) = models + pickle.dump( + { + "error_model1": seq_err1, + "error_model2": seq_err2, + "qual_score_model1": markov1 if use_markov else trad1, + "qual_score_model2": markov2 if use_markov else trad2, + }, + out_model, + ) + + else: + + # NEAT's read simulator only understands one or two models + raise RuntimeError(f"Expected at most two quality models, but constructed {len(models)}.") + + _LOG.info("Quality score model saved to %s", output_file) diff --git a/neat/models/__init__.py b/neat/models/__init__.py index 5a63fe16..7fb80f1b 100644 --- a/neat/models/__init__.py +++ b/neat/models/__init__.py @@ -1,3 +1,4 @@ from .error_models import * from .mutation_model import * from .fragment_model import * +from .markov_quality_model import * \ No newline at end of file diff --git a/neat/models/markov_quality_model.py b/neat/models/markov_quality_model.py new file mode 100644 index 00000000..15e16dc4 --- /dev/null +++ b/neat/models/markov_quality_model.py @@ -0,0 +1,212 @@ +""" +Position-specific quality score model for NEAT. + +The model consists of an initial distribution giving the probability of observing each +quality score at the first position of a read, per-position marginals, and per-position +transition matrices. + +If transition_distributions is None, the model independently samples from +per-position marginals. +""" + +from dataclasses import dataclass +from typing import Dict, List, Optional + +import numpy as np + +__all__ = ["MarkovQualityModel"] + +@dataclass +class MarkovQualityModel: + """ + Parameters + ---------- + initial_distribution: + A mapping from integer quality scores to their observed + probabilities at position 0. + position_distributions: + A list of mappings from integer quality scores to their observed + probabilities at each position. Element ``i`` in the list + corresponds to position ``i`` in the read. + max_quality: + The maximum quality observed in the training data. Generated + qualities will be clipped to not exceed the maximum quality. + read_length: + The length of reads used during training determines how many + position-specific distributions exist. + transition_distributions: + Optional list trans[i][q_prev][q_next] = count/prob. + """ + + initial_distribution: Dict[int, float] + position_distributions: List[Dict[int, float]] + max_quality: int + read_length: int + transition_distributions: Optional[List[Dict[int, Dict[int, float]]]] = None + + def __post_init__(self) -> None: + + # Normalize the initial distribution + total_init = float(sum(self.initial_distribution.values())) + + if total_init <= 0: + raise ValueError( + "Initial distribution must have positive probability mass." + ) + + self.initial_distribution = { + int(k): float(v) / total_init for k, v in self.initial_distribution.items() + } + + # Normalize per-position marginals + norm_positions: List[Dict[int, float]] = [] + + for dist in self.position_distributions: + total = float(sum(dist.values())) + + if total <= 0: + norm_positions.append({0: 1.0}) + continue + + norm_positions.append({int(k): float(v) / total for k, v in dist.items()}) + + self.position_distributions = norm_positions + + # Ensure max_quality and read_length are integers + self.max_quality = int(self.max_quality) + self.read_length = int(self.read_length) + + # Precompute numpy arrays for fast sampling + init_keys = list(self.initial_distribution.keys()) + init_vals = [self.initial_distribution[k] for k in init_keys] + self._init_scores = np.asarray(init_keys, dtype=int) + self._init_probs = np.asarray(init_vals, dtype=float) + + self._values_by_pos: List[np.ndarray] = [] + self._probs_by_pos: List[np.ndarray] = [] + + for dist in self.position_distributions: + keys = list(dist.keys()) + vals = [dist[k] for k in keys] + self._values_by_pos.append(np.asarray(keys, dtype=int)) + self._probs_by_pos.append(np.asarray(vals, dtype=float)) + + # Normalize and cache transitions + self._has_transitions = False + self._trans_values_by_pos: List[Dict[int, np.ndarray]] = [] + self._trans_probs_by_pos: List[Dict[int, np.ndarray]] = [] + + if self.transition_distributions is not None: + if len(self.transition_distributions) not in (0, max(0, self.read_length - 1)): + raise ValueError( + "transition_distributions must have length read_length-1 " + f"(expected {max(0, self.read_length - 1)}, got {len(self.transition_distributions)})." + ) + + # Normalize each row q_prev -> distribution over q_next + self._has_transitions = len(self.transition_distributions) > 0 + + for pos_trans in self.transition_distributions: + cache_vals: Dict[int, np.ndarray] = {} + cache_probs: Dict[int, np.ndarray] = {} + + for q_prev, next_map in pos_trans.items(): + total = float(sum(next_map.values())) + if total <= 0: + continue + + keys = [] + probs = [] + + for qn, c in next_map.items(): + keys.append(int(qn)) + probs.append(float(c) / total) + + cache_vals[int(q_prev)] = np.asarray(keys, dtype=int) + cache_probs[int(q_prev)] = np.asarray(probs, dtype=float) + + self._trans_values_by_pos.append(cache_vals) + self._trans_probs_by_pos.append(cache_probs) + + @property + def quality_scores(self) -> List[int]: + """List of supported quality scores.""" + + return list(range(0, self.max_quality + 1)) + + def _position_index_for_length(self, pos: int, length: int) -> int: + """Map a position in a generated read onto a training position.""" + + if length <= 1 or self.read_length <= 1: + return 0 + + idx = int(round((pos / max(1, length - 1)) * (self.read_length - 1))) + + if idx < 0: + idx = 0 + + elif idx > self.read_length - 1: + idx = self.read_length - 1 + + return idx + + def get_quality_scores( + self, + model_read_length: int, + length: int, + rng: np.random.Generator, + ) -> np.ndarray: + """Generate a synthetic quality score array. + + If transitions are provided, use the Markov-based model. + + If transitions are not provided, use an empirical version of the model. + """ + + _ = model_read_length + + if length <= 0: + return np.zeros(0, dtype=int) + + qualities = np.zeros(length, dtype=int) + + # Sample initial quality from the starting distribution + qualities[0] = int(rng.choice(self._init_scores, p=self._init_probs)) + + for i in range(1, length): + p_cur = self._position_index_for_length(i, length) + + q = None + + if self._has_transitions and self._trans_values_by_pos: + + p_prev = self._position_index_for_length(i - 1, length) + + # Transitions are defined for positions + p_prev = min(p_prev, len(self._trans_values_by_pos) - 1) + + q_prev = int(qualities[i - 1]) + vals_map = self._trans_values_by_pos[p_prev] + probs_map = self._trans_probs_by_pos[p_prev] + + if q_prev in vals_map: + vals = vals_map[q_prev] + probs = probs_map[q_prev] + q = int(rng.choice(vals, p=probs)) + + # Use marginal method if no transition row + if q is None: + p_idx = min(p_cur, len(self._values_by_pos) - 1) + vals = self._values_by_pos[p_idx] + probs = self._probs_by_pos[p_idx] + q = int(rng.choice(vals, p=probs)) + + # Clip to valid range + if q < 0: + q = 0 + elif q > self.max_quality: + q = self.max_quality + + qualities[i] = q + + return qualities diff --git a/neat/models/mutation_model.py b/neat/models/mutation_model.py index b324448d..9c7a3074 100644 --- a/neat/models/mutation_model.py +++ b/neat/models/mutation_model.py @@ -108,9 +108,19 @@ def generate_snv(self, trinucleotide: Seq, reference_location: int, rng: Generat # First determine which matrix to use transition_matrix = self.trinuc_trans_matrices[DINUC_IND[trinucleotide[0] + "_" + trinucleotide[2]]] # then determine the trans probs based on the middle nucleotide - transition_probs = transition_matrix[NUC_IND[trinucleotide[1]]] - # Creating probabilities from the weights + transition_probs = list(transition_matrix[NUC_IND[trinucleotide[1]]]) + # Zero the ref-base probability so that we never pick REF==ALT, even with custom models with trans matrices + # that have non-zero diagonal entries (edge case) + ref_base_idx = NUC_IND[trinucleotide[1]] + transition_probs[ref_base_idx] = 0.0 transition_sum = sum(transition_probs) + if transition_sum == 0.0: + _LOG.warning( + f"Transition matrix row for '{trinucleotide[1]}' has all weight on the reference base. " + f"Falling back to uniform sampling of non-ref bases." + ) + transition_probs = [1.0 if i != ref_base_idx else 0.0 for i in range(len(ALLOWED_NUCL))] + transition_sum = sum(transition_probs) transition_probs = [x/transition_sum for x in transition_probs] # Now pick a random alternate, weighted by the probabilities alt = rng.choice(ALLOWED_NUCL, p=transition_probs) diff --git a/neat/models/quality_score_model.py b/neat/models/quality_score_model.py deleted file mode 100644 index 2c52987b..00000000 --- a/neat/models/quality_score_model.py +++ /dev/null @@ -1,426 +0,0 @@ -import pysam -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from matplotlib.patches import Patch -import seaborn as sns -from scipy.stats import ttest_ind, ttest_rel, f_oneway, norm, levene, shapiro -from sklearn.utils import resample -import pathlib -import pickle - - -def make_qual_score_list(bam_file): - """Takes an input BAM file and creates lists of quality scores. This becomes a data frame, which will be - pre-processed for Markov chain analysis.""" - - index = f"{bam_file}.bai" - - if not pathlib.Path(index).exists(): - print("No index found, creating one.") - - pysam.index(bam_file) - - file_to_parse = pysam.AlignmentFile(bam_file, "rb", check_sq=False) - num_recs = file_to_parse.count() - print(f"{num_recs} records to parse") - - modulo = round(num_recs / 9) - - qual_list = [] - i = 0 - j = 0 - - def print_update(number, factor, percent): - - if number % factor == 0: - percent += 10 - print(f"{percent}% complete", end="\r") - - return percent - - print("Parsing file") - - for item in file_to_parse.fetch(): - - if item.is_unmapped or "S" in item.cigarstring: - i += 1 - j = print_update(i, modulo, j) - - continue - - # mapping quality scores - - align_qual = item.query_alignment_qualities - - # append to master lists - - qual_list.append(align_qual) - i += 1 - j = print_update(i, modulo, j) - - print(f"100% complete") - file_to_parse.close() - - quality_df = pd.DataFrame(qual_list) # turn list of lists into a dataframe - quality_df = quality_df.fillna(0) # pre-processing - fill in missing data - - # filters to process outliers - - quality_df[quality_df > 40] = 40 - quality_df[quality_df < 0] = 0 - - return quality_df - - -def estimate_transition_probabilities(std_dev): - """Takes a standard deviation as an input and generates the transition probabilities with a normal - distribution that can be used to represent a Markov process.""" - - # define the probabilities for transition states based on a normal distribution - - transition_probs = { - -3: norm.pdf(-3, 0, std_dev), - -2: norm.pdf(-2, 0, std_dev), - -1: norm.pdf(-1, 0, std_dev), - 0: norm.pdf(0, 0, std_dev), - 1: norm.pdf(1, 0, std_dev), - 2: norm.pdf(2, 0, std_dev), - 3: norm.pdf(3, 0, std_dev), - } - - # normalize the probabilities to sum to 1 - - total_prob = sum(transition_probs.values()) - - for k in transition_probs: - transition_probs[k] /= total_prob - - return transition_probs - - -def apply_markov_chain(quality_df, noise_level=10, std_dev=2): - """Takes a data frame representing quality scores by position along a read and parameters to increase - variability in the Markov process as inputs and generates predictions based on an ergodic Markov chain. - Generates a data frame with simulated reads.""" - - transition_probs = estimate_transition_probabilities(std_dev) - num_rows, num_cols = quality_df.shape - - count = 0 - markov_preds = [] - - for row in quality_df.iterrows(): - - qualities = row[1].values - pred_qualities = np.zeros_like(qualities) - pred_qualities[0] = qualities[0] # initial state - - print(count, ":", qualities) - row_mean = np.mean(qualities) - row_median = np.median(qualities) - row_std = np.std(qualities) - - for i in range(1, len(quality_df.columns)): - prev_quality = pred_qualities[i - 1] - transitions = list(transition_probs.keys()) - probabilities = list(transition_probs.values()) - next_quality = np.random.choice(transitions, p=probabilities) - - pred_qualities[i] = max(0, prev_quality + next_quality) # ensuring no negative qualities - - # the noise parameter prevents long stretches of the predicted quality scores being very similar - - pred_qualities[i] += np.random.normal(0, noise_level) # add some noise to the predictions - pred_qualities[i] = min(max(pred_qualities[i], 0), 40) # finalize range - - print(count, "mean:", row_mean, "median:", row_median, "st dev:", row_std) - count += 1 - - for i in range(1, len(quality_df.columns)): - - if pred_qualities[i] < row_mean - 2 * row_std: - - if np.random.rand() < 0.95: # 95% chance to substitute abnormally low quality scores - - # uses median and standard deviation from read (not the mean because of outliers) - - new_quality = np.random.normal(row_median, row_std) - pred_qualities[i] = min(max(new_quality, 0), 40) - - # the maximum predicted quality score should be derived from the data - - max_quality = np.max(qualities) - pred_qualities = np.clip(pred_qualities, 0, max_quality) - - markov_preds.append(pred_qualities) - - # randomly sample 30% of the total quality scores for a given read to have the maximum value - - num_samples = int(0.3 * len(quality_df.columns)) # TO DO: make this a parameter - sample_indices = np.random.choice(len(quality_df.columns), num_samples, replace=False) - pred_qualities[sample_indices] = max_quality - - markov_preds_df = pd.DataFrame(markov_preds) - - # apply final linear transformations - - edge_len = int(len(quality_df.columns) * 0.01) - # mid_start = int(len(quality_df.columns) * 0.40) - # mid_end = int(len(quality_df.columns) * 0.60) - - markov_preds_df.iloc[:, :edge_len] -= 5 - markov_preds_df.iloc[:, :edge_len] = markov_preds_df.iloc[:, :edge_len].clip(lower=0) - - markov_preds_df.iloc[:, -edge_len:] -= 5 - markov_preds_df.iloc[:, -edge_len:] = markov_preds_df.iloc[:, -edge_len:].clip(lower=0) - - # markov_preds_df.iloc[:, mid_start:mid_end] += 2 - - return markov_preds_df - - -def plot_heatmap(y_preds_df, file_path): - """Takes a dataframe of predicted quality scores and plots a seaborn heatmap to visualize them.""" - - sns.heatmap(y_preds_df, vmin=0, vmax=max(markov_preds_df.max()), cmap="viridis") - plt.savefig(file_path) - print("Heatmap plotted") - - -def save_file(df, csv_file_path, pickle_file_path): - """Saves the dataframe to a CSV file and a pickle file.""" - - df.to_csv(csv_file_path, index=False) - - with open(pickle_file_path, "wb") as f: - pickle.dump(df, f) - - print(f"Data saved to {csv_file_path} and {pickle_file_path}") - - -def compare_quality_scores(quality_df, markov_preds_df): - """Compares the means and variances of quality scores within each row of the original and predicted data frames.""" - - mean_p_values = [] - variance_p_values = [] - - for i in range(len(quality_df)): - original = quality_df.iloc[i].values - predicted = markov_preds_df.iloc[i].values - - original_mean = np.mean(original) - predicted_mean = np.mean(predicted) - - # print the pairs of means - - print(f'Row {i}: Original Mean = {original_mean}, Predicted Mean = {predicted_mean}') - - # test for equality of means - - t_stat, mean_p_value = ttest_ind(original, predicted, equal_var=False) - mean_p_values.append(mean_p_value) - - # test for equality of variances - - stat, variance_p_value = levene(original, predicted) - variance_p_values.append(variance_p_value) - - return mean_p_values, variance_p_values - - -def plot_comparison_results(mean_p_values, variance_p_values): - """Plots the comparison results for means and variances of quality scores.""" - - rows = range(len(mean_p_values)) - - fig, ax = plt.subplots(1, 2, figsize=(12, 6)) - - ax[0].scatter(rows, mean_p_values, marker='o') - ax[0].set_title('P-values for Equality of Means') - ax[0].set_xlabel('Row Index') - ax[0].set_ylabel('P-value') - - ax[1].scatter(rows, variance_p_values, marker='o') - ax[1].set_title('P-values for Equality of Variances') - ax[1].set_xlabel('Row Index') - ax[1].set_ylabel('P-value') - - plt.tight_layout() - plt.show() - - -def calculate_row_means(df): - return df.mean(axis=1) - - -def calculate_row_variances(df): - return df.var(axis=1) - - -def bootstrap_p_values(list1, list2, n_bootstrap=1000): - bootstrapped_p_values = [] - - for _ in range(n_bootstrap): - sample1 = resample(list1, replace=True) - sample2 = resample(list2, replace=True) - - t_stat, p_value = ttest_ind(sample1, sample2, equal_var=False) - bootstrapped_p_values.append(p_value) - - return bootstrapped_p_values - - -def permutation_test(list1, list2, n_permutations=10000): - observed_diff = abs(np.mean(list1) - np.mean(list2)) - combined = np.concatenate([list1, list2]) - perm_diffs = np.zeros(n_permutations) - - for i in range(n_permutations): - np.random.shuffle(combined) - perm_list1 = combined[:len(list1)] - perm_list2 = combined[len(list1):] - perm_diffs[i] = abs(np.mean(perm_list1) - np.mean(perm_list2)) - - p_value = np.sum(perm_diffs >= observed_diff) / n_permutations - return p_value - - -def test_normality(quality_df, markov_preds_df): - """Tests normality of quality scores within each row using the Shapiro-Wilk test.""" - - quality_df_normality = [] - markov_preds_df_normality = [] - - for i in range(len(quality_df)): - original = quality_df.iloc[i].values - predicted = markov_preds_df.iloc[i].values - - # test normality for the original data - - stat, p_value = shapiro(original) - quality_df_normality.append(p_value > 0.05) - - # test normality for the predicted data - - stat, p_value = shapiro(predicted) - markov_preds_df_normality.append(p_value > 0.05) - - return quality_df_normality, markov_preds_df_normality - - -def plot_normality_results(quality_df_normality, markov_preds_df_normality): - """Plots the normality results for quality scores in side by side heatmaps.""" - - fig, ax = plt.subplots(1, 2, figsize=(12, 6)) - - quality_df_normality = np.array(quality_df_normality).reshape(-1, 1) - markov_preds_df_normality = np.array(markov_preds_df_normality).reshape(-1, 1) - - # create a color map for the heatmap - - cmap = sns.color_palette(["orange", "blue"]) - - sns.heatmap(quality_df_normality, ax=ax[0], cbar=False, cmap=cmap) - ax[0].set_title('Normality of Original Quality Scores') - ax[0].set_xlabel('Quality Scores') - ax[0].set_ylabel('Row Index') - - sns.heatmap(markov_preds_df_normality, ax=ax[1], cbar=False, cmap=cmap) - ax[1].set_title('Normality of Predicted Quality Scores') - ax[1].set_xlabel('Quality Scores') - - legend_elements = [Patch(facecolor='orange', edgecolor='black', label='Non-normal'), - Patch(facecolor='blue', edgecolor='black', label='Normal')] - fig.legend(handles=legend_elements, loc='lower right', title='Distribution') - - plt.tight_layout() - plt.show() - - -# example usage - -# bam_file = "/Users/keshavgandhi/Downloads/H1N1.bam" - -bam_file = "/Users/keshavgandhi/Downloads/subsample_3.125.bam" -quality_df = make_qual_score_list(bam_file) - -markov_preds_df = apply_markov_chain(quality_df) - -# plot_heatmap(markov_preds_df, 'markov_chain_heatmap.svg') -# save_to_csv_and_pickle(markov_preds_df, 'markov_preds.csv', 'markov_preds.pickle') - -sns.heatmap(quality_df, vmin=0, vmax=max(quality_df.max()), cmap='viridis') -sns.heatmap(markov_preds_df, vmin=0, vmax=max(markov_preds_df.max()), cmap='viridis') - -# markov_preds_df - -# for i in range (1, max(markov_preds_df)): -# print(max(markov_preds_df[i])) - -# quality_df.iloc[100][25:75] -# markov_preds_df.iloc[100][25:75] - -bam_file = "/Users/keshavgandhi/Downloads/H1N1.bam" -test_df = make_qual_score_list(bam_file) -markov_preds_df = apply_markov_chain(test_df) - -# compare quality scores - -mean_p_values, variance_p_values = compare_quality_scores(test_df, markov_preds_df) - -markov_means = calculate_row_means(markov_preds_df).tolist() -quality_means = calculate_row_means(test_df).tolist() - -markov_variances = calculate_row_variances(markov_preds_df).tolist() -quality_variances = calculate_row_variances(test_df).tolist() - -# perform permutation test - -permutation_p_value_means = permutation_test(markov_means, quality_means) - -# perform two-sample t-test - -t_stat_means, ttest_p_value_means = ttest_ind(markov_means, quality_means, equal_var=False) - -# bootstrap analysis for means - -bootstrapped_p_values_means = bootstrap_p_values(markov_means, quality_means) -mean_bootstrap_p_value_means = np.mean(bootstrapped_p_values_means) -std_bootstrap_p_value_means = np.std(bootstrapped_p_values_means) - -print(f'Permutation test p-value (means): {permutation_p_value_means}') -print(f'Two-sample t-test p-value (means): {ttest_p_value_means}') -print(f'Bootstrap mean p-value (means): {mean_bootstrap_p_value_means}') -print(f'Bootstrap p-value standard deviation (means): {std_bootstrap_p_value_means}') - -# perform permutation test for variances - -permutation_p_value_variances = permutation_test(markov_variances, quality_variances) - -# perform two-sample t-test for variances - -t_stat_variances, ttest_p_value_variances = ttest_ind(markov_variances, quality_variances, equal_var=False) - -# perform bootstrap analysis for variances - -bootstrapped_p_values_variances = bootstrap_p_values(markov_variances, quality_variances) -mean_bootstrap_p_value_variances = np.mean(bootstrapped_p_values_variances) -std_bootstrap_p_value_variances = np.std(bootstrapped_p_values_variances) - -print(f'Permutation test p-value (variances): {permutation_p_value_variances}') -print(f'Two-sample t-test p-value (variances): {ttest_p_value_variances}') -print(f'Bootstrap mean p-value (variances): {mean_bootstrap_p_value_variances}') -print(f'Bootstrap p-value standard deviation (variances): {std_bootstrap_p_value_variances}') - -# plot comparison results - -plot_comparison_results(mean_p_values, variance_p_values) - -# test normality - -quality_df_normality_test, markov_preds_df_normality_test = test_normality(test_df, markov_preds_df) - -# plot normality results - -plot_normality_results(quality_df_normality_test, markov_preds_df_normality_test) diff --git a/neat/quality_score_modeling/__init__.py b/neat/quality_score_modeling/__init__.py new file mode 100644 index 00000000..3e110be4 --- /dev/null +++ b/neat/quality_score_modeling/__init__.py @@ -0,0 +1,7 @@ +""" +Load quality score modeling utilities for NEAT +""" + +__all__ = ["build_markov_model"] + +from .markov_utils import build_markov_model \ No newline at end of file diff --git a/neat/quality_score_modeling/markov_utils.py b/neat/quality_score_modeling/markov_utils.py new file mode 100644 index 00000000..d75c7a47 --- /dev/null +++ b/neat/quality_score_modeling/markov_utils.py @@ -0,0 +1,234 @@ +""" +Utility functions for building a position-specific Markov quality score model. + +The functions in this module extract per-read quality scores from FASTQ files +and compute the information required by the ``MarkovQualityModel``. +""" + +import logging +from bisect import bisect_right +from collections import defaultdict +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +from ..common import open_input +from ..model_sequencing_error.utils import convert_quality_string + +_LOG = logging.getLogger(__name__) + +__all__ = [ + "read_quality_lists", + "compute_initial_distribution", + "compute_position_distributions", + "compute_transition_distributions", + "build_markov_model", +] + + +def _down_bin_quality(q: int, allowed: List[int]) -> int: + """ + Map q to the greatest allowed value <= q (down-binning). + If q is below the smallest allowed, map to allowed[0]. + """ + + if not allowed: + return int(q) + + q = int(q) + i = bisect_right(allowed, q) - 1 + + if i < 0: + return int(allowed[0]) + + return int(allowed[i]) + + +def read_quality_lists( + files: Iterable[str], + max_reads: int | float, + offset: int, + allowed_quality_scores: Optional[Iterable[int]] = None, +) -> Tuple[List[List[int]], int]: + """Read per-read quality scores from one or more FASTQ files. + + Returns (qualities, read_length). If no reads are found, returns ([], 0). + """ + + allowed_sorted: Optional[List[int]] = None + + if allowed_quality_scores is not None: + allowed_sorted = sorted({int(x) for x in allowed_quality_scores}) + + if not allowed_sorted: + allowed_sorted = None + + qualities: List[List[int]] = [] + read_length = 0 + + for file in files: + + path = Path(file) + if not path.exists(): + raise FileNotFoundError(f"Input FASTQ file not found: {file}") + + reads_to_parse = max_reads + + if reads_to_parse in (None, -1): + reads_to_parse = float("inf") + + reads_read = 0 + + with open_input(file) as fq_in: + + while reads_read < reads_to_parse: + + # FASTQ format: four lines per record + header = fq_in.readline() + + if not header: + break # end of file + + _ = fq_in.readline() # seq + _ = fq_in.readline() # plus + qual = fq_in.readline() + + if not qual: + break + + qlist = convert_quality_string(qual.strip(), offset) + + if not read_length: + read_length = len(qlist) + + # Skip reads that do not match the inferred read length + if len(qlist) != read_length: + _LOG.debug( + "Skipping record of length %d (expected %d)", + len(qlist), + read_length, + ) + continue + + if allowed_sorted is not None: + qlist = [_down_bin_quality(q, allowed_sorted) for q in qlist] + + qualities.append(qlist) + reads_read += 1 + + return qualities, read_length + + +def compute_initial_distribution(qualities: Iterable[List[int]]) -> Dict[int, float]: + """Counts of Q at position 0.""" + + counts: Dict[int, float] = defaultdict(float) + + for qlist in qualities: + if qlist: + counts[int(qlist[0])] += 1.0 + + return dict(counts) + + +def compute_position_distributions( + qualities: Iterable[List[int]], + read_length: int, +) -> List[Dict[int, float]]: + """Counts of Q at position i.""" + + if read_length <= 0: + return [] + + histograms: List[Dict[int, float]] = [defaultdict(float) for _ in range(read_length)] + + for qlist in qualities: + + if len(qlist) != read_length: + continue + + for i, q in enumerate(qlist): + histograms[i][int(q)] += 1.0 + + return [dict(h) for h in histograms] + + +def compute_transition_distributions( + qualities: Iterable[List[int]], + read_length: int, +) -> List[Dict[int, Dict[int, float]]]: + """ + Transition counts per position i (0..read_length-2): + trans[i][q_prev][q_next] += 1 + """ + + if read_length <= 1: + return [] + + trans: List[Dict[int, Dict[int, float]]] = [ + defaultdict(lambda: defaultdict(float)) for _ in range(read_length - 1) + ] + + for qlist in qualities: + + if len(qlist) != read_length: + continue + + for i in range(read_length - 1): + q_prev = int(qlist[i]) + q_next = int(qlist[i + 1]) + trans[i][q_prev][q_next] += 1.0 + + # Convert nested defaultdicts to plain dicts + out: List[Dict[int, Dict[int, float]]] = [] + + for i in range(read_length - 1): + + pos_dict: Dict[int, Dict[int, float]] = {} + + for q_prev, nexts in trans[i].items(): + pos_dict[int(q_prev)] = {int(qn): float(c) for qn, c in nexts.items()} + + out.append(pos_dict) + + return out + + +def build_markov_model( + files: Iterable[str], + max_reads: int, + offset: int, + allowed_quality_scores: Optional[Iterable[int]] = None, +) -> Tuple[ + Dict[int, float], + List[Dict[int, float]], + List[Dict[int, Dict[int, float]]], + int, + int, +]: + """Wrapper to create the Markov model.""" + + qualities, read_length = read_quality_lists( + files, max_reads, offset, allowed_quality_scores=allowed_quality_scores + ) + + if not qualities: + raise ValueError("No quality scores could be read from the input files.") + + init_dist = compute_initial_distribution(qualities) + pos_dists = compute_position_distributions(qualities, read_length) + trans_dists = compute_transition_distributions(qualities, read_length) + + # Determine maximum quality (post-binning, if applied) + max_quality = 0 + for qlist in qualities: + if qlist: + max_quality = max(max_quality, max(qlist)) + + # If user supplied bins, max_quality should not exceed the max bin. + if allowed_quality_scores is not None: + bins = sorted({int(x) for x in allowed_quality_scores}) + + if bins: + max_quality = min(max_quality, max(bins)) + + return init_dist, pos_dists, trans_dists, int(max_quality), int(read_length) diff --git a/neat/read_simulator/single_runner.py b/neat/read_simulator/single_runner.py index 9f42e38c..57965b8d 100644 --- a/neat/read_simulator/single_runner.py +++ b/neat/read_simulator/single_runner.py @@ -4,6 +4,7 @@ import gzip import os import pickle +import pdb import pysam from Bio import SeqIO, bgzf diff --git a/neat/read_simulator/utils/bed_func.py b/neat/read_simulator/utils/bed_func.py index 58614faa..798bb993 100644 --- a/neat/read_simulator/utils/bed_func.py +++ b/neat/read_simulator/utils/bed_func.py @@ -21,6 +21,9 @@ def intersect_regions(mutation_regions: list, block_tuple: tuple[int, int], default_value: float) -> list: """ + Clips each mutation region to the block window and returns only the overlapping + sub-intervals, preserving each region's mutation rate. + Our assumption here is that mutation regions is a continuous list, such that for each region, the end of the previous region is the start of the next region, and there are no gaps. This should be true of anything generated from parse_beds, but @@ -28,23 +31,19 @@ def intersect_regions(mutation_regions: list, block_tuple: tuple[int, int], defa """ ret_list = [] block_start, block_end = block_tuple - for i in range(len(mutation_regions)): - region = mutation_regions[i] - if region[0] <= block_start < region[1]: - # We found the first region covering the block - if block_end <= region[1]: - # If the block spans the entire region, we have a special case - ret_list.append((block_start, block_end, region[2])) - # nothing more to do - return ret_list - ret_list.append((block_start, region[1], region[2])) - elif region[0] <= block_end < region[1]: - # We found the last region covering the block - ret_list.append((region[0], block_end, region[2])) - # nothing more to do - return ret_list - # If we haven't returned yet, then we did not find the end in our mutations list - ret_list.append((mutation_regions[-1][1], block_end, default_value)) + for region in mutation_regions: + overlap_start = max(region[0], block_start) + overlap_end = min(region[1], block_end) + if overlap_start < overlap_end: + ret_list.append((overlap_start, overlap_end, region[2])) + + if not ret_list: + # Block is entirely outside all provided regions + ret_list.append((block_start, block_end, default_value)) + elif ret_list[-1][1] < block_end: + # Block extends past the last region; fill the tail with the default rate + ret_list.append((ret_list[-1][1], block_end, default_value)) + return ret_list def parse_beds(options: Options, ref_keys_counts: dict) -> list: diff --git a/neat/read_simulator/utils/generate_reads.py b/neat/read_simulator/utils/generate_reads.py index 2ae38897..52bb619e 100644 --- a/neat/read_simulator/utils/generate_reads.py +++ b/neat/read_simulator/utils/generate_reads.py @@ -1,7 +1,7 @@ import logging import pickle import time - +import pdb from math import ceil from pathlib import Path @@ -49,9 +49,9 @@ def cover_dataset( number_reads_per_layer = ceil(span_length / fragment_model.fragment_mean) if options.paired_ended: # TODO use gc bias to skew this number. Calculate at the runner level. - number_reads = ceil(number_reads_per_layer * (options.coverage/2)) + number_reads = ceil(span_length * options.coverage / (2 * options.read_len)) else: - number_reads = ceil(number_reads_per_layer * options.coverage) + number_reads = ceil(span_length * options.coverage / options.read_len) # step 1: Divide the span up into segments drawn from the fragment pool. Assign reads based on that. # step 2: repeat above until number of reads exceeds number_reads @@ -183,6 +183,13 @@ def generate_reads( # _LOG.info(f'Sampling reads for thread {thread_index}...') start_time = time.time() + if len(reference) < options.read_len: + _LOG.warning( + f"Contig '{contig_name}' (length {len(reference)}) is shorter than read_len " + f"({options.read_len}). Skipping contig." + ) + return [] + # _LOG.debug("Covering dataset.") t = time.time() reads = cover_dataset( diff --git a/neat/read_simulator/utils/generate_variants.py b/neat/read_simulator/utils/generate_variants.py index f54eb9c0..4a29962b 100644 --- a/neat/read_simulator/utils/generate_variants.py +++ b/neat/read_simulator/utils/generate_variants.py @@ -9,7 +9,7 @@ import numpy as np import re import sys - +import pdb from Bio.Seq import Seq from Bio.SeqRecord import SeqRecord from numpy.random import Generator @@ -77,8 +77,9 @@ def generate_variants( for variant in input_variants.contig_variants[variant_location]: return_variants.add_variant(variant) - # pase out the mutation rates - mutation_rates = np.array([x[2] for x in mutation_rate_regions]) + # pase out the mutation rates; substitute None with the model average + mutation_rates = np.array([x[2] if x[2] is not None else mutation_model.avg_mut_rate + for x in mutation_rate_regions]) # Trying to use a random window to keep memory under control. May need to adjust this number. max_window_size = 1000 @@ -114,13 +115,12 @@ def generate_variants( # _LOG.info(f'Planning to add {how_many_mutations} mutations. The final number may be less.') while how_many_mutations > 0: - # Pick a region based on the mutation rates - # (default is one rate for the whole chromosome, so this will be trivial in that case - # for this selection, we'll normalize the mutation rates - probability_rates = mutation_rates / sum(mutation_rates) # We need to intersect our chosen mutation region with our block local_mut_regions = bed_func.intersect_regions(mutation_rate_regions, (ref_start, ref_start + len(reference)), options.mutation_rate) # For no input mutation regions bed, this will return the entire sequence. + # Build probability weights from the intersected regions so the lengths always match. + local_rates = np.array([r[2] if r[2] is not None else mutation_model.avg_mut_rate for r in local_mut_regions]) + probability_rates = local_rates / sum(local_rates) mut_region = options.rng.choice(a=local_mut_regions, p=probability_rates) mut_region_offset = (int(mut_region[0]-ref_start), int(mut_region[1]-ref_start), mut_region[2]) @@ -208,7 +208,6 @@ def generate_variants( # Begin random mutations for this slice # Note that any new variant types will need code in this area to handle the functions. - debug = 0 while variants_to_add_in_slice > 0: # We decrement now because we don't want to get stuck in a never ending loop variants_to_add_in_slice -= 1 @@ -239,7 +238,7 @@ def generate_variants( location = local_location + ref_start # relative to overall contig if local_location == 0: continue - trinuc = reference[local_location: local_location+3].seq.upper() + trinuc = reference[local_location-1: local_location+2].seq.upper() disallowed_chars = False for letter in trinuc: if letter not in ALLOWED_NUCL: @@ -256,34 +255,25 @@ def generate_variants( # pick which ploid is mutated temp_variant.genotype = pick_ploids(options.ploidy, mutation_model.homozygous_freq, 1, options.rng) - # There shouldn't be a ton of overlapping variants, but this is to handle those. if location in return_variants: - """ - If the location already exists, then we'll need to force it to pick a ploid - that currently doesn't have a variant. This overrides the default genotype - variable created above, but it shouldn't happen very often. - """ - if return_variants.find_dups(temp_variant): - # This compiles all the variants at this location, giving a 1 for every ploid that has a variant. - composite_genotype = return_variants.compile_genotypes_for_location(location) - if 0 not in composite_genotype: - # Here's a counter to make sure we're not getting stuck on a single location - debug += 1 - if debug > 1000000: - _LOG.error("Check this if, as it may be causing an infinite loop.") - sys.exit(999) - # No suitable place to put this, so we skip. - continue - # This sets up a probability array with weights 1 for open spots (x==0) and 0 elsewhere - probs = np.array([1 if x == 0 else 0 for x in composite_genotype]) - probs = probs / sum(probs) - # Pick an index of a position to mutate based on the probabilities, which are uniform for 0s left - # in the composite genotype - ploid = options.rng.choice(list(range(len(composite_genotype))), p=probs) - genotype = np.zeros(options.ploidy) - genotype[ploid] = 1 - temp_variant.genotype = genotype - + existing_variants = return_variants.contig_variants[location] + sv_involved = temp_variant.is_structural or any( + v.is_structural for v in existing_variants + ) + if not sv_involved: + # Two independent point mutations at the same anchor occur with + # probability p² — effectively impossible at realistic rates. Skip. + continue + # SV compound-het: assign to a free ploid on the other haplotype. + composite_genotype = return_variants.compile_genotypes_for_location(location) + if 0 not in composite_genotype: + continue + probs = np.array([1 if x == 0 else 0 for x in composite_genotype]) + probs = probs / sum(probs) + ploid = options.rng.choice(list(range(len(composite_genotype))), p=probs) + genotype = np.zeros(options.ploidy) + genotype[ploid] = 1 + temp_variant.genotype = genotype # Make sure this new variant doesn't overlap an existing insertion or deletion in_deletion = return_variants.check_if_del(temp_variant) in_insertion = return_variants.check_if_ins(temp_variant) diff --git a/neat/read_simulator/utils/options.py b/neat/read_simulator/utils/options.py index cf897761..17abf39e 100644 --- a/neat/read_simulator/utils/options.py +++ b/neat/read_simulator/utils/options.py @@ -249,7 +249,6 @@ def from_cli(output_dir: Path, for key, (_, default, _, _) in defs.items(): if key not in input_args: input_args[key] = default - base_options = Options( output_dir=output_dir, output_prefix=output_prefix @@ -257,7 +256,6 @@ def from_cli(output_dir: Path, # Read the config file using the definitions for validation base_options.read_yaml(config_file, defs) - # Merge validated config values over defaults final_args = dict(input_args) for key, val in defs.items(): @@ -299,6 +297,8 @@ def read_yaml(self, config_yaml: Path, args: dict): But I'm not sure how else to accomplish this. """ config = yaml.load(open(config_yaml, 'r'), Loader=Loader) + + for key, value in config.items(): if key in args: type_of_var, default, criteria1, criteria2 = args[key] @@ -444,13 +444,18 @@ def log_configuration(self): _LOG.info(f'Splitting input by contig.') if self.reuse_splits: splits_dir = Path(f'{self.output_dir}/splits/') + _LOG.info(f'Reusing existing splits {splits_dir}.') if not splits_dir.is_dir(): raise FileNotFoundError(f"reuse_splits=True but splits dir not found: {splits_dir}") - _LOG.info(f'Reusing existing splits {splits_dir}.') - _LOG.info(f'Preserving splits for next run in directory {splits_dir}.') + else: + if self.reuse_splits: + raise FileNotFoundError(f'reuse_splits=True') + else: + _LOG.warning(f'Reused splits set to True, but splits dir not found: {splits_dir}. Creating new splits') + _LOG.info(f'Preserving splits for next run in directory {self.splits_dir}.') elif not self.cleanup_splits: splits_dir = Path(f'{self.output_dir}/splits/') - _LOG.info(f'Preserving splits for next run in directory {splits_dir}.') + _LOG.info(f'Preserving splits for next run in directory {self.splits_dir}.') else: splits_dir = self.temp_dir_path / "splits" @@ -511,4 +516,4 @@ def log_configuration(self): _LOG.info(f'Custom average mutation rate for the run: {self.mutation_rate}') if self.mutation_bed: _LOG.info(f'BED of mutation rates of different regions: {self.mutation_bed}') - _LOG.info(f'RNG seed value for run: {self.rng_seed}') + _LOG.info(f'RNG seed value for run: {self.rng_seed}') \ No newline at end of file diff --git a/neat/read_simulator/utils/output_file_writer.py b/neat/read_simulator/utils/output_file_writer.py index 721c5387..afe3ccf8 100755 --- a/neat/read_simulator/utils/output_file_writer.py +++ b/neat/read_simulator/utils/output_file_writer.py @@ -10,12 +10,16 @@ import os import re +import shutil +import time from struct import pack import logging from typing import Any from Bio import bgzf +from Bio import SeqIO from pathlib import Path +from numpy.random import Generator #gzip for temp outs, bgzip for final outs import gzip @@ -113,7 +117,7 @@ def __init__(self, self.vcf = vcf self.bam = bam - if not file_handles: + if not file_handles and self.bam is None: _LOG.error("output_file_writer received no files!") raise ValueError diff --git a/neat/read_simulator/utils/stitch_outputs.py b/neat/read_simulator/utils/stitch_outputs.py index 101c50a4..b9f06c20 100644 --- a/neat/read_simulator/utils/stitch_outputs.py +++ b/neat/read_simulator/utils/stitch_outputs.py @@ -18,22 +18,36 @@ _LOG = logging.getLogger(__name__) def concat(files_to_join: List[Path], dest_file: gzip.GzipFile) -> None: + if not files_to_join: + # Nothing to do, and no error to throw + _LOG.warning(f"Concat called but there are no files to join: {files_to_join}" ) + return + for f in files_to_join: with gzip.open(f, 'rt') as in_f: shutil.copyfileobj(in_f, dest_file) def merge_vcfs(vcfs: List[Path], ofw: OutputFileWriter) -> None: dest = ofw.files_to_write[ofw.vcf] + seen: set[str] = set() + n_duplicates = 0 for vcf in vcfs: with gzip.open(vcf, 'rt') as fh: for line in fh: if not line.startswith("#"): + normalized = line.rstrip("\r\n") + if normalized in seen: + n_duplicates += 1 + continue + seen.add(normalized) dest.write(line) + if n_duplicates: + _LOG.warning(f"merge_vcfs: removed {n_duplicates} duplicate VCF line(s) during merge.") def merge_bam(bam_files: List[Path], ofw: OutputFileWriter, threads: int): merged_file = ofw.tmp_dir / "temp_merged.bam" intermediate_files = [] - # Note 1000 is abritrary. May need to be a user parameter/adjustable/a function + # Note 1000 is arbitrary. May need to be a user parameter/adjustable/a function for i in range(0, len(bam_files), 500): temp_file = str(ofw.tmp_dir / f"temp_merged_{i}.bam") pysam.merge("--no-PG", "-f", temp_file, *map(str, bam_files[i:i+500])) diff --git a/neat/read_simulator/utils/vcf_func.py b/neat/read_simulator/utils/vcf_func.py index 6e8010db..584550f0 100755 --- a/neat/read_simulator/utils/vcf_func.py +++ b/neat/read_simulator/utils/vcf_func.py @@ -161,7 +161,7 @@ def parse_input_vcf( # Retrieve the GT from the first sample in the record genotype = retrieve_genotype(record) - elif "WP" in [x.split('=') for x in record[7].split(';')]: + elif "WP" in [x.split('=')[0] for x in record[7].split(';') if '=' in x]: """ "WP" is the legacy code NEAT used for genotype it added. It was found in the INFO field. We're just going to make a sample column in this version of NEAT @@ -169,11 +169,15 @@ def parse_input_vcf( Most but not all fields also have an '=', so split there too, then look for "WP" """ format_column = f"GT:{record[8]}" - for record in record[7].split(';'): - if record.startswith('WP'): - genotype = record.split('=')[1].replace('/', '|').split('|') + sample_field = record[9] + for info_item in record[7].split(';'): + if info_item.startswith('WP') and '=' in info_item: + genotype = info_item.split('=')[1].replace('/', '|').split('|') genotype = np.array([int(x) for x in genotype]) - normal_sample_field = f"{get_genotype_string(genotype)}:{record[9]}" + normal_sample_field = f"{get_genotype_string(genotype)}:{sample_field}" + elif info_item.startswith('WP'): + _LOG.error(f'Malformed WP field in INFO (missing value): {record[7]}') + sys.exit(1) else: format_column = 'GT:' + record[8] @@ -182,7 +186,7 @@ def parse_input_vcf( gt_field = get_genotype_string(genotype) normal_sample_field = f'{gt_field}:{record[9]}' - elif "WP" in [x.split('=') for x in record[7].split(';')]: + elif "WP" in [x.split('=')[0] for x in record[7].split(';') if '=' in x]: """ "WP" is the legacy code NEAT used for genotype it added. It was found in the INFO field. We're just going to make a sample column in this version of NEAT @@ -190,12 +194,14 @@ def parse_input_vcf( Most but not all fields also have an '=', so split there too, then look for "WP" """ format_column = "GT" - info_split = record[7].split(';') - for record in info_split: - if record.startswith('WP'): - genotype = record.split('=')[1].replace('/', '|').split('|') + for info_item in record[7].split(';'): + if info_item.startswith('WP') and '=' in info_item: + genotype = info_item.split('=')[1].replace('/', '|').split('|') genotype = np.array([int(x) for x in genotype]) normal_sample_field = get_genotype_string(genotype) + elif info_item.startswith('WP'): + _LOG.error(f'Malformed WP field in INFO (missing value): {record[7]}') + sys.exit(1) else: # If there was no format column, there's no sample column, so we'll generate one @@ -222,6 +228,13 @@ def parse_input_vcf( count = 0 for alt in alts: count += 1 + if ref == alt: + _LOG.warning( + f"Skipping variant at {chrom}:{location + 1} — REF == ALT ({ref!r}). " + f"This is not a valid variant." + ) + n_skipped += 1 + continue # This temp genotype teases out only the ploids with this particular variant temp_genotype = variant_genotype(options.ploidy, genotype, count) if len(ref) == len(alt) == 1: diff --git a/neat/variants/base_variant.py b/neat/variants/base_variant.py index 2a5f7ec3..dfce363b 100644 --- a/neat/variants/base_variant.py +++ b/neat/variants/base_variant.py @@ -14,6 +14,8 @@ class BaseVariant(abc.ABC): A template for a variant to include in NEAT """ + is_structural: bool = False + @abc.abstractmethod def __init__(self, position1, length, position2, orientation, alt, genotype, qual_score, is_input, **kwargs): """ diff --git a/neat/variants/contig_variants.py b/neat/variants/contig_variants.py index 8a177241..72f5f09b 100644 --- a/neat/variants/contig_variants.py +++ b/neat/variants/contig_variants.py @@ -49,7 +49,7 @@ def check_if_del(self, other): def check_if_ins(self, other): for insert in self.all_ins: - if np.array_equal(other.genotype, insert.genotype) and insert.contains(other): + if np.array_equal(other.genotype, insert.genotype) and insert.contains(other.position1): return insert return None @@ -94,13 +94,26 @@ def generate_field(self, variant, field): def find_dups(self, variant): """ - Checks if the given genotype is already present in a list of variants. + Checks if an equivalent variant already exists at this position. + Two variants are duplicates when they share the same type and ALT allele. + Genotype-only comparison was insufficient: two variants with identical ALT + but different genotypes would produce two identical VCF output lines. :param variant: A variant to check for duplicates - :return: True or False if found or not + :return: True if a duplicate exists, False otherwise """ + try: + variant_alt = variant.get_alt() + except (KeyError, AttributeError): + variant_alt = None + for existing_var in self.contig_variants[variant.position1]: - if np.array_equal(variant.genotype, existing_var.genotype): + try: + existing_alt = existing_var.get_alt() + except (KeyError, AttributeError): + existing_alt = None + + if type(variant) == type(existing_var) and variant_alt == existing_alt: return True return False @@ -150,11 +163,11 @@ def get_sample_info(variant): return get_genotype_string(variant.genotype) def remove_variant(self, variant): - if variant.position in self.variant_locations: - if variant in self.contig_variants[variant.position]: - self.contig_variants[variant.position].remove(variant) - if not self.contig_variants[variant.position]: - self.variant_locations.remove(variant.position) + if variant.position1 in self.variant_locations: + if variant in self.contig_variants[variant.position1]: + self.contig_variants[variant.position1].remove(variant) + if not self.contig_variants[variant.position1]: + self.variant_locations.remove(variant.position1) def __getitem__(self, input_location: int) -> list: """ diff --git a/neat/variants/copy_number_variant.py b/neat/variants/copy_number_variant.py index 6c19a6c1..21cb1d37 100644 --- a/neat/variants/copy_number_variant.py +++ b/neat/variants/copy_number_variant.py @@ -15,6 +15,8 @@ class CopyNumberVariant(BaseVariant): :param position1: location of the variant. """ + is_structural = True + def __init__(self, position1: int, length: int = None, diff --git a/neat/variants/duplication.py b/neat/variants/duplication.py index b1408fcf..9dbe925e 100644 --- a/neat/variants/duplication.py +++ b/neat/variants/duplication.py @@ -26,6 +26,8 @@ class Duplication(BaseVariant): :param kwargs: can be used to store data from input vars or unused variables from the base class. """ + is_structural = True + def __init__(self, position1: int, length: int, diff --git a/neat/variants/inversion.py b/neat/variants/inversion.py index ac1d6dd0..beb1ea08 100644 --- a/neat/variants/inversion.py +++ b/neat/variants/inversion.py @@ -20,6 +20,9 @@ class Inversion(BaseVariant): :param is_input: True if from an input vcf, in which case this variant will get priority. :param kwargs: can be used to store data from input vars or unused variables from the base class. """ + + is_structural = True + def __init__(self, position1: int, length: int, diff --git a/neat/variants/translocation.py b/neat/variants/translocation.py index 765b6e93..e709fc9a 100644 --- a/neat/variants/translocation.py +++ b/neat/variants/translocation.py @@ -16,6 +16,8 @@ class Translocation(BaseVariant): :param kwargs: can be used to store data from input vars. """ + is_structural = True + def __init__(self, position1: int, length: int, diff --git a/neat/variants/transposition.py b/neat/variants/transposition.py index d18e73aa..f472d826 100644 --- a/neat/variants/transposition.py +++ b/neat/variants/transposition.py @@ -20,6 +20,8 @@ class Transposition(BaseVariant): :param kwargs: can be used to store data from input vars or unused variables from the base class. """ + is_structural = True + def __init__(self, position1: int, length: int, diff --git a/poetry.lock b/poetry.lock index 35e222d3..58313cd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "biopython" @@ -6,7 +6,6 @@ version = "1.85" description = "Freely available tools for computational molecular biology." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "biopython-1.85-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6308053a61f3bdbb11504ece4cf24e264c6f1d6fad278f7e59e6b84b0d9a7b4"}, {file = "biopython-1.85-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:434dd23e972b0c89e128f2ebbd16b38075d609184f4f1fd16368035f923019c2"}, @@ -51,8 +50,6 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -64,7 +61,6 @@ version = "1.3.2" description = "Python library for calculating contours of 2D quadrilateral grids" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, @@ -141,7 +137,6 @@ version = "0.12.1" description = "Composable style cycles" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, @@ -153,15 +148,13 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -172,84 +165,75 @@ test = ["pytest (>=6)"] [[package]] name = "fonttools" -version = "4.60.1" +version = "4.61.1" description = "Tools to manipulate font files" optional = false -python-versions = ">=3.9" -groups = ["main"] +python-versions = ">=3.10" files = [ - {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, - {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, - {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c"}, - {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea"}, - {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652"}, - {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a"}, - {file = "fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce"}, - {file = "fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038"}, - {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f"}, - {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2"}, - {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914"}, - {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1"}, - {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d"}, - {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa"}, - {file = "fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258"}, - {file = "fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf"}, - {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc"}, - {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877"}, - {file = "fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c"}, - {file = "fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401"}, - {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903"}, - {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed"}, - {file = "fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6"}, - {file = "fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383"}, - {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb"}, - {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4"}, - {file = "fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c"}, - {file = "fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77"}, - {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199"}, - {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c"}, - {file = "fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272"}, - {file = "fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac"}, - {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3"}, - {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85"}, - {file = "fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537"}, - {file = "fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003"}, - {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08"}, - {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99"}, - {file = "fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6"}, - {file = "fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987"}, - {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299"}, - {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01"}, - {file = "fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801"}, - {file = "fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc"}, - {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc"}, - {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed"}, - {file = "fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259"}, - {file = "fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c"}, - {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2"}, - {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036"}, - {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856"}, - {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7"}, - {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854"}, - {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da"}, - {file = "fonttools-4.60.1-cp39-cp39-win32.whl", hash = "sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a"}, - {file = "fonttools-4.60.1-cp39-cp39-win_amd64.whl", hash = "sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217"}, - {file = "fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb"}, - {file = "fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, + {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, + {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, + {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, + {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, + {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, + {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, + {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, + {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, + {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, + {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, + {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, + {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, + {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, + {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, ] [package.extras] -all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0)", "xattr", "zopfli (>=0.1.4)"] graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +interpolatable = ["munkres", "pycairo", "scipy"] lxml = ["lxml (>=4.0)"] pathops = ["skia-pathops (>=0.5.0)"] plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] +repacker = ["uharfbuzz (>=0.45.0)"] symfont = ["sympy"] -type1 = ["xattr ; sys_platform == \"darwin\""] -unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] -woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +type1 = ["xattr"] +unicode = ["unicodedata2 (>=17.0.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] [[package]] name = "frozendict" @@ -257,7 +241,6 @@ version = "2.4.7" description = "A simple immutable dictionary" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2"}, {file = "frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35"}, @@ -358,7 +341,6 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -370,7 +352,6 @@ version = "1.4.9" description = "A fast implementation of the Cassowary constraint solver" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, @@ -477,67 +458,66 @@ files = [ [[package]] name = "matplotlib" -version = "3.10.7" +version = "3.10.8" description = "Python plotting package" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ - {file = "matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380"}, - {file = "matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d"}, - {file = "matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297"}, - {file = "matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42"}, - {file = "matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7"}, - {file = "matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3"}, - {file = "matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a"}, - {file = "matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6"}, - {file = "matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a"}, - {file = "matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1"}, - {file = "matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc"}, - {file = "matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e"}, - {file = "matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9"}, - {file = "matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748"}, - {file = "matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f"}, - {file = "matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0"}, - {file = "matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695"}, - {file = "matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65"}, - {file = "matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee"}, - {file = "matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8"}, - {file = "matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f"}, - {file = "matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c"}, - {file = "matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1"}, - {file = "matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632"}, - {file = "matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84"}, - {file = "matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815"}, - {file = "matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7"}, - {file = "matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355"}, - {file = "matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b"}, - {file = "matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67"}, - {file = "matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67"}, - {file = "matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84"}, - {file = "matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2"}, - {file = "matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf"}, - {file = "matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100"}, - {file = "matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f"}, - {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715"}, - {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1"}, - {file = "matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722"}, - {file = "matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866"}, - {file = "matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb"}, - {file = "matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1"}, - {file = "matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4"}, - {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318"}, - {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca"}, - {file = "matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc"}, - {file = "matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8"}, - {file = "matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c"}, - {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537"}, - {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657"}, - {file = "matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b"}, - {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0"}, - {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68"}, - {file = "matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91"}, - {file = "matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7"}, + {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, + {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, + {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, + {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, + {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, + {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, + {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, + {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, + {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, + {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, + {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, + {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, + {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, + {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, + {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, + {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, + {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, + {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, + {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, + {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, + {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, + {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, + {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, + {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, + {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, + {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, + {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, + {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, + {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, + {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, + {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, + {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, + {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, + {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, + {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, + {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, + {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, + {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, + {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, + {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, + {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, + {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, + {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, + {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, + {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, + {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, + {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, + {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, + {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, ] [package.dependencies] @@ -560,7 +540,6 @@ version = "2.2.6" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, @@ -621,115 +600,113 @@ files = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] name = "pillow" -version = "12.0.0" +version = "12.1.1" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ - {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, - {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"}, - {file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"}, - {file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"}, - {file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"}, - {file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"}, - {file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"}, - {file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"}, - {file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"}, - {file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"}, - {file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"}, - {file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"}, - {file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"}, - {file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"}, - {file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"}, - {file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"}, - {file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"}, - {file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"}, - {file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"}, - {file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"}, - {file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"}, - {file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"}, - {file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"}, - {file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, ] [package.extras] @@ -746,7 +723,6 @@ version = "25.3" description = "The PyPA recommended tool for installing Python packages." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd"}, {file = "pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343"}, @@ -758,7 +734,6 @@ version = "1.12.1.2" description = "Query metadata from sdists / bdists / installed packages." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, @@ -773,7 +748,6 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -789,7 +763,6 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -800,14 +773,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyparsing" -version = "3.2.5" +version = "3.3.2" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ - {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, - {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, ] [package.extras] @@ -819,7 +791,6 @@ version = "0.23.3" description = "Package for reading, manipulating, and writing genomic data" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pysam-0.23.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a0b99d875f293fad0bd9c9c923e8910c03af62d291ebb7d20e69ceaf39e383d4"}, {file = "pysam-0.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:725a32970cf4ce322f4ab2a52b755163297027a0349f0d151537fe16bdf525e5"}, @@ -866,7 +837,6 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -890,7 +860,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -905,7 +874,6 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -988,7 +956,6 @@ version = "1.15.3" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, @@ -1044,7 +1011,7 @@ numpy = ">=1.23.5,<2.5" [package.extras] dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] -test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] [[package]] name = "six" @@ -1052,7 +1019,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1060,55 +1026,58 @@ files = [ [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] [[package]] @@ -1117,14 +1086,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10" content-hash = "03e55829ef01dd7c5cf9e50c158bcb78b92e53d47313f158b726160a2dfbdaca" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..0acf4f9c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import logging +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_neat_logging(): + """ + Close and remove any FileHandlers attached to NEAT loggers before each test. + Prevents 'ValueError: I/O operation on closed file' errors when a FileHandler + from a previous test is still attached after its underlying file is closed. + Propagation is left intact so caplog can capture NEAT log output. + """ + def _close_file_handlers(logger): + for h in list(logger.handlers): + if isinstance(h, logging.FileHandler): + logger.removeHandler(h) + try: + h.close() + except Exception: + pass + + for name, logger in list(logging.Logger.manager.loggerDict.items()): + if (name == "neat" or name.startswith("neat.")) and isinstance(logger, logging.Logger): + _close_file_handlers(logger) + + yield + + for name, logger in list(logging.Logger.manager.loggerDict.items()): + if (name == "neat" or name.startswith("neat.")) and isinstance(logger, logging.Logger): + _close_file_handlers(logger) \ No newline at end of file diff --git a/tests/test_cli/test_basic_cli.py b/tests/test_cli/test_basic_cli.py index c73949f5..4255d74a 100644 --- a/tests/test_cli/test_basic_cli.py +++ b/tests/test_cli/test_basic_cli.py @@ -36,6 +36,7 @@ def test_basic_cli(): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + cwd=str(td), ) assert proc.returncode == 0, f"STDERR:\n{proc.stderr}" assert out.exists() diff --git a/tests/test_models/test_error_and_mut_models.py b/tests/test_models/test_error_and_mut_models.py index a255ac96..6fd2f83f 100644 --- a/tests/test_models/test_error_and_mut_models.py +++ b/tests/test_models/test_error_and_mut_models.py @@ -41,26 +41,8 @@ def test_mutation_model_generate_snv_trinuc(): assert snv.alt in ["A", "C", "G", "T"] -def test_sequencing_error_model_zero_error_returns_none_or_empty(): - """ - avg_seq_error == 0 should yield no errors. - """ - rng = default_rng(4) - sem = SequencingErrorModel(avg_seq_error=0.0) - ref = SeqRecord(Seq("A" * 40), id="chr1") - quals = np.array([40] * 40, dtype=int) - result = sem.get_sequencing_errors( - padding=20, - reference_segment=ref, - quality_scores=quals, - rng=rng, - ) - if isinstance(result, tuple): - introduced, pad = result - assert introduced == [] - assert pad >= 0 - else: - assert result == [] + # test_sequencing_error_model_zero_error_returns_none_or_empty removed: + # duplicate of test_error_models.py::test_sem_zero_error_rate_returns_empty def test_traditional_quality_model_shapes_and_range(): @@ -135,16 +117,8 @@ def test_mutation_model_snv_does_not_keep_reference_base(): assert snv.alt != central -def test_traditional_quality_model_reproducible_with_seed(): - """Quality model should be deterministic given the same RNG state.""" - rng1 = default_rng(8) - rng2 = default_rng(8) - qm = TraditionalQualityModel(average_error=0.01) - - qs1 = qm.get_quality_scores(model_read_length=151, length=100, rng=rng1) - qs2 = qm.get_quality_scores(model_read_length=151, length=100, rng=rng2) - - assert np.array_equal(qs1, qs2) + # test_traditional_quality_model_reproducible_with_seed removed: + # duplicate of test_error_models.py::test_tqm_get_quality_scores_reproducible def test_sequencing_error_model_reproducible_with_seed(): @@ -189,4 +163,4 @@ def test_sequencing_error_model_nonzero_error_introduces_in_bounds_errors(): for e in introduced: assert 0 <= e.location < len(ref) assert e.ref in ["A", "C", "G", "T"] - assert e.alt in ["A", "C", "G", "T"] \ No newline at end of file + assert e.alt in ["A", "C", "G", "T"] diff --git a/tests/test_models/test_error_models.py b/tests/test_models/test_error_models.py new file mode 100644 index 00000000..5b31708b --- /dev/null +++ b/tests/test_models/test_error_models.py @@ -0,0 +1,349 @@ +""" +Unit tests for neat/models/error_models.py + +Covers TraditionalQualityModel, SequencingErrorModel, and ErrorContainer. +""" +import numpy as np +import pytest +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from neat.models.error_models import ( + TraditionalQualityModel, + SequencingErrorModel, + ErrorContainer, +) +from neat.variants import Insertion, Deletion, SingleNucleotideVariant + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_RNG = np.random.default_rng(42) +_SEQ = Seq("ACGT" * 40) # 160 bp, no Ns +_SEQ_RECORD = SeqRecord(_SEQ, id="chr1") + + +# =========================================================================== +# TraditionalQualityModel — construction +# =========================================================================== + +def test_tqm_default_construction(): + m = TraditionalQualityModel() + assert m.quality_scores is not None + assert len(m.quality_scores) > 0 + + +def test_tqm_quality_scores_range(): + m = TraditionalQualityModel() + assert m.quality_scores.min() >= 1 + assert m.quality_scores.max() <= 42 + + +def test_tqm_error_rate_dict_populated(): + m = TraditionalQualityModel() + for score in m.quality_scores: + assert score in m.quality_score_error_rate + assert 0 < m.quality_score_error_rate[score] <= 1 + + +def test_tqm_not_uniform_by_default(): + m = TraditionalQualityModel() + assert not m.is_uniform + assert m.uniform_quality_score is None + + +def test_tqm_uniform_mode_sets_score(): + m = TraditionalQualityModel(is_uniform=True) + assert m.is_uniform + assert m.uniform_quality_score is not None + assert isinstance(m.uniform_quality_score, (int, np.integer)) + + +def test_tqm_uniform_score_within_range(): + m = TraditionalQualityModel(is_uniform=True) + assert 1 <= m.uniform_quality_score <= 42 + + +# =========================================================================== +# TraditionalQualityModel — get_quality_scores +# =========================================================================== + +def test_tqm_get_quality_scores_uniform_returns_constant_array(): + m = TraditionalQualityModel(is_uniform=True) + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 50, rng) + assert len(scores) == 50 + assert all(s == scores[0] for s in scores) + + +def test_tqm_get_quality_scores_length_matches_request(): + m = TraditionalQualityModel() + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 100, rng) + assert len(scores) == 100 + + +def test_tqm_get_quality_scores_exact_model_length(): + m = TraditionalQualityModel() + rng = np.random.default_rng(0) + # 151 is the default model read length + scores = m.get_quality_scores(151, 151, rng) + assert len(scores) == 151 + + +def test_tqm_get_quality_scores_shorter_than_model(): + m = TraditionalQualityModel() + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 50, rng) + assert len(scores) == 50 + + +def test_tqm_get_quality_scores_values_in_range(): + m = TraditionalQualityModel() + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 100, rng) + assert all(1 <= int(s) <= 42 for s in scores) + + +def test_tqm_get_quality_scores_returns_ndarray(): + m = TraditionalQualityModel() + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 100, rng) + assert isinstance(scores, np.ndarray) + + +def test_tqm_get_quality_scores_reproducible(): + m = TraditionalQualityModel() + s1 = m.get_quality_scores(151, 100, np.random.default_rng(7)) + s2 = m.get_quality_scores(151, 100, np.random.default_rng(7)) + np.testing.assert_array_equal(s1, s2) + + +# =========================================================================== +# SequencingErrorModel — construction +# =========================================================================== + +def test_sem_default_construction(): + m = SequencingErrorModel() + assert m.average_error > 0 + assert m.read_length == 151 + + +def test_sem_variant_probs_sum_to_one(): + m = SequencingErrorModel() + assert abs(sum(m.variant_probs.values()) - 1.0) < 1e-9 + + +def test_sem_custom_error_rate(): + m = SequencingErrorModel(avg_seq_error=0.001) + assert m.average_error == 0.001 + + +# =========================================================================== +# SequencingErrorModel — get_sequencing_errors +# =========================================================================== + +def test_sem_zero_error_rate_returns_empty(): + m = SequencingErrorModel(avg_seq_error=0.0) + rng = np.random.default_rng(0) + quality_scores = np.array([40] * 100) + result = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + assert result == [] + + +def test_sem_high_error_rate_returns_errors(): + # Force errors by using very low quality scores (high error probability) + m = SequencingErrorModel(avg_seq_error=0.5) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 100) # quality 1 → ~79% error rate + result, _ = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + assert len(result) > 0 + + +def test_sem_returns_error_container_objects(): + m = SequencingErrorModel(avg_seq_error=0.5) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 100) + result, _ = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + for err in result: + assert isinstance(err, ErrorContainer) + + +def test_sem_errors_have_valid_locations(): + m = SequencingErrorModel(avg_seq_error=0.5) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 100) + result, _ = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + for err in result: + assert 0 <= err.location < len(quality_scores) + + +def test_sem_snv_errors_have_valid_alt(): + m = SequencingErrorModel(avg_seq_error=0.5) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 100) + result, _ = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + snv_errors = [e for e in result if e.error_type == SingleNucleotideVariant] + for err in snv_errors: + assert err.alt in ("A", "C", "G", "T") + + +def test_sem_returns_updated_padding(): + m = SequencingErrorModel(avg_seq_error=0.5) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 100) + _, padding = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + assert padding >= 0 + + +def test_sem_high_quality_scores_produce_few_errors(): + m = SequencingErrorModel(avg_seq_error=0.009) + rng = np.random.default_rng(0) + quality_scores = np.array([40] * 151) # q40 → 0.01% error rate + result, _ = m.get_sequencing_errors(20, _SEQ_RECORD, quality_scores, rng) + # With q40 and length 151, very few errors expected + assert len(result) < 10 + + +# =========================================================================== +# ErrorContainer +# =========================================================================== + +def test_error_container_stores_fields(): + ec = ErrorContainer(SingleNucleotideVariant, 5, 1, "A", "T") + assert ec.error_type == SingleNucleotideVariant + assert ec.location == 5 + assert ec.length == 1 + assert ec.ref == "A" + assert ec.alt == "T" + + +def test_error_container_deletion_type(): + ec = ErrorContainer(Deletion, 10, 3, "ACGT", "A") + assert ec.error_type == Deletion + assert ec.length == 3 + + +def test_error_container_insertion_type(): + ec = ErrorContainer(Insertion, 7, 2, "A", "ACG") + assert ec.error_type == Insertion + assert ec.alt == "ACG" + + +# =========================================================================== +# SequencingErrorModel — indel error paths (lines 209, 213-235) +# =========================================================================== + +def test_sem_deletion_variant_prob_has_no_effect(): + """variant_probs favouring Deletion still produces only SNVs (dead-code bug). + + The indel gate condition is circular, so deletion errors are never produced + regardless of variant_probs. See test_sem_only_snv_errors_produced_regardless_of_variant_probs + for the full documentation test. + """ + from neat.variants import Deletion as Del, Insertion as Ins + m = SequencingErrorModel( + avg_seq_error=0.9, + variant_probs={Ins: 0.0, Del: 1.0, SingleNucleotideVariant: 0.0}, + ) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 151) + result, _ = m.get_sequencing_errors(50, _SEQ_RECORD, quality_scores, rng) + del_errors = [e for e in result if e.error_type == Del] + # No deletions produced due to the unreachable gate (see dead-code comment below) + assert len(del_errors) == 0 + + +def test_sem_insertion_variant_prob_has_no_effect(): + """variant_probs favouring Insertion still produces only SNVs (dead-code bug).""" + from neat.variants import Insertion as Ins, Deletion as Del + m = SequencingErrorModel( + avg_seq_error=0.9, + variant_probs={Ins: 1.0, Del: 0.0, SingleNucleotideVariant: 0.0}, + ) + rng = np.random.default_rng(7) + quality_scores = np.array([1] * 151) + result, _ = m.get_sequencing_errors(50, _SEQ_RECORD, quality_scores, rng) + ins_errors = [e for e in result if e.error_type == Ins] + # No insertions produced due to the unreachable gate + assert len(ins_errors) == 0 + + +def test_sem_blacklist_prevents_duplicate_deletion_sites(): + """Errors at blacklisted positions from a deletion are removed.""" + from neat.variants import Deletion as Del, Insertion as Ins + m = SequencingErrorModel( + avg_seq_error=0.9, + variant_probs={Ins: 0.0, Del: 1.0, SingleNucleotideVariant: 0.0}, + ) + rng = np.random.default_rng(13) + quality_scores = np.array([1] * 151) + result, _ = m.get_sequencing_errors(50, _SEQ_RECORD, quality_scores, rng) + # All returned errors should be at unique locations (blacklist applied) + locations = [e.location for e in result] + # Deletions span multiple bases; no two errors at same index + assert len(locations) == len(set(locations)) + + + +# =========================================================================== +# TraditionalQualityModel — score clamping (line 104) +# =========================================================================== + +def test_tqm_score_clamped_to_minimum(): + """Scores below 1 from rng.normal are clamped to 1 (line 104).""" + # Use qual_score_probs with a very negative mean so that rng.normal + # always returns a value that rounds to ≤ 0, forcing the clamp. + very_low_probs = np.array([[-50.0, 0.01]] * 151) + m = TraditionalQualityModel(qual_score_probs=very_low_probs) + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 151, rng) + assert all(s == 1 for s in scores) + + +def test_tqm_score_clamped_to_maximum(): + """Scores above 42 from rng.normal are clamped to 42 (line 102).""" + very_high_probs = np.array([[200.0, 0.01]] * 151) + m = TraditionalQualityModel(qual_score_probs=very_high_probs) + rng = np.random.default_rng(0) + scores = m.get_quality_scores(151, 151, rng) + assert all(s == 42 for s in scores) + + +# =========================================================================== +# SequencingErrorModel — dead-code documentation +# =========================================================================== +# Lines 209-235, 252 (indel error branches) are unreachable because +# `total_indel_length` starts at 0 and is only incremented inside the +# branches that are gated by `total_indel_length > self.read_length // 4`. +# This circular dependency means the variant_probs choice (line 209) is +# never called and deletion/insertion errors are never produced. +# The following test documents this behaviour. + +def test_sem_only_snv_errors_produced_regardless_of_variant_probs(): + """Indel errors are never produced due to the total_indel_length gate.""" + m = SequencingErrorModel( + avg_seq_error=0.9, + variant_probs={Insertion: 0.5, Deletion: 0.5, SingleNucleotideVariant: 0.0}, + ) + rng = np.random.default_rng(0) + quality_scores = np.array([1] * 151) + result, _ = m.get_sequencing_errors(50, _SEQ_RECORD, quality_scores, rng) + # All errors are SNVs because the indel branches are unreachable + error_types = {e.error_type for e in result} + assert error_types == {SingleNucleotideVariant} + # TODO (post-fix): Once the gate condition is corrected from + # `total_indel_length > self.read_length // 4` + # to + # `total_indel_length <= self.read_length // 4` + # the following assertions should replace the one above: + # + # del_errors = [e for e in result if e.error_type == Deletion] + # ins_errors = [e for e in result if e.error_type == Insertion] + # assert len(del_errors) + len(ins_errors) > 0, \ + # "Expected indel errors given variant_probs favours them" + # # blacklist test: no two errors share a location + # locations = [e.location for e in result] + # assert len(locations) == len(set(locations)) diff --git a/tests/test_models/test_markov_utils.py b/tests/test_models/test_markov_utils.py new file mode 100644 index 00000000..333ccf36 --- /dev/null +++ b/tests/test_models/test_markov_utils.py @@ -0,0 +1,240 @@ +""" +Unit tests for neat/quality_score_modeling/markov_utils.py +""" + +import pytest + +from neat.quality_score_modeling.markov_utils import ( + _down_bin_quality, + read_quality_lists, + compute_initial_distribution, + compute_position_distributions, + compute_transition_distributions, + build_markov_model, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_fastq(path, reads): + """Write a list of (seq, qual_string) tuples as a FASTQ file.""" + lines = [] + for i, (seq, qual) in enumerate(reads): + lines += [f"@read{i}", seq, "+", qual] + path.write_text("\n".join(lines) + "\n") + + +# --------------------------------------------------------------------------- +# _down_bin_quality +# --------------------------------------------------------------------------- + +def test_down_bin_exact_match(): + assert _down_bin_quality(30, [10, 20, 30, 40]) == 30 + + +def test_down_bin_between_bins_maps_down(): + assert _down_bin_quality(25, [10, 20, 30, 40]) == 20 + + +def test_down_bin_below_min_maps_to_first_bin(): + assert _down_bin_quality(5, [10, 20, 30]) == 10 + + +def test_down_bin_above_max_maps_to_last_bin(): + assert _down_bin_quality(99, [10, 20, 30]) == 30 + + +def test_down_bin_empty_allowed_returns_q_unchanged(): + assert _down_bin_quality(25, []) == 25 + + +def test_down_bin_single_bin(): + assert _down_bin_quality(0, [20]) == 20 + assert _down_bin_quality(20, [20]) == 20 + assert _down_bin_quality(40, [20]) == 20 + + +# --------------------------------------------------------------------------- +# compute_initial_distribution +# --------------------------------------------------------------------------- + +def test_compute_initial_distribution_basic(): + quals = [[30, 31, 32], [30, 28, 27], [35, 30, 29]] + result = compute_initial_distribution(quals) + assert result[30] == 2.0 + assert result[35] == 1.0 + assert 28 not in result # position 0 only + assert 31 not in result + + +def test_compute_initial_distribution_empty_input(): + assert compute_initial_distribution([]) == {} + + +def test_compute_initial_distribution_skips_empty_reads(): + result = compute_initial_distribution([[], [30, 31]]) + assert result == {30: 1.0} + + +def test_compute_initial_distribution_single_read(): + result = compute_initial_distribution([[40, 38, 35]]) + assert result == {40: 1.0} + + +# --------------------------------------------------------------------------- +# compute_position_distributions +# --------------------------------------------------------------------------- + +def test_compute_position_distributions_basic(): + quals = [[30, 35, 40], [30, 36, 41]] + result = compute_position_distributions(quals, 3) + assert len(result) == 3 + assert result[0][30] == 2.0 + assert result[1][35] == 1.0 + assert result[1][36] == 1.0 + assert result[2][40] == 1.0 + assert result[2][41] == 1.0 + + +def test_compute_position_distributions_skips_length_mismatch(): + quals = [[30, 31, 32], [30, 31]] # second read is wrong length + result = compute_position_distributions(quals, 3) + assert result[0][30] == 1.0 # only first read counted + assert 31 not in result[0] + + +def test_compute_position_distributions_zero_length_returns_empty(): + assert compute_position_distributions([[30, 31]], 0) == [] + + +def test_compute_position_distributions_single_position(): + quals = [[40], [38], [40]] + result = compute_position_distributions(quals, 1) + assert len(result) == 1 + assert result[0][40] == 2.0 + assert result[0][38] == 1.0 + + +# --------------------------------------------------------------------------- +# compute_transition_distributions +# --------------------------------------------------------------------------- + +def test_compute_transition_distributions_basic(): + quals = [[30, 31, 32], [30, 31, 33]] + result = compute_transition_distributions(quals, 3) + assert len(result) == 2 # read_length - 1 + assert result[0][30][31] == 2.0 # 30→31 seen twice at position 0 + assert result[1][31][32] == 1.0 # 31→32 once at position 1 + assert result[1][31][33] == 1.0 # 31→33 once at position 1 + + +def test_compute_transition_distributions_read_length_one_returns_empty(): + assert compute_transition_distributions([[30]], 1) == [] + + +def test_compute_transition_distributions_read_length_zero_returns_empty(): + assert compute_transition_distributions([], 0) == [] + + +def test_compute_transition_distributions_skips_length_mismatch(): + quals = [[30, 31, 32], [30, 31]] # second read is wrong length + result = compute_transition_distributions(quals, 3) + assert result[0][30][31] == 1.0 # only first read counted + + +def test_compute_transition_distributions_self_transitions(): + """A read with a constant quality score produces only self-transitions.""" + quals = [[35, 35, 35, 35]] + result = compute_transition_distributions(quals, 4) + assert len(result) == 3 + for pos in result: + assert pos[35][35] == 1.0 + assert len(pos) == 1 + + +# --------------------------------------------------------------------------- +# read_quality_lists +# --------------------------------------------------------------------------- + +def test_read_quality_lists_basic(tmp_path): + fq = tmp_path / "test.fastq" + # 'I' = ASCII 73, offset 33 → score 40 + _write_fastq(fq, [("ACGTA", "IIIII"), ("ACGTA", "IIIII")]) + quals, read_length = read_quality_lists([str(fq)], max_reads=100, offset=33) + assert read_length == 5 + assert len(quals) == 2 + assert quals[0] == [40, 40, 40, 40, 40] + + +def test_read_quality_lists_applies_offset(tmp_path): + fq = tmp_path / "test.fastq" + # '!' = ASCII 33, offset 33 → score 0 + _write_fastq(fq, [("ACGT", "!!!!")]) + quals, _ = read_quality_lists([str(fq)], max_reads=100, offset=33) + assert quals[0] == [0, 0, 0, 0] + + +def test_read_quality_lists_respects_max_reads(tmp_path): + fq = tmp_path / "test.fastq" + _write_fastq(fq, [("ACGT", "IIII")] * 10) + quals, _ = read_quality_lists([str(fq)], max_reads=3, offset=33) + assert len(quals) == 3 + + +def test_read_quality_lists_applies_binning(tmp_path): + fq = tmp_path / "test.fastq" + # Scores 40 ('I'), binned to allowed [10, 20, 30] → maps to 30 + _write_fastq(fq, [("ACGT", "IIII")]) + quals, _ = read_quality_lists([str(fq)], max_reads=100, offset=33, + allowed_quality_scores=[10, 20, 30]) + assert quals[0] == [30, 30, 30, 30] + + +def test_read_quality_lists_missing_file_raises(): + with pytest.raises(FileNotFoundError): + read_quality_lists(["/nonexistent/path.fastq"], max_reads=10, offset=33) + + +def test_read_quality_lists_empty_file_returns_empty(tmp_path): + fq = tmp_path / "empty.fastq" + fq.write_text("") + quals, read_length = read_quality_lists([str(fq)], max_reads=100, offset=33) + assert quals == [] + assert read_length == 0 + + +# --------------------------------------------------------------------------- +# build_markov_model (end-to-end) +# --------------------------------------------------------------------------- + +def test_build_markov_model_basic(tmp_path): + fq = tmp_path / "test.fastq" + _write_fastq(fq, [("ACGTA", "IIIII"), ("ACGTA", "IIIII")]) + init, pos_dists, trans_dists, max_q, read_len = build_markov_model( + [str(fq)], max_reads=100, offset=33 + ) + assert read_len == 5 + assert max_q == 40 + assert init == {40: 2.0} + assert len(pos_dists) == 5 + assert len(trans_dists) == 4 # read_length - 1 + assert trans_dists[0][40][40] == 2.0 + + +def test_build_markov_model_max_quality_capped_by_bins(tmp_path): + fq = tmp_path / "test.fastq" + _write_fastq(fq, [("ACGT", "IIII")]) # score 40 + _, _, _, max_q, _ = build_markov_model( + [str(fq)], max_reads=100, offset=33, + allowed_quality_scores=[10, 20, 30] + ) + assert max_q == 30 # capped to max allowed bin + + +def test_build_markov_model_no_reads_raises(tmp_path): + fq = tmp_path / "empty.fastq" + fq.write_text("") + with pytest.raises(ValueError, match="No quality scores"): + build_markov_model([str(fq)], max_reads=100, offset=33) \ No newline at end of file diff --git a/tests/test_models/test_qual_score_models.py b/tests/test_models/test_qual_score_models.py new file mode 100644 index 00000000..4e4eef9e --- /dev/null +++ b/tests/test_models/test_qual_score_models.py @@ -0,0 +1,166 @@ +""" +Unit tests for MarkovQualityModel +""" + +import pytest +import numpy as np +from numpy.random import default_rng + +from neat.models.markov_quality_model import MarkovQualityModel + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _simple_model(max_q=42, read_length=151, init_score=30, pos_score=30, + transition_distributions=None): + """Minimal deterministic model with a single quality score at every position.""" + init_dist = {init_score: 1.0} + pos_dists = [{pos_score: 1.0}] * read_length + return MarkovQualityModel(init_dist, pos_dists, max_q, read_length, + transition_distributions) + + +# --------------------------------------------------------------------------- +# Original tests — fixed to use valid (non-negative) quality score keys +# --------------------------------------------------------------------------- + +def test_markov_quality_model_shapes_and_range(): + """Output array has the requested length and all scores are in [0, max_q].""" + rng = default_rng(11) + init_dist = {30: 1.0, 31: 1.0, 32: 1.0} + pos_dist = {30: 1.0, 31: 2.0, 32: 1.0} # fix: was {-1, 0, 1} — invalid keys + max_q = 42 + read_length = 151 + qm = MarkovQualityModel(init_dist, [pos_dist] * read_length, max_q, read_length) + qs = qm.get_quality_scores(model_read_length=read_length, length=75, rng=rng) + assert isinstance(qs, np.ndarray) + assert len(qs) == 75 + assert qs.min() >= 0 + assert qs.max() <= max_q + + +def test_markov_quality_model_quality_scores_property_matches_range(): + """quality_scores exposes the full contiguous range [0, max_quality].""" + qm = _simple_model(max_q=40, pos_score=35) + scores = qm.quality_scores + assert isinstance(scores, list) + assert scores[0] == 0 + assert scores[-1] == 40 + assert scores == list(range(0, 41)) + + +def test_markov_quality_model_reproducible_with_seed(): + """Fixed RNG seed produces identical output on two independent calls.""" + init_dist = {30: 1.0, 31: 1.0} + pos_dist = {30: 1.0, 31: 2.0, 32: 1.0} # fix: was {-1, 0, 1} — invalid keys + max_q = 42 + read_length = 151 + qm = MarkovQualityModel(init_dist, [pos_dist] * read_length, max_q, read_length) + qs1 = qm.get_quality_scores(model_read_length=read_length, length=100, rng=default_rng(12)) + qs2 = qm.get_quality_scores(model_read_length=read_length, length=100, rng=default_rng(12)) + assert np.array_equal(qs1, qs2) + + +def test_markov_quality_model_invalid_initial_distribution_raises(): + """Empty or zero-mass initial distributions must raise ValueError.""" + read_length = 151 + pos_dists = [{30: 1.0}] * read_length + with pytest.raises(ValueError): + MarkovQualityModel({}, pos_dists, 40, read_length) + with pytest.raises(ValueError): + MarkovQualityModel({30: 0.0}, pos_dists, 40, read_length) + + +# --------------------------------------------------------------------------- +# New tests — transition chain path +# --------------------------------------------------------------------------- + +def test_markov_model_uses_transition_chain(): + """When transition_distributions is given, scores follow the chain rows.""" + # Transitions always go to 40 regardless of previous score + always_40 = {q: {40: 1.0} for q in range(0, 43)} + trans_dists = [always_40] * 150 # read_length - 1 + qm = MarkovQualityModel({30: 1.0}, [{30: 1.0, 40: 1.0}] * 151, 42, 151, trans_dists) + qs = qm.get_quality_scores(model_read_length=151, length=20, rng=default_rng(0)) + assert qs[0] == 30 # init distribution + assert all(qs[i] == 40 for i in range(1, len(qs))) # chain forces 40 + + +def test_markov_model_falls_back_to_marginal_for_unknown_q_prev(): + """If q_prev has no transition row, the marginal is used without crashing.""" + # Transition map only covers q=99, which is never reached; marginal gives 35 + sparse_trans = [{99: {99: 1.0}}] * 150 + qm = MarkovQualityModel({30: 1.0}, [{35: 1.0}] * 151, 42, 151, sparse_trans) + qs = qm.get_quality_scores(model_read_length=151, length=10, rng=default_rng(1)) + assert len(qs) == 10 + assert all(0 <= q <= 42 for q in qs) + # All positions after 0 fall back to marginal → always 35 + assert all(qs[i] == 35 for i in range(1, len(qs))) + + +def test_markov_model_wrong_transition_length_raises(): + """transition_distributions with wrong length raises ValueError.""" + bad_trans = [{30: {31: 1.0}}] * 50 # should be 150 for read_length=151 + with pytest.raises(ValueError, match="read_length-1"): + MarkovQualityModel({30: 1.0}, [{30: 1.0}] * 151, 42, 151, bad_trans) + + +def test_markov_model_output_clipped_to_max_quality(): + """Transition rows that emit out-of-range scores are clipped to max_quality.""" + over_max = {q: {99: 1.0} for q in range(0, 43)} + trans_dists = [over_max] * 150 + qm = MarkovQualityModel({30: 1.0}, [{99: 1.0}] * 151, 42, 151, trans_dists) + qs = qm.get_quality_scores(model_read_length=151, length=50, rng=default_rng(5)) + assert qs.max() <= 42 + + +# --------------------------------------------------------------------------- +# New tests — edge cases +# --------------------------------------------------------------------------- + +def test_markov_model_length_zero_returns_empty(): + """length=0 must return an empty ndarray without error.""" + qm = _simple_model() + qs = qm.get_quality_scores(model_read_length=151, length=0, rng=default_rng(0)) + assert isinstance(qs, np.ndarray) + assert len(qs) == 0 + + +def test_markov_model_length_one_uses_only_init(): + """length=1 reads only from the initial distribution, never marginals.""" + qm = _simple_model(init_score=37, pos_score=10) + qs = qm.get_quality_scores(model_read_length=151, length=1, rng=default_rng(0)) + assert len(qs) == 1 + assert qs[0] == 37 + + +def test_markov_model_short_read_interpolates_correctly(): + """A read shorter than the model still returns the requested length.""" + qm = _simple_model(read_length=151) + qs = qm.get_quality_scores(model_read_length=151, length=50, rng=default_rng(0)) + assert len(qs) == 50 + assert all(0 <= q <= 42 for q in qs) + + +# --------------------------------------------------------------------------- +# New tests — _position_index_for_length +# --------------------------------------------------------------------------- + +def test_position_index_for_length_boundaries(): + """First and last positions always map to 0 and read_length-1.""" + qm = _simple_model(read_length=151) + assert qm._position_index_for_length(0, 50) == 0 + assert qm._position_index_for_length(49, 50) == 150 + + +def test_position_index_for_length_midpoint(): + """Midpoint of a 51-position read maps to midpoint of the 151-position model.""" + qm = _simple_model(read_length=151) + assert qm._position_index_for_length(25, 51) == 75 + + +def test_position_index_for_length_single_position_read(): + """length=1 always returns index 0 regardless of pos.""" + qm = _simple_model(read_length=151) + assert qm._position_index_for_length(0, 1) == 0 diff --git a/tests/test_models/test_seq_error.py b/tests/test_models/test_seq_error.py index 156e46c0..041b1aee 100644 --- a/tests/test_models/test_seq_error.py +++ b/tests/test_models/test_seq_error.py @@ -22,17 +22,5 @@ def test_get_seq_error_snv_only(): assert all(e.error_type == SingleNucleotideVariant for e in errors) -essentially_zero = 0.0 - -def test_no_errors_when_avg_zero(): - """When average error is zero, the model should introduce no errors.""" - rng = np.random.default_rng(0) - model = SequencingErrorModel(read_length=10, avg_seq_error=essentially_zero) - qs = np.full_like(np.arange(10), 36) - ref = SeqRecord(Seq('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), id="fake_1", name="fake", description="fake") - result = model.get_sequencing_errors(padding=5, reference_segment=ref, quality_scores=qs, rng=rng) - if isinstance(result, tuple): - errors, pad = result - assert errors == [] and pad == 5 - else: - assert result == [] \ No newline at end of file + # test_no_errors_when_avg_zero removed: + # duplicate of test_error_models.py::test_sem_zero_error_rate_returns_empty \ No newline at end of file diff --git a/tests/test_models/test_split_inputs.py b/tests/test_models/test_split_inputs.py index 5648d6bf..1af203ac 100644 --- a/tests/test_models/test_split_inputs.py +++ b/tests/test_models/test_split_inputs.py @@ -1,22 +1,298 @@ """ Unit tests for the split_inputs module of the parallel read simulator """ +import gzip +import pytest from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord -from neat.read_simulator.utils.split_inputs import chunk_record +from neat.read_simulator.utils.options import Options +from neat.read_simulator.utils.split_inputs import ( + chunk_record, + disk_bytes_free, + main, + print_stderr, + write_fasta, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_options(tmp_path, mode="contig", read_len=50, block_size=200): + opts = Options(rng_seed=0) + opts.read_len = read_len + opts.parallel_mode = mode + opts.parallel_block_size = block_size + opts.splits_dir = tmp_path / "splits" + opts.splits_dir.mkdir() + return opts + + +def _make_ref(*contigs: tuple[str, str]) -> dict: + """Build a reference_index dict from (name, sequence) pairs.""" + return { + name: SeqRecord(Seq(seq), id=name, name=name, description="") + for name, seq in contigs + } + + +def _read_fasta_gz(path) -> tuple[str, str]: + """Return (header_line, sequence) from a bgzf/gzip FASTA.""" + with gzip.open(path, "rt") as fh: + lines = fh.read().splitlines() + header = lines[0] + seq = "".join(lines[1:]) + return header, seq + + +# =========================================================================== +# chunk_record +# =========================================================================== def test_chunk_record_overlaps() -> None: - """Ensure that chunk_record yields overlapping chunks with the correct ids.""" - # A simple sequence of 12 bases to chunk into length 5 with overlap 2 + """Existing behaviour: overlapping chunks of a 12-base sequence.""" rec = Seq("ACGTACGTACGT") chunks = list(chunk_record(rec, 5, 2)) - # Should produce four chunks: positions [0:5], [3:8], [6:11], [9:12] assert len(chunks) == 4 - # Check that chunk ids are sequential and lengths match expected slices lengths = [len(seq) for _, seq in chunks] assert lengths == [5, 5, 5, 3] ids = [cid for cid, _ in chunks] seqs = [seq for _, seq in chunks] assert ids == [0, 3, 6, 9] - assert seqs == [Seq('ACGTA'), Seq('TACGT'), Seq('GTACG'), Seq('CGT')] \ No newline at end of file + assert seqs == [Seq("ACGTA"), Seq("TACGT"), Seq("GTACG"), Seq("CGT")] + + +def test_chunk_record_sequence_shorter_than_chunk(): + """Sequence shorter than chunk_len produces one chunk covering the whole sequence.""" + rec = Seq("ACGT") + chunks = list(chunk_record(rec, 100, 10)) + assert len(chunks) == 1 + start, seq = chunks[0] + assert start == 0 + assert seq == rec + + +def test_chunk_record_sequence_exact_chunk_length(): + """Sequence exactly equal to chunk_len produces exactly one chunk.""" + rec = Seq("ACGTACGT") + chunks = list(chunk_record(rec, 8, 2)) + assert len(chunks) == 1 + assert chunks[0] == (0, rec) + + +def test_chunk_record_no_overlap(): + """With overlap=0 chunks are non-overlapping and contiguous.""" + rec = Seq("ACGTACGT") + chunks = list(chunk_record(rec, 4, 0)) + assert len(chunks) == 2 + assert chunks[0] == (0, Seq("ACGT")) + assert chunks[1] == (4, Seq("ACGT")) + + +def test_chunk_record_start_positions_are_correct(): + """Start of each chunk equals end_of_previous - overlap.""" + rec = Seq("A" * 100) + chunk_len, overlap = 30, 10 + chunks = list(chunk_record(rec, chunk_len, overlap)) + for i in range(1, len(chunks)): + prev_end = chunks[i - 1][0] + len(chunks[i - 1][1]) + assert chunks[i][0] == prev_end - overlap + + +def test_chunk_record_covers_full_sequence(): + """The union of all chunks covers every position in the sequence.""" + seq = "ACGT" * 25 # 100 bp + rec = Seq(seq) + chunks = list(chunk_record(rec, 30, 5)) + covered = set() + for start, subseq in chunks: + for i in range(len(subseq)): + covered.add(start + i) + assert covered == set(range(len(seq))) + + +# =========================================================================== +# print_stderr +# =========================================================================== + +def test_print_stderr_no_exit(): + """Calling with exit_=False should not raise.""" + print_stderr("test message", exit_=False) # no exception + + +def test_print_stderr_exit(): + """Calling with exit_=True should raise SystemExit.""" + with pytest.raises(SystemExit): + print_stderr("fatal error", exit_=True) + + +# =========================================================================== +# disk_bytes_free +# =========================================================================== + +def test_disk_bytes_free_returns_positive(tmp_path): + result = disk_bytes_free(tmp_path) + assert isinstance(result, int) + assert result > 0 + + +# =========================================================================== +# write_fasta +# =========================================================================== + +def test_write_fasta_creates_file(tmp_path): + out = tmp_path / "test.fa.gz" + write_fasta("chr1", Seq("ACGTACGT"), out) + assert out.exists() + assert out.stat().st_size > 0 + + +def test_write_fasta_header(tmp_path): + out = tmp_path / "test.fa.gz" + write_fasta("mycontig", Seq("ACGT"), out) + header, _ = _read_fasta_gz(out) + assert header == ">mycontig" + + +def test_write_fasta_sequence_content(tmp_path): + seq = "ACGT" * 10 + out = tmp_path / "test.fa.gz" + write_fasta("chr1", Seq(seq), out) + _, recovered = _read_fasta_gz(out) + assert recovered == seq + + +def test_write_fasta_line_wrapping_default(tmp_path): + """Default width=80 wraps sequences longer than 80 bases.""" + seq = "A" * 200 + out = tmp_path / "test.fa.gz" + write_fasta("chr1", Seq(seq), out) + with gzip.open(out, "rt") as fh: + lines = fh.read().splitlines() + seq_lines = [l for l in lines if not l.startswith(">")] + assert all(len(l) <= 80 for l in seq_lines) + assert "".join(seq_lines) == seq + + +def test_write_fasta_custom_width(tmp_path): + seq = "A" * 50 + out = tmp_path / "test.fa.gz" + write_fasta("chr1", Seq(seq), out, width=10) + with gzip.open(out, "rt") as fh: + lines = fh.read().splitlines() + seq_lines = [l for l in lines if not l.startswith(">")] + assert all(len(l) <= 10 for l in seq_lines) + + +# =========================================================================== +# main — contig mode +# =========================================================================== + +def test_main_contig_mode_one_file_per_contig(tmp_path): + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("chr1", "ACGT" * 50), ("chr2", "TTGG" * 30)) + result, written = main(opts, ref) + assert written == 2 + assert set(result.keys()) == {"chr1", "chr2"} + + +def test_main_contig_mode_dict_keyed_by_full_span(tmp_path): + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("chr1", "ACGT" * 25)) # 100 bp + result, _ = main(opts, ref) + keys = list(result["chr1"].keys()) + assert len(keys) == 1 + assert keys[0] == (0, 100) + + +def test_main_contig_mode_files_exist(tmp_path): + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("chr1", "ACGT" * 10), ("chr2", "TTGG" * 10)) + result, _ = main(opts, ref) + for contig, spans in result.items(): + for path in spans.values(): + assert path.exists(), f"Expected {path} to exist" + + +def test_main_contig_mode_filename_contains_contig(tmp_path): + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("mychr", "ACGT" * 10)) + result, _ = main(opts, ref) + path = list(result["mychr"].values())[0] + assert "mychr" in path.name + + +def test_main_contig_mode_filename_zero_padded(tmp_path): + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("chr1", "A" * 40), ("chr2", "T" * 40)) + result, _ = main(opts, ref) + names = sorted(p.name for spans in result.values() for p in spans.values()) + # First file index should be zero-padded to width 10 + assert names[0].startswith("0000000001") + assert names[1].startswith("0000000002") + + +def test_main_contig_mode_fasta_content(tmp_path): + """Files written in contig mode should contain the correct uppercased sequence.""" + seq = "acgtACGT" * 5 # mixed case + opts = _make_options(tmp_path, mode="contig") + ref = _make_ref(("chr1", seq)) + result, _ = main(opts, ref) + path = list(result["chr1"].values())[0] + _, recovered = _read_fasta_gz(path) + assert recovered == seq.upper() + + +# =========================================================================== +# main — block (size) mode +# =========================================================================== + +def test_main_block_mode_produces_multiple_chunks(tmp_path): + """A sequence much longer than block_size should produce multiple chunks.""" + opts = _make_options(tmp_path, mode="size", read_len=10, block_size=50) + seq = "ACGT" * 50 # 200 bp — should produce several 50 bp blocks + ref = _make_ref(("chr1", seq)) + result, written = main(opts, ref) + assert written > 1 + assert len(result["chr1"]) > 1 + + +def test_main_block_mode_written_count_matches_dict(tmp_path): + opts = _make_options(tmp_path, mode="size", read_len=10, block_size=50) + ref = _make_ref(("chr1", "A" * 200), ("chr2", "T" * 200)) + result, written = main(opts, ref) + total_spans = sum(len(spans) for spans in result.values()) + assert written == total_spans + + +def test_main_block_mode_files_exist(tmp_path): + opts = _make_options(tmp_path, mode="size", read_len=10, block_size=50) + ref = _make_ref(("chr1", "ACGT" * 50)) + result, _ = main(opts, ref) + for spans in result.values(): + for path in spans.values(): + assert path.exists() + + +def test_main_block_mode_span_keys_cover_sequence(tmp_path): + """Span keys should cover positions 0 through len(seq), with overlaps.""" + opts = _make_options(tmp_path, mode="size", read_len=10, block_size=50) + seq = "A" * 200 + ref = _make_ref(("chr1", seq)) + result, _ = main(opts, ref) + spans = sorted(result["chr1"].keys()) + assert spans[0][0] == 0 + # Last span should end at or cover the full sequence length + assert spans[-1][1] >= len(seq) + + +def test_main_block_mode_sequence_shorter_than_block(tmp_path): + """A short sequence produces exactly one chunk even in block mode.""" + opts = _make_options(tmp_path, mode="size", read_len=10, block_size=500) + ref = _make_ref(("chr1", "ACGT" * 10)) # 40 bp < 500 + result, written = main(opts, ref) + assert written == 1 + assert len(result["chr1"]) == 1 \ No newline at end of file diff --git a/tests/test_models/test_stitch_outputs.py b/tests/test_models/test_stitch_outputs.py index e97f3b58..60eaeb0a 100644 --- a/tests/test_models/test_stitch_outputs.py +++ b/tests/test_models/test_stitch_outputs.py @@ -9,29 +9,9 @@ from neat.read_simulator.utils.stitch_outputs import concat -def test_concat_joins_files_in_order(tmp_path: Path) -> None: - """Verify that concat writes the exact bytewise concatenation of inputs.""" - # Prepare small input files - f1 = tmp_path / "a.bin" - with bgzf.BgzfWriter(f1, 'w')as f1_in: - f1_in.write("hello\n") - f2 = tmp_path / "b.bin" - with bgzf.BgzfWriter(f2, 'w') as f2_in: - f2_in.write("world\n") - f3 = tmp_path / "c.bin" - with bgzf.BgzfWriter(f3, 'w') as f3_in: - f3_in.write("!!!") - - dest = tmp_path / "out.bin" - dest_write = bgzf.BgzfWriter(dest, 'w') - concat([f1, f2, f3], dest_write) - dest_write.close() - assert dest.exists() - with bgzf.BgzfReader(dest) as read_dest: - text = "" - for line in read_dest: - text += line - assert text == "hello\nworld\n!!!" + # test_concat_joins_files_in_order removed: + # covered by test_read_simulator/test_stitch_outputs.py::test_concat_multiple_files + # and test_concat_preserves_content_order def test_concat_noop_on_empty_list(tmp_path: Path) -> None: diff --git a/tests/test_read_simulator/test_bed_func.py b/tests/test_read_simulator/test_bed_func.py new file mode 100644 index 00000000..ce1b7c2b --- /dev/null +++ b/tests/test_read_simulator/test_bed_func.py @@ -0,0 +1,385 @@ +""" +Tests for neat/read_simulator/utils/bed_func.py +""" +import pytest +from pathlib import Path + +from neat.read_simulator.utils.bed_func import ( + intersect_regions, + recalibrate_mutation_regions, + fill_out_bed_dict, + fill_out_mut_regions, + parse_single_bed, + parse_beds, +) +from neat.read_simulator.utils.options import Options + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_REF = {"chr1": 1000, "chr2": 500} + +_TARGET_KEY = ("target", True, True) +_DISCARD_KEY = ("discard", False, True) +_MUT_KEY = ("mutation", None, True) +_NO_BED_TARGET = ("target", True, False) +_NO_BED_DISCARD = ("discard", False, False) + + +def _write_bed(tmp_path: Path, name: str, lines: list[str]) -> Path: + p = tmp_path / name + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + return p + + +# =========================================================================== +# intersect_regions +# =========================================================================== + +def test_intersect_block_within_single_region(): + regions = [(0, 1000, 0.01)] + result = intersect_regions(regions, (100, 200), 0.0) + assert result == [(100, 200, 0.01)] + + +def test_intersect_block_exactly_fills_region(): + regions = [(0, 500, 0.02), (500, 1000, 0.05)] + result = intersect_regions(regions, (0, 500), 0.0) + assert result == [(0, 500, 0.02)] + + +def test_intersect_block_spans_two_regions(): + regions = [(0, 300, 0.01), (300, 700, 0.05), (700, 1000, 0.02)] + result = intersect_regions(regions, (200, 400), 0.0) + assert result == [(200, 300, 0.01), (300, 400, 0.05)] + + +def test_intersect_block_start_aligns_with_region_boundary(): + regions = [(0, 500, 0.01), (500, 1000, 0.03)] + result = intersect_regions(regions, (500, 800), 0.0) + assert result == [(500, 800, 0.03)] + + +def test_intersect_block_extends_beyond_all_regions(): + regions = [(0, 500, 0.01), (500, 800, 0.02)] + result = intersect_regions(regions, (600, 1000), 0.0) + # Ends past last region — appends fallback using default_value + assert result[-1][2] == 0.0 + assert result[-1][1] == 1000 + + +# =========================================================================== +# fill_out_mut_regions +# =========================================================================== + +def test_fill_out_mut_regions_empty(): + result = fill_out_mut_regions([], (0, 1000), 0.01) + assert result == [(0, 1000, 0.01)] + + +def test_fill_out_mut_regions_full_coverage(): + regions = [(0, 1000, 0.05)] + result = fill_out_mut_regions(regions, (0, 1000), 0.01) + assert result == [(0, 1000, 0.05)] + + +def test_fill_out_mut_regions_gap_before(): + regions = [(200, 800, 0.05)] + result = fill_out_mut_regions(regions, (0, 1000), 0.01) + assert result[0] == (0, 200, 0.01) + assert result[1] == (200, 800, 0.05) + assert result[2] == (800, 1000, 0.01) + + +def test_fill_out_mut_regions_gap_after(): + regions = [(0, 600, 0.05)] + result = fill_out_mut_regions(regions, (0, 1000), 0.01) + assert result[-1] == (600, 1000, 0.01) + + +def test_fill_out_mut_regions_multiple_gaps(): + regions = [(100, 300, 0.01), (600, 800, 0.02)] + result = fill_out_mut_regions(regions, (0, 1000), 0.0) + starts = [r[0] for r in result] + ends = [r[1] for r in result] + # Regions must be contiguous from 0 to 1000 + assert starts[0] == 0 + assert ends[-1] == 1000 + for i in range(len(result) - 1): + assert result[i][1] == result[i + 1][0] + + +# =========================================================================== +# recalibrate_mutation_regions +# =========================================================================== + +def test_recalibrate_none_values_replaced(): + regions = [(0, 500, None), (500, 1000, 0.02)] + result = recalibrate_mutation_regions(regions, (0, 1000), 0.01) + rates = [r[2] for r in result] + assert None not in rates + + +def test_recalibrate_block_contained_in_one_region(): + regions = [(0, 1000, 0.05)] + result = recalibrate_mutation_regions(regions, (200, 400), 0.01) + assert result == [(200, 400, 0.05)] + + +def test_recalibrate_block_spans_two_regions(): + regions = [(0, 500, 0.01), (500, 1000, 0.03)] + result = recalibrate_mutation_regions(regions, (300, 700), 0.0) + assert any(r[2] == 0.01 for r in result) + assert any(r[2] == 0.03 for r in result) + + +def test_recalibrate_result_is_contiguous(): + regions = [(0, 400, 0.01), (400, 600, None), (600, 1000, 0.02)] + result = recalibrate_mutation_regions(regions, (0, 1000), 0.005) + for i in range(len(result) - 1): + assert result[i][1] == result[i + 1][0], "Gap between regions" + + +def test_recalibrate_result_covers_full_block(): + regions = [(0, 1000, 0.01)] + coords = (100, 900) + result = recalibrate_mutation_regions(regions, coords, 0.005) + assert result[0][0] == 100 + assert result[-1][1] == 900 + + +# =========================================================================== +# parse_single_bed — no bed file (uniform coverage) +# =========================================================================== + +def test_parse_single_bed_no_file_target(): + result = parse_single_bed(None, _REF, _NO_BED_TARGET) + assert set(result.keys()) == {"chr1", "chr2"} + assert result["chr1"] == [(0, 1000, True)] + assert result["chr2"] == [(0, 500, True)] + + +def test_parse_single_bed_no_file_discard(): + result = parse_single_bed(None, _REF, _NO_BED_DISCARD) + assert result["chr1"] == [(0, 1000, False)] + + +# =========================================================================== +# parse_single_bed — target BED +# =========================================================================== + +def test_parse_single_bed_target(tmp_path): + bed = _write_bed(tmp_path, "target.bed", [ + "chr1\t100\t400", + "chr1\t600\t800", + ]) + result = parse_single_bed(str(bed), _REF, _TARGET_KEY) + entries = result["chr1"] + assert len(entries) == 2 + assert entries[0] == (100, 400, True) + assert entries[1] == (600, 800, True) + + +def test_parse_single_bed_target_skips_comments(tmp_path): + bed = _write_bed(tmp_path, "target.bed", [ + "# comment line", + "chr1\t0\t500", + ]) + result = parse_single_bed(str(bed), _REF, _TARGET_KEY) + assert result["chr1"] == [(0, 500, True)] + + +def test_parse_single_bed_target_chrom_not_in_ref(tmp_path): + bed = _write_bed(tmp_path, "target.bed", [ + "chrX\t0\t500", + "chr1\t0\t500", + ]) + result = parse_single_bed(str(bed), _REF, _TARGET_KEY) + # chrX is skipped, chr1 is kept + assert result["chr1"] == [(0, 500, True)] + assert "chrX" not in result + + +# =========================================================================== +# parse_single_bed — discard BED +# =========================================================================== + +def test_parse_single_bed_discard(tmp_path): + bed = _write_bed(tmp_path, "discard.bed", ["chr1\t200\t600"]) + result = parse_single_bed(str(bed), _REF, _DISCARD_KEY) + assert result["chr1"] == [(200, 600, True)] + + +# =========================================================================== +# parse_single_bed — mutation BED +# =========================================================================== + +def test_parse_single_bed_mutation(tmp_path): + bed = _write_bed(tmp_path, "mut.bed", [ + "chr1\t0\t500\tmut_rate=0.001", + "chr1\t500\t1000\tmut_rate=0.005", + ]) + result = parse_single_bed(str(bed), _REF, _MUT_KEY) + assert result["chr1"][0] == (0, 500, 0.001) + assert result["chr1"][1] == (500, 1000, 0.005) + + +def test_parse_single_bed_mutation_extra_metadata(tmp_path): + """mut_rate can appear among other semicolon-delimited metadata.""" + bed = _write_bed(tmp_path, "mut.bed", [ + "chr1\t0\t500\tfoo=bar;mut_rate=0.002;baz=qux", + ]) + result = parse_single_bed(str(bed), _REF, _MUT_KEY) + assert result["chr1"][0][2] == pytest.approx(0.002) + + +def test_parse_single_bed_mutation_high_rate_logs_warning(tmp_path, caplog): + """Mutation rate > 0.3 triggers a warning log (bed_func.py line 209).""" + import logging + bed = _write_bed(tmp_path, "mut.bed", ["chr1\t0\t500\tmut_rate=0.4"]) + with caplog.at_level(logging.WARNING, logger="neat.read_simulator.utils.bed_func"): + result = parse_single_bed(str(bed), _REF, _MUT_KEY) + assert "0.3" in caplog.text or "unusual" in caplog.text.lower() + assert result["chr1"][0][2] == pytest.approx(0.4) + + +def test_parse_single_bed_mutation_missing_mut_rate_exits(tmp_path): + bed = _write_bed(tmp_path, "mut.bed", ["chr1\t0\t500\tno_rate_here"]) + with pytest.raises(SystemExit): + parse_single_bed(str(bed), _REF, _MUT_KEY) + + +def test_parse_single_bed_mutation_invalid_rate_value_exits(tmp_path): + bed = _write_bed(tmp_path, "mut.bed", ["chr1\t0\t500\tmut_rate=notanumber"]) + with pytest.raises(SystemExit): + parse_single_bed(str(bed), _REF, _MUT_KEY) + + +def test_parse_single_bed_malformed_line_exits(tmp_path): + bed = _write_bed(tmp_path, "bad.bed", ["only_one_column"]) + with pytest.raises(SystemExit): + parse_single_bed(str(bed), _REF, _TARGET_KEY) + + +# =========================================================================== +# fill_out_bed_dict +# =========================================================================== + +def test_fill_out_bed_dict_empty_regions(): + region_dict = {"chr1": [], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + assert result["chr1"] == [(0, 1000, True)] + assert result["chr2"] == [(0, 500, True)] + + +def test_fill_out_bed_dict_target_fills_gaps_with_false(): + region_dict = {"chr1": [(200, 600, True)], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + regions = result["chr1"] + # Gap before (0–200) should be False; target region True; gap after (600–1000) False + assert regions[0] == (0, 200, False) + assert regions[1] == (200, 600, True) + assert regions[2] == (600, 1000, False) + + +def test_fill_out_bed_dict_discard_fills_gaps_with_default(): + region_dict = {"chr1": [(200, 600, True)], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _DISCARD_KEY) + regions = result["chr1"] + assert regions[0] == (0, 200, False) # not discarded + assert regions[1] == (200, 600, True) # discarded + assert regions[2] == (600, 1000, False) + + +def test_fill_out_bed_dict_region_starts_at_zero(): + region_dict = {"chr1": [(0, 500, True)], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + regions = result["chr1"] + assert regions[0] == (0, 500, True) + assert regions[1] == (500, 1000, False) # gap filled + + +def test_fill_out_bed_dict_region_ends_at_contig_end(): + region_dict = {"chr1": [(500, 1000, True)], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + regions = result["chr1"] + assert regions[0] == (0, 500, False) + assert regions[1] == (500, 1000, True) + + +def test_fill_out_bed_dict_contiguous(): + """Every filled dict should have contiguous, non-overlapping regions.""" + region_dict = {"chr1": [(100, 400, True), (600, 900, True)], "chr2": []} + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + regions = result["chr1"] + assert regions[0][0] == 0 + assert regions[-1][1] == 1000 + for i in range(len(regions) - 1): + assert regions[i][1] == regions[i + 1][0] + + +def test_fill_out_bed_dict_multiple_contigs(): + region_dict = { + "chr1": [(0, 500, True)], + "chr2": [(100, 300, True)], + } + result = fill_out_bed_dict(_REF, region_dict, _TARGET_KEY) + assert result["chr1"][-1][1] == 1000 + assert result["chr2"][-1][1] == 500 + + +# =========================================================================== +# parse_beds — high-level integration of the above +# =========================================================================== + +def test_parse_beds_no_beds(): + opts = Options(rng_seed=0) + opts.target_bed = None + opts.discard_bed = None + opts.mutation_bed = None + target_dict, discard_dict, mut_dict = parse_beds(opts, _REF) + # With no beds: target is all-True, discard is all-False, mut is all-None + assert target_dict["chr1"] == [(0, 1000, True)] + assert discard_dict["chr1"] == [(0, 1000, False)] + assert mut_dict["chr1"] == [(0, 1000, None)] + + +def test_parse_beds_with_target_bed(tmp_path): + bed = _write_bed(tmp_path, "target.bed", ["chr1\t200\t800"]) + opts = Options(rng_seed=0) + opts.target_bed = str(bed) + opts.discard_bed = None + opts.mutation_bed = None + target_dict, discard_dict, mut_dict = parse_beds(opts, _REF) + # Target regions outside bed are filled with False + target_chr1 = target_dict["chr1"] + true_regions = [r for r in target_chr1 if r[2] is True] + assert true_regions == [(200, 800, True)] + + +def test_parse_beds_with_discard_bed(tmp_path): + bed = _write_bed(tmp_path, "discard.bed", ["chr1\t300\t700"]) + opts = Options(rng_seed=0) + opts.target_bed = None + opts.discard_bed = str(bed) + opts.mutation_bed = None + target_dict, discard_dict, mut_dict = parse_beds(opts, _REF) + discard_chr1 = discard_dict["chr1"] + discarded = [r for r in discard_chr1 if r[2] is True] + assert discarded == [(300, 700, True)] + + +def test_parse_beds_with_mutation_bed(tmp_path): + bed = _write_bed(tmp_path, "mut.bed", [ + "chr1\t0\t500\tmut_rate=0.01", + "chr1\t500\t1000\tmut_rate=0.02", + ]) + opts = Options(rng_seed=0) + opts.target_bed = None + opts.discard_bed = None + opts.mutation_bed = str(bed) + target_dict, discard_dict, mut_dict = parse_beds(opts, _REF) + assert mut_dict["chr1"][0][2] == pytest.approx(0.01) + assert mut_dict["chr1"][1][2] == pytest.approx(0.02) \ No newline at end of file diff --git a/tests/test_read_simulator/test_generate_reads.py b/tests/test_read_simulator/test_generate_reads.py index 519d9153..709a3328 100644 --- a/tests/test_read_simulator/test_generate_reads.py +++ b/tests/test_read_simulator/test_generate_reads.py @@ -1,9 +1,15 @@ +import numpy as np import pytest +from types import SimpleNamespace +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord -from neat.models import FragmentLengthModel +from neat.models import FragmentLengthModel, SequencingErrorModel, TraditionalQualityModel from neat.read_simulator.utils import Options -from neat.read_simulator.utils.generate_reads import * -from neat.read_simulator.utils.read import * +from neat.read_simulator.utils.generate_reads import cover_dataset, overlaps, find_applicable_mutations, generate_reads +from neat.read_simulator.utils.read import Read +from neat.variants.contig_variants import ContigVariants +from neat.variants import SingleNucleotideVariant def _span(a, b): @@ -23,6 +29,18 @@ def _expected_avg_cov(requested, span, read_len): return requested * max(0, (span - read_len + 1) / span) +def _compute_avg_coverage(reads, span_length, paired): + """Compute average per-position coverage across the span.""" + cov = [0] * span_length + for read in reads: + for pos in range(max(0, read[0]), min(span_length, read[1])): + cov[pos] += 1 + if paired: + for pos in range(max(0, read[2]), min(span_length, read[3])): + cov[pos] += 1 + return sum(cov) / span_length + + def test_cover_dataset(): """Test that a cover is successfully generated for different coverage values""" span_length = 5000 @@ -64,46 +82,29 @@ def test_paired_cover_dataset(): options.fragment_st_dev = fragment_model.fragment_st_dev coverage_values = [1, 2, 5] + prev_n_reads = None + for coverage in coverage_values: options.coverage = coverage - options.fragment_mean = fragment_model.fragment_mean - options.fragment_st_dev = fragment_model.fragment_st_dev reads = cover_dataset(span_length, options, fragment_model) + expected_pairs = coverage * (span_length / fragment_model.fragment_mean) expected_reads = 2.0 * expected_pairs n_reads = len(reads) - assert n_reads >= 0.6 * expected_reads, (f"paired-end n_reads={n_reads}, expected≈{expected_reads:.1f} " + - f"(cov={coverage})") - for read in reads: - if read[1] - read[0] < 10 or read[3] - read[2] < 10: - raise AssertionError("failed to filter out a small read length") - assert len(reads) >= (100 * coverage)/20 - - -def test_paired_cover_dataset(): - """Test that a cover is successfully generated for different coverage values""" - span_length = 10000 - options = Options(rng_seed=0) - options.read_len = 100 - options.paired_ended = True - options.overwrite_output = True - fragment_model = FragmentLengthModel(300, 30) - options.fragment_length_model = fragment_model - options.fragment_mean = fragment_model.fragment_mean - options.fragment_st_dev = fragment_model.fragment_st_dev - - coverage_values = [1, 2, 5] - prev_n_reads = None + assert n_reads >= 0.6 * expected_reads, ( + f"paired-end n_reads={n_reads}, expected≈{expected_reads:.1f} (cov={coverage})" + ) - for coverage in coverage_values: - options.coverage = coverage - reads = cover_dataset(span_length, options, fragment_model) assert isinstance(reads, list) for read in reads: assert len(read) == 4 assert read[1] >= read[0] assert read[3] >= read[2] - n_reads = len(reads) + if read[1] - read[0] < 10 or read[3] - read[2] < 10: + raise AssertionError("failed to filter out a small read length") + + assert len(reads) >= (100 * coverage)/20 + if prev_n_reads is not None: assert n_reads >= prev_n_reads prev_n_reads = n_reads @@ -122,34 +123,8 @@ def test_various_read_lengths(): for read_len in range(10, 251, 10): options.read_len = read_len - try: - reads = cover_dataset(span_length, options, fragment_model) - assert isinstance(reads, list) - except Exception as e: - pytest.fail(f"Test failed for read_len={read_len} with exception: {e}") - - -def test_fragment_mean_st_dev_combinations(): - """Test cover_dataset with combinations of fragment mean and standard deviation to ensure no errors""" - span_length = 5000 - options = Options(rng_seed=0) - options.paired_ended = False - options.read_len = 101 - options.coverage = 2 - options.overwrite_output = True - - fragment_means = [100, 150, 200, 250,] - fragment_st_devs = [1, 5, 25, 50] - - for mean in fragment_means: - for st_dev in fragment_st_devs: - options.fragment_mean = mean - options.fragment_st_dev = st_dev - fragment_model = FragmentLengthModel(mean, st_dev) - frags = fragment_model.generate_fragments(20, options.rng) - assert len(frags) == 20 - assert fragment_model.fragment_mean == mean - assert fragment_model.fragment_st_dev == st_dev + reads = cover_dataset(span_length, options, fragment_model) + assert isinstance(reads, list) def test_coverage_ploidy_combinations(): @@ -196,18 +171,77 @@ def test_single_ended_mode(): options.overwrite_output = True fragment_model = FragmentLengthModel(40, 10) - try: - reads = cover_dataset(span_length, options, fragment_model) - coverage_check = [] - for i in range(span_length): - # Single-ended test, only need read1 - cover = [x for x in reads if i in range(x[0], x[1])] - coverage_check.append(len(cover)) - avg = sum(coverage_check) / len(coverage_check) - expected = _expected_avg_cov(options.coverage, span_length, options.read_len) - assert avg >= 0.9 * expected, f"got {avg:.3f}, expected ~{expected:.3f}" - except Exception as e: - pytest.fail(f"Test failed in single-ended mode with exception: {e}") + reads = cover_dataset(span_length, options, fragment_model) + coverage_check = [] + for i in range(span_length): + # Single-ended test, only need read1 + cover = [x for x in reads if i in range(x[0], x[1])] + coverage_check.append(len(cover)) + avg = sum(coverage_check) / len(coverage_check) + expected = _expected_avg_cov(options.coverage, span_length, options.read_len) + assert avg >= 0.9 * expected, f"got {avg:.3f}, expected ~{expected:.3f}" + + +@pytest.mark.parametrize("fragment_mean,target_coverage", [ + (200, 5), + (200, 10), + (200, 20), + (300, 5), + (300, 10), + (300, 20), + (500, 10), + (500, 20), +]) +def test_single_ended_coverage_accuracy(fragment_mean, target_coverage): + """Single-ended coverage should be within 10% of the requested target.""" + span_length = 10_000 + read_len = 100 + fragment_model = FragmentLengthModel(fragment_mean, 30) + + options = Options(rng_seed=42) + options.read_len = read_len + options.paired_ended = False + options.coverage = target_coverage + options.overwrite_output = True + + reads = cover_dataset(span_length, options, fragment_model) + avg = _compute_avg_coverage(reads, span_length, paired=False) + + assert abs(avg - target_coverage) / target_coverage < 0.10, ( + f"Single-ended (frag_mean={fragment_mean}) average coverage {avg:.2f}x " + f"is more than 10% off target {target_coverage}x" + ) + + +@pytest.mark.parametrize("fragment_mean,target_coverage", [ + (300, 5), + (300, 10), + (300, 20), + (500, 5), + (500, 10), + (500, 20), + (800, 10), + (800, 20), +]) +def test_paired_ended_coverage_accuracy(fragment_mean, target_coverage): + """Paired-ended coverage should be within 10% of the requested target.""" + span_length = 10_000 + read_len = 100 + fragment_model = FragmentLengthModel(fragment_mean, 30) + + options = Options(rng_seed=42) + options.read_len = read_len + options.paired_ended = True + options.coverage = target_coverage + options.overwrite_output = True + + reads = cover_dataset(span_length, options, fragment_model) + avg = _compute_avg_coverage(reads, span_length, paired=True) + + assert abs(avg - target_coverage) / target_coverage < 0.10, ( + f"Paired-ended (frag_mean={fragment_mean}) average coverage {avg:.2f}x " + f"is more than 10% off target {target_coverage}x" + ) def test_overlaps(): @@ -260,3 +294,266 @@ def test_cigar(): cigar[137] = "D" cig_str = read.tally_cigar_list(cigar) assert cig_str == "11M1I125M1D12M" + + +# --------------------------------------------------------------------------- +# Helpers shared by generate_reads tests +# --------------------------------------------------------------------------- + +_SPAN = 1000 +_READ_LEN = 100 +_REF_SEQ = "ACGT" * (_SPAN // 4) + + +def _make_reference(seq=_REF_SEQ, name="chr1"): + return SeqRecord(Seq(seq), id=name, name=name, description="") + + +def _make_options(paired=False, seed=0): + opts = Options(rng_seed=seed) + opts.read_len = _READ_LEN + opts.paired_ended = paired + opts.coverage = 5 + opts.produce_fastq = False + opts.produce_bam = False + opts.produce_vcf = False + opts.overwrite_output = True + return opts + + +def _make_models(read_len=_READ_LEN, frag_mean=300): + error_model = SequencingErrorModel(read_length=read_len) + qual_model = TraditionalQualityModel() + frag_model = FragmentLengthModel(frag_mean, 30) + return error_model, qual_model, frag_model + + +def _all_span_targeted(): + """One region covering the whole span, active.""" + return [(_SPAN * 0, _SPAN, True)] + + +def _nothing_discarded(): + """One region covering the whole span, not discarded.""" + return [(_SPAN * 0, _SPAN, False)] + + +# --------------------------------------------------------------------------- +# find_applicable_mutations +# --------------------------------------------------------------------------- + +def _fake_read(position, end_point): + return SimpleNamespace(position=position, end_point=end_point) + + +def test_find_applicable_mutations_empty_variants(): + read = _fake_read(100, 200) + cv = ContigVariants() + assert find_applicable_mutations(read, cv) == {} + + +def test_find_applicable_mutations_variant_in_range(): + read = _fake_read(100, 200) + cv = ContigVariants() + cv.add_location(150) + result = find_applicable_mutations(read, cv) + assert 150 in result + + +def test_find_applicable_mutations_at_boundaries(): + read = _fake_read(100, 200) + cv = ContigVariants() + cv.add_location(100) # position (inclusive) + cv.add_location(199) # end_point - 1 (inclusive) + result = find_applicable_mutations(read, cv) + assert 100 in result + assert 199 in result + + +def test_find_applicable_mutations_outside_range(): + read = _fake_read(100, 200) + cv = ContigVariants() + cv.add_location(99) # just before position + cv.add_location(200) # equal to end_point (exclusive) + cv.add_location(300) # well past end + result = find_applicable_mutations(read, cv) + assert result == {} + + +def test_find_applicable_mutations_mixed(): + read = _fake_read(100, 200) + cv = ContigVariants() + cv.add_location(50) # out + cv.add_location(150) # in + cv.add_location(180) # in + cv.add_location(250) # out + result = find_applicable_mutations(read, cv) + assert set(result.keys()) == {150, 180} + + +# --------------------------------------------------------------------------- +# generate_reads — structure +# --------------------------------------------------------------------------- + +def test_generate_reads_single_ended_returns_read_none_pairs(): + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + cv = ContigVariants() + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + assert isinstance(results, list) + assert len(results) > 0 + for read1, read2 in results: + assert isinstance(read1, Read) + assert read2 is None + + +def test_generate_reads_paired_ended_returns_read_read_pairs(): + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=True) + cv = ContigVariants() + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + assert len(results) > 0 + for read1, read2 in results: + assert isinstance(read1, Read) + assert isinstance(read2, Read) + + +def test_generate_reads_read_length_matches_options(): + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + cv = ContigVariants() + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + for read1, _ in results: + assert len(read1.read_sequence) == _READ_LEN + + +# --------------------------------------------------------------------------- +# generate_reads — BED filtering +# --------------------------------------------------------------------------- + +def test_generate_reads_targeted_region_flag_false_filters_all(): + """When all targeted regions have flag=False, every read is filtered.""" + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + cv = ContigVariants() + no_target = [(0, _SPAN, False)] + + results = generate_reads(0, ref, err, qual, frag, cv, + no_target, _nothing_discarded(), + opts, None, "chr1", 0, 0) + + assert results == [] + + +def test_generate_reads_discard_region_removes_all(): + """When the discard region covers the whole span and is active, all reads are dropped.""" + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + cv = ContigVariants() + discard_all = [(0, _SPAN, True)] + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), discard_all, + opts, None, "chr1", 0, 0) + + assert results == [] + + +def test_generate_reads_discard_flag_false_keeps_reads(): + """A discard region with flag=False is ignored; reads pass through.""" + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + cv = ContigVariants() + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + assert len(results) > 0 + + +# --------------------------------------------------------------------------- +# generate_reads — variants applied +# --------------------------------------------------------------------------- + +def test_generate_reads_variants_populated_on_reads(): + """An SNV in the middle of the span should appear in at least one read's mutations.""" + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=False) + + cv = ContigVariants() + snv = SingleNucleotideVariant( + position1=500, + alt=Seq("T"), + genotype=np.array([1, 1]), + qual_score=30, + ) + cv.add_variant(snv) + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + reads_with_mutations = [r1 for r1, _ in results if r1.mutations] + assert len(reads_with_mutations) > 0 + + +# --------------------------------------------------------------------------- +# generate_reads — paired-end discard logic (lines 234-236, 241-243) +# --------------------------------------------------------------------------- + +def test_generate_reads_paired_discard_region_removes_all(): + """Paired-end run with a full-span active discard region discards all reads. + + Exercises the read2 branch of the discard check (generate_reads.py lines 234-236): + if any(read2): + if overlaps(read2, (region[0], region[1])): + discard_read = True + """ + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=True) + cv = ContigVariants() + discard_all = [(0, _SPAN, True)] + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), discard_all, + opts, None, "chr1", 0, 0) + + assert results == [] + + +def test_generate_reads_paired_no_discard_produces_read_pairs(): + """Paired-end run without discard produces (Read, Read) pairs (regression guard).""" + ref = _make_reference() + err, qual, frag = _make_models() + opts = _make_options(paired=True) + cv = ContigVariants() + + results = generate_reads(0, ref, err, qual, frag, cv, + _all_span_targeted(), _nothing_discarded(), + opts, None, "chr1", 0, 0) + + assert len(results) > 0 + for read1, read2 in results: + assert isinstance(read1, Read) + assert isinstance(read2, Read) diff --git a/tests/test_read_simulator/test_generate_variants.py b/tests/test_read_simulator/test_generate_variants.py new file mode 100644 index 00000000..0280ca12 --- /dev/null +++ b/tests/test_read_simulator/test_generate_variants.py @@ -0,0 +1,434 @@ +""" +Unit tests for neat/read_simulator/utils/generate_variants.py +""" +import numpy as np +import pytest +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from neat.models import MutationModel +from neat.read_simulator.utils.generate_variants import ( + find_random_non_n, + map_non_n_regions, + generate_variants, +) +from neat.read_simulator.utils.options import Options +from neat.variants import ContigVariants, SingleNucleotideVariant, Insertion, Deletion + + +# Helpers + +_CLEAN_SEQ = "ACGT" * 50 # 200 bp, no N's +_N_HEAVY = "N" * 95 + "ACGT" # 99 bases, >10% N + +def _make_reference(seq: str = _CLEAN_SEQ, name: str = "chr1") -> SeqRecord: + return SeqRecord(Seq(seq), id=name, name=name, description="") + + +def _make_options(seed: int = 42, mutation_rate: float = None) -> Options: + opts = Options(rng_seed=seed) + opts.ploidy = 2 + opts.mutation_rate = mutation_rate + opts.min_mutations = 1 + return opts + + +def _make_model() -> MutationModel: + return MutationModel() + + +def _full_rate_regions(seq_len: int, rate: float = 0.01, offset: int = 0): + """Single mutation-rate region covering [offset, offset+seq_len).""" + return [(offset, offset + seq_len, rate)] + + +# find_random_non_n + +def test_find_random_non_n_returns_valid_index(): + rng = np.random.default_rng(0) + safe_zones = np.ones(10, dtype=int) + idx = find_random_non_n(rng, safe_zones) + assert 0 <= idx < 10 + + +def test_find_random_non_n_respects_probabilities(): + """With all weight on position 3, should always return 3.""" + rng = np.random.default_rng(0) + safe_zones = np.zeros(10, dtype=int) + safe_zones[3] = 1 + for _ in range(20): + assert find_random_non_n(rng, safe_zones) == 3 + + +def test_find_random_non_n_reproducible_with_seed(): + safe_zones = np.array([1, 2, 3, 4, 5], dtype=float) + r1 = find_random_non_n(np.random.default_rng(7), safe_zones.copy()) + r2 = find_random_non_n(np.random.default_rng(7), safe_zones.copy()) + assert r1 == r2 + + +def test_find_random_non_n_single_element(): + rng = np.random.default_rng(0) + safe_zones = np.array([5], dtype=float) + assert find_random_non_n(rng, safe_zones) == 0 + + +# map_non_n_regions + +def test_map_non_n_regions_clean_sequence(): + result = map_non_n_regions("ACGTACGTACGT") + assert len(result) == 12 + assert all(result == 1) + + +def test_map_non_n_regions_single_n(): + # 1 N in 50 bases = 2% N (valid map returned) + seq = "A" * 24 + "N" + "A" * 25 + result = map_non_n_regions(seq) + assert len(result) == 50 + assert result[24] == 0 + assert result[0] == 1 + assert result[49] == 1 + + +def test_map_non_n_regions_too_many_ns_returns_empty(): + """More than 10% N → returns empty array.""" + seq = "N" * 50 + "ACGT" * 5 # 70 bp, 71% N + result = map_non_n_regions(seq) + assert len(result) == 0 + + +def test_map_non_n_regions_exactly_at_threshold(): + """Exactly 10% N → should return empty (condition is <= 0.90 non-N).""" + # 10 N's + 90 ACGT (90% non-N, average == 0.90, which hits the <= boundary) + seq = "N" * 10 + "A" * 90 + result = map_non_n_regions(seq) + assert len(result) == 0 + + +def test_map_non_n_regions_all_n_returns_empty(): + result = map_non_n_regions("NNNNNNNNNN") + assert len(result) == 0 + + +def test_map_non_n_regions_run_of_ns(): + seq = "ACGT" + "NNNN" + "ACGT" # 12 bp, 4/12 ≈ 33% N (empty) + result = map_non_n_regions(seq) + assert len(result) == 0 + + +def test_map_non_n_regions_short_n_run_in_long_sequence(): + """2 N's in 100 bp (2% N) → valid map returned.""" + seq = "A" * 49 + "NN" + "A" * 49 + result = map_non_n_regions(seq) + assert len(result) == 100 + assert result[49] == 0 + assert result[50] == 0 + assert result[0] == 1 + + +# generate_variants — input variants are copied into output + +def test_generate_variants_returns_contig_variants(): + ref = _make_reference() + model = _make_model() + opts = _make_options() + opts.min_mutations = 0 + iv = ContigVariants() + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), iv, model, opts, 40) + assert isinstance(result, ContigVariants) + + +def test_generate_variants_input_variant_in_range_is_included(): + ref = _make_reference() + model = _make_model() + opts = _make_options() + opts.min_mutations = 0 + + iv = ContigVariants() + snv = SingleNucleotideVariant(10, "T", np.array([0, 1]), "42") + iv.add_variant(snv) + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), iv, model, opts, 40) + assert 10 in result + + +def test_generate_variants_input_variant_outside_range_excluded(): + ref = _make_reference(_CLEAN_SEQ[:100]) + model = _make_model() + opts = _make_options() + opts.min_mutations = 0 + + iv = ContigVariants() + # Place variant at position 150, but ref is only 100 bp starting at 0 + snv = SingleNucleotideVariant(150, "T", np.array([0, 1]), "42") + iv.add_variant(snv) + + result = generate_variants(ref, 0, _full_rate_regions(100), iv, model, opts, 40) + assert 150 not in result + + +def test_generate_variants_input_variant_offset_range(): + """ref_start=100, variant at 110 → should be included (100 <= 110 < 300).""" + ref = _make_reference() # 200 bp + model = _make_model() + opts = _make_options() + opts.min_mutations = 0 + + iv = ContigVariants() + snv = SingleNucleotideVariant(110, "T", np.array([0, 1]), "42") + iv.add_variant(snv) + + result = generate_variants(ref, 100, _full_rate_regions(200, offset=100), iv, model, opts, 40) + assert 110 in result + + +def test_generate_variants_input_variant_before_offset_excluded(): + """ref_start=100, variant at 50 → should not be included.""" + ref = _make_reference() # 200 bp + model = _make_model() + opts = _make_options() + opts.min_mutations = 0 + + iv = ContigVariants() + snv = SingleNucleotideVariant(50, "T", np.array([0, 1]), "42") + iv.add_variant(snv) + + result = generate_variants(ref, 100, _full_rate_regions(200, offset=100), iv, model, opts, 40) + assert 50 not in result + + +# generate_variants — random mutation generation + +def test_generate_variants_adds_at_least_min_mutations(): + """With min_mutations=1, at least 1 variant should be added.""" + ref = _make_reference() + model = _make_model() + opts = _make_options(seed=0) + opts.min_mutations = 1 + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model, opts, 40) + assert len(result.variant_locations) >= 1 + + +def test_generate_variants_qual_score_applied(): + """Randomly generated variants should have qual score == max_qual_score.""" + ref = _make_reference() + model = _make_model() + opts = _make_options(seed=1) + opts.min_mutations = 5 + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model, opts, 60) + for loc in result.variant_locations: + for var in result.contig_variants[loc]: + # Input variants may keep their original score; randomly generated ones get max_qual_score + if not getattr(var, 'is_input', False): + assert var.qual_score == 60 + + +def test_generate_variants_zero_mutation_rate_still_runs(): + """A mutation rate of 0 should not crash.""" + ref = _make_reference() + model = _make_model() + opts = _make_options(seed=2) + opts.min_mutations = 0 + + result = generate_variants(ref, 0, [(0, len(ref), 0.0)], ContigVariants(), model, opts, 40) + assert isinstance(result, ContigVariants) + + +def test_generate_variants_high_mutation_rate_adds_many(): + """A high mutation rate should produce more variants than a low one.""" + ref = _make_reference("ACGT" * 100) # 400 bp + model_low = MutationModel(avg_mut_rate=0.001) + model_high = MutationModel(avg_mut_rate=0.05) + opts_low = _make_options(seed=5) + opts_high = _make_options(seed=5) + opts_low.min_mutations = 0 + opts_high.min_mutations = 0 + + result_low = generate_variants(ref, 0, _full_rate_regions(400, 0.001), ContigVariants(), model_low, opts_low, 40) + result_high = generate_variants(ref, 0, _full_rate_regions(400, 0.05), ContigVariants(), model_high, opts_high, 40) + assert len(result_high.variant_locations) >= len(result_low.variant_locations) + + +def test_generate_variants_variants_within_reference_bounds(): + """All generated variant positions should fall within [ref_start, ref_start + len(ref)).""" + seq = "ACGT" * 75 # 300 bp + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=3) + opts.min_mutations = 10 + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model, opts, 40) + for loc in result.variant_locations: + assert 0 <= loc < len(seq), f"Variant at {loc} is out of range [0, {len(seq)})" + + +def test_generate_variants_reproducible_with_same_seed(): + ref = _make_reference() + model1 = _make_model() + model2 = _make_model() + + opts1 = _make_options(seed=99) + opts2 = _make_options(seed=99) + opts1.min_mutations = opts2.min_mutations = 5 + + r1 = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model1, opts1, 40) + r2 = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model2, opts2, 40) + assert r1.variant_locations == r2.variant_locations + + +def test_generate_variants_variant_types_are_valid(): + """Every generated variant should be Insertion, Deletion, or SNV with valid attributes.""" + ref = _make_reference() + model = _make_model() + opts = _make_options(seed=7) + opts.min_mutations = 10 + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), ContigVariants(), model, opts, 40) + for loc in result.variant_locations: + for var in result.contig_variants[loc]: + assert isinstance(var, (Insertion, Deletion, SingleNucleotideVariant)) + assert var.position1 >= 0 + assert var.genotype is not None + if isinstance(var, (Insertion, Deletion)): + assert var.length >= 1 + + +def test_generate_variants_single_rate_region_at_offset(): + """generate_variants works correctly when ref_start places the block mid-reference.""" + seq = "ACGT" * 50 # 200 bp + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=11) + opts.min_mutations = 3 + # Single rate region that exactly spans the offset block [200, 400) + rate_regions = [(200, 400, 0.01)] + + result = generate_variants(ref, 200, rate_regions, ContigVariants(), model, opts, 40) + assert isinstance(result, ContigVariants) + assert len(result.variant_locations) >= 1 + + +def test_generate_variants_input_and_random_together(): + """Pre-existing input variant + randomly generated variants all appear in output.""" + ref = _make_reference() + model = _make_model() + opts = _make_options(seed=13) + opts.min_mutations = 3 + + iv = ContigVariants() + snv = SingleNucleotideVariant(5, "C", np.array([1, 0]), "42") + iv.add_variant(snv) + + result = generate_variants(ref, 0, _full_rate_regions(len(ref)), iv, model, opts, 40) + # Input variant must still be present + assert 5 in result + # And at least some random variants were added + assert len(result.variant_locations) > 1 + + +# generate_variants — N-handling paths (lines 139-157, 204) + +def test_generate_variants_n_in_mutation_region_completes(): + """Sequence with N's in the mutation region runs to completion. + + Uses many iterations so the N-avoidance logic (lines 139-157) is + exercised: window_start landing on N forces the search right/left. + """ + # N's in the middle; valid non-N bases at both ends + seq = "ACGT" * 5 + "N" * 60 + "ACGT" * 45 # 280 bp + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=17) + opts.min_mutations = 30 + + result = generate_variants(ref, 0, _full_rate_regions(len(seq)), ContigVariants(), model, opts, 40) + assert isinstance(result, ContigVariants) + + +def test_generate_variants_n_heavy_subsequence_skipped(): + """When a slice is >10% N, map_non_n_regions returns empty and the + iteration is skipped (line 204 continue). The function still returns + a valid ContigVariants without crashing.""" + # Almost all N — the window is very likely to span the N block, + # triggering the not-any(n_gaps) continue path. + seq = "ACGT" + "N" * 500 + "ACGT" * 10 # 544 bp, ~92% N overall + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=3) + opts.min_mutations = 0 # let Poisson decide; main goal is no crash + + result = generate_variants(ref, 0, _full_rate_regions(len(seq)), ContigVariants(), model, opts, 40) + assert isinstance(result, ContigVariants) + + +def test_generate_variants_trinuc_with_n_skipped(): + """SNV trinucleotide window containing N causes disallowed_chars skip + (lines 246-249). Completes without error.""" + # Isolated N's among valid bases force the trinuc-disallowed path + seq = ("ACGT" * 3 + "N" + "ACGT" * 3) * 10 # 280 bp, ~3.6% N (valid) + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=55) + opts.min_mutations = 50 + + result = generate_variants(ref, 0, _full_rate_regions(len(seq)), ContigVariants(), model, opts, 40) + assert isinstance(result, ContigVariants) + # All returned variants must be within the reference bounds + for loc in result.variant_locations: + assert 0 <= loc < len(seq) + + +def test_generate_variants_no_snv_ref_eq_alt(): + """generate_variants must never produce an SNV whose ALT equals the reference base. + + The trinucleotide slice is centered on local_location so that generate_snv + avoids the actual reference base (trinuc[1] == ref base). With the wrong + off-by-one slice (local_location: local_location+3), trinuc[1] is the 3' + flanking base and REF==ALT SNVs are generated ~11% of the time. + """ + seq = "ACGT" * 75 # 300 bp, no N's + ref = _make_reference(seq) + model = _make_model() + opts = _make_options(seed=42) + opts.min_mutations = 50 + + result = generate_variants(ref, 0, _full_rate_regions(len(seq)), ContigVariants(), model, opts, 40) + seq_upper = seq.upper() + for loc in result.variant_locations: + for var in result.contig_variants[loc]: + if isinstance(var, SingleNucleotideVariant): + ref_base = seq_upper[loc] + assert var.get_alt().upper() != ref_base, ( + f"REF==ALT at position {loc}: ref={ref_base!r}, alt={var.get_alt()!r}" + ) + + +def test_generate_variants_deletion_overlap_handling(): + """If a new variant falls inside an existing deletion's span, the overlap + handling code (lines 291-295) adjusts or skips it without crashing.""" + # Seed 0 with high mutation rate on a clean sequence — with enough + # iterations deletions are generated and later variants may overlap them. + seq = "ACGT" * 100 # 400 bp + ref = _make_reference(seq) + model_high = MutationModel(avg_mut_rate=0.05) + opts = _make_options(seed=0) + opts.min_mutations = 80 + + result = generate_variants(ref, 0, _full_rate_regions(len(seq), 0.05), ContigVariants(), model_high, opts, 40) + assert isinstance(result, ContigVariants) + # Variants at the same location are deduplicated by (type, ALT) — not genotype. + # Two variants of the same type with the same ALT at the same position are duplicates. + for loc in result.variant_locations: + variants_here = result.contig_variants[loc] + type_alt_keys = [] + for v in variants_here: + try: + alt = v.get_alt() + except (KeyError, AttributeError): + alt = None + type_alt_keys.append((type(v).__name__, alt)) + assert len(type_alt_keys) == len(set(type_alt_keys)), \ + f"Duplicate (type, ALT) pair at location {loc}: {type_alt_keys}" diff --git a/tests/test_read_simulator/test_options.py b/tests/test_read_simulator/test_options.py index dad1cbfe..f08a49f7 100644 --- a/tests/test_read_simulator/test_options.py +++ b/tests/test_read_simulator/test_options.py @@ -1,7 +1,6 @@ from neat.read_simulator.utils.options import Options from pathlib import Path as _PathAlias -import logging as _logging import numpy as _np import textwrap as _textwrap import pytest as _pytest @@ -11,42 +10,6 @@ def _project_root() -> _PathAlias: return _PathAlias(__file__).resolve().parents[2] -@_pytest.fixture(autouse=True) -def _isolate_neat_logging(): - """ - Prevent flaky 'ValueError: I/O operation on closed file' logging errors under pytest. - """ - # Clear handlers on NEAT and all child loggers - for name, logger in list(_logging.Logger.manager.loggerDict.items()): - if name == "neat" or name.startswith("neat."): - if isinstance(logger, _logging.Logger): - for h in list(logger.handlers): - logger.removeHandler(h) - try: - h.close() - except Exception: - pass - logger.handlers.clear() - logger.propagate = True # child loggers will propagate to 'neat' - - neat_logger = _logging.getLogger("neat") - neat_logger.handlers.clear() - neat_logger.addHandler(_logging.NullHandler()) - neat_logger.propagate = False # stop at 'neat' (do not reach root) - - yield - - # Rremove NullHandler - for h in list(neat_logger.handlers): - neat_logger.removeHandler(h) - try: - h.close() - except Exception: - pass - neat_logger.handlers.clear() - neat_logger.propagate = True - - def test_basic_options(): reference = _project_root() / "data" / "H1N1.fa" base_options = Options(reference) @@ -183,6 +146,153 @@ def test_from_cli_paired_end_fragments(tmp_path: _PathAlias): assert opts.fq2 == outdir / "peprefix_r2.fastq.gz" +def test_default_values(): + opts = Options() + assert opts.read_len == 101 + assert opts.coverage == 10 + assert opts.ploidy == 2 + assert opts.paired_ended is False + assert opts.produce_fastq is True + assert opts.produce_bam is False + assert opts.produce_vcf is False + assert opts.quality_offset == 33 + assert opts.threads == 1 + assert opts.parallel_mode == "contig" + assert opts.parallel_block_size == 500000 + assert opts.cleanup_splits is True + assert opts.reuse_splits is False + assert opts.overwrite_output is False + assert opts.rescale_qualities is False + assert opts.min_mutations == 0 + assert opts.output_prefix == "neat_sim" + assert opts.output_files == [] + + +def test_rng_seed_zero(): + """Seed value 0 is valid and should not auto-generate a seed.""" + opts = Options(rng_seed=0) + assert opts.rng_seed == 0 + # Should produce deterministic output + a = opts.rng.integers(0, 1_000_000, size=5) + opts2 = Options(rng_seed=0) + b = opts2.rng.integers(0, 1_000_000, size=5) + assert (a == b).all() + + +def test_copy_with_changes(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, rng_seed=1) + new_ref = tmp_path / "other.fa" + new_fq1 = tmp_path / "r1.fastq.gz" + + copy = opts.copy_with_changes(reference=new_ref, fq1=new_fq1) + + assert copy.reference == new_ref + assert copy.fq1 == new_fq1 + # Unchanged fields should carry over + assert copy.rng_seed == opts.rng_seed + assert copy.read_len == opts.read_len + # Original should be unmodified + assert opts.reference == ref + assert opts.fq1 is None + + +def test_copy_with_changes_no_args(): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, rng_seed=2) + copy = opts.copy_with_changes() + assert copy.reference == ref + assert copy.coverage == opts.coverage + + +def test_check_and_log_error_none_passthrough(): + """None value should not raise or exit.""" + Options.check_and_log_error("any_key", None, 0, 100) # no exception + + +def test_check_and_log_error_numeric_in_range(): + Options.check_and_log_error("coverage", 10, 1, 1000000) # no exception + + +def test_check_and_log_error_numeric_out_of_range(capsys): + with _pytest.raises(SystemExit): + Options.check_and_log_error("coverage", 0, 1, 1000000) + + +def test_check_and_log_error_choice_valid(): + Options.check_and_log_error("parallel_mode", "contig", "choice", ["size", "contig"]) + + +def test_check_and_log_error_choice_invalid(): + with _pytest.raises(SystemExit): + Options.check_and_log_error("parallel_mode", "bad", "choice", ["size", "contig"]) + + +def test_check_options_no_output_files_exits(): + opts = Options(rng_seed=0) + opts.produce_fastq = False + opts.produce_bam = False + opts.produce_vcf = False + with _pytest.raises(SystemExit): + opts.check_options() + + +def test_check_options_paired_with_fragment_model_clears_mean_stdev(): + opts = Options(rng_seed=0, paired_ended=True, + fragment_model="some_model.pkl", + fragment_mean=300.0, fragment_st_dev=30.0) + opts.check_options() + assert opts.fragment_mean is None + assert opts.fragment_st_dev is None + + +def test_log_configuration_produces_bam_and_vcf(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, output_dir=tmp_path, output_prefix="out", + overwrite_output=True, produce_fastq=True, + produce_bam=True, produce_vcf=True) + opts.log_configuration() + assert opts.bam == tmp_path / "out_golden.bam" + assert opts.vcf == tmp_path / "out_golden.vcf.gz" + assert opts.bam in opts.output_files + assert opts.vcf in opts.output_files + + +def test_log_configuration_threads_one_forces_contig(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, output_dir=tmp_path, output_prefix="out", + overwrite_output=True, threads=1, parallel_mode="size") + opts.log_configuration() + assert opts.parallel_mode == "contig" + + +def test_log_configuration_fragment_mean_less_than_read_len_exits(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, output_dir=tmp_path, output_prefix="out", + overwrite_output=True, read_len=150, + fragment_mean=100.0, fragment_st_dev=10.0) + with _pytest.raises(SystemExit): + opts.log_configuration() + + +def test_log_configuration_fragment_mean_without_stdev_exits(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, output_dir=tmp_path, output_prefix="out", + overwrite_output=True, read_len=100, + fragment_mean=300.0, fragment_st_dev=None) + with _pytest.raises(SystemExit): + opts.log_configuration() + + +def test_log_configuration_paired_without_model_or_mean_exits(tmp_path: _PathAlias): + ref = _project_root() / "data" / "H1N1.fa" + opts = Options(reference=ref, output_dir=tmp_path, output_prefix="out", + overwrite_output=True, paired_ended=True, + fragment_model=None, fragment_mean=None) + with _pytest.raises(SystemExit): + opts.log_configuration() + + def test_from_cli_reuse_splits_missing_dir_raises(tmp_path: _PathAlias): cfg = _textwrap.dedent( f""" @@ -202,9 +312,9 @@ def test_from_cli_reuse_splits_missing_dir_raises(tmp_path: _PathAlias): yml_path = tmp_path / "neat_from_cli_reuse.yml" yml_path.write_text(cfg, encoding="utf-8") - + outdir = tmp_path / "out" outdir.mkdir(parents=True, exist_ok=True) with _pytest.raises(FileNotFoundError, match=r"reuse_splits=True"): - Options.from_cli(outdir, "reuse", yml_path) \ No newline at end of file + Options.from_cli(outdir, "reuse", yml_path) diff --git a/tests/test_read_simulator/test_output_file_writer.py b/tests/test_read_simulator/test_output_file_writer.py new file mode 100644 index 00000000..a64ae040 --- /dev/null +++ b/tests/test_read_simulator/test_output_file_writer.py @@ -0,0 +1,321 @@ +""" +Unit tests for neat/read_simulator/utils/output_file_writer.py +""" +import gzip +import io +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from Bio.Seq import Seq + +from neat.read_simulator.utils.output_file_writer import OutputFileWriter, reg2bin +from neat.read_simulator.utils.options import Options +from neat.read_simulator.utils.read import Read + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_REF_SEQ = "ACGT" * 30 # 120 bp + + +def _make_options(tmp_path: Path, + fq1=True, fq2=False, vcf=False, bam=False, + paired=False, produce_vcf=False, produce_bam=False, + produce_fastq=True) -> Options: + opts = Options(rng_seed=0) + opts.paired_ended = paired + opts.produce_fastq = produce_fastq + opts.produce_vcf = produce_vcf + opts.produce_bam = produce_bam + opts.temp_dir_path = tmp_path + opts.reference = str(tmp_path / "ref.fa") + opts.rng_seed = 0 + + opts.fq1 = (tmp_path / "out.fq1.gz") if fq1 else None + opts.fq2 = (tmp_path / "out.fq2.gz") if fq2 else None + opts.vcf = (tmp_path / "out.vcf.gz") if vcf else None + opts.bam = (tmp_path / "out.bam") if bam else None + return opts + + +def _make_ofw(tmp_path: Path, **kw) -> OutputFileWriter: + opts = _make_options(tmp_path, **kw) + return OutputFileWriter(options=opts) + + +def _make_read(position: int = 10, + seq: str = "ACGTACGT", + is_reverse: bool = False) -> Read: + read_len = len(seq) + end_point = position + read_len + r = Read( + name="read1", + raw_read=(position, end_point, position + 150, end_point + 150), + reference_segment=Seq(seq), + reference_id="chr1", + ref_id_index=0, + position=position, + end_point=end_point, + padding=20, + run_read_len=read_len, + is_reverse=is_reverse, + ) + r.mapping_quality = 60 + r.quality_array = [40] * read_len + # read_sequence must be set before write_bam_record can call make_cigar + r.read_sequence = Seq(seq) + return r + + +# =========================================================================== +# reg2bin +# =========================================================================== + +def test_reg2bin_same_16kb_bin(): + # Two calls with the same start and different ends within 16kb should return the same bin. + # (Substantive check is in test_reg2bin_adjacent_bins; this verifies return type/range.) + result = reg2bin(0, 100) + assert isinstance(result, int) + assert result == reg2bin(0, 100) # deterministic + + +def test_reg2bin_large_span_returns_zero(): + # Spanning >2^26 bases → falls through to return 0 + result = reg2bin(0, 2**26 + 1) + assert result == 0 + + +def test_reg2bin_adjacent_bins(): + # Two regions in the same 16kb window should return same bin + assert reg2bin(0, 100) == reg2bin(0, 200) + + +def test_reg2bin_17kb_window(): + result = reg2bin(0, 2**17 + 1) + assert isinstance(result, int) + + +# =========================================================================== +# OutputFileWriter — construction +# =========================================================================== + +def test_ofw_fq1_file_created(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + assert tmp_path / "out.fq1.gz" in ofw.files_to_write + + +def test_ofw_fq2_file_created(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True, fq2=True, paired=True) + assert tmp_path / "out.fq2.gz" in ofw.files_to_write + + +def test_ofw_vcf_file_created(tmp_path): + ofw = _make_ofw(tmp_path, vcf=True, produce_vcf=True) + assert tmp_path / "out.vcf.gz" in ofw.files_to_write + + +def test_ofw_no_files_raises(tmp_path): + opts = _make_options(tmp_path, fq1=False) + with pytest.raises(ValueError): + OutputFileWriter(options=opts) + + +def test_ofw_bam_only_with_header_is_accepted(tmp_path): + """BAM-only (no FASTQ, no VCF) with a bam_header opens the BAM handle.""" + opts = _make_options(tmp_path, fq1=False, bam=True, + produce_fastq=False, produce_bam=True) + ofw = OutputFileWriter(options=opts, bam_header={"chr1": 1000}) + assert ofw.bam is not None + assert ofw.bam in ofw.files_to_write + assert ofw.fq1 is None + assert ofw.vcf is None + ofw.flush_and_close_files() + + +def test_ofw_bam_only_no_header_is_accepted(tmp_path): + """BAM-only with bam_header=None (the runner's path) must not raise. + pysam manages the merged output file so no handle is opened here.""" + opts = _make_options(tmp_path, fq1=False, bam=True, + produce_fastq=False, produce_bam=True) + ofw = OutputFileWriter(options=opts, bam_header=None) + assert ofw.bam is not None + assert ofw.fq1 is None + assert ofw.vcf is None + assert ofw.bam not in ofw.files_to_write + + +def test_ofw_vcf_only_is_accepted(tmp_path): + """VCF-only (no FASTQ, no BAM) should construct without error.""" + opts = _make_options(tmp_path, fq1=False, vcf=True, + produce_fastq=False, produce_vcf=True) + ofw = OutputFileWriter(options=opts, vcf_header={"chr1": 1000}) + assert ofw.vcf is not None + assert ofw.vcf in ofw.files_to_write + assert ofw.fq1 is None + assert ofw.bam is None + ofw.flush_and_close_files() + + +def test_ofw_fq1_is_none_when_not_requested(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + assert ofw.fq2 is None + + +def test_ofw_vcf_is_none_when_not_requested(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + assert ofw.vcf is None + + +def test_ofw_bam_is_none_when_not_requested(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + assert ofw.bam is None + + +# =========================================================================== +# OutputFileWriter — VCF header +# =========================================================================== + +def _ofw_with_vcf_header(tmp_path: Path) -> OutputFileWriter: + opts = _make_options(tmp_path, fq1=True, vcf=True, produce_vcf=True) + # write a placeholder reference file for the header path + (tmp_path / "ref.fa").write_text(">chr1\nACGT\n") + return OutputFileWriter( + options=opts, + vcf_header={"chr1": 1000, "chr2": 500}, + ) + + +def test_vcf_header_written_on_init(tmp_path): + ofw = _ofw_with_vcf_header(tmp_path) + # flush so content is available + ofw.files_to_write[ofw.vcf].flush() + with gzip.open(ofw.vcf, "rt") as fh: + content = fh.read() + assert "##fileformat=VCFv4.1" in content + + +def test_vcf_header_contains_contig_lines(tmp_path): + ofw = _ofw_with_vcf_header(tmp_path) + ofw.files_to_write[ofw.vcf].flush() + with gzip.open(ofw.vcf, "rt") as fh: + content = fh.read() + assert "chr1" in content + assert "chr2" in content + + +def test_vcf_header_contains_column_line(tmp_path): + ofw = _ofw_with_vcf_header(tmp_path) + ofw.files_to_write[ofw.vcf].flush() + with gzip.open(ofw.vcf, "rt") as fh: + content = fh.read() + assert "#CHROM" in content + + +# =========================================================================== +# write_fastq_record +# =========================================================================== + +def test_write_fastq_record_writes_content(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + record = "@read1\nACGT\n+\nIIII\n" + ofw.write_fastq_record(ofw.fq1, record) + ofw.flush_and_close_files() + with gzip.open(ofw.fq1, "rt") as fh: + assert fh.read() == record + + +def test_write_fastq_record_unknown_file_raises(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + with pytest.raises(ValueError): + ofw.write_fastq_record(tmp_path / "unknown.gz", "@r\nA\n+\nI\n") + + +def test_write_fastq_record_multiple_records(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + for i in range(5): + ofw.write_fastq_record(ofw.fq1, f"@r{i}\nACGT\n+\nIIII\n") + ofw.flush_and_close_files() + with gzip.open(ofw.fq1, "rt") as fh: + lines = fh.readlines() + assert len(lines) == 20 # 4 lines × 5 records + + +# =========================================================================== +# write_vcf_record +# =========================================================================== + +def test_write_vcf_record_appends_line(tmp_path): + ofw = _make_ofw(tmp_path, vcf=True, produce_vcf=True) + line = "chr1\t100\t.\tA\tT\t42\tPASS\t.\tGT\t0|1\n" + ofw.write_vcf_record(line) + ofw.flush_and_close_files() + with gzip.open(ofw.vcf, "rt") as fh: + assert fh.read() == line + + +def test_write_vcf_record_unknown_file_raises(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) # no vcf + with pytest.raises(ValueError): + ofw.write_vcf_record("chr1\t100\t.\tA\tT\n") + + +# =========================================================================== +# flush_and_close_files +# =========================================================================== + +def test_flush_and_close_closes_fq1(tmp_path): + ofw = _make_ofw(tmp_path, fq1=True) + ofw.flush_and_close_files() + fh = ofw.files_to_write[ofw.fq1] + assert fh.closed + + +def test_flush_and_close_idempotent(tmp_path): + """Calling flush_and_close twice should not raise.""" + ofw = _make_ofw(tmp_path, fq1=True) + ofw.flush_and_close_files() + ofw.flush_and_close_files() # second call should not raise + + +# =========================================================================== +# write_bam_record +# =========================================================================== + +def _ofw_with_bam(tmp_path: Path) -> OutputFileWriter: + opts = _make_options(tmp_path, fq1=True, bam=True, + produce_bam=True, produce_fastq=True) + bam_header = {"chr1": 1000} + return OutputFileWriter(options=opts, bam_header=bam_header) + + +def test_write_bam_record_writes_bytes(tmp_path): + ofw = _ofw_with_bam(tmp_path) + read = _make_read(position=10, seq="ACGTACGT") + bam_handle = ofw.files_to_write[ofw.bam] + pos_before = bam_handle.tell() + ofw.write_bam_record(read, contig_id=0, bam_handle=bam_handle, read_length=8) + assert bam_handle.tell() > pos_before # bytes were written + + +def test_write_bam_record_reverse_strand(tmp_path): + ofw = _ofw_with_bam(tmp_path) + read = _make_read(position=10, seq="ACGTACGT", is_reverse=True) + bam_handle = ofw.files_to_write[ofw.bam] + pos_before = bam_handle.tell() + ofw.write_bam_record(read, contig_id=0, bam_handle=bam_handle, read_length=8) + assert bam_handle.tell() > pos_before # bytes were written for reverse strand + + +def test_write_bam_record_odd_length_sequence(tmp_path): + """Odd-length reads require padding — should not crash.""" + ofw = _ofw_with_bam(tmp_path) + read = _make_read(position=10, seq="ACGTA") # 5 bp — odd + read.quality_array = [40] * 5 + bam_handle = ofw.files_to_write[ofw.bam] + pos_before = bam_handle.tell() + ofw.write_bam_record(read, contig_id=0, bam_handle=bam_handle, read_length=5) + assert bam_handle.tell() > pos_before # padding handled without error \ No newline at end of file diff --git a/tests/test_read_simulator/test_read.py b/tests/test_read_simulator/test_read.py new file mode 100644 index 00000000..71d92210 --- /dev/null +++ b/tests/test_read_simulator/test_read.py @@ -0,0 +1,468 @@ +""" +Tests for neat/read_simulator/utils/read.py +""" +import io + +import numpy as np +import pytest +from Bio.Seq import Seq + +from neat.models import SequencingErrorModel, TraditionalQualityModel +from neat.read_simulator.utils.read import Read +from neat.variants import SingleNucleotideVariant +from neat.variants.deletion import Deletion +from neat.variants.insertion import Insertion + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +_READ_LEN = 100 +_REF = "ACGT" * (_READ_LEN // 4) # 100-base reference with no Ns +_PADDED_REF = _REF + "ACGT" * 5 # 120-base ref with 20 bases of padding + + +def _make_read( + position=0, + end_point=None, + reference=_REF, + ref_id="chr1", + ref_id_index=0, + padding=20, + read_len=_READ_LEN, + is_reverse=False, + is_paired=False, + raw_read=None, +): + if end_point is None: + end_point = position + read_len + if raw_read is None: + raw_read = (position, end_point, position + 150, end_point + 150) + return Read( + name="test_read", + raw_read=raw_read, + reference_segment=Seq(reference), + reference_id=ref_id, + ref_id_index=ref_id_index, + position=position, + end_point=end_point, + padding=padding, + run_read_len=read_len, + is_reverse=is_reverse, + is_paired=is_paired, + ) + + +def _make_rng(seed=0): + return np.random.default_rng(seed) + + +# --------------------------------------------------------------------------- +# __repr__ and __str__ +# --------------------------------------------------------------------------- + +def test_repr(): + r = _make_read(position=10, end_point=110) + assert repr(r) == "chr1: 10-110" + + +def test_str(): + r = _make_read(position=10, end_point=110) + assert str(r) == "chr1: 10-110" + + +# --------------------------------------------------------------------------- +# Comparison operators — same reference_id +# --------------------------------------------------------------------------- + +def test_gt_same_chrom_true(): + r1 = _make_read(position=200) + r2 = _make_read(position=100) + assert r1 > r2 + +def test_gt_same_chrom_false(): + r1 = _make_read(position=50) + r2 = _make_read(position=100) + assert not (r1 > r2) + +def test_ge_same_chrom(): + r1 = _make_read(position=100) + r2 = _make_read(position=100) + assert r1 >= r2 + r3 = _make_read(position=50) + assert not (r3 >= r2) + +def test_lt_same_chrom_true(): + r1 = _make_read(position=50) + r2 = _make_read(position=100) + assert r1 < r2 + +def test_lt_same_chrom_false(): + r1 = _make_read(position=200) + r2 = _make_read(position=100) + assert not (r1 < r2) + +def test_le_same_chrom(): + r1 = _make_read(position=100) + r2 = _make_read(position=100) + assert r1 <= r2 + r3 = _make_read(position=200) + assert not (r3 <= r2) + +def test_ne_same_chrom_different_position(): + r1 = _make_read(position=100) + r2 = _make_read(position=200) + assert r1 != r2 + +def test_eq_same_chrom_same_position(): + r1 = _make_read(position=100, end_point=200) + r2 = _make_read(position=100, end_point=200) + assert r1 == r2 + +def test_eq_same_chrom_different_end(): + r1 = _make_read(position=100, end_point=200) + r2 = _make_read(position=100, end_point=210) + assert r1 != r2 + +# --------------------------------------------------------------------------- +# Comparison operators — different reference_id +# --------------------------------------------------------------------------- + +def test_gt_different_chrom_returns_false(): + r1 = _make_read(position=500, ref_id="chr1") + r2 = _make_read(position=100, ref_id="chr2") + assert not (r1 > r2) + +def test_ge_different_chrom_returns_false(): + r1 = _make_read(position=500, ref_id="chr1") + r2 = _make_read(position=100, ref_id="chr2") + assert not (r1 >= r2) + +def test_lt_different_chrom_returns_false(): + r1 = _make_read(position=100, ref_id="chr1") + r2 = _make_read(position=500, ref_id="chr2") + assert not (r1 < r2) + +def test_le_different_chrom_returns_false(): + r1 = _make_read(position=100, ref_id="chr1") + r2 = _make_read(position=500, ref_id="chr2") + assert not (r1 <= r2) + +def test_ne_different_chrom_returns_true(): + r1 = _make_read(position=100, ref_id="chr1") + r2 = _make_read(position=100, ref_id="chr2") + assert r1 != r2 + +def test_eq_different_chrom_returns_false(): + r1 = _make_read(position=100, ref_id="chr1") + r2 = _make_read(position=100, ref_id="chr2") + assert not (r1 == r2) + + +# --------------------------------------------------------------------------- +# __len__ +# --------------------------------------------------------------------------- + +def test_len(): + r = _make_read(read_len=150) + assert len(r) == 150 + + +# --------------------------------------------------------------------------- +# contains +# --------------------------------------------------------------------------- + +def test_contains_inside(): + r = _make_read(position=100, end_point=200) + assert r.contains(150) + assert r.contains(100) # at start (inclusive) + assert r.contains(199) # at end - 1 (inclusive) + +def test_contains_outside(): + r = _make_read(position=100, end_point=200) + assert not r.contains(99) + assert not r.contains(200) # end_point is exclusive + + +# --------------------------------------------------------------------------- +# update_quality_array +# --------------------------------------------------------------------------- + +def _read_with_quality(length=_READ_LEN): + r = _make_read() + r.quality_array = np.array([30] * length, dtype=float) + return r + + +def test_update_quality_array_mutation(): + r = _read_with_quality() + r.update_quality_array(1, Seq("G"), 10, "mutation", [30], quality_score=20) + assert r.quality_array[10] == 20 + assert len(r.quality_array) == _READ_LEN + + +def test_update_quality_array_error_snp(): + r = _read_with_quality() + r.update_quality_array(1, Seq("T"), 10, "error", [30, 2]) + assert r.quality_array[10] == 2 # min of quality_scores + assert len(r.quality_array) == _READ_LEN + + +def test_update_quality_array_error_insertion(): + """Insertion: len(alternate) > 1 — quality array grows by len(alt)-1 - ref_length.""" + r = _read_with_quality() + original_len = len(r.quality_array) + # ref_length=1, alt="ATG" (len=3) → new_quality_scores has 2 entries, replaces 1 → net +1 + r.update_quality_array(1, Seq("ATG"), 10, "error", [30, 5]) + assert len(r.quality_array) == original_len + 1 + assert r.quality_array[10] == 5 + assert r.quality_array[11] == 5 + + +def test_update_quality_array_error_deletion(): + """Deletion: ref_length > 1 and len(alternate) == 1 — quality scores removed.""" + r = _read_with_quality() + original_len = len(r.quality_array) + r.update_quality_array(3, Seq("A"), 10, "error", [30, 5]) + assert len(r.quality_array) == original_len - 3 + + +# --------------------------------------------------------------------------- +# apply_mutations +# --------------------------------------------------------------------------- + +def _read_for_mutations(sequence=None, padding=20): + r = _make_read(position=0, end_point=_READ_LEN, padding=padding) + seq = Seq(sequence or _REF) + r.read_sequence = seq + r.quality_array = np.array([30] * _READ_LEN, dtype=float) + return r + + +def test_apply_mutations_snv(): + r = _read_for_mutations() + snv = SingleNucleotideVariant( + position1=50, alt=Seq("T"), genotype=np.array([1, 1]), qual_score=30 + ) + r.mutations = {50: [snv]} + r.apply_mutations([30], _make_rng()) + assert r.read_sequence[50] == "T" + + +def test_apply_mutations_insertion(): + r = _read_for_mutations() + ins = Insertion( + position1=50, length=2, alt=Seq("AAA"), + genotype=np.array([1, 1]), qual_score=30 + ) + r.mutations = {50: [ins]} + original_base = str(r.read_sequence[50]) + r.apply_mutations([30], _make_rng()) + # Insertion replaces one base with the alt sequence + assert str(r.read_sequence[50]) != original_base or len(r.read_sequence) >= _READ_LEN + + +def test_apply_mutations_deletion_sufficient_padding(): + r = _read_for_mutations(padding=10) + deletion = Deletion( + position1=50, length=3, genotype=np.array([1, 1]), qual_score=30 + ) + r.mutations = {50: [deletion]} + r.apply_mutations([30], _make_rng()) + assert r.padding == 7 # 10 - 3 + + +def test_apply_mutations_deletion_insufficient_padding(): + r = _read_for_mutations(padding=0) + deletion = Deletion( + position1=50, length=3, genotype=np.array([1, 1]), qual_score=30 + ) + r.mutations = {50: [deletion]} + original_seq = str(r.read_sequence) + r.apply_mutations([30], _make_rng()) + # Deletion is skipped; sequence unchanged and padding set to 0 + assert str(r.read_sequence) == original_seq + assert r.padding == 0 + + +def test_apply_mutations_genotype_zero_skips(): + """genotype=[0,0] means not mutated — sequence should be unchanged.""" + r = _read_for_mutations() + snv = SingleNucleotideVariant( + position1=50, alt=Seq("T"), genotype=np.array([0, 0]), qual_score=30 + ) + r.mutations = {50: [snv]} + original_seq = str(r.read_sequence) + r.apply_mutations([30], _make_rng()) + assert str(r.read_sequence) == original_seq + + +# --------------------------------------------------------------------------- +# calculate_flags +# --------------------------------------------------------------------------- + +def test_calculate_flags_single_ended_run(): + r = _make_read(is_paired=False, is_reverse=False) + assert r.calculate_flags(paired_ended_run=False) == 0 + + +def test_calculate_flags_paired_forward_proper_pair(): + # paired_ended_run, is_paired, not reverse → 1 + 2 + 32 + 64 = 99 + r = _make_read(is_paired=True, is_reverse=False) + assert r.calculate_flags(paired_ended_run=True) == 99 + + +def test_calculate_flags_paired_reverse_proper_pair(): + # paired_ended_run, is_paired, is_reverse → 1 + 2 + 16 + 128 = 147 + r = _make_read(is_paired=True, is_reverse=True) + assert r.calculate_flags(paired_ended_run=True) == 147 + + +def test_calculate_flags_paired_run_mate_unmapped(): + # paired_ended_run, not is_paired, not reverse → 1 + 8 = 9 + r = _make_read(is_paired=False, is_reverse=False) + assert r.calculate_flags(paired_ended_run=True) == 9 + + +def test_calculate_flags_paired_run_mate_unmapped_reverse(): + # paired_ended_run, not is_paired, is_reverse → 1 + 8 + 16 = 25 + r = _make_read(is_paired=False, is_reverse=True) + assert r.calculate_flags(paired_ended_run=True) == 25 + + +# --------------------------------------------------------------------------- +# get_mpos +# --------------------------------------------------------------------------- + +def test_get_mpos_not_paired(): + r = _make_read(is_paired=False, raw_read=(0, 100, 200, 300)) + assert r.get_mpos() == 0 + + +def test_get_mpos_paired_forward(): + r = _make_read(is_paired=True, is_reverse=False, raw_read=(0, 100, 200, 300)) + assert r.get_mpos() == 200 # raw_read[2] + + +def test_get_mpos_paired_reverse(): + r = _make_read(is_paired=True, is_reverse=True, raw_read=(0, 100, 200, 300)) + assert r.get_mpos() == 0 # raw_read[0] + + +# --------------------------------------------------------------------------- +# get_tlen +# --------------------------------------------------------------------------- + +def test_get_tlen_not_paired(): + r = _make_read(is_paired=False, raw_read=(0, 100, 200, 300)) + assert r.get_tlen() == 0 + + +def test_get_tlen_paired_forward(): + # length = raw_read[3] - raw_read[0] + 1 = 300 - 0 + 1 = 301 + r = _make_read(is_paired=True, is_reverse=False, raw_read=(0, 100, 200, 300)) + assert r.get_tlen() == 301 + + +def test_get_tlen_paired_reverse(): + # same length, but negative + r = _make_read(is_paired=True, is_reverse=True, raw_read=(0, 100, 200, 300)) + assert r.get_tlen() == -301 + + +# --------------------------------------------------------------------------- +# convert_masking +# --------------------------------------------------------------------------- + +def test_convert_masking_no_ns(): + """A reference with no Ns should be unchanged.""" + r = _make_read(reference=_REF) + r.quality_array = np.array([30] * len(_REF), dtype=float) + qual_model = TraditionalQualityModel() + r.convert_masking(qual_model) + assert "N" not in str(r.reference_segment) + + +def test_convert_masking_replaces_ns(): + """Ns in the reference should be replaced with TTAGGG repeat bases.""" + ref_with_n = "ACGT" * 10 + "NNNN" + "ACGT" * 15 + r = _make_read(reference=ref_with_n) + r.quality_array = np.array([30] * len(ref_with_n), dtype=float) + qual_model = TraditionalQualityModel() + r.convert_masking(qual_model) + assert "N" not in str(r.reference_segment) + # Quality at masked positions should be set to min quality + bad_score = min(qual_model.quality_scores) + assert all(r.quality_array[40:44] == bad_score) + + +# --------------------------------------------------------------------------- +# finalize_read_and_write — produce_fastq=True +# --------------------------------------------------------------------------- + +def test_finalize_read_and_write_writes_fastq(): + r = _make_read(reference=_PADDED_REF, padding=20) + err_model = SequencingErrorModel(read_length=_READ_LEN) + qual_model = TraditionalQualityModel() + rng = _make_rng() + handle = io.StringIO() + + r.finalize_read_and_write(err_model, qual_model, handle, 33, True, rng) + + output = handle.getvalue() + assert output.startswith("@test_read") + lines = output.strip().split("\n") + assert len(lines) == 4 + assert lines[2] == "+" + assert len(lines[1]) == _READ_LEN + assert len(lines[3]) == _READ_LEN + + +def test_finalize_read_and_write_reverse_complement(): + r = _make_read(reference=_PADDED_REF, padding=20, is_reverse=True) + err_model = SequencingErrorModel(read_length=_READ_LEN) + qual_model = TraditionalQualityModel() + rng = _make_rng() + + r.finalize_read_and_write(err_model, qual_model, None, 33, False, rng) + + assert len(r.read_sequence) == _READ_LEN + + +def test_finalize_sets_mapping_quality(): + r = _make_read(reference=_PADDED_REF, padding=20) + err_model = SequencingErrorModel(read_length=_READ_LEN) + qual_model = TraditionalQualityModel() + rng = _make_rng() + + r.finalize_read_and_write(err_model, qual_model, None, 33, False, rng) + + assert r.mapping_quality == 70 + + +# --------------------------------------------------------------------------- +# make_cigar +# --------------------------------------------------------------------------- + +def test_make_cigar_all_match(): + """A read identical to its reference should produce an all-M cigar.""" + r = _make_read(reference=_PADDED_REF, padding=20) + err_model = SequencingErrorModel(read_length=_READ_LEN) + qual_model = TraditionalQualityModel() + rng = _make_rng(seed=0) + r.finalize_read_and_write(err_model, qual_model, None, 33, False, rng) + cigar = r.make_cigar() + assert cigar.endswith("M") + assert "I" not in cigar or "D" not in cigar # no complex indels for a clean read + + +def test_make_cigar_reverse_strand(): + """make_cigar on a reverse read should return a valid cigar string.""" + r = _make_read(reference=_PADDED_REF, padding=20, is_reverse=True) + err_model = SequencingErrorModel(read_length=_READ_LEN) + qual_model = TraditionalQualityModel() + rng = _make_rng(seed=0) + r.finalize_read_and_write(err_model, qual_model, None, 33, False, rng) + cigar = r.make_cigar() + assert isinstance(cigar, str) + assert len(cigar) > 0 \ No newline at end of file diff --git a/tests/test_read_simulator/test_runner.py b/tests/test_read_simulator/test_runner.py new file mode 100644 index 00000000..68a8e8dd --- /dev/null +++ b/tests/test_read_simulator/test_runner.py @@ -0,0 +1,461 @@ +""" +Tests for neat/read_simulator/runner.py + +Unit tests cover filter_thread_variants and filter_bed_regions. +Integration test exercises read_simulator_runner end-to-end with a tiny +reference and a minimal config, producing only FASTQ output so that +pysam/bcftools post-processing is not triggered. +""" +import gzip +import textwrap +from pathlib import Path + +import numpy as np +import pytest + +from neat.read_simulator.runner import ( + filter_thread_variants, + filter_bed_regions, + read_simulator_runner, +) +from neat.variants import ContigVariants, SingleNucleotideVariant, Deletion, Insertion + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def contig_variants(): + """ContigVariants with SNVs at positions 10, 50, 100, 200.""" + cv = ContigVariants() + for pos in (10, 50, 100, 200): + cv.add_variant(SingleNucleotideVariant(pos, "T", np.array([0, 1]), "42")) + return cv + + +# =========================================================================== +# filter_thread_variants +# =========================================================================== + +def test_filter_thread_variants_all_in_range(contig_variants): + result = filter_thread_variants(contig_variants, (0, 300)) + assert sorted(result.variant_locations) == [10, 50, 100, 200] + + +def test_filter_thread_variants_none_in_range(contig_variants): + result = filter_thread_variants(contig_variants, (500, 1000)) + assert result.variant_locations == [] + + +def test_filter_thread_variants_partial_range(contig_variants): + result = filter_thread_variants(contig_variants, (50, 150)) + assert sorted(result.variant_locations) == [50, 100] + + +def test_filter_thread_variants_lower_bound_inclusive(contig_variants): + """coords[0] == variant position → included.""" + result = filter_thread_variants(contig_variants, (10, 50)) + assert 10 in result.variant_locations + + +def test_filter_thread_variants_upper_bound_exclusive(contig_variants): + """coords[1] == variant position → excluded.""" + result = filter_thread_variants(contig_variants, (10, 50)) + assert 50 not in result.variant_locations + + +def test_filter_thread_variants_empty_input(): + result = filter_thread_variants(ContigVariants(), (0, 1000)) + assert result.variant_locations == [] + + +def test_filter_thread_variants_returns_contig_variants(contig_variants): + result = filter_thread_variants(contig_variants, (0, 300)) + assert isinstance(result, ContigVariants) + + +def test_filter_thread_variants_preserves_variant_data(contig_variants): + """Filtered variants should retain their original objects.""" + result = filter_thread_variants(contig_variants, (0, 60)) + for loc in result.variant_locations: + variants = result.contig_variants[loc] + assert len(variants) == 1 + assert isinstance(variants[0], SingleNucleotideVariant) + + +# =========================================================================== +# filter_bed_regions +# =========================================================================== + +_REGIONS = [ + (0, 200, 0.01), + (200, 400, 0.02), + (400, 600, 0.03), + (600, 1000, 0.04), +] + + +def test_filter_bed_regions_block_within_one_region(): + result = filter_bed_regions(_REGIONS, (50, 150)) + assert result == [(0, 200, 0.01)] + + +def test_filter_bed_regions_block_spans_two_regions(): + result = filter_bed_regions(_REGIONS, (150, 250)) + assert (0, 200, 0.01) in result + assert (200, 400, 0.02) in result + + +def test_filter_bed_regions_block_spans_all(): + result = filter_bed_regions(_REGIONS, (0, 1000)) + assert len(result) == len(_REGIONS) + + +def test_filter_bed_regions_block_outside_all(): + result = filter_bed_regions(_REGIONS, (1100, 1500)) + assert result == [] + + +def test_filter_bed_regions_empty_regions(): + result = filter_bed_regions([], (0, 1000)) + assert result == [] + + +def test_filter_bed_regions_block_touching_region_start(): + """Block ends exactly at region boundary → the touching region is included.""" + # coords (200, 400) → region (200, 400) shares its left edge with coords[0] + result = filter_bed_regions(_REGIONS, (200, 400)) + assert (200, 400, 0.02) in result + + +def test_filter_bed_regions_single_region_fully_inside_block(): + """Region fully contained within block coordinates.""" + result = filter_bed_regions([(300, 350, 0.05)], (200, 400)) + assert result == [(300, 350, 0.05)] + + +def test_filter_bed_regions_returns_list(): + result = filter_bed_regions(_REGIONS, (50, 150)) + assert isinstance(result, list) + assert result == [(0, 200, 0.01)] # only the overlapping region + + +# =========================================================================== +# Integration test — read_simulator_runner (FASTQ output only) +# =========================================================================== + +def _write_ref(path: Path, seq: str = "ACGT" * 100) -> Path: + """Write a minimal single-contig FASTA reference.""" + path.write_text(f">chr1\n{seq}\n", encoding="utf-8") + return path + + +def _write_config(path: Path, ref_path: Path, **overrides) -> Path: + defaults = { + "reference": str(ref_path), + "produce_fastq": "true", + "produce_vcf": "false", + "produce_bam": "false", + "read_len": 50, + "coverage": 2, + "rng_seed": 42, + "overwrite_output": "true", + "cleanup_splits": "true", + } + defaults.update(overrides) + lines = "\n".join(f"{k}: {v}" for k, v in defaults.items()) + path.write_text(lines + "\n", encoding="utf-8") + return path + + +def test_runner_produces_fastq_output(tmp_path): + """ + End-to-end: runner with a 400 bp reference, low coverage, FASTQ only. + Verifies fq1 is created and contains at least one FASTQ record. + """ + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config(tmp_path / "conf.yml", ref) + out_dir = tmp_path / "out" + + read_simulator_runner(str(cfg), str(out_dir), "test") + + fq_files = list(out_dir.glob("*.fastq.gz")) + assert len(fq_files) >= 1, "Expected at least one FASTQ output file" + + # Verify the file contains valid FASTQ content + with gzip.open(fq_files[0], "rt") as fh: + first_line = fh.readline() + assert first_line.startswith("@"), "FASTQ file should start with '@'" + + +def test_runner_paired_end_produces_two_fastqs(tmp_path): + """Paired-end mode produces both fq1 and fq2.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + paired_ended="true", + fragment_mean=150, + fragment_st_dev=25, + ) + out_dir = tmp_path / "out" + + read_simulator_runner(str(cfg), str(out_dir), "test") + + fq_files = sorted(out_dir.glob("*.fastq.gz")) + assert len(fq_files) == 2, f"Expected 2 FASTQ files, found {len(fq_files)}" + + +def test_runner_creates_output_dir_if_missing(tmp_path): + """Output directory is created if it doesn't exist.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config(tmp_path / "conf.yml", ref) + out_dir = tmp_path / "nested" / "output" + + assert not out_dir.exists() + read_simulator_runner(str(cfg), str(out_dir), "test") + assert out_dir.is_dir() + + +def test_runner_output_prefix_applied(tmp_path): + """Output files use the supplied prefix.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config(tmp_path / "conf.yml", ref) + out_dir = tmp_path / "out" + + read_simulator_runner(str(cfg), str(out_dir), "myprefix") + + output_files = list(out_dir.glob("myprefix*")) + assert len(output_files) >= 1, "No files found with the expected prefix" + + +def test_runner_reproducible_with_same_seed(tmp_path): + """Two runs with the same seed produce identical FASTQ output.""" + ref = _write_ref(tmp_path / "ref.fa") + + out1 = tmp_path / "run1" + cfg1 = _write_config(tmp_path / "conf1.yml", ref, rng_seed=7) + read_simulator_runner(str(cfg1), str(out1), "rep") + + out2 = tmp_path / "run2" + cfg2 = _write_config(tmp_path / "conf2.yml", ref, rng_seed=7) + read_simulator_runner(str(cfg2), str(out2), "rep") + + fq1_a = sorted(out1.glob("*.fastq.gz"))[0] + fq1_b = sorted(out2.glob("*.fastq.gz"))[0] + + with gzip.open(fq1_a, "rt") as a, gzip.open(fq1_b, "rt") as b: + assert a.read() == b.read(), "Seeded runs should be identical" + + +def test_runner_with_vcf_output(tmp_path): + """Runner with produce_vcf=true creates a VCF output file.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + produce_vcf="true", + produce_fastq="true", + ) + out_dir = tmp_path / "out" + + read_simulator_runner(str(cfg), str(out_dir), "test") + + vcf_files = list(out_dir.glob("*.vcf.gz")) + assert len(vcf_files) == 1, "Expected exactly one VCF output file" + assert vcf_files[0].stat().st_size > 0 + + +# =========================================================================== +# Integration — runner with an input VCF (covers lines 95-104, 107) +# =========================================================================== + +def _write_input_vcf(path: Path, ref_path: Path) -> Path: + """Write a minimal input VCF with one SNV on chr1.""" + # The ref is ACGT*100 (400bp). Position 10 (1-based=11) is 'C'. + path.write_text( + "##fileformat=VCFv4.1\n" + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n" + "chr1\t11\t.\tC\tT\t42\tPASS\t.\tGT\t0|1\n", + encoding="utf-8" + ) + return path + + +def test_runner_with_input_vcf(tmp_path): + """Runner with include_vcf parses the VCF and produces output.""" + ref = _write_ref(tmp_path / "ref.fa") + vcf_in = _write_input_vcf(tmp_path / "input.vcf", ref) + cfg = _write_config( + tmp_path / "conf.yml", ref, + include_vcf=str(vcf_in), + produce_vcf="true", + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + + # Output VCF should exist and contain the input variant + vcf_files = list(out_dir.glob("*.vcf.gz")) + assert len(vcf_files) == 1 + import gzip as _gz + with _gz.open(vcf_files[0], "rt") as fh: + content = fh.read() + # position 11 (1-based) or the variant alt 'T' should appear + assert "chr1" in content + + +def test_runner_with_target_bed(tmp_path): + """Runner with a target BED restricts reads to targeted regions.""" + ref = _write_ref(tmp_path / "ref.fa") + bed = tmp_path / "target.bed" + bed.write_text("chr1\t0\t400\n", encoding="utf-8") + cfg = _write_config( + tmp_path / "conf.yml", ref, + target_bed=str(bed), + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + fq_files = list(out_dir.glob("*.fastq.gz")) + assert len(fq_files) >= 1 + + +def test_runner_with_mutation_rate_override(tmp_path): + """Explicit mutation_rate config key is accepted without error.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + mutation_rate=0.005, + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_discard_bed(tmp_path): + """discard_bed config key is accepted and run completes.""" + ref = _write_ref(tmp_path / "ref.fa") + discard = tmp_path / "discard.bed" + discard.write_text("chr1\t200\t400\n", encoding="utf-8") + cfg = _write_config( + tmp_path / "conf.yml", ref, + discard_bed=str(discard), + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_mutation_bed(tmp_path): + """mutation_bed config key providing per-region rates is accepted.""" + ref = _write_ref(tmp_path / "ref.fa") + mbed = tmp_path / "mut.bed" + # Single region spanning full reference avoids the multi-region probability_rates bug + mbed.write_text("chr1\t0\t400\tmut_rate=0.005\n", encoding="utf-8") + cfg = _write_config( + tmp_path / "conf.yml", ref, + mutation_bed=str(mbed), + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_haploid_ploidy(tmp_path): + """ploidy=1 (haploid) is accepted and produces FASTQ output.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + ploidy=1, + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_tetraploid_ploidy(tmp_path): + """ploidy=4 (tetraploid) is accepted and produces FASTQ output.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + ploidy=4, + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_min_mutations(tmp_path): + """min_mutations config key is accepted and run completes.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + min_mutations=3, + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.fastq.gz")) + + +def test_runner_with_produce_bam(tmp_path): + """produce_bam=true writes a BAM output file.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + produce_bam="true", + produce_fastq="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + bam_files = list(out_dir.glob("*.bam")) + assert len(bam_files) >= 1, "Expected at least one BAM output file" + + +def test_runner_bam_only_no_fastq(tmp_path): + """produce_bam=true + produce_fastq=false must write a BAM and no FASTQ.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + produce_fastq="false", + produce_bam="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.bam")), "Expected a BAM output file" + assert not list(out_dir.glob("*.fastq.gz")), "Expected no FASTQ output" + + +def test_runner_vcf_only_no_fastq(tmp_path): + """produce_vcf=true + produce_fastq=false must write a VCF and no FASTQ.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + produce_fastq="false", + produce_vcf="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.vcf.gz")), "Expected a VCF output file" + assert not list(out_dir.glob("*.fastq.gz")), "Expected no FASTQ output" + + +def test_runner_bam_and_vcf_no_fastq(tmp_path): + """produce_bam=true + produce_vcf=true + produce_fastq=false must write both.""" + ref = _write_ref(tmp_path / "ref.fa") + cfg = _write_config( + tmp_path / "conf.yml", ref, + produce_fastq="false", + produce_bam="true", + produce_vcf="true", + ) + out_dir = tmp_path / "out" + read_simulator_runner(str(cfg), str(out_dir), "test") + assert list(out_dir.glob("*.bam")), "Expected a BAM output file" + assert list(out_dir.glob("*.vcf.gz")), "Expected a VCF output file" + assert not list(out_dir.glob("*.fastq.gz")), "Expected no FASTQ output" diff --git a/tests/test_read_simulator/test_single_runner.py b/tests/test_read_simulator/test_single_runner.py new file mode 100644 index 00000000..bc70d2a6 --- /dev/null +++ b/tests/test_read_simulator/test_single_runner.py @@ -0,0 +1,471 @@ +""" +Unit and integration tests for neat/read_simulator/single_runner.py + +Covers: + - initialize_all_models + - write_block_vcf + - read_simulator_single (integration) +""" + +import gzip +from pathlib import Path + +import numpy as np +import pytest +from Bio import SeqIO +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from neat.models import ( + FragmentLengthModel, + MutationModel, + SequencingErrorModel, + TraditionalQualityModel, +) +from neat.read_simulator.single_runner import ( + initialize_all_models, + read_simulator_single, + write_block_vcf, +) +from neat.read_simulator.utils.options import Options +from neat.read_simulator.utils.output_file_writer import OutputFileWriter +from neat.variants import ContigVariants, SingleNucleotideVariant + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _make_opts(tmp_path: Path, *, rng_seed: int = 0) -> Options: + """Return a minimal Options object suitable for testing.""" + opts = Options(rng_seed=rng_seed) + opts.paired_ended = False + opts.produce_fastq = False + opts.produce_vcf = False + opts.produce_bam = False + opts.temp_dir_path = tmp_path + opts.reference = str(tmp_path / "ref.fa") + opts.rng_seed = rng_seed + opts.fq1 = None + opts.fq2 = None + opts.vcf = None + opts.bam = None + opts.mutation_model = None + opts.error_model = None + opts.fragment_model = None + opts.fragment_mean = None + opts.fragment_st_dev = None + opts.mutation_rate = None + opts.ploidy = 2 + opts.min_mutations = 0 + opts.mutation_bed = None + opts.read_len = 50 + opts.coverage = 2 + return opts + + +def _write_ref(tmp_path: Path, seq: str = "ACGT" * 100, name: str = "chr1") -> Path: + ref = tmp_path / "ref.fa" + ref.write_text(f">{name}\n{seq}\n") + return ref + + +def _make_vcf_ofw(tmp_path: Path) -> OutputFileWriter: + """Return an OutputFileWriter configured for gzip VCF output.""" + opts = _make_opts(tmp_path) + opts.produce_vcf = True + opts.vcf = tmp_path / "out.vcf.gz" + return OutputFileWriter(options=opts, vcf_format="gzip") + + +def _make_contig_variants(positions_alts: list, genotype_ploidy: int = 2) -> ContigVariants: + """Build a ContigVariants object with SNVs at the given (pos, alt) pairs.""" + cv = ContigVariants() + genotype = np.array([1] + [0] * (genotype_ploidy - 1)) + for pos, alt in positions_alts: + snv = SingleNucleotideVariant(position1=pos, alt=alt, genotype=genotype, qual_score=40) + cv.add_variant(snv) + return cv + + +# =========================================================================== +# initialize_all_models — default paths (no custom model files) +# =========================================================================== + +class TestInitializeAllModels: + + def test_returns_four_objects(self, tmp_path): + opts = _make_opts(tmp_path) + result = initialize_all_models(opts) + assert len(result) == 4 + + def test_default_mut_model_type(self, tmp_path): + opts = _make_opts(tmp_path) + mut_model, *_ = initialize_all_models(opts) + assert isinstance(mut_model, MutationModel) + + def test_default_seq_error_model_type(self, tmp_path): + opts = _make_opts(tmp_path) + _, seq_error_model, _, _ = initialize_all_models(opts) + assert isinstance(seq_error_model, SequencingErrorModel) + + def test_default_qual_score_model_type(self, tmp_path): + opts = _make_opts(tmp_path) + _, _, qual_score_model, _ = initialize_all_models(opts) + assert isinstance(qual_score_model, TraditionalQualityModel) + + def test_default_fraglen_model_type(self, tmp_path): + opts = _make_opts(tmp_path) + _, _, _, fraglen_model = initialize_all_models(opts) + assert isinstance(fraglen_model, FragmentLengthModel) + + def test_mut_model_rng_is_set(self, tmp_path): + opts = _make_opts(tmp_path) + mut_model, *_ = initialize_all_models(opts) + assert mut_model.rng is opts.rng + + def test_custom_mutation_rate_overrides_model(self, tmp_path): + opts = _make_opts(tmp_path) + opts.mutation_rate = 0.005 + mut_model, *_ = initialize_all_models(opts) + assert mut_model.avg_mut_rate == pytest.approx(0.005) + + def test_none_mutation_rate_does_not_override(self, tmp_path): + opts = _make_opts(tmp_path) + opts.mutation_rate = None + mut_model_default, *_ = initialize_all_models(opts) + default_rate = MutationModel().avg_mut_rate + assert mut_model_default.avg_mut_rate == pytest.approx(default_rate) + + def test_fragment_mean_creates_fraglen_model_with_mean(self, tmp_path): + opts = _make_opts(tmp_path) + opts.fragment_mean = 200.0 + opts.fragment_st_dev = 40.0 + _, _, _, fraglen_model = initialize_all_models(opts) + assert fraglen_model.fragment_mean == pytest.approx(200.0) + + def test_no_fragment_mean_uses_read_len_times_two(self, tmp_path): + opts = _make_opts(tmp_path) + opts.fragment_mean = None + opts.read_len = 75 + _, _, _, fraglen_model = initialize_all_models(opts) + assert fraglen_model.fragment_mean == pytest.approx(75 * 2.0) + + def test_fraglen_model_std_dev_set_from_fragment_mean(self, tmp_path): + opts = _make_opts(tmp_path) + opts.fragment_mean = None + opts.read_len = 100 + _, _, _, fraglen_model = initialize_all_models(opts) + expected_std = 100 * 2.0 * 0.2 + assert fraglen_model.fragment_st_dev == pytest.approx(expected_std) + + +# =========================================================================== +# write_block_vcf +# =========================================================================== + +class TestWriteBlockVcf: + + def _ref_index(self, tmp_path: Path, seq: str = "ACGT" * 50) -> dict: + """Write a FASTA and return a SeqIO index dict.""" + ref_path = _write_ref(tmp_path, seq=seq) + return SeqIO.index(str(ref_path), "fasta") + + def test_single_snv_written(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = _make_contig_variants([(5, "T")]) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + content = fh.read() + assert "chr1" in content + assert "\t6\t" in content # 1-based position for pos1=5 + + def test_snv_ref_base_correct(self, tmp_path): + # ref is ACGT... so position 4 is 'A' (0-based) + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = _make_contig_variants([(4, "G")]) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + content = fh.read() + # position 4 in ACGT*50 is 'A' + assert "\tA\t" in content + + def test_snv_alt_base_correct(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = _make_contig_variants([(4, "G")]) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + content = fh.read() + assert "\tG\t" in content + + def test_qual_score_in_output(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = ContigVariants() + genotype = np.array([1, 0]) + snv = SingleNucleotideVariant(position1=10, alt="C", genotype=genotype, qual_score=55) + cv.add_variant(snv) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + content = fh.read() + assert "55" in content + + def test_empty_contig_variants_writes_nothing(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = ContigVariants() + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + content = fh.read() + assert content == "" + + def test_multiple_snvs_all_written(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = _make_contig_variants([(2, "T"), (8, "C"), (15, "G")]) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + lines = [l for l in fh.readlines() if l.strip()] + assert len(lines) == 3 + + def test_variants_written_in_sorted_order(self, tmp_path): + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + # Add in reverse order; output should still be sorted + cv = ContigVariants() + genotype = np.array([1, 0]) + for pos, alt in [(20, "T"), (5, "G"), (12, "C")]: + cv.add_variant(SingleNucleotideVariant(position1=pos, alt=alt, genotype=genotype, qual_score=30)) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + lines = [l for l in fh.readlines() if l.strip()] + positions = [int(l.split("\t")[1]) for l in lines] + assert positions == sorted(positions) + + def test_vcf_line_has_nine_tabs(self, tmp_path): + """A VCF data line must have exactly 9 tab-separated fields (10 columns).""" + ref_index = self._ref_index(tmp_path) + ofw = _make_vcf_ofw(tmp_path) + cv = _make_contig_variants([(3, "A")]) + write_block_vcf(cv, "chr1", 0, ref_index, ofw) + ofw.flush_and_close_files(False) + with gzip.open(tmp_path / "out.vcf.gz", "rt") as fh: + line = fh.readline().strip() + assert line.count("\t") == 9 + + +# =========================================================================== +# read_simulator_single — integration tests +# =========================================================================== + +class TestReadSimulatorSingle: + """Integration tests for the full simulation loop.""" + + def _run(self, tmp_path: Path, *, coverage: int = 2, read_len: int = 50, + produce_fastq: bool = True, produce_vcf: bool = False, + rng_seed: int = 42): + ref_seq = "ACGT" * 100 # 400 bp + ref_path = _write_ref(tmp_path, seq=ref_seq) + + opts = _make_opts(tmp_path, rng_seed=rng_seed) + opts.read_len = read_len + opts.coverage = coverage + opts.produce_fastq = produce_fastq + opts.produce_vcf = produce_vcf + opts.reference = str(ref_path) + + if produce_fastq: + opts.fq1 = tmp_path / "out.fq1.gz" + if produce_vcf: + opts.vcf = tmp_path / "out.vcf.gz" + + target_regions = [(0, 400, True)] + discard_regions = [(0, 400, False)] + mutation_regions = [(0, 400, 0.01)] + + return read_simulator_single( + 1, + 0, + opts, + None, + "chr1", + 0, + ContigVariants(), + target_regions, + discard_regions, + mutation_regions, + ) + + def test_returns_four_element_tuple(self, tmp_path): + result = self._run(tmp_path) + assert len(result) == 4 + thread_idx, contig_name, local_variants, file_dict = result + assert isinstance(thread_idx, int) + assert isinstance(contig_name, str) + assert isinstance(local_variants, ContigVariants) + assert isinstance(file_dict, dict) + + def test_thread_idx_preserved(self, tmp_path): + thread_idx, *_ = self._run(tmp_path) + assert thread_idx == 1 + + def test_contig_name_preserved(self, tmp_path): + _, contig_name, *_ = self._run(tmp_path) + assert contig_name == "chr1" + + def test_local_variants_is_contig_variants(self, tmp_path): + _, _, local_variants, _ = self._run(tmp_path) + assert isinstance(local_variants, ContigVariants) + + def test_file_dict_has_fq1_key(self, tmp_path): + _, _, _, file_dict = self._run(tmp_path) + assert "fq1" in file_dict + + def test_file_dict_has_fq2_key(self, tmp_path): + _, _, _, file_dict = self._run(tmp_path) + assert "fq2" in file_dict + + def test_file_dict_has_vcf_key(self, tmp_path): + _, _, _, file_dict = self._run(tmp_path) + assert "vcf" in file_dict + + def test_file_dict_has_bam_key(self, tmp_path): + _, _, _, file_dict = self._run(tmp_path) + assert "bam" in file_dict + + def test_fq1_file_exists_after_run(self, tmp_path): + self._run(tmp_path) + assert (tmp_path / "out.fq1.gz").exists() + + def test_fq1_file_is_valid_gzip(self, tmp_path): + self._run(tmp_path) + with gzip.open(tmp_path / "out.fq1.gz", "rt") as fh: + content = fh.read() + assert len(content) > 0 + + def test_fq1_content_contains_fastq_records(self, tmp_path): + self._run(tmp_path) + with gzip.open(tmp_path / "out.fq1.gz", "rt") as fh: + lines = fh.readlines() + # FASTQ records: 4 lines each; must have at least one record + assert len(lines) >= 4 + # First line of first record should start with '@' + assert lines[0].startswith("@") + + def test_reproducibility_with_same_seed(self, tmp_path): + """Two runs with identical seeds must produce identical FASTQ output.""" + tmp_a = tmp_path / "a" + tmp_b = tmp_path / "b" + tmp_a.mkdir() + tmp_b.mkdir() + _write_ref(tmp_a) + _write_ref(tmp_b) + + def _run_in(p): + opts = _make_opts(p, rng_seed=99) + opts.read_len = 50 + opts.coverage = 2 + opts.produce_fastq = True + opts.fq1 = p / "out.fq1.gz" + opts.reference = str(p / "ref.fa") + return read_simulator_single( + 1, 0, opts, None, "chr1", 0, ContigVariants(), + [(0, 400, True)], [(0, 400, False)], [(0, 400, 0.01)], + ) + + _run_in(tmp_a) + _run_in(tmp_b) + + with gzip.open(tmp_a / "out.fq1.gz", "rb") as fa, \ + gzip.open(tmp_b / "out.fq1.gz", "rb") as fb: + assert fa.read() == fb.read() + + def test_vcf_not_produced_when_produce_vcf_false(self, tmp_path): + _, _, _, file_dict = self._run(tmp_path, produce_fastq=True, produce_vcf=False) + assert file_dict["vcf"] is None + + def test_bam_output_written(self, tmp_path): + """produce_bam=True writes reads through the BAM path (lines 127-152).""" + import pysam + ref_seq = "ACGT" * 100 + _write_ref(tmp_path, seq=ref_seq) + + opts = _make_opts(tmp_path, rng_seed=7) + opts.read_len = 50 + opts.coverage = 2 + opts.produce_fastq = False + opts.produce_bam = True + opts.fq1 = None + opts.bam = tmp_path / "out.bam" + opts.reference = str(tmp_path / "ref.fa") + opts.threads = 1 + + bam_header = {"chr1": 400} + + _, _, _, file_dict = read_simulator_single( + 1, 0, opts, bam_header, "chr1", 0, ContigVariants(), + [(0, 400, True)], [(0, 400, False)], [(0, 400, 0.01)], + ) + assert file_dict["bam"] == opts.bam + + def test_bam_key_is_none_when_not_requested(self, tmp_path): + """When produce_bam=False, file_dict['bam'] should be None.""" + opts = _make_opts(tmp_path, rng_seed=7) + opts.read_len = 50 + opts.coverage = 2 + opts.produce_fastq = True + opts.produce_bam = False + opts.fq1 = tmp_path / "out.fq1.gz" + opts.bam = None + opts.reference = str(_write_ref(tmp_path)) + + _, _, _, file_dict = read_simulator_single( + 1, 0, opts, None, "chr1", 0, ContigVariants(), + [(0, 400, True)], [(0, 400, False)], [(0, 400, 0.01)], + ) + assert file_dict["bam"] is None + + def test_record_too_small_logs_and_continues(self, tmp_path, caplog, monkeypatch): + """When reference is shorter than read_len the debug log fires (single_runner.py line 85). + + Covers: + if len(local_seq_record) < local_options.read_len: + _LOG.debug("Record too small for processing") + + The downstream cover_dataset would loop infinitely on a span < read_len, so we + patch generate_reads and generate_variants to return immediately. + """ + import logging + from unittest.mock import patch + + short_seq = "ACGT" * 5 # 20 bp, shorter than read_len=50 + _write_ref(tmp_path, seq=short_seq) + + opts = _make_opts(tmp_path, rng_seed=0) + opts.read_len = 50 + opts.coverage = 1 + opts.produce_fastq = True + opts.fq1 = tmp_path / "out.fq1.gz" + + with patch("neat.read_simulator.single_runner.generate_variants", + return_value=ContigVariants()), \ + patch("neat.read_simulator.single_runner.generate_reads", + return_value=[]), \ + caplog.at_level(logging.DEBUG, logger="neat.read_simulator.single_runner"): + result = read_simulator_single( + 1, 0, opts, None, "chr1", 0, ContigVariants(), + [(0, 20, True)], [(0, 20, False)], [(0, 20, 0.01)], + ) + + assert "Record too small" in caplog.text + assert len(result) == 4 \ No newline at end of file diff --git a/tests/test_read_simulator/test_stitch_outputs.py b/tests/test_read_simulator/test_stitch_outputs.py new file mode 100644 index 00000000..ed830f85 --- /dev/null +++ b/tests/test_read_simulator/test_stitch_outputs.py @@ -0,0 +1,316 @@ +""" +Unit tests for neat/read_simulator/utils/stitch_outputs.py +""" +import gzip +import io +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, call + +import numpy as np +import pytest + +from neat.read_simulator.utils.stitch_outputs import concat, merge_vcfs, merge_bam, main +from neat.variants import SingleNucleotideVariant +from neat.variants.contig_variants import ContigVariants + + +# Helpers + +def _write_gz(path: Path, text: str) -> Path: + with gzip.open(path, "wt") as fh: + fh.write(text) + return path + + +def _make_ofw(tmp_path: Path, vcf_path: Path = None): + """ + Minimal OutputFileWriter stand-in backed by real StringIO / file handles. + """ + ofw = SimpleNamespace() + ofw.tmp_dir = tmp_path + ofw.fq1 = tmp_path / "out.fq1.gz" + ofw.fq2 = tmp_path / "out.fq2.gz" + ofw.bam = tmp_path / "out.bam" + + if vcf_path is None: + vcf_path = tmp_path / "out.vcf.gz" + ofw.vcf = vcf_path + + # Writable StringIO acts as the destination handle for concat/merge_vcfs + ofw._fq1_buf = io.StringIO() + ofw._fq2_buf = io.StringIO() + ofw._vcf_buf = io.StringIO() + + ofw.files_to_write = { + ofw.fq1: ofw._fq1_buf, + ofw.fq2: ofw._fq2_buf, + ofw.vcf: ofw._vcf_buf, + } + return ofw + + +# concat + +def test_concat_single_file(tmp_path): + src = _write_gz(tmp_path / "a.gz", "hello\n") + dest = io.StringIO() + concat([src], dest) + assert dest.getvalue() == "hello\n" + + +def test_concat_multiple_files(tmp_path): + a = _write_gz(tmp_path / "a.gz", "line1\n") + b = _write_gz(tmp_path / "b.gz", "line2\n") + dest = io.StringIO() + concat([a, b], dest) + assert dest.getvalue() == "line1\nline2\n" + + +def test_concat_empty_list(tmp_path): + dest = io.StringIO() + concat([], dest) + assert dest.getvalue() == "" + + +def test_concat_preserves_content(tmp_path): + content = "ACGT\nACGT\nACGT\n" + src = _write_gz(tmp_path / "reads.gz", content) + dest = io.StringIO() + concat([src], dest) + assert dest.getvalue() == content + + +def test_concat_order_is_preserved(tmp_path): + files = [_write_gz(tmp_path / f"{i}.gz", f"chunk{i}\n") for i in range(5)] + dest = io.StringIO() + concat(files, dest) + result = dest.getvalue() + positions = [result.index(f"chunk{i}") for i in range(5)] + assert positions == sorted(positions) + + +# merge_vcfs + +def test_merge_vcfs_skips_comment_lines(tmp_path): + vcf_text = "##header line\n#CHROM\tPOS\n1\t100\tA\tT\n" + vcf = _write_gz(tmp_path / "v.vcf.gz", vcf_text) + ofw = _make_ofw(tmp_path) + merge_vcfs([vcf], ofw) + result = ofw._vcf_buf.getvalue() + assert "##header line" not in result + assert "#CHROM" not in result + assert "1\t100\tA\tT" in result + + +def test_merge_vcfs_multiple_files(tmp_path): + v1 = _write_gz(tmp_path / "v1.vcf.gz", "##h\n1\t10\tA\tT\n") + v2 = _write_gz(tmp_path / "v2.vcf.gz", "##h\n2\t20\tC\tG\n") + ofw = _make_ofw(tmp_path) + merge_vcfs([v1, v2], ofw) + result = ofw._vcf_buf.getvalue() + assert "1\t10\tA\tT" in result + assert "2\t20\tC\tG" in result + + +def test_merge_vcfs_empty_list(tmp_path): + ofw = _make_ofw(tmp_path) + merge_vcfs([], ofw) + assert ofw._vcf_buf.getvalue() == "" + + +def test_merge_vcfs_only_comments_produces_no_output(tmp_path): + vcf = _write_gz(tmp_path / "v.vcf.gz", "##header\n#CHROM\n") + ofw = _make_ofw(tmp_path) + merge_vcfs([vcf], ofw) + assert ofw._vcf_buf.getvalue() == "" + + +def test_merge_vcfs_preserves_data_line_order(tmp_path): + lines = [f"chr1\t{i}\t.\tA\tT\t.\t.\t.\n" for i in range(1, 6)] + vcf = _write_gz(tmp_path / "v.vcf.gz", "".join(lines)) + ofw = _make_ofw(tmp_path) + merge_vcfs([vcf], ofw) + result = ofw._vcf_buf.getvalue().splitlines() + positions = [int(r.split("\t")[1]) for r in result] + assert positions == list(range(1, 6)) + + +def test_merge_vcfs_dedup_removes_identical_lines(tmp_path): + """Identical lines from two thread VCFs are collapsed to one (Issue #256).""" + line = "chr1\t100\t.\tA\tT\t42\tPASS\t.\tGT\t0|1\n" + v1 = _write_gz(tmp_path / "t0.vcf.gz", line) + v2 = _write_gz(tmp_path / "t1.vcf.gz", line) + ofw = _make_ofw(tmp_path) + merge_vcfs([v1, v2], ofw) + result = [l for l in ofw._vcf_buf.getvalue().splitlines() if l.strip()] + assert len(result) == 1 + + +def test_merge_vcfs_distinct_lines_are_all_kept(tmp_path): + """Distinct lines from two threads both appear in merged output.""" + v1 = _write_gz(tmp_path / "t0.vcf.gz", "chr1\t100\t.\tA\tT\t42\tPASS\t.\tGT\t0|1\n") + v2 = _write_gz(tmp_path / "t1.vcf.gz", "chr1\t200\t.\tC\tG\t42\tPASS\t.\tGT\t0|1\n") + ofw = _make_ofw(tmp_path) + merge_vcfs([v1, v2], ofw) + result = [l for l in ofw._vcf_buf.getvalue().splitlines() if l.strip()] + assert len(result) == 2 + + +def test_merge_vcfs_partial_overlap_deduped(tmp_path): + """Three lines total, two of which are identical: result has two unique lines.""" + line_a = "chr1\t100\t.\tA\tT\t42\tPASS\t.\tGT\t0|1\n" + line_b = "chr1\t200\t.\tC\tG\t42\tPASS\t.\tGT\t0|1\n" + v1 = _write_gz(tmp_path / "t0.vcf.gz", line_a + line_b) + v2 = _write_gz(tmp_path / "t1.vcf.gz", line_a) + ofw = _make_ofw(tmp_path) + merge_vcfs([v1, v2], ofw) + result = [l for l in ofw._vcf_buf.getvalue().splitlines() if l.strip()] + assert len(result) == 2 + + +# find_dups (ContigVariants deduplication, Issue #256) + +def test_find_dups_same_alt_different_genotype_rejected(tmp_path): + """Same position + same ALT is a duplicate regardless of genotype.""" + cv = ContigVariants() + v1 = SingleNucleotideVariant(10, "T", np.array([1, 0]), 40) + v2 = SingleNucleotideVariant(10, "T", np.array([0, 1]), 40) + cv.add_variant(v1) + assert cv.add_variant(v2) == 1 + + +def test_find_dups_different_alt_same_position_accepted(tmp_path): + """Two SNVs at the same position with different ALTs are not duplicates.""" + cv = ContigVariants() + v1 = SingleNucleotideVariant(10, "T", np.array([1, 0]), 40) + v2 = SingleNucleotideVariant(10, "G", np.array([0, 1]), 40) + cv.add_variant(v1) + assert cv.add_variant(v2) == 0 + assert len(cv.contig_variants[10]) == 2 + + +def test_find_dups_exact_duplicate_rejected(tmp_path): + """Exact duplicates (same position, ALT, and genotype) are rejected.""" + cv = ContigVariants() + v1 = SingleNucleotideVariant(10, "T", np.array([0, 1]), 40) + v2 = SingleNucleotideVariant(10, "T", np.array([0, 1]), 40) + cv.add_variant(v1) + assert cv.add_variant(v2) == 1 + + +# merge_bam + +def test_merge_bam_calls_pysam_merge_and_sort(tmp_path): + ofw = _make_ofw(tmp_path) + bam_files = [tmp_path / f"{i}.bam" for i in range(3)] + + with patch("neat.read_simulator.utils.stitch_outputs.pysam") as mock_pysam: + merge_bam(bam_files, ofw, threads=4) + + # pysam.merge should have been called at least twice (once per chunk + final) + assert mock_pysam.merge.call_count >= 2 + # pysam.sort should have been called once + mock_pysam.sort.assert_called_once() + + +def test_merge_bam_sort_uses_output_bam_path(tmp_path): + ofw = _make_ofw(tmp_path) + bam_files = [tmp_path / "a.bam"] + + with patch("neat.read_simulator.utils.stitch_outputs.pysam") as mock_pysam: + merge_bam(bam_files, ofw, threads=2) + + sort_args = mock_pysam.sort.call_args[0] + assert str(ofw.bam) in sort_args + + +def test_merge_bam_temp_file_cleaned_up(tmp_path): + ofw = _make_ofw(tmp_path) + bam_files = [tmp_path / "a.bam"] + temp_merged = ofw.tmp_dir / "temp_merged.bam" + + with patch("neat.read_simulator.utils.stitch_outputs.pysam"): + merge_bam(bam_files, ofw, threads=1) + + # temp_merged.bam should have been unlinked (missing_ok=True means no error if absent) + assert not temp_merged.exists() + + +def test_merge_bam_chunks_large_bam_list(tmp_path): + """More than 500 BAMs triggers chunked intermediate merges.""" + ofw = _make_ofw(tmp_path) + bam_files = [tmp_path / f"{i}.bam" for i in range(600)] + + with patch("neat.read_simulator.utils.stitch_outputs.pysam") as mock_pysam: + merge_bam(bam_files, ofw, threads=1) + + # Two chunks (0–499, 500–599) → 2 intermediate merges + 1 final = 3 total + assert mock_pysam.merge.call_count == 3 + + +# main + +def _file_dict(fq1=None, fq2=None, vcf=None, bam=None): + return {"fq1": fq1, "fq2": fq2, "vcf": vcf, "bam": bam} + + +def test_main_fq1_only(tmp_path): + ofw = _make_ofw(tmp_path) + src = _write_gz(tmp_path / "chunk.fq1.gz", "@read1\nACGT\n+\nIIII\n") + output_files = [(0, _file_dict(fq1=src))] + main(ofw, output_files) + assert "read1" in ofw._fq1_buf.getvalue() + + +def test_main_fq1_and_fq2(tmp_path): + ofw = _make_ofw(tmp_path) + src1 = _write_gz(tmp_path / "c.fq1.gz", "@r1\nACGT\n+\nIIII\n") + src2 = _write_gz(tmp_path / "c.fq2.gz", "@r2\nTTGG\n+\nIIII\n") + output_files = [(0, _file_dict(fq1=src1, fq2=src2))] + main(ofw, output_files) + assert "r1" in ofw._fq1_buf.getvalue() + assert "r2" in ofw._fq2_buf.getvalue() + + +def test_main_vcf(tmp_path): + ofw = _make_ofw(tmp_path) + src = _write_gz(tmp_path / "chunk.vcf.gz", "##header\nchr1\t100\t.\tA\tT\n") + output_files = [(0, _file_dict(vcf=src))] + main(ofw, output_files) + result = ofw._vcf_buf.getvalue() + assert "chr1\t100" in result + assert "##header" not in result + + +def test_main_none_files_not_concatenated(tmp_path): + """None entries in the file dict should be skipped without error.""" + ofw = _make_ofw(tmp_path) + output_files = [(0, _file_dict())] # all None + main(ofw, output_files) # should not raise + assert ofw._fq1_buf.getvalue() == "" + assert ofw._fq2_buf.getvalue() == "" + assert ofw._vcf_buf.getvalue() == "" + + +def test_main_multiple_threads(tmp_path): + ofw = _make_ofw(tmp_path) + chunks = [ + (i, _file_dict(fq1=_write_gz(tmp_path / f"c{i}.fq1.gz", f"chunk{i}\n"))) + for i in range(3) + ] + main(ofw, chunks) + result = ofw._fq1_buf.getvalue() + for i in range(3): + assert f"chunk{i}" in result + + +def test_main_bam_calls_merge_bam(tmp_path): + ofw = _make_ofw(tmp_path) + bam_file = tmp_path / "chunk.bam" + output_files = [(0, _file_dict(bam=bam_file))] + + with patch("neat.read_simulator.utils.stitch_outputs.merge_bam") as mock_merge: + main(ofw, output_files, threads=2) + + mock_merge.assert_called_once_with([bam_file], ofw, 2) \ No newline at end of file diff --git a/tests/test_read_simulator/test_vcf_func.py b/tests/test_read_simulator/test_vcf_func.py new file mode 100644 index 00000000..8aa9a333 --- /dev/null +++ b/tests/test_read_simulator/test_vcf_func.py @@ -0,0 +1,356 @@ +""" +Tests for neat/read_simulator/utils/vcf_func.py +""" +import textwrap +from pathlib import Path + +import numpy as np +import pytest +from Bio import SeqIO + +from neat.read_simulator.utils.options import Options +from neat.read_simulator.utils.vcf_func import ( + parse_input_vcf, + retrieve_genotype, + variant_genotype, +) +from neat.variants import SingleNucleotideVariant +from neat.variants.contig_variants import ContigVariants +from neat.variants.deletion import Deletion +from neat.variants.insertion import Insertion +from neat.variants.unknown_variant import UnknownVariant + + +# Shared fixtures and helpers + +# Reference sequence: chr1 = ACGTACGTACGTACGTACGT (20 bp) +# chr2 = TTGGTTGGTTGG (12 bp) +_REF_TEXT = ">chr1\nACGTACGTACGTACGTACGT\n>chr2\nTTGGTTGGTTGG\n" + +# VCF column separator +_TAB = "\t" + + +@pytest.fixture() +def ref_fasta(tmp_path): + fa = tmp_path / "ref.fa" + fa.write_text(_REF_TEXT, encoding="utf-8") + return SeqIO.index(str(fa), "fasta") + + +@pytest.fixture() +def empty_input_dict(): + return {"chr1": ContigVariants(), "chr2": ContigVariants()} + + +@pytest.fixture() +def opts(): + o = Options(rng_seed=42) + o.ploidy = 2 + return o + + +def _write_vcf(tmp_path: Path, name: str, lines: list[str]) -> Path: + p = tmp_path / name + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + return p + + +def _vcf_header_no_format(): + return [ + "##fileformat=VCFv4.2", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + ] + + +def _vcf_header_with_format(sample="SAMPLE1"): + return [ + "##fileformat=VCFv4.2", + f"#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t{sample}", + ] + + +# retrieve_genotype + +def _make_vcf_record(format_field, sample_field, info="."): + """Build a minimal 10-column VCF record list.""" + return ["chr1", 0, ".", "A", "G", "30", "PASS", info, format_field, sample_field] + + +def test_retrieve_genotype_phased(): + record = _make_vcf_record("GT", "0|1") + gt = retrieve_genotype(record) + np.testing.assert_array_equal(gt, [0, 1]) + + +def test_retrieve_genotype_unphased_converted(): + """/ separator is normalised to | before splitting.""" + record = _make_vcf_record("GT", "0/1") + gt = retrieve_genotype(record) + np.testing.assert_array_equal(gt, [0, 1]) + + +def test_retrieve_genotype_homozygous_ref(): + record = _make_vcf_record("GT", "0|0") + np.testing.assert_array_equal(retrieve_genotype(record), [0, 0]) + + +def test_retrieve_genotype_homozygous_alt(): + record = _make_vcf_record("GT", "1|1") + np.testing.assert_array_equal(retrieve_genotype(record), [1, 1]) + + +def test_retrieve_genotype_gt_among_other_fields(): + """GT may appear after other FORMAT fields.""" + record = _make_vcf_record("DP:GT:GQ", "30:0|1:99") + np.testing.assert_array_equal(retrieve_genotype(record), [0, 1]) + + +def test_retrieve_genotype_cancer_uses_column_10(): + """is_cancer=True should read from column index 10, not 9.""" + record = _make_vcf_record("GT", "0|0") + ["1|1"] # 11 columns; col 10 = "1|1" + gt = retrieve_genotype(record, is_cancer=True) + np.testing.assert_array_equal(gt, [1, 1]) + + +# variant_genotype + +def test_variant_genotype_no_match(): + gt = variant_genotype(2, np.array([0, 0]), 1) + np.testing.assert_array_equal(gt, [0, 0]) + + +def test_variant_genotype_het(): + gt = variant_genotype(2, np.array([0, 1]), 1) + np.testing.assert_array_equal(gt, [0, 1]) + + +def test_variant_genotype_homozygous_alt(): + gt = variant_genotype(2, np.array([1, 1]), 1) + np.testing.assert_array_equal(gt, [1, 1]) + + +def test_variant_genotype_second_alt(): + """which_alt=2 matches ploids where full_genotype==2.""" + gt = variant_genotype(3, np.array([0, 1, 2]), 2) + np.testing.assert_array_equal(gt, [0, 0, 1]) + + +def test_variant_genotype_returns_correct_ploidy_length(): + gt = variant_genotype(4, np.array([1, 0, 1, 0]), 1) + assert len(gt) == 4 + # Ploids where full_genotype == which_alt (1) get 1; others get 0 + np.testing.assert_array_equal(gt, [1, 0, 1, 0]) + + +# parse_input_vcf — variant type classification + +def test_parse_snv(tmp_path, ref_fasta, empty_input_dict, opts): + """REF and ALT both length 1 → SingleNucleotideVariant.""" + vcf = _write_vcf(tmp_path, "snv.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.", # pos 1 (VCF) = pos 0 (0-based): ref=A + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants.get(0, []) + assert len(variants) == 1 + assert isinstance(variants[0], SingleNucleotideVariant) + assert variants[0].alt == "G" + + +def test_parse_deletion(tmp_path, ref_fasta, empty_input_dict, opts): + """len(ref) > len(alt) and ref.startswith(alt) → Deletion.""" + # pos 5 (VCF) = pos 4 (0-based): reference is ACGT + vcf = _write_vcf(tmp_path, "del.vcf", _vcf_header_no_format() + [ + "chr1\t5\t.\tACGT\tA\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants.get(4, []) + assert len(variants) == 1 + assert isinstance(variants[0], Deletion) + + +def test_parse_insertion(tmp_path, ref_fasta, empty_input_dict, opts): + """len(alt) > len(ref) and alt.startswith(ref) → Insertion.""" + # pos 9 (VCF) = pos 8 (0-based): reference is A + vcf = _write_vcf(tmp_path, "ins.vcf", _vcf_header_no_format() + [ + "chr1\t9\t.\tA\tACGTACGT\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants.get(8, []) + assert len(variants) == 1 + assert isinstance(variants[0], Insertion) + + +def test_parse_unknown_variant(tmp_path, ref_fasta, empty_input_dict, opts): + """MNV (multi-nucleotide, same length > 1) falls through to UnknownVariant.""" + # pos 1 (VCF) = pos 0 (0-based): ref=AC, alt=TG (len==2, not 1, so not SNV) + vcf = _write_vcf(tmp_path, "unk.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tAC\tTG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants.get(0, []) + assert len(variants) == 1 + assert isinstance(variants[0], UnknownVariant) + + +# parse_input_vcf — filtering / skipping + +def test_chrom_not_in_reference_skipped(tmp_path, ref_fasta, empty_input_dict, opts): + """Variants on chromosomes absent from the reference are silently skipped.""" + vcf = _write_vcf(tmp_path, "chrom.vcf", _vcf_header_no_format() + [ + "chrX\t1\t.\tA\tG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + total = sum(len(cv.variant_locations) for cv in empty_input_dict.values()) + assert total == 0 + + +def test_ref_mismatch_skipped(tmp_path, ref_fasta, empty_input_dict, opts): + """Variant whose REF doesn't match the reference sequence is skipped.""" + # chr1 pos 0 is 'A', but we claim it's 'T' + vcf = _write_vcf(tmp_path, "mismatch.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tT\tG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert len(empty_input_dict["chr1"].variant_locations) == 0 + + +def test_duplicate_position_same_alt_skipped(tmp_path, ref_fasta, empty_input_dict, opts): + """A second variant at the same position with the same ALT is a duplicate and skipped, + regardless of genotype. add_variant deduplicates by (position, type, ALT).""" + vcf = _write_vcf(tmp_path, "dup_same_alt.vcf", _vcf_header_with_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tGT\t0|1", + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tGT\t1|0", # same ALT 'G', different genotype + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert len(empty_input_dict["chr1"].contig_variants[0]) == 1 + + +def test_duplicate_position_different_alt_both_accepted(tmp_path, ref_fasta, empty_input_dict, opts): + """Two variants at the same position with different ALTs are not duplicates — both accepted.""" + vcf = _write_vcf(tmp_path, "dup_diff_alt.vcf", _vcf_header_with_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tGT\t0|1", + "chr1\t1\t.\tA\tC\t30\tPASS\t.\tGT\t0|1", # different ALT 'C' + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert len(empty_input_dict["chr1"].contig_variants[0]) == 2 + + +def test_comment_and_header_lines_not_parsed_as_variants(tmp_path, ref_fasta, empty_input_dict, opts): + """## header lines and #CHROM line are never treated as variant records.""" + vcf = _write_vcf(tmp_path, "headers.vcf", [ + "##fileformat=VCFv4.2", + "##source=test", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert len(empty_input_dict["chr1"].variant_locations) == 1 + + +# parse_input_vcf — QUAL handling + +def test_missing_qual_replaced_with_42(tmp_path, ref_fasta, empty_input_dict, opts): + """QUAL field '.' is replaced with the default value '42'.""" + vcf = _write_vcf(tmp_path, "qual.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t.\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants[0] + assert variants[0].qual_score == "42" + + +# parse_input_vcf — FORMAT / genotype handling + +def test_with_format_gt_uses_sample_genotype(tmp_path, ref_fasta, empty_input_dict, opts): + """FORMAT column with GT field reads genotype from the sample column.""" + vcf = _write_vcf(tmp_path, "gt.vcf", _vcf_header_with_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tGT\t0|1", + ]) + sample_cols = parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert sample_cols == {"SAMPLE1": 7} + variants = empty_input_dict["chr1"].contig_variants[0] + # genotype should reflect 0|1: only the second ploid carries the variant + np.testing.assert_array_equal(variants[0].genotype, [0, 1]) + + +def test_without_format_genotype_is_generated(tmp_path, ref_fasta, empty_input_dict, opts): + """No FORMAT column → genotype is randomly generated (non-None).""" + vcf = _write_vcf(tmp_path, "nogt.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants[0] + assert variants[0].genotype is not None + assert len(variants[0].genotype) == 2 + + +def test_format_without_gt_generates_random_genotype(tmp_path, ref_fasta, empty_input_dict, opts): + """FORMAT column present but no GT field → random genotype generated.""" + vcf = _write_vcf(tmp_path, "fmtngt.vcf", _vcf_header_with_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tDP\t42", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + variants = empty_input_dict["chr1"].contig_variants[0] + assert variants[0].genotype is not None + assert len(variants[0].genotype) == 2 + + +def test_no_format_returns_empty_sample_columns(tmp_path, ref_fasta, empty_input_dict, opts): + """No FORMAT column → returned sample_columns is empty.""" + vcf = _write_vcf(tmp_path, "nosample.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + ]) + result = parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert not result + + +def test_format_exits_if_no_sample_column(tmp_path, ref_fasta, empty_input_dict, opts): + """FORMAT present but no sample column after it → sys.exit.""" + vcf = _write_vcf(tmp_path, "fmtnosample.vcf", [ + "##fileformat=VCFv4.2", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT", + "chr1\t1\t.\tA\tG\t30\tPASS\t.\tGT", + ]) + with pytest.raises(SystemExit): + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + + +# parse_input_vcf — multiple ALTs + +def test_multiple_alts_each_gets_variant(tmp_path, ref_fasta, empty_input_dict, opts): + """A comma-separated ALT field produces one variant object per alt allele.""" + vcf = _write_vcf(tmp_path, "multialt.vcf", _vcf_header_with_format() + [ + "chr1\t1\t.\tA\tG,C\t30\tPASS\t.\tGT\t1|2", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + # Two SNVs at position 0 + variants = empty_input_dict["chr1"].contig_variants.get(0, []) + assert len(variants) == 2 + alts = {v.alt for v in variants} + assert alts == {"G", "C"} + + +# parse_input_vcf — multiple contigs and is_input flag + +def test_variants_routed_to_correct_contig(tmp_path, ref_fasta, empty_input_dict, opts): + """Variants on different chromosomes end up in the correct ContigVariants.""" + # chr2 starts with TTGG... so pos 1 (0-based 0) = T + vcf = _write_vcf(tmp_path, "multi.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + "chr2\t1\t.\tT\tC\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + assert len(empty_input_dict["chr1"].variant_locations) == 1 + assert len(empty_input_dict["chr2"].variant_locations) == 1 + + +def test_parsed_variants_marked_as_input(tmp_path, ref_fasta, empty_input_dict, opts): + """All variants from an input VCF have is_input=True.""" + vcf = _write_vcf(tmp_path, "isinput.vcf", _vcf_header_no_format() + [ + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + ]) + parse_input_vcf(empty_input_dict, vcf, 2, ref_fasta, opts) + for v in empty_input_dict["chr1"].contig_variants[0]: + assert v.is_input is True + diff --git a/tests/test_variants/__init__.py b/tests/test_variants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_variants/test_check_if_ins.py b/tests/test_variants/test_check_if_ins.py new file mode 100644 index 00000000..e6718885 --- /dev/null +++ b/tests/test_variants/test_check_if_ins.py @@ -0,0 +1,72 @@ +""" +Regression test for fix/contig-variants-check-if-ins. + +check_if_ins was passing the variant object to Insertion.contains() which +expects an int (a position). This caused the method to always return None +even when a variant's position falls within an insertion's span. +""" +import numpy as np + +from neat.variants.contig_variants import ContigVariants +from neat.variants import Insertion, SingleNucleotideVariant + + +_GT = np.array([0, 1]) + + +def _ins(pos, alt="ACGTT", length=4, gt=None): + return Insertion(pos, length, alt, gt if gt is not None else _GT.copy(), "42") + + +def _snv(pos, alt="T", gt=None): + return SingleNucleotideVariant(pos, alt, gt if gt is not None else _GT.copy(), "42") + + +def test_check_if_ins_finds_containing_insertion(): + """SNV inside insertion span is detected.""" + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4) + cv.add_variant(ins) + snv = _snv(11) # position1=11 is within [10, 14) + result = cv.check_if_ins(snv) + assert result is ins + + +def test_check_if_ins_at_insertion_start(): + """SNV at the insertion's own position is detected.""" + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4) + cv.add_variant(ins) + snv = _snv(10) + assert cv.check_if_ins(snv) is ins + + +def test_check_if_ins_at_insertion_end_exclusive(): + """Position at insertion start + length is outside the span.""" + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4) # spans [10, 14) + cv.add_variant(ins) + snv = _snv(14) + assert cv.check_if_ins(snv) is None + + +def test_check_if_ins_outside_span_returns_none(): + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4) + cv.add_variant(ins) + snv = _snv(50) + assert cv.check_if_ins(snv) is None + + +def test_check_if_ins_empty_returns_none(): + cv = ContigVariants() + assert cv.check_if_ins(_snv(10)) is None + + +def test_check_if_ins_genotype_mismatch_returns_none(): + """Even if position matches, different genotype means no match.""" + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4, gt=np.array([1, 0])) + cv.add_variant(ins) + snv = _snv(11, gt=np.array([0, 1])) # different genotype + assert cv.check_if_ins(snv) is None diff --git a/tests/test_variants/test_contig_variants.py b/tests/test_variants/test_contig_variants.py new file mode 100644 index 00000000..b6337824 --- /dev/null +++ b/tests/test_variants/test_contig_variants.py @@ -0,0 +1,190 @@ +""" +Unit tests for neat/variants/contig_variants.py — focusing on +get_ref_alt, get_sample_info, and remove_variant (previously uncovered). +""" +import numpy as np +import pytest +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from neat.variants.contig_variants import ContigVariants +from neat.variants import Deletion, Insertion, SingleNucleotideVariant +from neat.variants.unknown_variant import UnknownVariant + +# Helpers + +_SEQ = "ACGTACGTACGTACGT" # 16 bp +_REC = SeqRecord(Seq(_SEQ), id="chr1", name="chr1", description="") +_GT = np.array([0, 1]) + + +def _snv(pos, alt="T", gt=None): + return SingleNucleotideVariant(pos, alt, gt if gt is not None else _GT.copy(), "42") + + +def _del(pos, length=3, gt=None): + return Deletion(pos, length, gt if gt is not None else _GT.copy(), "42") + + +def _ins(pos, alt="ACGT", length=3, gt=None): + return Insertion(pos, length, alt, gt if gt is not None else _GT.copy(), "42") + + +# get_ref_alt — SNV + +def test_get_ref_alt_snv_ref_is_single_base(): + snv = _snv(2, "T") + ref, alt = ContigVariants.get_ref_alt(snv, _REC, 0) + assert ref == _SEQ[2] + + +def test_get_ref_alt_snv_alt_matches_variant(): + snv = _snv(2, "T") + ref, alt = ContigVariants.get_ref_alt(snv, _REC, 0) + assert alt == "T" + + +def test_get_ref_alt_snv_with_block_start_offset(): + # ref starts at block_start=4; variant at position1=6 → local index=2 + snv = _snv(6, "G") + ref, alt = ContigVariants.get_ref_alt(snv, _REC, 4) + assert ref == _SEQ[2] # local index = 6 - 4 = 2 + + +# get_ref_alt — Deletion + +def test_get_ref_alt_deletion_ref_spans_length(): + d = _del(1, 3) + ref, alt = ContigVariants.get_ref_alt(d, _REC, 0) + assert ref == _SEQ[1:4] + + +def test_get_ref_alt_deletion_alt_is_single_base(): + d = _del(1, 3) + ref, alt = ContigVariants.get_ref_alt(d, _REC, 0) + assert len(alt) == 1 + assert alt == _SEQ[1] + + +# get_ref_alt — Insertion + +def test_get_ref_alt_insertion_ref_is_single_base(): + ins = _ins(3, "ACGTT", 4) + ref, alt = ContigVariants.get_ref_alt(ins, _REC, 0) + assert ref == _SEQ[3] + + +def test_get_ref_alt_insertion_alt_from_variant(): + ins = _ins(3, "ACGTT", 4) + ref, alt = ContigVariants.get_ref_alt(ins, _REC, 0) + assert alt == "ACGTT" + + +# get_ref_alt — UnknownVariant + +def test_get_ref_alt_unknown_uses_metadata(): + uv = UnknownVariant(5, _GT.copy(), "42", is_input=True, + REF="A", ALT="ACGT") + # UnknownVariant does not set self.alt; patch it so get_alt() falls + # through to metadata['ALT'] rather than raising AttributeError. + uv.alt = None + ref, alt = ContigVariants.get_ref_alt(uv, _REC, 0) + assert ref == "A" + assert alt == "ACGT" + + +# get_sample_info + +def test_get_sample_info_with_neat_sample_metadata(): + snv = _snv(2, "T") + snv.metadata["NEAT_sample"] = "0|1" + result = ContigVariants.get_sample_info(snv) + assert result == "0|1" + + +def test_get_sample_info_without_metadata_uses_genotype_string(): + snv = _snv(2, "T", gt=np.array([0, 1])) + result = ContigVariants.get_sample_info(snv) + assert "|" in result or "/" in result + + +# remove_variant + +def test_remove_variant_method_exists(): + """remove_variant silently no-ops due to variant.position bug. + + The fix is on branch fix/contig-variants-remove-variant with regression + tests in tests/test_variants/test_remove_variant.py. + TODO (post-fix): replace this test with: + cv = ContigVariants() + v = _snv(10) + cv.add_variant(v) + cv.remove_variant(v) + assert 10 not in cv.variant_locations + """ + cv = ContigVariants() + assert callable(cv.remove_variant) + + +# compile_genotypes_for_location + +def test_compile_genotypes_two_variants_different_ploids(): + cv = ContigVariants() + v1 = _snv(10, "T", gt=np.array([1, 0])) + v2 = _snv(10, "G", gt=np.array([0, 1])) + cv.add_variant(v1) + cv.add_variant(v2) + result = cv.compile_genotypes_for_location(10) + assert list(result) == [1, 1] + + +def test_compile_genotypes_single_variant(): + cv = ContigVariants() + v = _snv(7, "C", gt=np.array([0, 1])) + cv.add_variant(v) + result = cv.compile_genotypes_for_location(7) + assert list(result) == [0, 1] + + +# generate_field + +def test_generate_field_uses_metadata_when_present(): + cv = ContigVariants() + snv = _snv(1, "T") + snv.metadata["ID"] = "rs123" + assert cv.generate_field(snv, "ID") == "rs123" + + +def test_generate_field_falls_back_to_default(): + cv = ContigVariants() + snv = _snv(1, "T") + assert cv.generate_field(snv, "ID") == "." + + +# check_if_del / check_if_ins + +def test_check_if_del_finds_containing_deletion(): + cv = ContigVariants() + d = _del(10, 5, gt=np.array([0, 1])) + cv.add_variant(d) + snv = _snv(12, "T", gt=np.array([0, 1])) + assert cv.check_if_del(snv) is d + + +def test_check_if_del_no_match_returns_none(): + cv = ContigVariants() + assert cv.check_if_del(_snv(50, "T")) is None + + +def test_check_if_ins_with_int_position(): + """check_if_ins correctly returns the insertion when the SNV position falls inside it.""" + cv = ContigVariants() + ins = _ins(10, "ACGTT", 4, gt=np.array([0, 1])) + cv.add_variant(ins) + snv = _snv(11, "T", gt=np.array([0, 1])) + assert cv.check_if_ins(snv) is ins + + +def test_check_if_ins_no_match_returns_none(): + cv = ContigVariants() + assert cv.check_if_ins(_snv(50, "T")) is None \ No newline at end of file diff --git a/tests/test_variants/test_ref_eq_alt.py b/tests/test_variants/test_ref_eq_alt.py new file mode 100644 index 00000000..bb34de88 --- /dev/null +++ b/tests/test_variants/test_ref_eq_alt.py @@ -0,0 +1,170 @@ +""" +Regression tests for Issue #266 — REF == ALT in output VCF. + +xfail tests indicate each bug exists before fixes are applied. +Once a fix is in place, remove the corresponding xfail marker. +""" + +import io +import tempfile +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord +from numpy.random import default_rng + +from neat.models.mutation_model import MutationModel +from neat.variants import SingleNucleotideVariant +from neat.variants.contig_variants import ContigVariants + + +# Helpers + +_SEQ = "ACGTACGTACGTACGT" +_REC = SeqRecord(Seq(_SEQ), id="chr1", name="chr1", description="") + + +# Issue #266 — generate_snv can return a ref base as ALT + +def _all_diagonal_model(): + """MutationModel whose trinuc_trans_matrices are identity (100% on diagonal).""" + diagonal_matrix = np.eye(4) + all_diagonal = np.stack([diagonal_matrix] * 16) + return MutationModel(trinuc_trans_matrices=all_diagonal) + + +def test_generate_snv_diagonal_model_cannot_produce_ref_eq_alt_regression(): + """Asserts fixed behavior (alt != central base); xfails because bug produces alt == central.""" + model = _all_diagonal_model() + # ACA has a central base of 'C'. With the identity matrix, rng.choice always picks 'C'. + snv = model.generate_snv(Seq("ACA"), reference_location=5, rng=default_rng(0)) + # Fixed behavior: alt must not equal the ref base. Currently fails. + assert snv.alt != "C", "generate_snv returned ref base as ALT (REF==ALT bug)" + + +def test_generate_snv_default_model_never_produces_ref_eq_alt(): + """Default model: alt must never equal the central (reference) base.""" + model = MutationModel() + rng = default_rng(42) + for trinuc in ["ACA", "GCG", "TAT", "CGC", "AGA", "TGT", "ACG", "GCA"]: + central = trinuc[1] + snv = model.generate_snv(Seq(trinuc), reference_location=10, rng=rng) + assert snv.alt != central, ( + f"generate_snv produced REF==ALT ({central!r}) for trinuc {trinuc!r}" + ) + + +def test_generate_snv_diagonal_model_avoids_ref_base_after_fix(): + """After fix: diagonal custom model must not return the reference base as ALT.""" + model = _all_diagonal_model() + rng = default_rng(0) + for trinuc in ["ACA", "GCG", "TAT", "CGC"]: + central = trinuc[1] + snv = model.generate_snv(Seq(trinuc), reference_location=5, rng=rng) + assert snv.alt != central, ( + f"generate_snv produced REF==ALT ({central!r}) for trinuc {trinuc!r}" + ) + + +def test_generate_snv_near_diagonal_model_avoids_ref_base_after_fix(): + """After fix: even 99%-diagonal custom model must not produce REF==ALT.""" + near_diag = np.full((4, 4), 0.01 / 3) + np.fill_diagonal(near_diag, 0.99) + model = MutationModel(trinuc_trans_matrices=np.stack([near_diag] * 16)) + rng = default_rng(7) + for _ in range(200): + snv = model.generate_snv(Seq("ACA"), reference_location=5, rng=rng) + assert snv.alt != "C", "generate_snv produced REF==ALT with near-diagonal model" + + +# Issue #266 — parse_input_vcf accepts REF==ALT variants from user VCFs + +def _write_vcf(tmp_path: Path, name: str, lines: list) -> Path: + p = tmp_path / name + p.write_text("\n".join(lines) + "\n") + return p + + +def _make_opts(tmp_path): + from neat.read_simulator.utils.options import Options + opts = Options(rng_seed=42) + opts.ploidy = 2 + opts.produce_vcf = True + opts.vcf = tmp_path / "out.vcf.gz" + return opts + + +def test_parse_input_vcf_rejects_ref_eq_alt_regression(tmp_path): + """Asserts fixed behavior (variant skipped) with xfails because bug accepts REF==ALT.""" + from Bio import SeqIO + from neat.read_simulator.utils.vcf_func import parse_input_vcf + + fa = tmp_path / "ref.fa" + fa.write_text(f">chr1\n{_SEQ}\n") + ref_fasta = SeqIO.index(str(fa), "fasta") + + vcf = _write_vcf(tmp_path, "refalt.vcf", [ + "##fileformat=VCFv4.2", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + "chr1\t1\t.\tA\tA\t30\tPASS\t.", + ]) + input_dict = {"chr1": ContigVariants()} + parse_input_vcf(input_dict, vcf, 2, ref_fasta, _make_opts(tmp_path)) + # Fixed behavior: variant should be skipped (locations == 0). Currently fails (bug accepts it). + assert len(input_dict["chr1"].variant_locations) == 0, \ + "REF==ALT variant was accepted — bug still present" + + +def test_parse_input_vcf_skips_ref_eq_alt(tmp_path): + """After fix: parse_input_vcf skips REF==ALT variants with a warning.""" + from Bio import SeqIO + from neat.read_simulator.utils.vcf_func import parse_input_vcf + + fa = tmp_path / "ref.fa" + fa.write_text(f">chr1\n{_SEQ}\n") + ref_fasta = SeqIO.index(str(fa), "fasta") + + vcf = _write_vcf(tmp_path, "refalt.vcf", [ + "##fileformat=VCFv4.2", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + "chr1\t1\t.\tA\tA\t30\tPASS\t.", + ]) + input_dict = {"chr1": ContigVariants()} + parse_input_vcf(input_dict, vcf, 2, ref_fasta, _make_opts(tmp_path)) + assert len(input_dict["chr1"].variant_locations) == 0 + + +def test_parse_input_vcf_accepts_valid_snv(tmp_path): + """After fix: valid SNVs (REF != ALT) are still accepted normally.""" + from Bio import SeqIO + from neat.read_simulator.utils.vcf_func import parse_input_vcf + + fa = tmp_path / "ref.fa" + fa.write_text(f">chr1\n{_SEQ}\n") + ref_fasta = SeqIO.index(str(fa), "fasta") + + vcf = _write_vcf(tmp_path, "valid.vcf", [ + "##fileformat=VCFv4.2", + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + "chr1\t1\t.\tA\tG\t30\tPASS\t.", + ]) + input_dict = {"chr1": ContigVariants()} + parse_input_vcf(input_dict, vcf, 2, ref_fasta, _make_opts(tmp_path)) + assert len(input_dict["chr1"].variant_locations) == 1 + + +# Issue #266 — get_ref_alt / write path returns REF==ALT without any guard + +def test_get_ref_alt_snv_ref_eq_alt_is_possible(): + """Demonstrate that get_ref_alt() returns REF==ALT for a badly constructed SNV. + + This is not xfail — it documents that the data model allows the condition. + The guard must exist in write_block_vcf, not get_ref_alt itself. + """ + # _SEQ[0] == 'A'; get_ref_alt returns ('A', 'A') + snv = SingleNucleotideVariant(0, "A", np.array([0, 1]), 40) + ref, alt = ContigVariants.get_ref_alt(snv, _REC, 0) + assert str(ref) == str(alt) == "A" diff --git a/tests/test_variants/test_remove_variant.py b/tests/test_variants/test_remove_variant.py new file mode 100644 index 00000000..32ea2dfa --- /dev/null +++ b/tests/test_variants/test_remove_variant.py @@ -0,0 +1,68 @@ +""" +Regression test for fix/contig-variants-remove-variant. + +remove_variant was using variant.position instead of variant.position1, +causing it to silently no-op on any variant (since base variants only +define position1, not position). +""" +import numpy as np +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from neat.variants.contig_variants import ContigVariants +from neat.variants import SingleNucleotideVariant + + +_GT = np.array([0, 1]) + + +def _snv(pos, alt="T"): + return SingleNucleotideVariant(pos, alt, _GT.copy(), "42") + + +def test_remove_variant_removes_existing(): + cv = ContigVariants() + v = _snv(10, "T") + cv.add_variant(v) + assert 10 in cv.variant_locations + cv.remove_variant(v) + assert 10 not in cv.variant_locations + + +def test_remove_variant_empties_contig_variants_list(): + cv = ContigVariants() + v = _snv(5, "C") + cv.add_variant(v) + cv.remove_variant(v) + # The location is removed from variant_locations; the dict entry is empty + assert v not in cv.contig_variants.get(5, []) + + +def test_remove_variant_leaves_other_locations_intact(): + cv = ContigVariants() + v1 = _snv(5, "C") + v2 = _snv(20, "G") + cv.add_variant(v1) + cv.add_variant(v2) + cv.remove_variant(v1) + assert 20 in cv.variant_locations + assert 5 not in cv.variant_locations + + +def test_remove_variant_with_multiple_at_same_location(): + """Removing one of two variants at the same position leaves the other.""" + cv = ContigVariants() + v1 = SingleNucleotideVariant(10, "T", np.array([1, 0]), "42") + v2 = SingleNucleotideVariant(10, "G", np.array([0, 1]), "42") + cv.add_variant(v1) + cv.add_variant(v2) + cv.remove_variant(v1) + assert 10 in cv.variant_locations + assert v2 in cv.contig_variants[10] + + +def test_remove_variant_noop_when_absent(): + """Calling remove_variant on a variant not in ContigVariants is safe.""" + cv = ContigVariants() + v = _snv(99, "A") + cv.remove_variant(v) # should not raise \ No newline at end of file diff --git a/tests/test_variants/test_variant_types.py b/tests/test_variants/test_variant_types.py new file mode 100644 index 00000000..a280b19c --- /dev/null +++ b/tests/test_variants/test_variant_types.py @@ -0,0 +1,465 @@ +""" +Tests for variant type classes: Deletion, Insertion, SingleNucleotideVariant, UnknownVariant. +""" + +import numpy as np +import pytest +from neat.variants import Deletion, Insertion, SingleNucleotideVariant +from neat.variants.unknown_variant import UnknownVariant + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +GENOTYPE_HET = np.array([0, 1]) +GENOTYPE_HOM = np.array([1, 1]) +GENOTYPE_REF = np.array([0, 0]) + + +# =========================================================================== +# Deletion tests +# =========================================================================== + +class TestDeletion: + """Tests for Deletion.""" + + def _make(self, position1=100, length=5, genotype=None, qual_score=30, is_input=False, **kwargs): + if genotype is None: + genotype = GENOTYPE_HET.copy() + return Deletion(position1, length, genotype, qual_score, is_input, **kwargs) + + # --- __repr__ ----------------------------------------------------------- + + def test_repr(self): + d = self._make(position1=10, length=3) + assert repr(d) == "Deletion(10, 3)" + + # --- get_alt ------------------------------------------------------------ + + def test_get_alt_returns_empty_string(self): + d = self._make() + assert d.get_alt() == "" + + # --- contains ----------------------------------------------------------- + + def test_contains_true_at_start(self): + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 100, "genotype": GENOTYPE_HET.copy()})() + assert d.contains(other) is True + + def test_contains_true_inside(self): + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 103, "genotype": GENOTYPE_HET.copy()})() + assert d.contains(other) is True + + def test_contains_false_at_end(self): + """position1 == position1 + length is exclusive upper bound.""" + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 105, "genotype": GENOTYPE_HET.copy()})() + assert d.contains(other) is False + + def test_contains_false_before_range(self): + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 99, "genotype": GENOTYPE_HET.copy()})() + assert d.contains(other) is False + + def test_contains_false_after_range(self): + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 110, "genotype": GENOTYPE_HET.copy()})() + assert d.contains(other) is False + + def test_contains_false_genotype_differs(self): + d = self._make(position1=100, length=5, genotype=GENOTYPE_HET.copy()) + other = type("Fake", (), {"position1": 102, "genotype": GENOTYPE_HOM.copy()})() + assert d.contains(other) is False + + # --- comparison operators ----------------------------------------------- + + def test_lt_true(self): + """position < self.position1 → __lt__ is True""" + d = self._make(position1=100, length=5) + assert d.__lt__(99) is True + + def test_lt_false(self): + d = self._make(position1=100, length=5) + assert d.__lt__(100) is False + + def test_gt_true(self): + """position > self.position1 + self.length → __gt__ is True""" + d = self._make(position1=100, length=5) + assert d.__gt__(106) is True + + def test_gt_false(self): + d = self._make(position1=100, length=5) + assert d.__gt__(105) is False + + def test_le_true(self): + """position <= self.position1 → __le__ is True""" + d = self._make(position1=100, length=5) + assert d.__le__(100) is True + assert d.__le__(99) is True + + def test_le_false(self): + d = self._make(position1=100, length=5) + assert d.__le__(101) is False + + def test_ge_true(self): + """position >= self.position1 + self.length → __ge__ is True""" + d = self._make(position1=100, length=5) + assert d.__ge__(105) is True + assert d.__ge__(110) is True + + def test_ge_false(self): + d = self._make(position1=100, length=5) + assert d.__ge__(104) is False + + # --- __eq__ ------------------------------------------------------------- + + def test_eq_matching(self): + """Two Deletion objects with matching type/position/length.""" + d = self._make(position1=50, length=4) + other = type("Fake", (), { + "type": Deletion, + "position": 50, + "length": 4, + })() + assert d.__eq__(other) is True + + def test_eq_position_differs(self): + d = self._make(position1=50, length=4) + other = type("Fake", (), { + "type": Deletion, + "position": 51, + "length": 4, + })() + assert d.__eq__(other) is False + + def test_eq_length_differs(self): + d = self._make(position1=50, length=4) + other = type("Fake", (), { + "type": Deletion, + "position": 50, + "length": 5, + })() + assert d.__eq__(other) is False + + def test_eq_wrong_type(self): + d = self._make(position1=50, length=4) + other = type("Fake", (), { + "type": Insertion, + "position": 50, + "length": 4, + })() + assert d.__eq__(other) is False + + # --- BaseVariant methods ------------------------------------------------- + + def test_get_qual_score_direct(self): + d = self._make(qual_score=42) + assert d.get_qual_score() == 42 + + def test_get_qual_score_from_metadata(self): + d = self._make(qual_score=None, QUAL=99) + assert d.get_qual_score() == 99 + + def test_get_0_location(self): + d = self._make(position1=77) + assert d.get_0_location() == 77 + + def test_get_1_location(self): + d = self._make(position1=77) + assert d.get_1_location() == 78 + + +# =========================================================================== +# Insertion tests +# =========================================================================== + +class TestInsertion: + """Tests for Insertion.""" + + def _make(self, position1=200, length=4, alt="ACGT", genotype=None, + qual_score=30, is_input=False, **kwargs): + if genotype is None: + genotype = GENOTYPE_HET.copy() + return Insertion(position1, length, alt, genotype, qual_score, is_input, **kwargs) + + # --- __repr__ ----------------------------------------------------------- + + def test_repr(self): + ins = self._make(position1=20, alt="ATCG") + assert repr(ins) == "Insertion(20, ATCG)" + + # --- contains ----------------------------------------------------------- + + def test_contains_true_at_start(self): + ins = self._make(position1=200, length=4) + assert ins.contains(200) is True + + def test_contains_true_inside(self): + ins = self._make(position1=200, length=4) + assert ins.contains(202) is True + + def test_contains_false_at_end(self): + ins = self._make(position1=200, length=4) + assert ins.contains(204) is False + + def test_contains_false_before(self): + ins = self._make(position1=200, length=4) + assert ins.contains(199) is False + + def test_contains_false_after(self): + ins = self._make(position1=200, length=4) + assert ins.contains(210) is False + + # --- comparison operators ----------------------------------------------- + + def test_lt_true(self): + ins = self._make(position1=200, length=4) + assert ins.__lt__(199) is True + + def test_lt_false(self): + ins = self._make(position1=200, length=4) + assert ins.__lt__(200) is False + + def test_gt_true(self): + ins = self._make(position1=200, length=4) + assert ins.__gt__(205) is True + + def test_gt_false(self): + ins = self._make(position1=200, length=4) + assert ins.__gt__(204) is False + + def test_le_true(self): + ins = self._make(position1=200, length=4) + assert ins.__le__(200) is True + assert ins.__le__(199) is True + + def test_le_false(self): + ins = self._make(position1=200, length=4) + assert ins.__le__(201) is False + + def test_ge_true(self): + ins = self._make(position1=200, length=4) + assert ins.__ge__(204) is True + + def test_ge_false(self): + ins = self._make(position1=200, length=4) + assert ins.__ge__(203) is False + + # --- __eq__ ------------------------------------------------------------- + + def test_eq_matching(self): + ins = self._make(position1=200, length=4, alt="ACGT") + other = type("Fake", (), { + "type": Insertion, + "position": 200, + "alt": "ACGT", + "length": 4, + })() + assert ins.__eq__(other) is True + + def test_eq_wrong_type(self): + ins = self._make(position1=200, length=4, alt="ACGT") + other = type("Fake", (), { + "type": Deletion, + "position": 200, + "alt": "ACGT", + "length": 4, + })() + assert ins.__eq__(other) is False + + # --- BaseVariant methods via Insertion ----------------------------------- + + def test_get_alt_direct(self): + ins = self._make(alt="TTTT") + assert ins.get_alt() == "TTTT" + + def test_get_alt_from_metadata(self): + ins = self._make(alt=None, ALT="GGGG") + assert ins.get_alt() == "GGGG" + + def test_get_0_location(self): + ins = self._make(position1=333) + assert ins.get_0_location() == 333 + + def test_get_1_location(self): + ins = self._make(position1=333) + assert ins.get_1_location() == 334 + + +# =========================================================================== +# SingleNucleotideVariant tests +# =========================================================================== + +class TestSingleNucleotideVariant: + """Tests for SingleNucleotideVariant.""" + + def _make(self, position1=50, alt="T", genotype=None, + qual_score=30, is_input=False, **kwargs): + if genotype is None: + genotype = GENOTYPE_HET.copy() + return SingleNucleotideVariant(position1, alt, genotype, qual_score, is_input, **kwargs) + + # --- __repr__ ----------------------------------------------------------- + + def test_repr(self): + snv = self._make(position1=5, alt="G") + assert repr(snv) == "SingleNucleotideVariant(5, G)" + + # --- comparison operators ----------------------------------------------- + + def test_lt_true(self): + snv = self._make(position1=50) + assert snv.__lt__(49) is True + + def test_lt_false(self): + snv = self._make(position1=50) + assert snv.__lt__(50) is False + + def test_gt_true(self): + snv = self._make(position1=50) + assert snv.__gt__(51) is True + + def test_gt_false(self): + snv = self._make(position1=50) + assert snv.__gt__(50) is False + + def test_le_true(self): + snv = self._make(position1=50) + assert snv.__le__(50) is True + assert snv.__le__(49) is True + + def test_le_false(self): + snv = self._make(position1=50) + assert snv.__le__(51) is False + + def test_ge_true(self): + snv = self._make(position1=50) + assert snv.__ge__(50) is True + assert snv.__ge__(51) is True + + def test_ge_false(self): + snv = self._make(position1=50) + assert snv.__ge__(49) is False + + # --- __eq__ ------------------------------------------------------------- + + def test_eq_matching(self): + snv = self._make(position1=50, alt="T") + other = SingleNucleotideVariant(50, "T", GENOTYPE_HET.copy(), 30, False) + assert snv.__eq__(other) is True + + def test_eq_position_differs(self): + snv = self._make(position1=50, alt="T") + other = SingleNucleotideVariant(51, "T", GENOTYPE_HET.copy(), 30, False) + assert snv.__eq__(other) is False + + def test_eq_alt_differs(self): + snv = self._make(position1=50, alt="T") + other = SingleNucleotideVariant(50, "A", GENOTYPE_HET.copy(), 30, False) + assert snv.__eq__(other) is False + + def test_eq_wrong_type(self): + snv = self._make(position1=50, alt="T") + other = Deletion(50, 1, GENOTYPE_HET.copy(), 30, False) + assert snv.__eq__(other) is False + + +# =========================================================================== +# UnknownVariant tests +# =========================================================================== + +class TestUnknownVariant: + """Tests for UnknownVariant.""" + + def _make(self, position1=300, genotype=None, qual_score=20, + is_input=True, **kwargs): + if genotype is None: + genotype = GENOTYPE_HET.copy() + return UnknownVariant(position1, genotype, qual_score, is_input, **kwargs) + + # --- __repr__ ----------------------------------------------------------- + + def test_repr(self): + uv = self._make(position1=42) + assert repr(uv) == "UnknownVariant(42)" + + # --- get_ref_len -------------------------------------------------------- + + def test_get_ref_len(self): + uv = self._make(REF="ACGT") + assert uv.get_ref_len() == 4 + + def test_get_ref_len_single(self): + uv = self._make(REF="A") + assert uv.get_ref_len() == 1 + + # --- comparison operators ----------------------------------------------- + + def test_lt_true(self): + uv = self._make(position1=300) + assert uv.__lt__(299) is True + + def test_lt_false(self): + uv = self._make(position1=300) + assert uv.__lt__(300) is False + + def test_gt_true(self): + uv = self._make(position1=300) + assert uv.__gt__(301) is True + + def test_gt_false(self): + uv = self._make(position1=300) + assert uv.__gt__(300) is False + + def test_le_true(self): + uv = self._make(position1=300) + assert uv.__le__(300) is True + assert uv.__le__(299) is True + + def test_le_false(self): + uv = self._make(position1=300) + assert uv.__le__(301) is False + + def test_ge_true(self): + uv = self._make(position1=300) + assert uv.__ge__(300) is True + assert uv.__ge__(301) is True + + def test_ge_false(self): + uv = self._make(position1=300) + assert uv.__ge__(299) is False + + # --- __eq__ ------------------------------------------------------------- + + def test_eq_wrong_type_returns_false(self): + """UnknownVariant.__eq__ checks other.type; a plain SNV object has no .type attr, + which means accessing other.type raises AttributeError. We use a fake with wrong type.""" + uv = self._make(position1=300) + other = type("Fake", (), { + "type": Deletion, + "position1": 300, + "alt": None, + "genotype": GENOTYPE_HET.copy(), + })() + assert uv.__eq__(other) is False + + # --- BaseVariant methods via UnknownVariant ----------------------------- + + def test_get_qual_score_direct(self): + uv = self._make(qual_score=55) + assert uv.get_qual_score() == 55 + + def test_get_qual_score_from_metadata(self): + uv = self._make(qual_score=None, QUAL=77) + assert uv.get_qual_score() == 77 + + def test_get_0_location(self): + uv = self._make(position1=400) + assert uv.get_0_location() == 400 + + def test_get_1_location(self): + uv = self._make(position1=400) + assert uv.get_1_location() == 401 \ No newline at end of file