From b9f1c86928d775a98846428e1bd4ae02a9ddbf3a Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 15 Jan 2025 16:05:01 -0700 Subject: [PATCH 1/5] WIP --- prymer/primer3/__init__.py | 4 +- prymer/primer3/primer3.py | 52 ++++--- prymer/primer3/primer3_input.py | 183 ----------------------- prymer/primer3/primer3_parameters.py | 140 ++++++++++++++--- prymer/primer3/primer3_weights.py | 158 ------------------- tests/primer3/test_primer3.py | 30 ++-- tests/primer3/test_primer3_input.py | 10 +- tests/primer3/test_primer3_parameters.py | 16 +- 8 files changed, 179 insertions(+), 414 deletions(-) delete mode 100644 prymer/primer3/primer3_input.py delete mode 100644 prymer/primer3/primer3_weights.py diff --git a/prymer/primer3/__init__.py b/prymer/primer3/__init__.py index 0d247b4..061d6ca 100644 --- a/prymer/primer3/__init__.py +++ b/prymer/primer3/__init__.py @@ -4,7 +4,7 @@ from prymer.primer3.primer3_failure_reason import Primer3FailureReason from prymer.primer3.primer3_input import Primer3Input from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask @@ -24,7 +24,7 @@ "DesignPrimerPairsTask", "DesignRightPrimersTask", "PickHybProbeOnly", - "PrimerAndAmpliconParameters", + "AmpliconParameters", "ProbeParameters", "ProbeWeights", "PrimerAndAmpliconWeights", diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py index 6f26693..c53afb2 100644 --- a/prymer/primer3/primer3.py +++ b/prymer/primer3/primer3.py @@ -37,21 +37,21 @@ ``` The `design()` method on `Primer3` is used to design the primers given a -[`Primer3Input`][prymer.primer3.primer3_input.Primer3Input]. The latter includes all the +[`Primer3Parameters`][prymer.primer3.primer3_input.Primer3Parameters]. The latter includes all the parameters and target region. ```python ->>> from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +>>> from prymer.primer3.primer3_parameters import AmpliconParameters >>> from prymer import MinOptMax >>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) ->>> params = PrimerAndAmpliconParameters( \ +>>> params = AmpliconParameters( \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ ) ->>> design_input = Primer3Input( \ +>>> design_input = AmpliconParameters( \ target=target, \ primer_and_amplicon_params=params, \ task=DesignLeftPrimersTask(), \ @@ -138,8 +138,9 @@ from prymer.model import PrimerPair from prymer.model import Span from prymer.model import Strand +from prymer.primer3 import AmpliconParameters, ProbeParameters from prymer.primer3.primer3_failure_reason import Primer3FailureReason -from prymer.primer3.primer3_input import Primer3Input +from prymer.primer3.primer3_parameters import Primer3Parameters from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask @@ -292,7 +293,7 @@ def get_design_sequences(self, region: Span) -> tuple[str, str]: @staticmethod def _screen_pair_results( - design_input: Primer3Input, designed_primer_pairs: list[PrimerPair] + design_input: AmpliconParameters, designed_primer_pairs: list[PrimerPair] ) -> tuple[list[PrimerPair], list[Oligo]]: """Screens primer pair designs emitted by Primer3 for dinucleotide run length. @@ -310,13 +311,13 @@ def _screen_pair_results( valid: bool = True if ( primer_pair.left_primer.longest_dinucleotide_run_length - > design_input.primer_and_amplicon_params.primer_max_dinuc_bases + > design_input.primer_max_dinuc_bases ): # if the left primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.left_primer) valid = False if ( primer_pair.right_primer.longest_dinucleotide_run_length - > design_input.primer_and_amplicon_params.primer_max_dinuc_bases + > design_input.primer_max_dinuc_bases ): # if the right primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.right_primer) valid = False @@ -324,7 +325,7 @@ def _screen_pair_results( valid_primer_pair_designs.append(primer_pair) return valid_primer_pair_designs, dinuc_pair_failures - def design(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901 + def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C901 """Designs primers, primer pairs, and/or internal probes given a target region. Args: @@ -342,19 +343,21 @@ def design(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901 design_region: Span match design_input.task: case PickHybProbeOnly(): - if design_input.target.length < design_input.probe_params.probe_sizes.min: + input = typing.cast(ProbeParameters, design_input) + if input.target.length < input.probe_sizes.min: raise ValueError( "Target region required to be at least as large as the" " minimal probe size: " - f"target length: {design_input.target.length}, " - f"minimal probe size: {design_input.probe_params.probe_sizes.min}" + f"target length: {input.target.length}, " + f"minimal probe size: {input.probe_sizes.min}" ) design_region = design_input.target case DesignRightPrimersTask() | DesignLeftPrimersTask() | DesignPrimerPairsTask(): + input = typing.cast(AmpliconParameters, design_input) design_region = self._create_design_region( - target_region=design_input.target, - max_amplicon_length=design_input.primer_and_amplicon_params.max_amplicon_length, - min_primer_length=design_input.primer_and_amplicon_params.min_primer_length, + target_region=input.target, + max_amplicon_length=input.max_amplicon_length, + min_primer_length=input.min_primer_length, ) case _ as unreachable: assert_never(unreachable) # pragma: no cover @@ -403,14 +406,15 @@ def design(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901 match design_input.task: case DesignPrimerPairsTask(): # Primer pair design + input = typing.cast(AmpliconParameters, design_input) all_pair_results: list[PrimerPair] = Primer3._build_primer_pairs( - design_input=design_input, + design_input=input, design_results=primer3_results, design_region=design_region, unmasked_design_seq=soft_masked, ) return Primer3._assemble_primer_pairs( - design_input=design_input, + design_input=input, design_results=primer3_results, unfiltered_designs=all_pair_results, ) @@ -435,7 +439,7 @@ def design(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901 @staticmethod def _build_oligos( - design_input: Primer3Input, + design_input: Primer3Parameters, design_results: dict[str, Any], design_region: Span, design_task: Union[DesignLeftPrimersTask, DesignRightPrimersTask, PickHybProbeOnly], @@ -515,7 +519,7 @@ def _build_oligos( @staticmethod def _assemble_single_designs( - design_input: Primer3Input, + design_input: Primer3Parameters, design_results: dict[str, str], unfiltered_designs: list[Oligo], ) -> Primer3Result: @@ -540,7 +544,7 @@ def _assemble_single_designs( @staticmethod def _build_primer_pairs( - design_input: Primer3Input, + design_input: Primer3Parameters, design_results: dict[str, Any], design_region: Span, unmasked_design_seq: str, @@ -603,7 +607,7 @@ def _build_primer_pair(num: int, primer_pair: tuple[Oligo, Oligo]) -> PrimerPair @staticmethod def _assemble_primer_pairs( - design_input: Primer3Input, + design_input: AmpliconParameters, design_results: dict[str, Any], unfiltered_designs: list[PrimerPair], ) -> Primer3Result: @@ -718,7 +722,7 @@ def _create_design_region( return design_region -def _check_design_results(design_input: Primer3Input, design_results: dict[str, str]) -> int: +def _check_design_results(design_input: Primer3Parameters, design_results: dict[str, str]) -> int: """Checks for any additional Primer3 errors and reports out the count of emitted designs.""" count_tag = design_input.task.count_tag maybe_count: Optional[str] = design_results.get(count_tag) @@ -733,7 +737,7 @@ def _check_design_results(design_input: Primer3Input, design_results: dict[str, return count -def _has_acceptable_dinuc_run(design_input: Primer3Input, oligo_design: Oligo) -> bool: +def _has_acceptable_dinuc_run(design_input: Primer3Parameters, oligo_design: Oligo) -> bool: """ True if the design's longest dinucleotide run is no more than the stipulated maximum. @@ -744,7 +748,7 @@ def _has_acceptable_dinuc_run(design_input: Primer3Input, oligo_design: Oligo) - `ProbeParameters.probe_max_dinuc_bases`. Args: - design_input: the Primer3Input object that wraps task-specific and design-specific params + design_input: the Primer3Parameters object that wraps task-specific and design-specific params oligo_design: the design candidate Returns: diff --git a/prymer/primer3/primer3_input.py b/prymer/primer3/primer3_input.py deleted file mode 100644 index 63ca943..0000000 --- a/prymer/primer3/primer3_input.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -# Primer3Input Class and Methods - -This module contains the [`Primer3Input`][prymer.primer3.Primer3Input] class. The class -wraps together different helper classes to assemble user-specified criteria and parameters for -input to Primer3. - -The module uses: - -1. [`PrimerAndAmpliconParameters`][prymer.primer3.primer3_parameters.Primer3Parameters] - to specify user-specified criteria for primer design -2. [`ProbeParameters`][prymer.primer3.primer3_parameters.ProbeParameters] - to specify user-specified criteria for probe design -3. [`PrimerAndAmpliconWeights`][prymer.primer3.primer3_weights.PrimerAndAmpliconWeights] - to establish penalties based on those criteria -4. [`ProbeWeights`][prymer.primer3.primer3_weights.ProbeWeights] to specify penalties based on probe - design criteria -5. [`Primer3Task`][prymer.primer3.primer3_task.Primer3Task] to organize task-specific - logic. -6. [`Span`](index.md#prymer.api.span.Span] to specify the target region. - -The `Primer3Input.to_input_tags(]` method -The main purpose of this class is to generate the -[`Primer3InputTag`s][prymer.primer3.primer3_input_tag.Primer3InputTag]s required by -`Primer3` for specifying how to design the primers, returned by the `to_input_tags(]` method. - -## Examples - -The following examples builds the `Primer3` tags for designing left primers: - -```python ->>> from prymer import MinOptMax, Strand, Span ->>> from prymer.primer3 import DesignLeftPrimersTask ->>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) ->>> design_region = Span(refname="chr1", start=150, end=300, strand=Strand.POSITIVE) ->>> params = PrimerAndAmpliconParameters( \ - amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ - amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ - primer_sizes=MinOptMax(min=29, max=31, opt=30), \ - primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ - primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ - ) ->>> design_input = Primer3Input(target=target, \ - primer_and_amplicon_params=params, \ - task=DesignLeftPrimersTask() \ - ) - ->>> for tag, value in design_input.to_input_tags(design_region=design_region).items(): \ - print(f"{tag.value} -> {value}") -PRIMER_TASK -> pick_primer_list -PRIMER_PICK_LEFT_PRIMER -> 1 -PRIMER_PICK_RIGHT_PRIMER -> 0 -PRIMER_PICK_INTERNAL_OLIGO -> 0 -SEQUENCE_INCLUDED_REGION -> 1,51 -PRIMER_PRODUCT_OPT_SIZE -> 200 -PRIMER_PRODUCT_SIZE_RANGE -> 100-250 -PRIMER_PRODUCT_MIN_TM -> 55.0 -PRIMER_PRODUCT_OPT_TM -> 70.0 -PRIMER_PRODUCT_MAX_TM -> 100.0 -PRIMER_MIN_SIZE -> 29 -PRIMER_OPT_SIZE -> 30 -PRIMER_MAX_SIZE -> 31 -PRIMER_MIN_TM -> 63.0 -PRIMER_OPT_TM -> 65.0 -PRIMER_MAX_TM -> 67.0 -PRIMER_MIN_GC -> 30.0 -PRIMER_OPT_GC_PERCENT -> 45.0 -PRIMER_MAX_GC -> 65.0 -PRIMER_GC_CLAMP -> 0 -PRIMER_MAX_END_GC -> 5 -PRIMER_MAX_POLY_X -> 5 -PRIMER_MAX_NS_ACCEPTED -> 1 -PRIMER_LOWERCASE_MASKING -> 1 -PRIMER_NUM_RETURN -> 5 -PRIMER_MAX_SELF_ANY_TH -> 53.0 -PRIMER_MAX_SELF_END_TH -> 53.0 -PRIMER_MAX_HAIRPIN_TH -> 53.0 -PRIMER_PAIR_WT_PRODUCT_SIZE_LT -> 1.0 -PRIMER_PAIR_WT_PRODUCT_SIZE_GT -> 1.0 -PRIMER_PAIR_WT_PRODUCT_TM_LT -> 0.0 -PRIMER_PAIR_WT_PRODUCT_TM_GT -> 0.0 -PRIMER_WT_END_STABILITY -> 0.25 -PRIMER_WT_GC_PERCENT_LT -> 0.25 -PRIMER_WT_GC_PERCENT_GT -> 0.25 -PRIMER_WT_SELF_ANY -> 0.1 -PRIMER_WT_SELF_END -> 0.1 -PRIMER_WT_SIZE_LT -> 0.5 -PRIMER_WT_SIZE_GT -> 0.1 -PRIMER_WT_TM_LT -> 1.0 -PRIMER_WT_TM_GT -> 1.0 -PRIMER_WT_SELF_ANY_TH -> 0.0 -PRIMER_WT_SELF_END_TH -> 0.0 -PRIMER_WT_HAIRPIN_TH -> 0.0 - -``` - -""" - -from dataclasses import MISSING -from dataclasses import dataclass -from dataclasses import fields -from typing import Any -from typing import Optional - -from prymer.model import Span -from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters -from prymer.primer3.primer3_parameters import ProbeParameters -from prymer.primer3.primer3_task import Primer3TaskType -from prymer.primer3.primer3_weights import PrimerAndAmpliconWeights -from prymer.primer3.primer3_weights import ProbeWeights - - -@dataclass(frozen=True, init=True, slots=True) -class Primer3Input: - """Assembles necessary inputs for Primer3 to orchestrate primer, primer pair, and/or internal - probe design. - - At least one set of design parameters (either `PrimerAndAmpliconParameters` - or `ProbeParameters`) must be specified. - - If `PrimerAndAmpliconParameters` is provided but `PrimerAndAmpliconWeights` is not provided, - default `PrimerAndAmpliconWeights` will be used. - - Similarly, if `ProbeParameters` is provided but `ProbeWeights` is not provided, default - `ProbeWeights` will be used. - - Please see primer3_parameters.py for details on the defaults. - - - Raises: - ValueError: if neither the primer or probe parameters are specified - """ - - target: Span - task: Primer3TaskType - primer_and_amplicon_params: Optional[PrimerAndAmpliconParameters] = None - probe_params: Optional[ProbeParameters] = None - primer_weights: Optional[PrimerAndAmpliconWeights] = None - probe_weights: Optional[ProbeWeights] = None - - def __post_init__(self) -> None: - # check for at least one set of params - # for the set of params given, check that weights were given; use defaults if not given - if self.primer_and_amplicon_params is None and self.probe_params is None: - raise ValueError( - "Primer3 requires at least one set of parameters" - " for either primer or probe design" - ) - - if self.primer_and_amplicon_params is not None and self.primer_weights is None: - object.__setattr__(self, "primer_weights", PrimerAndAmpliconWeights()) - - if self.probe_params is not None and self.probe_weights is None: - object.__setattr__(self, "probe_weights", ProbeWeights()) - - def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: - """Assembles `Primer3InputTag` and values for input to `Primer3` - - The target region must be wholly contained within design region. - - Args: - design_region: the design region, which wholly contains the target region, in which - primers are to be designed. - - Returns: - a mapping of `Primer3InputTag`s to associated value - """ - primer3_task_params = self.task.to_input_tags( - design_region=design_region, target=self.target - ) - assembled_tags: dict[Primer3InputTag, Any] = {**primer3_task_params} - - optional_attributes = { - field.name: getattr(self, field.name) - for field in fields(self) - if field.default is not MISSING - } - for settings in optional_attributes.values(): - if settings is not None: - assembled_tags.update(settings.to_input_tags()) - - return assembled_tags diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index 4707964..951afc8 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -27,7 +27,7 @@ class stores user input for internal probe design and maps it to the correct Pri ## Examples ```python ->>> params = PrimerAndAmpliconParameters( \ +>>> params = AmpliconParameters( \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ @@ -63,18 +63,48 @@ class stores user input for internal probe design and maps it to the correct Pri ``` """ -import warnings +from abc import ABC, abstractmethod from dataclasses import dataclass from dataclasses import fields from typing import Any from typing import Optional -from prymer.model import MinOptMax +from prymer.model import MinOptMax, Span from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_task import Primer3TaskType + + +class Primer3Parameters(ABC): + target: Span + task: Primer3TaskType + + @abstractmethod + def _to_input_tags(self) -> dict[Primer3InputTag, Any]: ... + + def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: + """Assembles `Primer3InputTag` and values for input to `Primer3` + + The target region must be wholly contained within design region. + + Args: + task: TODO + design_region: the design region, which wholly contains the target region, in which + primers are to be designed. + + Returns: + a mapping of `Primer3InputTag`s to associated value + """ + primer3_task_params = self.task.to_input_tags( + design_region=design_region, target=self.target + ) + assembled_tags: dict[Primer3InputTag, Any] = {**primer3_task_params} + assembled_tags.update(self._to_input_tags()) + + return assembled_tags @dataclass(frozen=True, init=True, slots=True) -class PrimerAndAmpliconParameters: +class AmpliconParameters(Primer3Parameters): """Holds common primer and amplicon design options that Primer3 uses to inform primer design. Attributes: @@ -93,6 +123,26 @@ class PrimerAndAmpliconParameters: primer_max_3p_homodimer_tm: the max melting temperature acceptable for self-complementarity anchored at the 3' end primer_max_hairpin_tm: the max melting temperature acceptable for secondary structure + product_size_lt: weight for products shorter than + `PrimerAndAmpliconParameters.amplicon_sizes.opt` + product_size_gt: weight for products longer than + `PrimerAndAmpliconParameters.amplicon_sizes.opt` + product_tm_lt: weight for products with a Tm lower than + `PrimerAndAmpliconParameters.amplicon_tms.opt` + product_tm_gt: weight for products with a Tm greater than + `PrimerAndAmpliconParameters.amplicon_tms.opt` + primer_end_stability: penalty for the calculated maximum stability + for the last five 3' bases of primer + primer_gc_lt: penalty for primers with GC percent lower than + `PrimerAndAmpliconParameters.primer_gcs.opt` + primer_gc_gt: weight for primers with GC percent higher than + `PrimerAndAmpliconParameters.primer_gcs.opt` + primer_homodimer_wt: penalty for the individual primer self binding value as specified + in `PrimerAndAmpliconParameters.primer_max_homodimer_tm` + primer_3p_homodimer_wt: weight for the 3'-anchored primer self binding value as specified in + `PrimerAndAmpliconParameters.primer_max_3p_homodimer_tm` + primer_secondary_structure_wt: penalty weight for the primer hairpin structure melting + temperature as defined in `PrimerAndAmpliconParameters.PRIMER_MAX_HAIRPIN_TH` Please see the Primer3 manual for additional details: https://primer3.org/manual.html#globalTags @@ -105,7 +155,8 @@ class PrimerAndAmpliconParameters: melting temperature threshold (i.e. when provided, values should be specified independent of primer design.) """ - + target: Span + task: Primer3TaskType amplicon_sizes: MinOptMax[int] amplicon_tms: MinOptMax[float] primer_sizes: MinOptMax[int] @@ -120,6 +171,22 @@ class PrimerAndAmpliconParameters: primer_max_homodimer_tm: Optional[float] = None primer_max_3p_homodimer_tm: Optional[float] = None primer_max_hairpin_tm: Optional[float] = None + product_size_lt: float = 1.0 + product_size_gt: float = 1.0 + product_tm_lt: float = 0.0 + product_tm_gt: float = 0.0 + primer_end_stability: float = 0.25 + primer_gc_lt: float = 0.25 + primer_gc_gt: float = 0.25 + primer_self_any: float = 0.1 + primer_self_end: float = 0.1 + primer_size_lt: float = 0.5 + primer_size_gt: float = 0.1 + primer_tm_lt: float = 1.0 + primer_tm_gt: float = 1.0 + primer_homodimer_wt: float = 0.0 + primer_3p_homodimer_wt: float = 0.0 + primer_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: if self.primer_max_dinuc_bases % 2 == 1: @@ -169,6 +236,22 @@ def to_input_tags(self) -> dict[Primer3InputTag, Any]: Primer3InputTag.PRIMER_MAX_SELF_ANY_TH: self.primer_max_homodimer_tm, Primer3InputTag.PRIMER_MAX_SELF_END_TH: self.primer_max_3p_homodimer_tm, Primer3InputTag.PRIMER_MAX_HAIRPIN_TH: self.primer_max_hairpin_tm, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT: self.product_size_lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT: self.product_size_gt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT: self.product_tm_lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT: self.product_tm_gt, + Primer3InputTag.PRIMER_WT_END_STABILITY: self.primer_end_stability, + Primer3InputTag.PRIMER_WT_GC_PERCENT_LT: self.primer_gc_lt, + Primer3InputTag.PRIMER_WT_GC_PERCENT_GT: self.primer_gc_gt, + Primer3InputTag.PRIMER_WT_SELF_ANY: self.primer_self_any, + Primer3InputTag.PRIMER_WT_SELF_END: self.primer_self_end, + Primer3InputTag.PRIMER_WT_SIZE_LT: self.primer_size_lt, + Primer3InputTag.PRIMER_WT_SIZE_GT: self.primer_size_gt, + Primer3InputTag.PRIMER_WT_TM_LT: self.primer_tm_lt, + Primer3InputTag.PRIMER_WT_TM_GT: self.primer_tm_gt, + Primer3InputTag.PRIMER_WT_SELF_ANY_TH: self.primer_homodimer_wt, + Primer3InputTag.PRIMER_WT_SELF_END_TH: self.primer_3p_homodimer_wt, + Primer3InputTag.PRIMER_WT_HAIRPIN_TH: self.primer_secondary_structure_wt, } return mapped_dict @@ -190,19 +273,7 @@ def min_primer_length(self) -> int: @dataclass(frozen=True, init=True, slots=True) -class Primer3Parameters(PrimerAndAmpliconParameters): - """A deprecated alias for `PrimerAndAmpliconParameters` intended to maintain backwards - compatibility with earlier releases of `prymer`.""" - - warnings.warn( - "The Primer3Parameters class was deprecated, use PrimerAndAmpliconParameters instead", - DeprecationWarning, - stacklevel=2, - ) - - -@dataclass(frozen=True, init=True, slots=True) -class ProbeParameters: +class ProbeParameters(Primer3Parameters): """Holds common primer design options that Primer3 uses to inform internal probe design. Attributes: @@ -217,6 +288,18 @@ class ProbeParameters: probe_max_3p_homodimer_tm: the max melting temperature acceptable for self-complementarity anchored at the 3' end probe_max_hairpin_tm: the max melting temperature acceptable for secondary structure + probe_size_lt: penalty for probes shorter than `ProbeParameters.probe_sizes.opt` + probe_size_gt: penalty for probes longer than `ProbeParameters.probe_sizes.opt` + probe_tm_lt: penalty for probes with a Tm lower than `ProbeParameters.probe_tms.opt` + probe_tm_gt: penalty for probes with a Tm greater than `ProbeParameters.probe_tms.opt` + probe_gc_lt: penalty for probes with GC content lower than `ProbeParameters.probe_gcs.opt` + probe_gc_gt: penalty for probes with GC content greater than `ProbeParameters.probe_gcs.opt` + probe_homodimer_wt: penalty for probe self-complementarity as defined in + `ProbeParameters.probe_max_self_any_thermo` + probe_3p_homodimer_wt: penalty for probe 3' complementarity as defined in + `ProbeParameters.probe_max_self_end_thermo` + probe_secondary_structure_wt: penalty for the most stable primer hairpin structure value as + defined in `ProbeParameters.probe_max_hairpin_thermo` The attributes that have default values specified take their default values from the Primer3 manual. @@ -237,7 +320,8 @@ class ProbeParameters: of probe design.) """ - + target: Span + task: Primer3TaskType probe_sizes: MinOptMax[int] probe_tms: MinOptMax[float] probe_gcs: MinOptMax[float] @@ -248,6 +332,15 @@ class ProbeParameters: probe_max_homodimer_tm: Optional[float] = None probe_max_3p_homodimer_tm: Optional[float] = None probe_max_hairpin_tm: Optional[float] = None + probe_size_lt: float = 1.0 + probe_size_gt: float = 1.0 + probe_tm_lt: float = 1.0 + probe_tm_gt: float = 1.0 + probe_gc_lt: float = 0.0 + probe_gc_gt: float = 0.0 + probe_homodimer_wt: float = 0.0 + probe_3p_homodimer_wt: float = 0.0 + probe_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: if not isinstance(self.probe_sizes.min, int): @@ -285,6 +378,15 @@ def to_input_tags(self) -> dict[Primer3InputTag, Any]: Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_END_TH: self.probe_max_3p_homodimer_tm, Primer3InputTag.PRIMER_INTERNAL_MAX_HAIRPIN_TH: self.probe_max_hairpin_tm, Primer3InputTag.PRIMER_NUM_RETURN: self.number_probes_return, + Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT: self.probe_size_lt, + Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT: self.probe_size_gt, + Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT: self.probe_tm_lt, + Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT: self.probe_tm_gt, + Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT: self.probe_gc_lt, + Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT: self.probe_gc_gt, + Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH: self.probe_homodimer_wt, + Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH: self.probe_3p_homodimer_wt, + Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH: self.probe_secondary_structure_wt, } return mapped_dict diff --git a/prymer/primer3/primer3_weights.py b/prymer/primer3/primer3_weights.py deleted file mode 100644 index 9206e8e..0000000 --- a/prymer/primer3/primer3_weights.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -# Primer3Weights Class and Methods - -The PrimerAndAmpliconWeights class holds the penalty weights that Primer3 uses to score -primer designs. - -The ProbeWeights class holds the penalty weights that Primer3 uses to score internal probe designs. - -Primer3 considers the differential between user input (e.g., constraining the optimal -primer size to be 18 bp) and the characteristics of a specific primer design (e.g., if the primer -size is 19 bp). Depending on the "weight" of that characteristic, Primer3 uses an objective function -to score a primer design and help define what an "optimal" design looks like. - -By modifying these weights, users can prioritize specific primer design characteristics. Each of -the defaults provided here are derived from the Primer3 manual: https://primer3.org/manual.html - -## Examples of interacting with the PrimerAndAmpliconWeights class - -Example: - -```python ->>> PrimerAndAmpliconWeights() # default implementation -PrimerAndAmpliconWeights(product_size_lt=1.0, product_size_gt=1.0, product_tm_lt=0.0, product_tm_gt=0.0, primer_end_stability=0.25, primer_gc_lt=0.25, primer_gc_gt=0.25, primer_self_any=0.1, primer_self_end=0.1, primer_size_lt=0.5, primer_size_gt=0.1, primer_tm_lt=1.0, primer_tm_gt=1.0, primer_homodimer_wt=0.0, primer_3p_homodimer_wt=0.0, primer_secondary_structure_wt=0.0) ->>> PrimerAndAmpliconWeights(product_size_lt=5.0) -PrimerAndAmpliconWeights(product_size_lt=5.0, product_size_gt=1.0, product_tm_lt=0.0, product_tm_gt=0.0, primer_end_stability=0.25, primer_gc_lt=0.25, primer_gc_gt=0.25, primer_self_any=0.1, primer_self_end=0.1, primer_size_lt=0.5, primer_size_gt=0.1, primer_tm_lt=1.0, primer_tm_gt=1.0, primer_homodimer_wt=0.0, primer_3p_homodimer_wt=0.0, primer_secondary_structure_wt=0.0) - -``` - -""" # noqa: E501 - -from dataclasses import dataclass -from typing import Any - -from prymer.primer3.primer3_input_tag import Primer3InputTag - - -@dataclass(frozen=True, init=True, slots=True) -class PrimerAndAmpliconWeights: - """Holds the primer-specific weights that Primer3 uses to adjust design penalties. - - The weights that Primer3 uses when a parameter is less than optimal are labeled with "_lt". - "_gt" weights are penalties applied when a parameter is greater than optimal. - - Some of these settings depart from the default settings enumerated in the Primer3 manual. - Please see the Primer3 manual for additional details: - https://primer3.org/manual.html#globalTags - - Attributes: - product_size_lt: weight for products shorter than - `PrimerAndAmpliconParameters.amplicon_sizes.opt` - product_size_gt: weight for products longer than - `PrimerAndAmpliconParameters.amplicon_sizes.opt` - product_tm_lt: weight for products with a Tm lower than - `PrimerAndAmpliconParameters.amplicon_tms.opt` - product_tm_gt: weight for products with a Tm greater than - `PrimerAndAmpliconParameters.amplicon_tms.opt` - primer_end_stability: penalty for the calculated maximum stability - for the last five 3' bases of primer - primer_gc_lt: penalty for primers with GC percent lower than - `PrimerAndAmpliconParameters.primer_gcs.opt` - primer_gc_gt: weight for primers with GC percent higher than - `PrimerAndAmpliconParameters.primer_gcs.opt` - primer_homodimer_wt: penalty for the individual primer self binding value as specified - in `PrimerAndAmpliconParameters.primer_max_homodimer_tm` - primer_3p_homodimer_wt: weight for the 3'-anchored primer self binding value as specified in - `PrimerAndAmpliconParameters.primer_max_3p_homodimer_tm` - primer_secondary_structure_wt: penalty weight for the primer hairpin structure melting - temperature as defined in `PrimerAndAmpliconParameters.PRIMER_MAX_HAIRPIN_TH` - - """ - - product_size_lt: float = 1.0 - product_size_gt: float = 1.0 - product_tm_lt: float = 0.0 - product_tm_gt: float = 0.0 - primer_end_stability: float = 0.25 - primer_gc_lt: float = 0.25 - primer_gc_gt: float = 0.25 - primer_self_any: float = 0.1 - primer_self_end: float = 0.1 - primer_size_lt: float = 0.5 - primer_size_gt: float = 0.1 - primer_tm_lt: float = 1.0 - primer_tm_gt: float = 1.0 - primer_homodimer_wt: float = 0.0 - primer_3p_homodimer_wt: float = 0.0 - primer_secondary_structure_wt: float = 0.0 - - def to_input_tags(self) -> dict[Primer3InputTag, Any]: - """Maps weights to Primer3InputTag to feed directly into Primer3.""" - mapped_dict = { - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT: self.product_size_lt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT: self.product_size_gt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT: self.product_tm_lt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT: self.product_tm_gt, - Primer3InputTag.PRIMER_WT_END_STABILITY: self.primer_end_stability, - Primer3InputTag.PRIMER_WT_GC_PERCENT_LT: self.primer_gc_lt, - Primer3InputTag.PRIMER_WT_GC_PERCENT_GT: self.primer_gc_gt, - Primer3InputTag.PRIMER_WT_SELF_ANY: self.primer_self_any, - Primer3InputTag.PRIMER_WT_SELF_END: self.primer_self_end, - Primer3InputTag.PRIMER_WT_SIZE_LT: self.primer_size_lt, - Primer3InputTag.PRIMER_WT_SIZE_GT: self.primer_size_gt, - Primer3InputTag.PRIMER_WT_TM_LT: self.primer_tm_lt, - Primer3InputTag.PRIMER_WT_TM_GT: self.primer_tm_gt, - Primer3InputTag.PRIMER_WT_SELF_ANY_TH: self.primer_homodimer_wt, - Primer3InputTag.PRIMER_WT_SELF_END_TH: self.primer_3p_homodimer_wt, - Primer3InputTag.PRIMER_WT_HAIRPIN_TH: self.primer_secondary_structure_wt, - } - return mapped_dict - - -@dataclass(frozen=True, init=True, slots=True) -class ProbeWeights: - """Holds the probe-specific weights that Primer3 uses to adjust design penalties. - - Attributes: - probe_size_lt: penalty for probes shorter than `ProbeParameters.probe_sizes.opt` - probe_size_gt: penalty for probes longer than `ProbeParameters.probe_sizes.opt` - probe_tm_lt: penalty for probes with a Tm lower than `ProbeParameters.probe_tms.opt` - probe_tm_gt: penalty for probes with a Tm greater than `ProbeParameters.probe_tms.opt` - probe_gc_lt: penalty for probes with GC content lower than `ProbeParameters.probe_gcs.opt` - probe_gc_gt: penalty for probes with GC content greater than `ProbeParameters.probe_gcs.opt` - probe_wt_self_any_th: penalty for probe self-complementarity as defined in - `ProbeParameters.probe_max_self_any_thermo` - probe_wt_self_end: penalty for probe 3' complementarity as defined in - `ProbeParameters.probe_max_self_end_thermo` - probe_wt_hairpin_th: penalty for the most stable primer hairpin structure value as defined - in `ProbeParameters.probe_max_hairpin_thermo` - - Each of these defaults are taken from the Primer3 manual. More details can be found here: - https://primer3.org/manual.html - - """ - - probe_size_lt: float = 1.0 - probe_size_gt: float = 1.0 - probe_tm_lt: float = 1.0 - probe_tm_gt: float = 1.0 - probe_gc_lt: float = 0.0 - probe_gc_gt: float = 0.0 - probe_homodimer_wt: float = 0.0 - probe_3p_homodimer_wt: float = 0.0 - probe_secondary_structure_wt: float = 0.0 - - def to_input_tags(self) -> dict[Primer3InputTag, Any]: - """Maps weights to Primer3InputTag to feed directly into Primer3.""" - mapped_dict = { - Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT: self.probe_size_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT: self.probe_size_gt, - Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT: self.probe_tm_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT: self.probe_tm_gt, - Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT: self.probe_gc_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT: self.probe_gc_gt, - Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH: self.probe_homodimer_wt, - Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH: self.probe_3p_homodimer_wt, - Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH: self.probe_secondary_structure_wt, - } - return mapped_dict diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py index 8c0ce6f..cf8c868 100644 --- a/tests/primer3/test_primer3.py +++ b/tests/primer3/test_primer3.py @@ -17,7 +17,7 @@ from prymer.primer3.primer3 import Primer3Result from prymer.primer3.primer3 import _has_acceptable_dinuc_run from prymer.primer3.primer3_input import Primer3Input -from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask @@ -36,8 +36,8 @@ def vcf_path() -> Path: @pytest.fixture -def single_primer_params() -> PrimerAndAmpliconParameters: - return PrimerAndAmpliconParameters( +def single_primer_params() -> AmpliconParameters: + return AmpliconParameters( amplicon_sizes=MinOptMax(min=100, max=250, opt=200), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), primer_sizes=MinOptMax(min=29, max=31, opt=30), @@ -49,8 +49,8 @@ def single_primer_params() -> PrimerAndAmpliconParameters: @pytest.fixture -def pair_primer_params() -> PrimerAndAmpliconParameters: - return PrimerAndAmpliconParameters( +def pair_primer_params() -> AmpliconParameters: + return AmpliconParameters( amplicon_sizes=MinOptMax(min=100, max=200, opt=150), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=72.5), primer_sizes=MinOptMax(min=20, max=30, opt=25), @@ -62,8 +62,8 @@ def pair_primer_params() -> PrimerAndAmpliconParameters: @pytest.fixture -def design_fail_gen_primer3_params() -> PrimerAndAmpliconParameters: - return PrimerAndAmpliconParameters( +def design_fail_gen_primer3_params() -> AmpliconParameters: + return AmpliconParameters( amplicon_sizes=MinOptMax(min=200, max=300, opt=250), amplicon_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), primer_sizes=MinOptMax(min=24, max=27, opt=26), @@ -144,7 +144,7 @@ def valid_primer_pairs( def test_design_raises( genome_ref: Path, - single_primer_params: PrimerAndAmpliconParameters, + single_primer_params: AmpliconParameters, ) -> None: """Test that design() raises when given an invalid argument.""" @@ -166,7 +166,7 @@ def test_design_raises( def test_left_primer_valid_designs( genome_ref: Path, - single_primer_params: PrimerAndAmpliconParameters, + single_primer_params: AmpliconParameters, ) -> None: """Test that left primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) @@ -217,7 +217,7 @@ def test_left_primer_valid_designs( def test_right_primer_valid_designs( genome_ref: Path, - single_primer_params: PrimerAndAmpliconParameters, + single_primer_params: AmpliconParameters, ) -> None: """Test that right primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) @@ -264,7 +264,7 @@ def test_right_primer_valid_designs( def test_primer_pair_design( - genome_ref: Path, pair_primer_params: PrimerAndAmpliconParameters + genome_ref: Path, pair_primer_params: AmpliconParameters ) -> None: """Test that paired primer design produces left and right primers within design constraints. Additionally, assert that `PrimerPair.amplicon_sequence()` matches reference sequence.""" @@ -347,7 +347,7 @@ def test_primer_pair_design( def test_fasta_close_valid( - genome_ref: Path, single_primer_params: PrimerAndAmpliconParameters + genome_ref: Path, single_primer_params: AmpliconParameters ) -> None: """Test that fasta file is closed when underlying subprocess is terminated.""" designer = Primer3(genome_fasta=genome_ref) @@ -415,7 +415,7 @@ def test_variant_lookup( def test_screen_pair_results( valid_primer_pairs: list[PrimerPair], genome_ref: Path, - pair_primer_params: PrimerAndAmpliconParameters, + pair_primer_params: AmpliconParameters, ) -> None: """Test that `_has_acceptable_dinuc_run()` and `_screen_pair_results()` use `Primer3Parameters.primer_max_dinuc_bases` to disqualify primers when applicable. @@ -476,7 +476,7 @@ def test_screen_pair_results( def test_build_failures( valid_primer_pairs: list[PrimerPair], genome_ref: Path, - pair_primer_params: PrimerAndAmpliconParameters, + pair_primer_params: AmpliconParameters, ) -> None: """Test that `build_failures()` parses Primer3 `failure_strings` correctly and includes failures related to long dinucleotide runs.""" @@ -515,7 +515,7 @@ def test_build_failures( def test_build_failures_debugs( valid_primer_pairs: list[PrimerPair], genome_ref: Path, - pair_primer_params: PrimerAndAmpliconParameters, + pair_primer_params: AmpliconParameters, caplog: pytest.LogCaptureFixture, ) -> None: """Test that we log a debug message in the event of an unknown Primer3Failure reason.""" diff --git a/tests/primer3/test_primer3_input.py b/tests/primer3/test_primer3_input.py index e855e29..490a3bd 100644 --- a/tests/primer3/test_primer3_input.py +++ b/tests/primer3/test_primer3_input.py @@ -8,7 +8,7 @@ from prymer.primer3 import DesignRightPrimersTask from prymer.primer3 import Primer3Input from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import PickHybProbeOnly from prymer.primer3.primer3_task import Primer3TaskType @@ -17,8 +17,8 @@ @pytest.fixture -def valid_primer_amplicon_params() -> PrimerAndAmpliconParameters: - return PrimerAndAmpliconParameters( +def valid_primer_amplicon_params() -> AmpliconParameters: + return AmpliconParameters( amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), @@ -55,7 +55,7 @@ def valid_probe_weights() -> ProbeWeights: ], ) def test_primer_design_only_valid( - valid_primer_amplicon_params: PrimerAndAmpliconParameters, + valid_primer_amplicon_params: AmpliconParameters, task_type: Primer3TaskType, valid_primer_weights: PrimerAndAmpliconWeights, ) -> None: @@ -163,7 +163,7 @@ def test_no_params_given_raises( def test_requires_params_sets( task_type: Primer3TaskType, valid_probe_params: ProbeParameters, - valid_primer_amplicon_params: PrimerAndAmpliconParameters, + valid_primer_amplicon_params: AmpliconParameters, valid_primer_weights: PrimerAndAmpliconWeights, valid_probe_weights: ProbeWeights, expected_req_primer_amplicon_params: bool, diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py index 47f397f..81650ac 100644 --- a/tests/primer3/test_primer3_parameters.py +++ b/tests/primer3/test_primer3_parameters.py @@ -4,13 +4,13 @@ from prymer import MinOptMax from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters @pytest.fixture -def valid_primer_amplicon_params() -> PrimerAndAmpliconParameters: - return PrimerAndAmpliconParameters( +def valid_primer_amplicon_params() -> AmpliconParameters: + return AmpliconParameters( amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), @@ -29,7 +29,7 @@ def valid_probe_params() -> ProbeParameters: def test_primer_amplicon_param_construction_valid( - valid_primer_amplicon_params: PrimerAndAmpliconParameters, + valid_primer_amplicon_params: AmpliconParameters, ) -> None: """Test PrimerAndAmpliconParameters class instantiation with valid input""" assert valid_primer_amplicon_params.amplicon_sizes.min == 200 @@ -61,7 +61,7 @@ def test_probe_param_construction_valid( def test_primer_amplicon_param_construction_raises( - valid_primer_amplicon_params: PrimerAndAmpliconParameters, + valid_primer_amplicon_params: AmpliconParameters, ) -> None: """Test that PrimerAndAmpliconParameters post_init raises with invalid input.""" # overriding mypy here to test a case that normally would be caught by mypy @@ -96,7 +96,7 @@ def test_primer_probe_param_construction_raises( def test_primer_amplicon_params_to_input_tags( - valid_primer_amplicon_params: PrimerAndAmpliconParameters, + valid_primer_amplicon_params: AmpliconParameters, ) -> None: """Test that to_input_tags() works as expected""" test_dict = valid_primer_amplicon_params.to_input_tags() @@ -125,7 +125,7 @@ def test_primer_amplicon_params_to_input_tags( assert ambiguous_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 0 -def test_max_ampl_length(valid_primer_amplicon_params: PrimerAndAmpliconParameters) -> None: +def test_max_ampl_length(valid_primer_amplicon_params: AmpliconParameters) -> None: """Test that max_amplicon_length() returns expected int""" assert valid_primer_amplicon_params.max_amplicon_length == 300 change_max_length = replace( @@ -134,7 +134,7 @@ def test_max_ampl_length(valid_primer_amplicon_params: PrimerAndAmpliconParamete assert change_max_length.max_amplicon_length == 1000 -def test_max_primer_length(valid_primer_amplicon_params: PrimerAndAmpliconParameters) -> None: +def test_max_primer_length(valid_primer_amplicon_params: AmpliconParameters) -> None: """Test that max_primer_length() returns expected int""" assert valid_primer_amplicon_params.max_primer_length == 27 change_max_length = replace( From db8b12cc24e56ad6f5657f7ce967ba82d71a9e67 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 15 Jan 2025 16:10:42 -0700 Subject: [PATCH 2/5] more --- prymer/primer3/primer3_parameters.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index 951afc8..d4cf8ec 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -62,7 +62,7 @@ class stores user input for internal probe design and maps it to the correct Pri ``` """ - +import typing from abc import ABC, abstractmethod from dataclasses import dataclass from dataclasses import fields @@ -78,6 +78,18 @@ class Primer3Parameters(ABC): target: Span task: Primer3TaskType + def as_amplicon_params(self) -> "AmpliconParameters": + """Use this method when you want to treat these parameters as amplicon parameters.""" + if isinstance(self, AmpliconParameters): + return typing.cast(AmpliconParameters, self) + raise Exception("The parameters are not amplicon parameters") + + def as_probe_params(self) -> "ProbeParameters": + """Use this method when you want to treat these parameters as probe parameters.""" + if isinstance(self, ProbeParameters): + return typing.cast(ProbeParameters, self) + raise Exception("The parameters are not amplicon parameters") + @abstractmethod def _to_input_tags(self) -> dict[Primer3InputTag, Any]: ... From e0a6decb8e3c873d04a83c363693ffde07361e24 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 15 Jan 2025 18:46:32 -0700 Subject: [PATCH 3/5] more --- prymer/api/picking.py | 20 +-- prymer/primer3/__init__.py | 8 +- prymer/primer3/primer3.py | 71 +++------ prymer/primer3/primer3_parameters.py | 89 ++++++++--- prymer/primer3/primer3_task.py | 54 +++---- tests/api/test_picking.py | 106 +++++++------ tests/primer3/test_primer3.py | 103 ++++++------- tests/primer3/test_primer3_input.py | 182 ----------------------- tests/primer3/test_primer3_parameters.py | 126 +++++++++++++++- tests/primer3/test_primer3_weights.py | 46 ------ 10 files changed, 352 insertions(+), 453 deletions(-) delete mode 100644 tests/primer3/test_primer3_input.py delete mode 100644 tests/primer3/test_primer3_weights.py diff --git a/prymer/api/picking.py b/prymer/api/picking.py index dda039b..9c61a27 100644 --- a/prymer/api/picking.py +++ b/prymer/api/picking.py @@ -30,7 +30,7 @@ from prymer.model import Oligo from prymer.model import PrimerPair from prymer.model import Span -from prymer.primer3 import PrimerAndAmpliconWeights +from prymer.primer3 import AmpliconParameters def score( @@ -40,7 +40,7 @@ def score( amplicon_tm: float, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, ) -> float: """Score the amplicon in a manner similar to Primer3 @@ -59,7 +59,7 @@ def score( amplicon_tm: the melting temperature of the amplicon amplicon_sizes: minimum, optimal, and maximum amplicon sizes (lengths) amplicon_tms: minimum, optimal, and maximum amplicon Tms - weights: the set of penalty weights + params: the set of primer3 parameters Returns: the penalty for the whole amplicon. @@ -73,9 +73,9 @@ def score( if amplicon_sizes.opt == 0: size_penalty = 0.0 elif amplicon.length > amplicon_sizes.opt: - size_penalty = (amplicon.length - amplicon_sizes.opt) * weights.product_size_gt + size_penalty = (amplicon.length - amplicon_sizes.opt) * params.product_size_gt else: - size_penalty = (amplicon_sizes.opt - amplicon.length) * weights.product_size_lt + size_penalty = (amplicon_sizes.opt - amplicon.length) * params.product_size_lt # The penalty for the amplicon melting temperature. # The difference in melting temperature between the calculated and optimal is weighted by the @@ -84,9 +84,9 @@ def score( if amplicon_tms.opt == 0.0: tm_penalty = 0.0 elif amplicon_tm > amplicon_tms.opt: - tm_penalty = (amplicon_tm - amplicon_tms.opt) * weights.product_tm_gt + tm_penalty = (amplicon_tm - amplicon_tms.opt) * params.product_tm_gt else: - tm_penalty = (amplicon_tms.opt - amplicon_tm) * weights.product_tm_lt + tm_penalty = (amplicon_tms.opt - amplicon_tm) * params.product_tm_lt # Put it all together return left_primer.penalty + right_primer.penalty + size_penalty + tm_penalty @@ -99,7 +99,7 @@ def build_primer_pairs( # noqa: C901 amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], max_heterodimer_tm: Optional[float], - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, fasta_path: Path, thermo: Optional[Thermo] = None, ) -> Iterator[PrimerPair]: @@ -117,7 +117,7 @@ def build_primer_pairs( # noqa: C901 amplicon_tms: minimum, optimal, and maximum amplicon Tms max_heterodimer_tm: if supplied, heterodimer Tms will be calculated for primer pairs, and those exceeding the maximum Tm will be discarded - weights: the set of penalty weights + params: the set of penalty params fasta_path: the path to the FASTA file from which the amplicon sequence will be retrieved. thermo: a [`Thermo`][prymer.Thermo] instance for performing thermodynamic calculations including amplicon tm; if not provided, a default Thermo instance will be created @@ -197,7 +197,7 @@ def build_primer_pairs( # noqa: C901 amplicon_tm=amp_tm, amplicon_sizes=amplicon_sizes, amplicon_tms=amplicon_tms, - weights=weights, + params=params, ) pairings.append((i, j, penalty, amp_tm)) diff --git a/prymer/primer3/__init__.py b/prymer/primer3/__init__.py index 061d6ca..87caa2d 100644 --- a/prymer/primer3/__init__.py +++ b/prymer/primer3/__init__.py @@ -2,30 +2,26 @@ from prymer.primer3.primer3 import Primer3Failure from prymer.primer3.primer3 import Primer3Result from prymer.primer3.primer3_failure_reason import Primer3FailureReason -from prymer.primer3.primer3_input import Primer3Input from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_parameters import AmpliconParameters +from prymer.primer3.primer3_parameters import Primer3Parameters from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask from prymer.primer3.primer3_task import DesignRightPrimersTask from prymer.primer3.primer3_task import PickHybProbeOnly -from prymer.primer3.primer3_weights import PrimerAndAmpliconWeights -from prymer.primer3.primer3_weights import ProbeWeights __all__ = [ "Primer3", "Primer3Result", "Primer3Failure", "Primer3FailureReason", - "Primer3Input", "Primer3InputTag", "DesignLeftPrimersTask", "DesignPrimerPairsTask", "DesignRightPrimersTask", "PickHybProbeOnly", + "Primer3Parameters", "AmpliconParameters", "ProbeParameters", - "ProbeWeights", - "PrimerAndAmpliconWeights", ] diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py index c53afb2..8553fac 100644 --- a/prymer/primer3/primer3.py +++ b/prymer/primer3/primer3.py @@ -44,18 +44,15 @@ >>> from prymer.primer3.primer3_parameters import AmpliconParameters >>> from prymer import MinOptMax >>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) ->>> params = AmpliconParameters( \ +>>> design_input = AmpliconParameters( \ + task=DesignLeftPrimersTask(), \ + target=target, \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ ) ->>> design_input = AmpliconParameters( \ - target=target, \ - primer_and_amplicon_params=params, \ - task=DesignLeftPrimersTask(), \ -) >>> left_result = designer.design(design_input=design_input) ``` @@ -138,10 +135,9 @@ from prymer.model import PrimerPair from prymer.model import Span from prymer.model import Strand -from prymer.primer3 import AmpliconParameters, ProbeParameters from prymer.primer3.primer3_failure_reason import Primer3FailureReason -from prymer.primer3.primer3_parameters import Primer3Parameters from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_parameters import Primer3Parameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask from prymer.primer3.primer3_task import DesignRightPrimersTask @@ -293,7 +289,7 @@ def get_design_sequences(self, region: Span) -> tuple[str, str]: @staticmethod def _screen_pair_results( - design_input: AmpliconParameters, designed_primer_pairs: list[PrimerPair] + design_input: Primer3Parameters, designed_primer_pairs: list[PrimerPair] ) -> tuple[list[PrimerPair], list[Oligo]]: """Screens primer pair designs emitted by Primer3 for dinucleotide run length. @@ -311,13 +307,13 @@ def _screen_pair_results( valid: bool = True if ( primer_pair.left_primer.longest_dinucleotide_run_length - > design_input.primer_max_dinuc_bases + > design_input.max_dinuc_bases ): # if the left primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.left_primer) valid = False if ( primer_pair.right_primer.longest_dinucleotide_run_length - > design_input.primer_max_dinuc_bases + > design_input.max_dinuc_bases ): # if the right primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.right_primer) valid = False @@ -343,21 +339,21 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 design_region: Span match design_input.task: case PickHybProbeOnly(): - input = typing.cast(ProbeParameters, design_input) - if input.target.length < input.probe_sizes.min: + probe_params = design_input.as_probe_params + if probe_params.target.length < probe_params.probe_sizes.min: raise ValueError( "Target region required to be at least as large as the" " minimal probe size: " - f"target length: {input.target.length}, " - f"minimal probe size: {input.probe_sizes.min}" + f"target length: {design_input.target.length}, " + f"minimal probe size: {probe_params.probe_sizes.min}" ) design_region = design_input.target case DesignRightPrimersTask() | DesignLeftPrimersTask() | DesignPrimerPairsTask(): - input = typing.cast(AmpliconParameters, design_input) + amplicon_params = design_input.as_amplicon_params design_region = self._create_design_region( - target_region=input.target, - max_amplicon_length=input.max_amplicon_length, - min_primer_length=input.min_primer_length, + target_region=amplicon_params.target, + max_amplicon_length=amplicon_params.max_amplicon_length, + min_primer_length=amplicon_params.min_primer_length, ) case _ as unreachable: assert_never(unreachable) # pragma: no cover @@ -406,15 +402,14 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 match design_input.task: case DesignPrimerPairsTask(): # Primer pair design - input = typing.cast(AmpliconParameters, design_input) all_pair_results: list[PrimerPair] = Primer3._build_primer_pairs( - design_input=input, + design_input=design_input, design_results=primer3_results, design_region=design_region, unmasked_design_seq=soft_masked, ) return Primer3._assemble_primer_pairs( - design_input=input, + design_input=design_input.as_amplicon_params, design_results=primer3_results, unfiltered_designs=all_pair_results, ) @@ -529,12 +524,12 @@ def _assemble_single_designs( valid_designs = [ design for design in unfiltered_designs - if _has_acceptable_dinuc_run(oligo_design=design, design_input=design_input) + if design.longest_dinucleotide_run_length <= design_input.max_dinuc_bases ] dinuc_failures = [ design for design in unfiltered_designs - if not _has_acceptable_dinuc_run(oligo_design=design, design_input=design_input) + if not design.longest_dinucleotide_run_length <= design_input.max_dinuc_bases ] failure_strings = [design_results[f"PRIMER_{design_input.task.task_type}_EXPLAIN"]] @@ -607,7 +602,7 @@ def _build_primer_pair(num: int, primer_pair: tuple[Oligo, Oligo]) -> PrimerPair @staticmethod def _assemble_primer_pairs( - design_input: AmpliconParameters, + design_input: Primer3Parameters, design_results: dict[str, Any], unfiltered_designs: list[PrimerPair], ) -> Primer3Result: @@ -735,29 +730,3 @@ def _check_design_results(design_input: Primer3Parameters, design_results: dict[ count: int = int(maybe_count) return count - - -def _has_acceptable_dinuc_run(design_input: Primer3Parameters, oligo_design: Oligo) -> bool: - """ - True if the design's longest dinucleotide run is no more than the stipulated maximum. - - For primer designs, the maximum is recorded in the input's - `PrimerAndAmpliconParameters.primer_max_dinuc_bases`. - - For probe designs, the maximum is recorded in the input's - `ProbeParameters.probe_max_dinuc_bases`. - - Args: - design_input: the Primer3Parameters object that wraps task-specific and design-specific params - oligo_design: the design candidate - - Returns: - - """ - max_dinuc_bases: int = -1 - if design_input.task.requires_primer_amplicon_params: - max_dinuc_bases = design_input.primer_and_amplicon_params.primer_max_dinuc_bases - elif design_input.task.requires_probe_params: - max_dinuc_bases = design_input.probe_params.probe_max_dinuc_bases - - return oligo_design.longest_dinucleotide_run_length <= max_dinuc_bases diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index d4cf8ec..2e1c37b 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -27,15 +27,25 @@ class stores user input for internal probe design and maps it to the correct Pri ## Examples ```python +>>> from prymer.primer3 import DesignPrimerPairsTask +>>> from prymer import Strand >>> params = AmpliconParameters( \ + task=DesignPrimerPairsTask(), \ + target=Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ ) ->>> for tag, value in params.to_input_tags().items(): \ +>>> design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) +>>> for tag, value in params.to_input_tags(design_region=design_region).items(): \ print(f"{tag.value} -> {value}") +PRIMER_TASK -> generic +PRIMER_PICK_LEFT_PRIMER -> 1 +PRIMER_PICK_RIGHT_PRIMER -> 1 +PRIMER_PICK_INTERNAL_OLIGO -> 0 +SEQUENCE_TARGET -> 200,101 PRIMER_PRODUCT_OPT_SIZE -> 200 PRIMER_PRODUCT_SIZE_RANGE -> 100-250 PRIMER_PRODUCT_MIN_TM -> 55.0 @@ -59,17 +69,36 @@ class stores user input for internal probe design and maps it to the correct Pri PRIMER_MAX_SELF_ANY_TH -> 53.0 PRIMER_MAX_SELF_END_TH -> 53.0 PRIMER_MAX_HAIRPIN_TH -> 53.0 +PRIMER_PAIR_WT_PRODUCT_SIZE_LT -> 1.0 +PRIMER_PAIR_WT_PRODUCT_SIZE_GT -> 1.0 +PRIMER_PAIR_WT_PRODUCT_TM_LT -> 0.0 +PRIMER_PAIR_WT_PRODUCT_TM_GT -> 0.0 +PRIMER_WT_END_STABILITY -> 0.25 +PRIMER_WT_GC_PERCENT_LT -> 0.25 +PRIMER_WT_GC_PERCENT_GT -> 0.25 +PRIMER_WT_SELF_ANY -> 0.1 +PRIMER_WT_SELF_END -> 0.1 +PRIMER_WT_SIZE_LT -> 0.5 +PRIMER_WT_SIZE_GT -> 0.1 +PRIMER_WT_TM_LT -> 1.0 +PRIMER_WT_TM_GT -> 1.0 +PRIMER_WT_SELF_ANY_TH -> 0.0 +PRIMER_WT_SELF_END_TH -> 0.0 +PRIMER_WT_HAIRPIN_TH -> 0.0 ``` """ -import typing -from abc import ABC, abstractmethod + +from abc import ABC +from abc import abstractmethod from dataclasses import dataclass from dataclasses import fields +from functools import cached_property from typing import Any from typing import Optional -from prymer.model import MinOptMax, Span +from prymer.model import MinOptMax +from prymer.model import Span from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_task import Primer3TaskType @@ -78,17 +107,25 @@ class Primer3Parameters(ABC): target: Span task: Primer3TaskType + @cached_property def as_amplicon_params(self) -> "AmpliconParameters": """Use this method when you want to treat these parameters as amplicon parameters.""" if isinstance(self, AmpliconParameters): - return typing.cast(AmpliconParameters, self) + return self raise Exception("The parameters are not amplicon parameters") + @cached_property def as_probe_params(self) -> "ProbeParameters": """Use this method when you want to treat these parameters as probe parameters.""" if isinstance(self, ProbeParameters): - return typing.cast(ProbeParameters, self) - raise Exception("The parameters are not amplicon parameters") + return self + raise Exception("The parameters are not probe parameters") + + @property + @abstractmethod + def max_dinuc_bases(self) -> int: + """The maximum number of bases in a dinucleotide run in the primer/probe""" + pass @abstractmethod def _to_input_tags(self) -> dict[Primer3InputTag, Any]: ... @@ -96,16 +133,16 @@ def _to_input_tags(self) -> dict[Primer3InputTag, Any]: ... def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: """Assembles `Primer3InputTag` and values for input to `Primer3` - The target region must be wholly contained within design region. + The target region must be wholly contained within design region. - Args: - task: TODO - design_region: the design region, which wholly contains the target region, in which - primers are to be designed. + Args: + task: TODO + design_region: the design region, which wholly contains the target region, in which + primers are to be designed. - Returns: - a mapping of `Primer3InputTag`s to associated value - """ + Returns: + a mapping of `Primer3InputTag`s to associated value + """ primer3_task_params = self.task.to_input_tags( design_region=design_region, target=self.target ) @@ -167,6 +204,7 @@ class AmpliconParameters(Primer3Parameters): melting temperature threshold (i.e. when provided, values should be specified independent of primer design.) """ + target: Span task: Primer3TaskType amplicon_sizes: MinOptMax[int] @@ -201,6 +239,9 @@ class AmpliconParameters(Primer3Parameters): primer_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: + if not self.task.is_amplicon_design_task: + raise ValueError(f"Task '{self.task}' must be an amplicon design task.") + if self.primer_max_dinuc_bases % 2 == 1: raise ValueError("Primer Max Dinuc Bases must be an even number of bases") if not isinstance(self.amplicon_sizes.min, int) or not isinstance( @@ -220,7 +261,11 @@ def __post_init__(self) -> None: if field.name in thermo_max_fields and getattr(self, field.name) is None: object.__setattr__(self, field.name, default_thermo_max) - def to_input_tags(self) -> dict[Primer3InputTag, Any]: + @property + def max_dinuc_bases(self) -> int: + return self.primer_max_dinuc_bases + + def _to_input_tags(self) -> dict[Primer3InputTag, Any]: """Converts input params to Primer3InputTag to feed directly into Primer3.""" mapped_dict: dict[Primer3InputTag, Any] = { Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE: self.amplicon_sizes.opt, @@ -293,7 +338,7 @@ class ProbeParameters(Primer3Parameters): probe_tms: the min, optimal, and max probe melting temperatures probe_gcs: the min and max GC content for individual probes number_probes_return: the number of probes to return - probe_max_dinuc_bases: the max number of bases in a dinucleotide run in a probe + probe_max_dinuc_bases: the max number of bases in a dinucleotide run in a probe probe_max_polyX: the max homopolymer length acceptable within a probe probe_max_Ns: the max number of ambiguous bases acceptable within a probe probe_max_homodimer_tm: the max melting temperature acceptable for self-complementarity @@ -332,6 +377,7 @@ class ProbeParameters(Primer3Parameters): of probe design.) """ + target: Span task: Primer3TaskType probe_sizes: MinOptMax[int] @@ -355,6 +401,9 @@ class ProbeParameters(Primer3Parameters): probe_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: + if not self.task.is_probe_design_task: + raise ValueError(f"Task '{self.task}' must be an probe design task.") + if not isinstance(self.probe_sizes.min, int): raise TypeError("Probe sizes must be integers") if not isinstance(self.probe_tms.min, float) or not isinstance(self.probe_gcs.min, float): @@ -372,7 +421,11 @@ def __post_init__(self) -> None: if field.name in thermo_max_fields and getattr(self, field.name) is None: object.__setattr__(self, field.name, default_thermo_max) - def to_input_tags(self) -> dict[Primer3InputTag, Any]: + @property + def max_dinuc_bases(self) -> int: + return self.probe_max_dinuc_bases + + def _to_input_tags(self) -> dict[Primer3InputTag, Any]: """Converts input params to Primer3InputTag to feed directly into Primer3.""" mapped_dict: dict[Primer3InputTag, Any] = { Primer3InputTag.PRIMER_INTERNAL_MIN_SIZE: self.probe_sizes.min, diff --git a/prymer/primer3/primer3_task.py b/prymer/primer3/primer3_task.py index 3adc2db..6722d47 100644 --- a/prymer/primer3/primer3_task.py +++ b/prymer/primer3/primer3_task.py @@ -110,6 +110,7 @@ """Type alias for all `Primer3Task`s, to enable exhaustiveness checking.""" + @unique class TaskType(UppercaseStrEnum): """Represents the type of design task: design primer pairs, individual primers @@ -169,21 +170,32 @@ def to_input_tags(self, target: Span, design_region: Span) -> dict[Primer3InputT count_tag: ClassVar[str] = NotImplemented """The tag returned by Primer3 that provides the number of primers returned""" + is_amplicon_design_task: ClassVar[bool] = NotImplemented + """True if this task designs amplicons (primer or primer pairs)""" + + is_probe_design_task: ClassVar[bool] = NotImplemented + """True if this task designs probes""" + @classmethod @abstractmethod def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: """Aligns the set of input parameters specific to primer pair or single primer design""" @classmethod - def __init_subclass__(cls, task_type: TaskType, **kwargs: Any) -> None: + def __init_subclass__(cls, + task_type: TaskType, + is_amplicon_design_task: bool, + **kwargs: Any) -> None: # See: https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__ super().__init_subclass__(**kwargs) cls.task_type = task_type cls.count_tag = f"PRIMER_{task_type}_NUM_RETURNED" + cls.is_amplicon_design_task = is_amplicon_design_task + cls.is_probe_design_task = not is_amplicon_design_task -class DesignPrimerPairsTask(Primer3Task, task_type=TaskType.PAIR): +class DesignPrimerPairsTask(Primer3Task, task_type=TaskType.PAIR, is_amplicon_design_task=True): """Stores task-specific Primer3 settings for designing primer pairs""" @classmethod @@ -197,16 +209,8 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT f"{target.length}", } - @property - def requires_primer_amplicon_params(self) -> bool: - return True - @property - def requires_probe_params(self) -> bool: - return False - - -class DesignLeftPrimersTask(Primer3Task, task_type=TaskType.LEFT): +class DesignLeftPrimersTask(Primer3Task, task_type=TaskType.LEFT, is_amplicon_design_task=True): """Stores task-specific characteristics for designing left primers.""" @classmethod @@ -219,16 +223,8 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT Primer3InputTag.SEQUENCE_INCLUDED_REGION: f"1,{target.start - design_region.start}", } - @property - def requires_primer_amplicon_params(self) -> bool: - return True - - @property - def requires_probe_params(self) -> bool: - return False - -class DesignRightPrimersTask(Primer3Task, task_type=TaskType.RIGHT): +class DesignRightPrimersTask(Primer3Task, task_type=TaskType.RIGHT, is_amplicon_design_task=True): """Stores task-specific characteristics for designing right primers""" @classmethod @@ -243,16 +239,8 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT Primer3InputTag.SEQUENCE_INCLUDED_REGION: f"{start},{length}", } - @property - def requires_primer_amplicon_params(self) -> bool: - return True - - @property - def requires_probe_params(self) -> bool: - return False - -class PickHybProbeOnly(Primer3Task, task_type=TaskType.INTERNAL): +class PickHybProbeOnly(Primer3Task, task_type=TaskType.INTERNAL, is_amplicon_design_task=False): """Stores task-specific characteristics for designing an internal hybridization probe.""" @classmethod @@ -263,11 +251,3 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER: 0, Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO: 1, } - - @property - def requires_primer_amplicon_params(self) -> bool: - return False - - @property - def requires_probe_params(self) -> bool: - return True diff --git a/tests/api/test_picking.py b/tests/api/test_picking.py index 677c01f..6aad8ae 100644 --- a/tests/api/test_picking.py +++ b/tests/api/test_picking.py @@ -1,4 +1,4 @@ -import dataclasses +from dataclasses import replace from math import floor from pathlib import Path from typing import Optional @@ -11,9 +11,11 @@ from prymer import Oligo from prymer import PrimerPair from prymer import Span +from prymer import Strand from prymer import Thermo from prymer.api import picking -from prymer.primer3 import PrimerAndAmpliconWeights +from prymer.primer3 import AmpliconParameters +from prymer.primer3 import DesignPrimerPairsTask @pytest.fixture @@ -27,8 +29,16 @@ def amplicon_tms() -> MinOptMax[float]: @pytest.fixture -def weights() -> PrimerAndAmpliconWeights: - return PrimerAndAmpliconWeights( +def params() -> AmpliconParameters: + target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) + return AmpliconParameters( + target=target, + task=DesignPrimerPairsTask(), + amplicon_sizes=MinOptMax(min=200, opt=250, max=300), + amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), + primer_sizes=MinOptMax(min=18, opt=21, max=27), + primer_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), + primer_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), product_size_lt=0.5, product_size_gt=1.5, product_tm_lt=10, @@ -37,11 +47,19 @@ def weights() -> PrimerAndAmpliconWeights: @pytest.fixture -def all_zero_weights() -> PrimerAndAmpliconWeights: - d = dataclasses.asdict(PrimerAndAmpliconWeights()) - for key in d: - d[key] = 0.0 - return PrimerAndAmpliconWeights(**d) +def all_zero_weights(params: AmpliconParameters) -> AmpliconParameters: + return replace(params, + product_size_gt = 0, + product_tm_lt = 0, + product_tm_gt = 0, + primer_end_stability = 0, + primer_gc_lt = 0, + primer_gc_gt = 0, + product_size_lt = 0, + primer_homodimer_wt = 0, + primer_3p_homodimer_wt = 0, + primer_secondary_structure_wt = 0, + ) # 1000 bases with 100 bases per line @@ -105,7 +123,7 @@ def pp(lp: Oligo, rp: Oligo, bases: Optional[str] = None, tm: Optional[float] = def _score( pair: PrimerPair, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, sizes: MinOptMax[int], tms: MinOptMax[float], ) -> float: @@ -117,7 +135,7 @@ def _score( amplicon_tm=pair.amplicon_tm, amplicon_sizes=sizes, amplicon_tms=tms, - weights=weights, + params=params, ) @@ -127,86 +145,86 @@ def _score( def test_score_returns_sum_of_primer_penalties_when_all_weights_zero( - all_zero_weights: PrimerAndAmpliconWeights, + all_zero_weights: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 100, 1.7), p("GTGCATACTAG", 59.8, 200, 3.1)) - score = _score(pair=pair, weights=all_zero_weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=all_zero_weights, sizes=amplicon_sizes, tms=amplicon_tms) assert score == 1.7 + 3.1 def test_score_returns_sum_of_primer_penalties_when_amplicon_optimal( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 1.7), p("GTGCATACTAG", 59.8, 240, 3.1), tm=80) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt assert pair.amplicon_tm == amplicon_tms.opt assert score == 1.7 + 3.1 def test_score_when_amplicon_longer_than_optimal( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 1.0), p("GTGCATACTAG", 59.8, 250, 1.0), tm=80) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt + 10 assert pair.amplicon_tm == amplicon_tms.opt - assert score == pytest.approx(1 + 1 + (10 * weights.product_size_gt)) + assert score == pytest.approx(1 + 1 + (10 * params.product_size_gt)) def test_score_when_amplicon_shorter_than_optimal( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 1.0), p("GTGCATACTAG", 59.8, 220, 1.0), tm=80) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt - 20 assert pair.amplicon_tm == amplicon_tms.opt - assert score == pytest.approx(1 + 1 + (20 * weights.product_size_lt)) + assert score == pytest.approx(1 + 1 + (20 * params.product_size_lt)) def test_score_when_amplicon_tm_higher_than_optimal( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 1.0), p("GTGCATACTAG", 59.8, 240, 1.0), tm=82.15) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt assert pair.amplicon_tm == amplicon_tms.opt + 2.15 - assert score == pytest.approx(1 + 1 + (2.15 * weights.product_tm_gt)) + assert score == pytest.approx(1 + 1 + (2.15 * params.product_tm_gt)) def test_score_when_amplicon_tm_lower_than_optimal( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 1.0), p("GTGCATACTAG", 59.8, 240, 1.0), tm=75.67) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt assert pair.amplicon_tm == amplicon_tms.opt - 4.33 - assert score == pytest.approx(1 + 1 + (4.33 * weights.product_tm_lt)) + assert score == pytest.approx(1 + 1 + (4.33 * params.product_tm_lt)) def test_score_realistic( - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: pair = pp(p("ACGACTCATG", 60.1, 101, 3.1), p("GTGCATACTAG", 59.8, 256, 4.08), tm=75.67) - score = _score(pair=pair, weights=weights, sizes=amplicon_sizes, tms=amplicon_tms) + score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt + 16 assert pair.amplicon_tm == amplicon_tms.opt - 4.33 assert score == pytest.approx( - 3.1 + 4.08 + (16 * weights.product_size_gt) + (4.33 * weights.product_tm_lt) + 3.1 + 4.08 + (16 * params.product_size_gt) + (4.33 * params.product_tm_lt) ) @@ -217,7 +235,7 @@ def test_score_realistic( def test_build_primer_pairs_no_primers( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: @@ -229,7 +247,7 @@ def test_build_primer_pairs_no_primers( amplicon_sizes=amplicon_sizes, amplicon_tms=amplicon_tms, max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -238,7 +256,7 @@ def test_build_primer_pairs_no_primers( def test_build_primer_pairs_single_pair( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, amplicon_sizes: MinOptMax[int], amplicon_tms: MinOptMax[float], ) -> None: @@ -250,7 +268,7 @@ def test_build_primer_pairs_single_pair( amplicon_sizes=amplicon_sizes, amplicon_tms=amplicon_tms, max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -269,7 +287,7 @@ def test_build_primer_pairs_single_pair( def test_build_primers_amplicon_size_filtering( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, ) -> None: pairs = list( picking.build_primer_pairs( @@ -279,7 +297,7 @@ def test_build_primers_amplicon_size_filtering( amplicon_sizes=MinOptMax(100, 150, 200), amplicon_tms=MinOptMax(0.0, 0.0, 100.0), max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -297,7 +315,7 @@ def test_build_primers_amplicon_size_filtering( def test_build_primers_heterodimer_filtering( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, ) -> None: pairs = list( picking.build_primer_pairs( @@ -313,7 +331,7 @@ def test_build_primers_heterodimer_filtering( amplicon_sizes=MinOptMax(10, 150, 200), amplicon_tms=MinOptMax(0.0, 0.0, 100.0), max_heterodimer_tm=50, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -335,7 +353,7 @@ def test_build_primers_heterodimer_filtering( def test_build_primer_pairs_amplicon_tm_filtering( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, ) -> None: amp_bases = REF_BASES[200:300] amp_tm = Thermo().tm(amp_bases) @@ -352,7 +370,7 @@ def test_build_primer_pairs_amplicon_tm_filtering( amplicon_sizes=MinOptMax(0, 0, 500), amplicon_tms=MinOptMax(0, 75, max_tm), max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -362,7 +380,7 @@ def test_build_primer_pairs_amplicon_tm_filtering( def test_build_primer_pairs_fails_when_primers_on_wrong_reference( fasta: Path, - weights: PrimerAndAmpliconWeights, + params: AmpliconParameters, ) -> None: target = Span("chr1", 240, 260) valid_lefts = [p(REF_BASES[200:220], tm=60, pos=201, pen=1)] @@ -377,7 +395,7 @@ def test_build_primer_pairs_fails_when_primers_on_wrong_reference( amplicon_sizes=MinOptMax(0, 100, 500), amplicon_tms=MinOptMax(0, 80, 150), max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) @@ -392,7 +410,7 @@ def test_build_primer_pairs_fails_when_primers_on_wrong_reference( amplicon_sizes=MinOptMax(0, 100, 500), amplicon_tms=MinOptMax(0, 80, 150), max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) @@ -406,7 +424,7 @@ def test_build_primer_pairs_fails_when_primers_on_wrong_reference( amplicon_sizes=MinOptMax(0, 100, 500), amplicon_tms=MinOptMax(0, 80, 150), max_heterodimer_tm=None, - weights=weights, + params=params, fasta_path=fasta, ) ) diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py index cf8c868..78e90ef 100644 --- a/tests/primer3/test_primer3.py +++ b/tests/primer3/test_primer3.py @@ -15,8 +15,6 @@ from prymer.primer3.primer3 import Primer3 from prymer.primer3.primer3 import Primer3Failure from prymer.primer3.primer3 import Primer3Result -from prymer.primer3.primer3 import _has_acceptable_dinuc_run -from prymer.primer3.primer3_input import Primer3Input from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask @@ -36,8 +34,15 @@ def vcf_path() -> Path: @pytest.fixture -def single_primer_params() -> AmpliconParameters: +def target() -> Span: + return Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) + + +@pytest.fixture +def single_primer_params(target: Span) -> AmpliconParameters: return AmpliconParameters( + target=target, + task=DesignLeftPrimersTask(), amplicon_sizes=MinOptMax(min=100, max=250, opt=200), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), primer_sizes=MinOptMax(min=29, max=31, opt=30), @@ -49,8 +54,10 @@ def single_primer_params() -> AmpliconParameters: @pytest.fixture -def pair_primer_params() -> AmpliconParameters: +def pair_primer_params(target: Span) -> AmpliconParameters: return AmpliconParameters( + target=target, + task=DesignPrimerPairsTask(), amplicon_sizes=MinOptMax(min=100, max=200, opt=150), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=72.5), primer_sizes=MinOptMax(min=20, max=30, opt=25), @@ -62,8 +69,10 @@ def pair_primer_params() -> AmpliconParameters: @pytest.fixture -def design_fail_gen_primer3_params() -> AmpliconParameters: +def design_fail_gen_primer3_params(target: Span) -> AmpliconParameters: return AmpliconParameters( + target=target, + task=DesignPrimerPairsTask(), amplicon_sizes=MinOptMax(min=200, max=300, opt=250), amplicon_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), primer_sizes=MinOptMax(min=24, max=27, opt=26), @@ -73,8 +82,10 @@ def design_fail_gen_primer3_params() -> AmpliconParameters: @pytest.fixture -def valid_probe_params() -> ProbeParameters: +def valid_probe_params(target: Span) -> ProbeParameters: return ProbeParameters( + target=target, + task=PickHybProbeOnly(), probe_sizes=MinOptMax(min=18, opt=22, max=30), probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), @@ -150,13 +161,10 @@ def test_design_raises( target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - illegal_primer3_params = replace( + invalid_design_input = replace( single_primer_params, - number_primers_return="invalid", # type: ignore - ) - invalid_design_input = Primer3Input( target=target, - primer_and_amplicon_params=illegal_primer3_params, + number_primers_return="invalid", # type: ignore task=DesignLeftPrimersTask(), ) with pytest.raises(ValueError, match="Illegal PRIMER_NUM_RETURN value: invalid"): @@ -170,15 +178,15 @@ def test_left_primer_valid_designs( ) -> None: """Test that left primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + single_primer_params, target=target, - primer_and_amplicon_params=single_primer_params, task=DesignLeftPrimersTask(), ) expected_thermo_max = single_primer_params.primer_tms.min - 10 - assert design_input.primer_and_amplicon_params.primer_max_homodimer_tm == expected_thermo_max - assert design_input.primer_and_amplicon_params.primer_max_3p_homodimer_tm == expected_thermo_max - assert design_input.primer_and_amplicon_params.primer_max_hairpin_tm == expected_thermo_max + assert design_input.primer_max_homodimer_tm == expected_thermo_max + assert design_input.primer_max_3p_homodimer_tm == expected_thermo_max + assert design_input.primer_max_hairpin_tm == expected_thermo_max with Primer3(genome_fasta=genome_ref) as designer: for _ in range(10): # run many times to ensure we can re-use primer3 @@ -221,9 +229,9 @@ def test_right_primer_valid_designs( ) -> None: """Test that right primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + single_primer_params, target=target, - primer_and_amplicon_params=single_primer_params, task=DesignRightPrimersTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -263,15 +271,13 @@ def test_right_primer_valid_designs( assert actual_design.bases == reverse_complement(underlying_ref_seq) -def test_primer_pair_design( - genome_ref: Path, pair_primer_params: AmpliconParameters -) -> None: +def test_primer_pair_design(genome_ref: Path, pair_primer_params: AmpliconParameters) -> None: """Test that paired primer design produces left and right primers within design constraints. Additionally, assert that `PrimerPair.amplicon_sequence()` matches reference sequence.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + pair_primer_params, target=target, - primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -346,20 +352,14 @@ def test_primer_pair_design( assert pair_design.right_primer.bases.upper() == right_from_ref.upper() -def test_fasta_close_valid( - genome_ref: Path, single_primer_params: AmpliconParameters -) -> None: +def test_fasta_close_valid(genome_ref: Path, single_primer_params: AmpliconParameters) -> None: """Test that fasta file is closed when underlying subprocess is terminated.""" designer = Primer3(genome_fasta=genome_ref) assert designer._fasta.is_open() designer.close() assert designer._fasta.closed target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( - target=target, - primer_and_amplicon_params=single_primer_params, - task=DesignLeftPrimersTask(), - ) + design_input = replace(single_primer_params, target=target, task=DesignLeftPrimersTask()) with pytest.raises(ValueError, match="I/O operation on closed file"): designer.design(design_input=design_input) @@ -424,16 +424,16 @@ def test_screen_pair_results( If one primer of a primer pair should have a dinucleotide run above the set threshold, then the pair is considered invalid.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + pair_primer_params, target=target, - primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) - lower_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 - altered_design_input = Primer3Input( + altered_design_input = replace( + pair_primer_params, target=target, - primer_and_amplicon_params=lower_dinuc_thresh, + primer_max_dinuc_bases=2, # lower from 6 to 2 task=DesignPrimerPairsTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -442,21 +442,15 @@ def test_screen_pair_results( design_input=design_input, designed_primer_pairs=valid_primer_pairs ) assert len(base_dinuc_pair_failures) == 0 - assert design_input.primer_and_amplicon_params is not None + assert design_input is not None for primer_pair in base_primer_pair_designs: assert ( primer_pair.left_primer.longest_dinucleotide_run_length - <= design_input.primer_and_amplicon_params.primer_max_dinuc_bases + <= design_input.max_dinuc_bases ) assert ( primer_pair.right_primer.longest_dinucleotide_run_length - <= design_input.primer_and_amplicon_params.primer_max_dinuc_bases - ) - assert _has_acceptable_dinuc_run( - design_input=design_input, oligo_design=primer_pair.left_primer - ) - assert _has_acceptable_dinuc_run( - design_input=design_input, oligo_design=primer_pair.right_primer + <= design_input.max_dinuc_bases ) # 1 primer from every pair will fail lowered dinuc threshold of 2 @@ -464,10 +458,9 @@ def test_screen_pair_results( altered_designs, altered_dinuc_failures = designer._screen_pair_results( design_input=altered_design_input, designed_primer_pairs=valid_primer_pairs ) - assert altered_design_input.primer_and_amplicon_params is not None + assert altered_design_input is not None assert [ - design.longest_dinucleotide_run_length - > altered_design_input.primer_and_amplicon_params.primer_max_dinuc_bases + design.longest_dinucleotide_run_length > altered_design_input.primer_max_dinuc_bases for design in altered_dinuc_failures ] assert len(altered_designs) == 0 @@ -482,10 +475,10 @@ def test_build_failures( related to long dinucleotide runs.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - low_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 - altered_design_input = Primer3Input( + altered_design_input = replace( + pair_primer_params, target=target, - primer_and_amplicon_params=low_dinuc_thresh, + primer_max_dinuc_bases=2, # lower from 6 to 2 task=DesignPrimerPairsTask(), ) designer = Primer3(genome_fasta=genome_ref) @@ -522,9 +515,9 @@ def test_build_failures_debugs( caplog.set_level(logging.DEBUG) target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + pair_primer_params, target=target, - primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) designer = Primer3(genome_fasta=genome_ref) @@ -631,9 +624,9 @@ def test_create_design_region_raises_when_primers_would_not_fit_in_design_region def test_probe_design_raises(genome_ref: Path, valid_probe_params: ProbeParameters) -> None: """Test that we raise an error when the target region is smaller than the minimal probe size.""" target = Span(refname="chr1", start=201, end=217, strand=Strand.POSITIVE) - design_input = Primer3Input( + design_input = replace( + valid_probe_params, target=target, - probe_params=valid_probe_params, task=PickHybProbeOnly(), ) with Primer3(genome_fasta=genome_ref) as designer: diff --git a/tests/primer3/test_primer3_input.py b/tests/primer3/test_primer3_input.py deleted file mode 100644 index 490a3bd..0000000 --- a/tests/primer3/test_primer3_input.py +++ /dev/null @@ -1,182 +0,0 @@ -import pytest - -from prymer import MinOptMax -from prymer import Span -from prymer import Strand -from prymer.primer3 import DesignLeftPrimersTask -from prymer.primer3 import DesignPrimerPairsTask -from prymer.primer3 import DesignRightPrimersTask -from prymer.primer3 import Primer3Input -from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import AmpliconParameters -from prymer.primer3.primer3_parameters import ProbeParameters -from prymer.primer3.primer3_task import PickHybProbeOnly -from prymer.primer3.primer3_task import Primer3TaskType -from prymer.primer3.primer3_weights import PrimerAndAmpliconWeights -from prymer.primer3.primer3_weights import ProbeWeights - - -@pytest.fixture -def valid_primer_amplicon_params() -> AmpliconParameters: - return AmpliconParameters( - amplicon_sizes=MinOptMax(min=200, opt=250, max=300), - amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), - primer_sizes=MinOptMax(min=18, opt=21, max=27), - primer_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), - primer_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), - ) - - -@pytest.fixture -def valid_probe_params() -> ProbeParameters: - return ProbeParameters( - probe_sizes=MinOptMax(min=18, opt=22, max=30), - probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), - probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), - ) - - -@pytest.fixture -def valid_primer_weights() -> PrimerAndAmpliconWeights: - return PrimerAndAmpliconWeights() - - -@pytest.fixture -def valid_probe_weights() -> ProbeWeights: - return ProbeWeights() - - -@pytest.mark.parametrize( - "task_type", - [ - DesignRightPrimersTask(), - DesignLeftPrimersTask(), - DesignPrimerPairsTask(), - ], -) -def test_primer_design_only_valid( - valid_primer_amplicon_params: AmpliconParameters, - task_type: Primer3TaskType, - valid_primer_weights: PrimerAndAmpliconWeights, -) -> None: - test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - test_input = Primer3Input( - target=test_target, - primer_weights=valid_primer_weights, - task=task_type, - primer_and_amplicon_params=valid_primer_amplicon_params, - ) - mapped_dict = test_input.to_input_tags(design_region=test_design_region) - assert len(mapped_dict.keys()) == 44 - - -@pytest.mark.parametrize( - "task_type", [DesignRightPrimersTask(), DesignLeftPrimersTask(), DesignPrimerPairsTask()] -) -def test_primer_design_only_raises( - task_type: Primer3TaskType, valid_primer_weights: PrimerAndAmpliconWeights -) -> None: - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - with pytest.raises(ValueError, match="Primer3 requires at least one set of parameters"): - Primer3Input( - target=test_target, - primer_weights=valid_primer_weights, - task=task_type, - primer_and_amplicon_params=None, - ) - - -def test_probe_design_only_valid( - valid_probe_params: ProbeParameters, valid_probe_weights: ProbeWeights -) -> None: - test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - test_input = Primer3Input( - target=test_target, - probe_weights=valid_probe_weights, - task=PickHybProbeOnly(), - probe_params=valid_probe_params, - primer_and_amplicon_params=None, - ) - mapped_dict = test_input.to_input_tags(design_region=test_design_region) - assert mapped_dict[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 1 - assert Primer3InputTag.PRIMER_NUM_RETURN in mapped_dict - - assert len(mapped_dict.keys()) == 28 - - # test instantiation of default `ProbeWeights` when they are not provided - altered_input = Primer3Input( - target=test_target, - probe_weights=None, - task=PickHybProbeOnly(), - probe_params=valid_probe_params, - primer_and_amplicon_params=None, - ) - altered_mapped_dict = altered_input.to_input_tags(design_region=test_target) - assert altered_mapped_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT] == 0.0 - - -def test_probe_design_only_raises(valid_probe_weights: ProbeWeights) -> None: - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - with pytest.raises(ValueError, match="Primer3 requires at least one set"): - Primer3Input( - target=test_target, - probe_weights=valid_probe_weights, - task=PickHybProbeOnly(), - primer_and_amplicon_params=None, - ) - - -@pytest.mark.parametrize( - "task_type", - [ - DesignRightPrimersTask(), - DesignLeftPrimersTask(), - DesignPrimerPairsTask(), - PickHybProbeOnly(), - ], -) -def test_no_params_given_raises( - valid_primer_weights: PrimerAndAmpliconWeights, task_type: Primer3TaskType -) -> None: - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - with pytest.raises(ValueError, match="Primer3 requires at least one set"): - Primer3Input( - target=test_target, - primer_weights=valid_primer_weights, - task=task_type, - primer_and_amplicon_params=None, - probe_params=None, - ) - - -@pytest.mark.parametrize( - "task_type, expected_req_primer_amplicon_params, expected_req_probe_params", - [ - (DesignPrimerPairsTask(), True, False), - (DesignRightPrimersTask(), True, False), - (DesignLeftPrimersTask(), True, False), - (PickHybProbeOnly(), False, True), - ], -) -def test_requires_params_sets( - task_type: Primer3TaskType, - valid_probe_params: ProbeParameters, - valid_primer_amplicon_params: AmpliconParameters, - valid_primer_weights: PrimerAndAmpliconWeights, - valid_probe_weights: ProbeWeights, - expected_req_primer_amplicon_params: bool, - expected_req_probe_params: bool, -) -> None: - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - test_input = Primer3Input( - target=test_target, - primer_weights=valid_primer_weights, - probe_weights=valid_probe_weights, - task=task_type, - probe_params=valid_probe_params, - primer_and_amplicon_params=valid_primer_amplicon_params, - ) - assert test_input.task.requires_probe_params == expected_req_probe_params - assert test_input.task.requires_primer_amplicon_params == expected_req_primer_amplicon_params diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py index 81650ac..297b1f1 100644 --- a/tests/primer3/test_primer3_parameters.py +++ b/tests/primer3/test_primer3_parameters.py @@ -3,14 +3,33 @@ import pytest from prymer import MinOptMax +from prymer import Span +from prymer import Strand +from prymer.primer3 import DesignLeftPrimersTask +from prymer.primer3 import DesignPrimerPairsTask +from prymer.primer3 import DesignRightPrimersTask from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters +from prymer.primer3.primer3_task import PickHybProbeOnly +from prymer.primer3.primer3_task import Primer3TaskType @pytest.fixture -def valid_primer_amplicon_params() -> AmpliconParameters: +def target() -> Span: + return Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) + + +@pytest.fixture +def design_region() -> Span: + return Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) + + +@pytest.fixture +def valid_primer_amplicon_params(target: Span) -> AmpliconParameters: return AmpliconParameters( + target=target, + task=DesignLeftPrimersTask(), amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), @@ -20,14 +39,65 @@ def valid_primer_amplicon_params() -> AmpliconParameters: @pytest.fixture -def valid_probe_params() -> ProbeParameters: +def valid_probe_params(target: Span) -> ProbeParameters: return ProbeParameters( + target=target, + task=PickHybProbeOnly(), probe_sizes=MinOptMax(min=18, opt=22, max=30), probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), ) +@pytest.mark.parametrize( + "task_type", + [ + DesignRightPrimersTask(), + DesignLeftPrimersTask(), + DesignPrimerPairsTask(), + ], +) +def test_primer_design_only_valid( + valid_primer_amplicon_params: AmpliconParameters, + task_type: Primer3TaskType, +) -> None: + test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) + test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) + test_input = replace( + valid_primer_amplicon_params, + target=test_target, + task=task_type, + ) + mapped_dict = test_input.to_input_tags(design_region=test_design_region) + assert len(mapped_dict.keys()) == 44 + + +def test_probe_design_only_valid( + valid_probe_params: ProbeParameters, +) -> None: + test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) + test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) + test_input = replace( + valid_probe_params, + target=test_target, + task=PickHybProbeOnly(), + ) + mapped_dict = test_input.to_input_tags(design_region=test_design_region) + assert mapped_dict[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 1 + assert Primer3InputTag.PRIMER_NUM_RETURN in mapped_dict + + assert len(mapped_dict.keys()) == 28 + + # test instantiation of default `ProbeParameters` when they are not provided + altered_input = replace( + valid_probe_params, + target=test_target, + task=PickHybProbeOnly(), + ) + altered_mapped_dict = altered_input.to_input_tags(design_region=test_target) + assert altered_mapped_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT] == 0.0 + + def test_primer_amplicon_param_construction_valid( valid_primer_amplicon_params: AmpliconParameters, ) -> None: @@ -97,9 +167,10 @@ def test_primer_probe_param_construction_raises( def test_primer_amplicon_params_to_input_tags( valid_primer_amplicon_params: AmpliconParameters, + design_region: Span, ) -> None: """Test that to_input_tags() works as expected""" - test_dict = valid_primer_amplicon_params.to_input_tags() + test_dict = valid_primer_amplicon_params.to_input_tags(design_region=design_region) assert test_dict[Primer3InputTag.PRIMER_NUM_RETURN] == 5 assert test_dict[Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE] == "200-300" assert test_dict[Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE] == 250 @@ -121,7 +192,7 @@ def test_primer_amplicon_params_to_input_tags( assert test_dict[Primer3InputTag.PRIMER_MAX_NS_ACCEPTED] == 1 assert test_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 1 ambiguous_primer_design = replace(valid_primer_amplicon_params, avoid_masked_bases=False) - ambiguous_dict = ambiguous_primer_design.to_input_tags() + ambiguous_dict = ambiguous_primer_design.to_input_tags(design_region=design_region) assert ambiguous_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 0 @@ -141,3 +212,50 @@ def test_max_primer_length(valid_primer_amplicon_params: AmpliconParameters) -> valid_primer_amplicon_params, primer_sizes=MinOptMax(min=18, opt=35, max=50) ) assert change_max_length.max_primer_length == 50 + + +def test_primer_weights_valid( + design_region: Span, valid_primer_amplicon_params: AmpliconParameters +) -> None: + """Test instantiation of `AmpliconParameters` object with valid input""" + test_dict = valid_primer_amplicon_params.to_input_tags(design_region=design_region) + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT] == 1 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_WT_END_STABILITY] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_LT] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_GT] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_SELF_ANY] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_SELF_END] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_LT] == 0.5 + assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_GT] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_TM_LT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_WT_TM_GT] == 1.0 + assert len((test_dict.values())) == 44 + + +def test_probe_weights_valid(design_region: Span, valid_probe_params: ProbeParameters) -> None: + test_dict = valid_probe_params.to_input_tags(design_region=design_region) + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH] == 0.0 + assert len(test_dict) == 28 + + +def test_primer_weights_to_input_tags( + design_region: Span, valid_primer_amplicon_params: AmpliconParameters +) -> None: + """Test results from to_input_tags() with and without default values""" + default_map = valid_primer_amplicon_params.to_input_tags(design_region) + assert default_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 + customized_map = replace(valid_primer_amplicon_params, product_size_lt=5).to_input_tags( + design_region=design_region + ) + assert customized_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 5 diff --git a/tests/primer3/test_primer3_weights.py b/tests/primer3/test_primer3_weights.py deleted file mode 100644 index 4acdd30..0000000 --- a/tests/primer3/test_primer3_weights.py +++ /dev/null @@ -1,46 +0,0 @@ -from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_weights import PrimerAndAmpliconWeights -from prymer.primer3.primer3_weights import ProbeWeights - - -def test_primer_weights_valid() -> None: - """Test instantiation of `PrimerAndAmpliconWeights` object with valid input""" - test_weights = PrimerAndAmpliconWeights() - test_dict = test_weights.to_input_tags() - assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 - assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT] == 1 - assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_WT_END_STABILITY] == 0.25 - assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_LT] == 0.25 - assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_GT] == 0.25 - assert test_dict[Primer3InputTag.PRIMER_WT_SELF_ANY] == 0.1 - assert test_dict[Primer3InputTag.PRIMER_WT_SELF_END] == 0.1 - assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_LT] == 0.5 - assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_GT] == 0.1 - assert test_dict[Primer3InputTag.PRIMER_WT_TM_LT] == 1.0 - assert test_dict[Primer3InputTag.PRIMER_WT_TM_GT] == 1.0 - assert len((test_dict.values())) == 16 - - -def test_probe_weights_valid() -> None: - test_weights = ProbeWeights() - test_dict = test_weights.to_input_tags() - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT] == 1.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT] == 1.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT] == 1.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT] == 1.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH] == 0.0 - assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH] == 0.0 - assert len(test_dict) == 9 - - -def test_primer_weights_to_input_tags() -> None: - """Test results from to_input_tags() with and without default values""" - default_map = PrimerAndAmpliconWeights().to_input_tags() - assert default_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 - customized_map = PrimerAndAmpliconWeights(product_size_lt=5).to_input_tags() - assert customized_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 5 From 67f103b58bcc03aa82f7b5e966481c4b18aabafb Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 15 Jan 2025 19:16:57 -0700 Subject: [PATCH 4/5] fixups --- prymer/api/picking.py | 8 +- prymer/model.py | 49 +++++++++++ prymer/primer3/primer3_parameters.py | 101 +++++++++++------------ prymer/primer3/primer3_task.py | 8 +- tests/api/test_picking.py | 37 ++++----- tests/primer3/test_primer3_parameters.py | 7 +- 6 files changed, 123 insertions(+), 87 deletions(-) diff --git a/prymer/api/picking.py b/prymer/api/picking.py index 9c61a27..99bfa6f 100644 --- a/prymer/api/picking.py +++ b/prymer/api/picking.py @@ -73,9 +73,9 @@ def score( if amplicon_sizes.opt == 0: size_penalty = 0.0 elif amplicon.length > amplicon_sizes.opt: - size_penalty = (amplicon.length - amplicon_sizes.opt) * params.product_size_gt + size_penalty = (amplicon.length - amplicon_sizes.opt) * params.amplicon_size_wt.gt else: - size_penalty = (amplicon_sizes.opt - amplicon.length) * params.product_size_lt + size_penalty = (amplicon_sizes.opt - amplicon.length) * params.amplicon_size_wt.lt # The penalty for the amplicon melting temperature. # The difference in melting temperature between the calculated and optimal is weighted by the @@ -84,9 +84,9 @@ def score( if amplicon_tms.opt == 0.0: tm_penalty = 0.0 elif amplicon_tm > amplicon_tms.opt: - tm_penalty = (amplicon_tm - amplicon_tms.opt) * params.product_tm_gt + tm_penalty = (amplicon_tm - amplicon_tms.opt) * params.amplicon_tm_wt.gt else: - tm_penalty = (amplicon_tms.opt - amplicon_tm) * params.product_tm_lt + tm_penalty = (amplicon_tms.opt - amplicon_tm) * params.amplicon_tm_wt.lt # Put it all together return left_primer.penalty + right_primer.penalty + size_penalty + tm_penalty diff --git a/prymer/model.py b/prymer/model.py index 471b1b0..4378b5b 100644 --- a/prymer/model.py +++ b/prymer/model.py @@ -83,6 +83,55 @@ def __str__(self) -> str: return f"(min:{self.min}, opt:{self.opt}, max:{self.max})" +@dataclass(slots=True, frozen=True, init=True) +class WeightRange(Generic[Numeric]): + """Stores a pair of penalty weights. + + Weights are used when comparing a primer or probe property (e.g. primer length) to the optimal + parameterized value. If the value is less than, then the `lt` weight is used. If the value is + greater than, then the `gt` weight is used. + + The two values can be either int or float values but must be of the same type within one + Range object (for example, `lt` cannot be a float while `gt` is an int). + + Examples of interacting with the `Range` class + + ```python + >>> range = WeightRange(lt=1.0, gt=4.0) + >>> print(range) + (lt:1.0, gt:4.0) + >>> list(range) + [1.0, 4.0] + + ``` + + Attributes: + lt: the minimum value (inclusive) + gt: the maximum value (inclusive) + + Raises: + ValueError: if lt and gt are not the same type + """ + + lt: Numeric + gt: Numeric + + def __post_init__(self) -> None: + dtype = type(self.lt) + if not isinstance(self.gt, dtype): + raise TypeError( + "Min and max must be the same type; " f"received min: {dtype}, max: {type(self.gt)}" + ) + + def __iter__(self) -> Iterator[float]: + """Returns an iterator of min and max""" + return iter([self.lt, self.gt]) + + def __str__(self) -> str: + """Returns a string representation of min and max""" + return f"(lt:{self.lt}, gt:{self.gt})" + + @unique class Strand(StrEnum): """Represents the strand of a span to the genome.""" diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index 2e1c37b..231581c 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -99,6 +99,7 @@ class stores user input for internal probe design and maps it to the correct Pri from prymer.model import MinOptMax from prymer.model import Span +from prymer.model import WeightRange from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_task import Primer3TaskType @@ -136,7 +137,6 @@ def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: The target region must be wholly contained within design region. Args: - task: TODO design_region: the design region, which wholly contains the target region, in which primers are to be designed. @@ -172,19 +172,13 @@ class AmpliconParameters(Primer3Parameters): primer_max_3p_homodimer_tm: the max melting temperature acceptable for self-complementarity anchored at the 3' end primer_max_hairpin_tm: the max melting temperature acceptable for secondary structure - product_size_lt: weight for products shorter than + amplicon_size_wt: weight for products shorter/longer than `PrimerAndAmpliconParameters.amplicon_sizes.opt` - product_size_gt: weight for products longer than - `PrimerAndAmpliconParameters.amplicon_sizes.opt` - product_tm_lt: weight for products with a Tm lower than - `PrimerAndAmpliconParameters.amplicon_tms.opt` - product_tm_gt: weight for products with a Tm greater than + amplicon_tm_wt: weight for products with a Tm lower/greater than `PrimerAndAmpliconParameters.amplicon_tms.opt` - primer_end_stability: penalty for the calculated maximum stability + primer_end_stability_wt: penalty for the calculated maximum stability for the last five 3' bases of primer - primer_gc_lt: penalty for primers with GC percent lower than - `PrimerAndAmpliconParameters.primer_gcs.opt` - primer_gc_gt: weight for primers with GC percent higher than + primer_gc_wt: weight for primers with GC percent lower/higher than `PrimerAndAmpliconParameters.primer_gcs.opt` primer_homodimer_wt: penalty for the individual primer self binding value as specified in `PrimerAndAmpliconParameters.primer_max_homodimer_tm` @@ -203,6 +197,10 @@ class AmpliconParameters(Primer3Parameters): If these values are provided, users should provide the absolute value of the melting temperature threshold (i.e. when provided, values should be specified independent of primer design.) + + The parameters ending with `_wt` are are "weight" values, used to score the primer based + on if the primer property is less than or greater than the corresponding parameter (e.g. primer + length). """ target: Span @@ -221,19 +219,14 @@ class AmpliconParameters(Primer3Parameters): primer_max_homodimer_tm: Optional[float] = None primer_max_3p_homodimer_tm: Optional[float] = None primer_max_hairpin_tm: Optional[float] = None - product_size_lt: float = 1.0 - product_size_gt: float = 1.0 - product_tm_lt: float = 0.0 - product_tm_gt: float = 0.0 - primer_end_stability: float = 0.25 - primer_gc_lt: float = 0.25 - primer_gc_gt: float = 0.25 - primer_self_any: float = 0.1 - primer_self_end: float = 0.1 - primer_size_lt: float = 0.5 - primer_size_gt: float = 0.1 - primer_tm_lt: float = 1.0 - primer_tm_gt: float = 1.0 + amplicon_size_wt: WeightRange[float] = WeightRange(1.0, 1.0) + amplicon_tm_wt: WeightRange[float] = WeightRange(0.0, 0.0) + primer_end_stability_wt: float = 0.25 + primer_gc_wt: WeightRange[float] = WeightRange(0.25, 0.25) + primer_self_any_wt: float = 0.1 + primer_self_end_wt: float = 0.1 + primer_size_wt: WeightRange[float] = WeightRange(0.5, 0.1) + primer_tm_wt: WeightRange[float] = WeightRange(1.0, 1.0) primer_homodimer_wt: float = 0.0 primer_3p_homodimer_wt: float = 0.0 primer_secondary_structure_wt: float = 0.0 @@ -293,19 +286,19 @@ def _to_input_tags(self) -> dict[Primer3InputTag, Any]: Primer3InputTag.PRIMER_MAX_SELF_ANY_TH: self.primer_max_homodimer_tm, Primer3InputTag.PRIMER_MAX_SELF_END_TH: self.primer_max_3p_homodimer_tm, Primer3InputTag.PRIMER_MAX_HAIRPIN_TH: self.primer_max_hairpin_tm, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT: self.product_size_lt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT: self.product_size_gt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT: self.product_tm_lt, - Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT: self.product_tm_gt, - Primer3InputTag.PRIMER_WT_END_STABILITY: self.primer_end_stability, - Primer3InputTag.PRIMER_WT_GC_PERCENT_LT: self.primer_gc_lt, - Primer3InputTag.PRIMER_WT_GC_PERCENT_GT: self.primer_gc_gt, - Primer3InputTag.PRIMER_WT_SELF_ANY: self.primer_self_any, - Primer3InputTag.PRIMER_WT_SELF_END: self.primer_self_end, - Primer3InputTag.PRIMER_WT_SIZE_LT: self.primer_size_lt, - Primer3InputTag.PRIMER_WT_SIZE_GT: self.primer_size_gt, - Primer3InputTag.PRIMER_WT_TM_LT: self.primer_tm_lt, - Primer3InputTag.PRIMER_WT_TM_GT: self.primer_tm_gt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT: self.amplicon_size_wt.lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT: self.amplicon_size_wt.gt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT: self.amplicon_tm_wt.lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT: self.amplicon_tm_wt.gt, + Primer3InputTag.PRIMER_WT_END_STABILITY: self.primer_end_stability_wt, + Primer3InputTag.PRIMER_WT_GC_PERCENT_LT: self.primer_gc_wt.lt, + Primer3InputTag.PRIMER_WT_GC_PERCENT_GT: self.primer_gc_wt.gt, + Primer3InputTag.PRIMER_WT_SELF_ANY: self.primer_self_any_wt, + Primer3InputTag.PRIMER_WT_SELF_END: self.primer_self_end_wt, + Primer3InputTag.PRIMER_WT_SIZE_LT: self.primer_size_wt.lt, + Primer3InputTag.PRIMER_WT_SIZE_GT: self.primer_size_wt.gt, + Primer3InputTag.PRIMER_WT_TM_LT: self.primer_tm_wt.lt, + Primer3InputTag.PRIMER_WT_TM_GT: self.primer_tm_wt.gt, Primer3InputTag.PRIMER_WT_SELF_ANY_TH: self.primer_homodimer_wt, Primer3InputTag.PRIMER_WT_SELF_END_TH: self.primer_3p_homodimer_wt, Primer3InputTag.PRIMER_WT_HAIRPIN_TH: self.primer_secondary_structure_wt, @@ -345,12 +338,10 @@ class ProbeParameters(Primer3Parameters): probe_max_3p_homodimer_tm: the max melting temperature acceptable for self-complementarity anchored at the 3' end probe_max_hairpin_tm: the max melting temperature acceptable for secondary structure - probe_size_lt: penalty for probes shorter than `ProbeParameters.probe_sizes.opt` - probe_size_gt: penalty for probes longer than `ProbeParameters.probe_sizes.opt` - probe_tm_lt: penalty for probes with a Tm lower than `ProbeParameters.probe_tms.opt` - probe_tm_gt: penalty for probes with a Tm greater than `ProbeParameters.probe_tms.opt` - probe_gc_lt: penalty for probes with GC content lower than `ProbeParameters.probe_gcs.opt` - probe_gc_gt: penalty for probes with GC content greater than `ProbeParameters.probe_gcs.opt` + probe_size_wt: penalty for probes shorter/longer than `ProbeParameters.probe_sizes.opt` + probe_tm_wt: penalty for probes with a Tm lower/greater than `ProbeParameters.probe_tms.opt` + probe_gc_wt: penalty for probes with GC content lower/greater than + `ProbeParameters.probe_gcs.opt` probe_homodimer_wt: penalty for probe self-complementarity as defined in `ProbeParameters.probe_max_self_any_thermo` probe_3p_homodimer_wt: penalty for probe 3' complementarity as defined in @@ -376,6 +367,9 @@ class ProbeParameters(Primer3Parameters): melting temperature threshold (i.e. when provided, values should be specified as independent of probe design.) + The parameters ending with `_wt` are are "weight" values, used to score the probe based + on if the probe property is less than or greater than the corresponding parameter (e.g. probe + length). """ target: Span @@ -390,12 +384,9 @@ class ProbeParameters(Primer3Parameters): probe_max_homodimer_tm: Optional[float] = None probe_max_3p_homodimer_tm: Optional[float] = None probe_max_hairpin_tm: Optional[float] = None - probe_size_lt: float = 1.0 - probe_size_gt: float = 1.0 - probe_tm_lt: float = 1.0 - probe_tm_gt: float = 1.0 - probe_gc_lt: float = 0.0 - probe_gc_gt: float = 0.0 + probe_size_wt: WeightRange[float] = WeightRange(1.0, 1.0) + probe_tm_wt: WeightRange[float] = WeightRange(1.0, 1.0) + probe_gc_wt: WeightRange[float] = WeightRange(0.0, 0.0) probe_homodimer_wt: float = 0.0 probe_3p_homodimer_wt: float = 0.0 probe_secondary_structure_wt: float = 0.0 @@ -443,12 +434,12 @@ def _to_input_tags(self) -> dict[Primer3InputTag, Any]: Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_END_TH: self.probe_max_3p_homodimer_tm, Primer3InputTag.PRIMER_INTERNAL_MAX_HAIRPIN_TH: self.probe_max_hairpin_tm, Primer3InputTag.PRIMER_NUM_RETURN: self.number_probes_return, - Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT: self.probe_size_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT: self.probe_size_gt, - Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT: self.probe_tm_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT: self.probe_tm_gt, - Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT: self.probe_gc_lt, - Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT: self.probe_gc_gt, + Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT: self.probe_size_wt.lt, + Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT: self.probe_size_wt.gt, + Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT: self.probe_tm_wt.lt, + Primer3InputTag.PRIMER_INTERNAL_WT_TM_GT: self.probe_tm_wt.gt, + Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_LT: self.probe_gc_wt.lt, + Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT: self.probe_gc_wt.gt, Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH: self.probe_homodimer_wt, Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH: self.probe_3p_homodimer_wt, Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH: self.probe_secondary_structure_wt, diff --git a/prymer/primer3/primer3_task.py b/prymer/primer3/primer3_task.py index 6722d47..ce6ec2b 100644 --- a/prymer/primer3/primer3_task.py +++ b/prymer/primer3/primer3_task.py @@ -110,7 +110,6 @@ """Type alias for all `Primer3Task`s, to enable exhaustiveness checking.""" - @unique class TaskType(UppercaseStrEnum): """Represents the type of design task: design primer pairs, individual primers @@ -182,10 +181,9 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT """Aligns the set of input parameters specific to primer pair or single primer design""" @classmethod - def __init_subclass__(cls, - task_type: TaskType, - is_amplicon_design_task: bool, - **kwargs: Any) -> None: + def __init_subclass__( + cls, task_type: TaskType, is_amplicon_design_task: bool, **kwargs: Any + ) -> None: # See: https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__ super().__init_subclass__(**kwargs) diff --git a/tests/api/test_picking.py b/tests/api/test_picking.py index 6aad8ae..40b28cb 100644 --- a/tests/api/test_picking.py +++ b/tests/api/test_picking.py @@ -14,6 +14,7 @@ from prymer import Strand from prymer import Thermo from prymer.api import picking +from prymer.model import WeightRange from prymer.primer3 import AmpliconParameters from prymer.primer3 import DesignPrimerPairsTask @@ -39,26 +40,22 @@ def params() -> AmpliconParameters: primer_sizes=MinOptMax(min=18, opt=21, max=27), primer_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), - product_size_lt=0.5, - product_size_gt=1.5, - product_tm_lt=10, - product_tm_gt=15, + amplicon_size_wt=WeightRange(0.5, 1.5), + amplicon_tm_wt=WeightRange(10, 15), ) @pytest.fixture def all_zero_weights(params: AmpliconParameters) -> AmpliconParameters: - return replace(params, - product_size_gt = 0, - product_tm_lt = 0, - product_tm_gt = 0, - primer_end_stability = 0, - primer_gc_lt = 0, - primer_gc_gt = 0, - product_size_lt = 0, - primer_homodimer_wt = 0, - primer_3p_homodimer_wt = 0, - primer_secondary_structure_wt = 0, + return replace( + params, + amplicon_size_wt=WeightRange(0.0, 0.0), + amplicon_tm_wt=WeightRange(0, 0), + primer_end_stability_wt=0, + primer_gc_wt=WeightRange(0, 0), + primer_homodimer_wt=0, + primer_3p_homodimer_wt=0, + primer_secondary_structure_wt=0, ) @@ -175,7 +172,7 @@ def test_score_when_amplicon_longer_than_optimal( score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt + 10 assert pair.amplicon_tm == amplicon_tms.opt - assert score == pytest.approx(1 + 1 + (10 * params.product_size_gt)) + assert score == pytest.approx(1 + 1 + (10 * params.amplicon_size_wt.gt)) def test_score_when_amplicon_shorter_than_optimal( @@ -187,7 +184,7 @@ def test_score_when_amplicon_shorter_than_optimal( score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt - 20 assert pair.amplicon_tm == amplicon_tms.opt - assert score == pytest.approx(1 + 1 + (20 * params.product_size_lt)) + assert score == pytest.approx(1 + 1 + (20 * params.amplicon_size_wt.lt)) def test_score_when_amplicon_tm_higher_than_optimal( @@ -199,7 +196,7 @@ def test_score_when_amplicon_tm_higher_than_optimal( score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt assert pair.amplicon_tm == amplicon_tms.opt + 2.15 - assert score == pytest.approx(1 + 1 + (2.15 * params.product_tm_gt)) + assert score == pytest.approx(1 + 1 + (2.15 * params.amplicon_tm_wt.gt)) def test_score_when_amplicon_tm_lower_than_optimal( @@ -211,7 +208,7 @@ def test_score_when_amplicon_tm_lower_than_optimal( score = _score(pair=pair, params=params, sizes=amplicon_sizes, tms=amplicon_tms) assert pair.amplicon.length == amplicon_sizes.opt assert pair.amplicon_tm == amplicon_tms.opt - 4.33 - assert score == pytest.approx(1 + 1 + (4.33 * params.product_tm_lt)) + assert score == pytest.approx(1 + 1 + (4.33 * params.amplicon_tm_wt.lt)) def test_score_realistic( @@ -224,7 +221,7 @@ def test_score_realistic( assert pair.amplicon.length == amplicon_sizes.opt + 16 assert pair.amplicon_tm == amplicon_tms.opt - 4.33 assert score == pytest.approx( - 3.1 + 4.08 + (16 * params.product_size_gt) + (4.33 * params.product_tm_lt) + 3.1 + 4.08 + (16 * params.amplicon_size_wt.gt) + (4.33 * params.amplicon_tm_wt.lt) ) diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py index 297b1f1..809bbd6 100644 --- a/tests/primer3/test_primer3_parameters.py +++ b/tests/primer3/test_primer3_parameters.py @@ -5,6 +5,7 @@ from prymer import MinOptMax from prymer import Span from prymer import Strand +from prymer.model import WeightRange from prymer.primer3 import DesignLeftPrimersTask from prymer.primer3 import DesignPrimerPairsTask from prymer.primer3 import DesignRightPrimersTask @@ -255,7 +256,7 @@ def test_primer_weights_to_input_tags( """Test results from to_input_tags() with and without default values""" default_map = valid_primer_amplicon_params.to_input_tags(design_region) assert default_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 - customized_map = replace(valid_primer_amplicon_params, product_size_lt=5).to_input_tags( - design_region=design_region - ) + customized_map = replace( + valid_primer_amplicon_params, amplicon_size_wt=WeightRange(5.0, 1.0) + ).to_input_tags(design_region=design_region) assert customized_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 5 From 116ea3961bd2153ab37e2a740269500e0e9109c0 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 15 Jan 2025 22:30:12 -0700 Subject: [PATCH 5/5] removing task and target from Primer3Parameters and sub-classes --- prymer/api/picking.py | 4 +- prymer/primer3/primer3.py | 144 +++++++++++++---------- prymer/primer3/primer3_parameters.py | 66 +---------- prymer/primer3/primer3_task.py | 20 +--- tests/api/test_picking.py | 5 - tests/primer3/test_primer3.py | 111 +++++------------ tests/primer3/test_primer3_parameters.py | 72 +++--------- 7 files changed, 144 insertions(+), 278 deletions(-) diff --git a/prymer/api/picking.py b/prymer/api/picking.py index 99bfa6f..f63eb3d 100644 --- a/prymer/api/picking.py +++ b/prymer/api/picking.py @@ -116,11 +116,11 @@ def build_primer_pairs( # noqa: C901 amplicon_sizes: minimum, optimal, and maximum amplicon sizes (lengths) amplicon_tms: minimum, optimal, and maximum amplicon Tms max_heterodimer_tm: if supplied, heterodimer Tms will be calculated for primer pairs, - and those exceeding the maximum Tm will be discarded + and those exceeding the maximum Tm will be discarded params: the set of penalty params fasta_path: the path to the FASTA file from which the amplicon sequence will be retrieved. thermo: a [`Thermo`][prymer.Thermo] instance for performing thermodynamic calculations - including amplicon tm; if not provided, a default Thermo instance will be created + including amplicon tm; if not provided, a default Thermo instance will be created Returns: An iterator over all the valid primer pairs, sorted by primer pair penalty. diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py index 8553fac..ae37b2f 100644 --- a/prymer/primer3/primer3.py +++ b/prymer/primer3/primer3.py @@ -43,17 +43,15 @@ ```python >>> from prymer.primer3.primer3_parameters import AmpliconParameters >>> from prymer import MinOptMax ->>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) ->>> design_input = AmpliconParameters( \ - task=DesignLeftPrimersTask(), \ - target=target, \ +>>> params = AmpliconParameters( \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ ) ->>> left_result = designer.design(design_input=design_input) +>>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) +>>> left_result = designer.design(task=DesignLeftPrimersTask(), params=params, target=target) ``` @@ -137,11 +135,14 @@ from prymer.model import Strand from prymer.primer3.primer3_failure_reason import Primer3FailureReason from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import Primer3Parameters +from prymer.primer3.primer3_parameters import ProbeParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask from prymer.primer3.primer3_task import DesignRightPrimersTask from prymer.primer3.primer3_task import PickHybProbeOnly +from prymer.primer3.primer3_task import Primer3TaskType @dataclass(init=True, slots=True, frozen=True) @@ -289,12 +290,12 @@ def get_design_sequences(self, region: Span) -> tuple[str, str]: @staticmethod def _screen_pair_results( - design_input: Primer3Parameters, designed_primer_pairs: list[PrimerPair] + params: Primer3Parameters, designed_primer_pairs: list[PrimerPair] ) -> tuple[list[PrimerPair], list[Oligo]]: """Screens primer pair designs emitted by Primer3 for dinucleotide run length. Args: - design_input: the target region, design task, specifications, and scoring penalties + params: the parameters for the design task designed_primer_pairs: the unfiltered primer pair designs emitted by Primer3 Returns: @@ -306,14 +307,12 @@ def _screen_pair_results( for primer_pair in designed_primer_pairs: valid: bool = True if ( - primer_pair.left_primer.longest_dinucleotide_run_length - > design_input.max_dinuc_bases + primer_pair.left_primer.longest_dinucleotide_run_length > params.max_dinuc_bases ): # if the left primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.left_primer) valid = False if ( - primer_pair.right_primer.longest_dinucleotide_run_length - > design_input.max_dinuc_bases + primer_pair.right_primer.longest_dinucleotide_run_length > params.max_dinuc_bases ): # if the right primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.right_primer) valid = False @@ -321,43 +320,65 @@ def _screen_pair_results( valid_primer_pair_designs.append(primer_pair) return valid_primer_pair_designs, dinuc_pair_failures - def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C901 - """Designs primers, primer pairs, and/or internal probes given a target region. + def _build_design_region( + self, task: Primer3TaskType, params: Primer3Parameters, target: Span + ) -> Span: + """Builds the design region, which wholly contains the target, for the given design task. Args: - design_input: encapsulates the target region, design task, specifications, and scoring - penalties - - Returns: - Primer3Result containing both the valid and failed designs emitted by Primer3 - - Raises: - ValueError: if Primer3 returns errors or does not return output - ValueError: if Primer3 output is malformed - ValueError: if an unknown design task is given + task: the target task + params: the parameters for the design task + target: the target region """ - design_region: Span - match design_input.task: + match task: case PickHybProbeOnly(): - probe_params = design_input.as_probe_params - if probe_params.target.length < probe_params.probe_sizes.min: + if not isinstance(params, ProbeParameters): + raise TypeError( + f"For the {type(task).__name__} task, must supply ProbeParameters instance." + f" Found: {type(params).__name__}" + ) + if target.length < params.probe_sizes.min: raise ValueError( "Target region required to be at least as large as the" " minimal probe size: " - f"target length: {design_input.target.length}, " - f"minimal probe size: {probe_params.probe_sizes.min}" + f"target length: {target.length}, " + f"minimal probe size: {params.probe_sizes.min}" ) - design_region = design_input.target + return target case DesignRightPrimersTask() | DesignLeftPrimersTask() | DesignPrimerPairsTask(): - amplicon_params = design_input.as_amplicon_params - design_region = self._create_design_region( - target_region=amplicon_params.target, - max_amplicon_length=amplicon_params.max_amplicon_length, - min_primer_length=amplicon_params.min_primer_length, + if not isinstance(params, AmpliconParameters): + raise TypeError( + f"For the {type(task).__name__} task, must supply AmpliconParameters." + f"instance. Found: {type(params).__name__}" + ) + return self._create_design_region( + target_region=target, + max_amplicon_length=params.max_amplicon_length, + min_primer_length=params.min_primer_length, ) case _ as unreachable: assert_never(unreachable) # pragma: no cover + def design( + self, task: Primer3TaskType, params: Primer3Parameters, target: Span + ) -> Primer3Result: # noqa: C901 + """Designs primers, primer pairs, and/or internal probes given a target region. + + Args: + task: the design task to perform + params: the primer3-specific parameters. The parameters must match the task. + target: the region to target + + Returns: + Primer3Result containing both the valid and failed designs emitted by Primer3 + + Raises: + ValueError: if Primer3 returns errors or does not return output + ValueError: if Primer3 output is malformed + ValueError: if an unknown design task is given + """ + design_region: Span = self._build_design_region(task=task, params=params, target=target) + soft_masked, hard_masked = self.get_design_sequences(design_region) # use 1-base coords, explain primer designs, use hard-masked sequence, and compute # thermodynamic attributes @@ -368,9 +389,11 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 Primer3InputTag.PRIMER_THERMODYNAMIC_OLIGO_ALIGNMENT: 1, } - assembled_primer3_tags = { + # build all the input tags + assembled_primer3_tags: dict[Primer3InputTag, Any] = { **global_primer3_params, - **design_input.to_input_tags(design_region=design_region), + **task.to_input_tags(design_region=design_region, target=target), + **params.to_input_tags(), } # split the tags into sequence and non-sequence tags @@ -400,16 +423,16 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 if "PRIMER_ERROR" in primer3_results: raise ValueError("Primer3 failed: " + primer3_results["PRIMER_ERROR"]) - match design_input.task: + match task: case DesignPrimerPairsTask(): # Primer pair design all_pair_results: list[PrimerPair] = Primer3._build_primer_pairs( - design_input=design_input, + task=task, design_results=primer3_results, design_region=design_region, unmasked_design_seq=soft_masked, ) return Primer3._assemble_primer_pairs( - design_input=design_input.as_amplicon_params, + params=params, design_results=primer3_results, unfiltered_designs=all_pair_results, ) @@ -417,14 +440,15 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 case DesignLeftPrimersTask() | DesignRightPrimersTask() | PickHybProbeOnly(): # Single primer or probe design all_single_results: list[Oligo] = Primer3._build_oligos( - design_input=design_input, + task=task, design_results=primer3_results, design_region=design_region, - design_task=design_input.task, + design_task=task, unmasked_design_seq=soft_masked, ) return Primer3._assemble_single_designs( - design_input=design_input, + task=task, + params=params, design_results=primer3_results, unfiltered_designs=all_single_results, ) @@ -434,7 +458,7 @@ def design(self, design_input: Primer3Parameters) -> Primer3Result: # noqa: C90 @staticmethod def _build_oligos( - design_input: Primer3Parameters, + task: Primer3TaskType, design_results: dict[str, Any], design_region: Span, design_task: Union[DesignLeftPrimersTask, DesignRightPrimersTask, PickHybProbeOnly], @@ -444,7 +468,7 @@ def _build_oligos( Builds a list of single oligos from Primer3 output. Args: - design_input: the target region, design task, specifications, and scoring penalties + task: the design task design_results: design results emitted by Primer3 and captured by design() design_region: the padded design region design_task: the design task @@ -456,7 +480,7 @@ def _build_oligos( Raises: ValueError: if Primer3 does not return primer designs """ - count: int = _check_design_results(design_input, design_results) + count: int = _check_design_results(task, design_results) primers: list[Oligo] = [] for idx in range(count): @@ -514,7 +538,8 @@ def _build_oligos( @staticmethod def _assemble_single_designs( - design_input: Primer3Parameters, + task: Primer3TaskType, + params: Primer3Parameters, design_results: dict[str, str], unfiltered_designs: list[Oligo], ) -> Primer3Result: @@ -524,22 +549,22 @@ def _assemble_single_designs( valid_designs = [ design for design in unfiltered_designs - if design.longest_dinucleotide_run_length <= design_input.max_dinuc_bases + if design.longest_dinucleotide_run_length <= params.max_dinuc_bases ] dinuc_failures = [ design for design in unfiltered_designs - if not design.longest_dinucleotide_run_length <= design_input.max_dinuc_bases + if not design.longest_dinucleotide_run_length <= params.max_dinuc_bases ] - failure_strings = [design_results[f"PRIMER_{design_input.task.task_type}_EXPLAIN"]] + failure_strings = [design_results[f"PRIMER_{task.task_type}_EXPLAIN"]] failures = Primer3._build_failures(dinuc_failures, failure_strings) design_candidates: Primer3Result = Primer3Result(designs=valid_designs, failures=failures) return design_candidates @staticmethod def _build_primer_pairs( - design_input: Primer3Parameters, + task: Primer3TaskType, design_results: dict[str, Any], design_region: Span, unmasked_design_seq: str, @@ -548,7 +573,7 @@ def _build_primer_pairs( Builds a list of primer pairs from single primer designs emitted from Primer3. Args: - design_input: the target region, design task, specifications, and scoring penalties + task: the design task design_results: design results emitted by Primer3 and captured by design() design_region: the padded design region unmasked_design_seq: the reference sequence corresponding to the target region @@ -560,7 +585,7 @@ def _build_primer_pairs( ValueError: if Primer3 does not return the same number of left and right designs """ left_primers = Primer3._build_oligos( - design_input=design_input, + task=task, design_results=design_results, design_region=design_region, design_task=DesignLeftPrimersTask(), @@ -568,7 +593,7 @@ def _build_primer_pairs( ) right_primers = Primer3._build_oligos( - design_input=design_input, + task=task, design_results=design_results, design_region=design_region, design_task=DesignRightPrimersTask(), @@ -602,7 +627,7 @@ def _build_primer_pair(num: int, primer_pair: tuple[Oligo, Oligo]) -> PrimerPair @staticmethod def _assemble_primer_pairs( - design_input: Primer3Parameters, + params: Primer3Parameters, design_results: dict[str, Any], unfiltered_designs: list[PrimerPair], ) -> Primer3Result: @@ -613,8 +638,7 @@ def _assemble_primer_pairs( Primer3. Args: - design_input: encapsulates the target region, design task, specifications, - and scoring penalties + params: the parameters for the design task unfiltered_designs: list of primer pairs emitted from Primer3 design_results: key-value pairs of results reported by Primer3 @@ -624,7 +648,7 @@ def _assemble_primer_pairs( valid_primer_pair_designs: list[PrimerPair] dinuc_pair_failures: list[Oligo] valid_primer_pair_designs, dinuc_pair_failures = Primer3._screen_pair_results( - design_input=design_input, designed_primer_pairs=unfiltered_designs + params=params, designed_primer_pairs=unfiltered_designs ) failure_strings = [ @@ -717,9 +741,9 @@ def _create_design_region( return design_region -def _check_design_results(design_input: Primer3Parameters, design_results: dict[str, str]) -> int: +def _check_design_results(task: Primer3TaskType, design_results: dict[str, str]) -> int: """Checks for any additional Primer3 errors and reports out the count of emitted designs.""" - count_tag = design_input.task.count_tag + count_tag = task.count_tag maybe_count: Optional[str] = design_results.get(count_tag) if maybe_count is None: # no count tag was found if "PRIMER_ERROR" in design_results: diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index 231581c..c3d4c65 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -30,22 +30,14 @@ class stores user input for internal probe design and maps it to the correct Pri >>> from prymer.primer3 import DesignPrimerPairsTask >>> from prymer import Strand >>> params = AmpliconParameters( \ - task=DesignPrimerPairsTask(), \ - target=Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ ) ->>> design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) ->>> for tag, value in params.to_input_tags(design_region=design_region).items(): \ +>>> for tag, value in params.to_input_tags().items(): \ print(f"{tag.value} -> {value}") -PRIMER_TASK -> generic -PRIMER_PICK_LEFT_PRIMER -> 1 -PRIMER_PICK_RIGHT_PRIMER -> 1 -PRIMER_PICK_INTERNAL_OLIGO -> 0 -SEQUENCE_TARGET -> 200,101 PRIMER_PRODUCT_OPT_SIZE -> 200 PRIMER_PRODUCT_SIZE_RANGE -> 100-250 PRIMER_PRODUCT_MIN_TM -> 55.0 @@ -93,35 +85,15 @@ class stores user input for internal probe design and maps it to the correct Pri from abc import abstractmethod from dataclasses import dataclass from dataclasses import fields -from functools import cached_property from typing import Any from typing import Optional from prymer.model import MinOptMax -from prymer.model import Span from prymer.model import WeightRange from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_task import Primer3TaskType class Primer3Parameters(ABC): - target: Span - task: Primer3TaskType - - @cached_property - def as_amplicon_params(self) -> "AmpliconParameters": - """Use this method when you want to treat these parameters as amplicon parameters.""" - if isinstance(self, AmpliconParameters): - return self - raise Exception("The parameters are not amplicon parameters") - - @cached_property - def as_probe_params(self) -> "ProbeParameters": - """Use this method when you want to treat these parameters as probe parameters.""" - if isinstance(self, ProbeParameters): - return self - raise Exception("The parameters are not probe parameters") - @property @abstractmethod def max_dinuc_bases(self) -> int: @@ -129,27 +101,7 @@ def max_dinuc_bases(self) -> int: pass @abstractmethod - def _to_input_tags(self) -> dict[Primer3InputTag, Any]: ... - - def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: - """Assembles `Primer3InputTag` and values for input to `Primer3` - - The target region must be wholly contained within design region. - - Args: - design_region: the design region, which wholly contains the target region, in which - primers are to be designed. - - Returns: - a mapping of `Primer3InputTag`s to associated value - """ - primer3_task_params = self.task.to_input_tags( - design_region=design_region, target=self.target - ) - assembled_tags: dict[Primer3InputTag, Any] = {**primer3_task_params} - assembled_tags.update(self._to_input_tags()) - - return assembled_tags + def to_input_tags(self) -> dict[Primer3InputTag, Any]: ... @dataclass(frozen=True, init=True, slots=True) @@ -203,8 +155,6 @@ class AmpliconParameters(Primer3Parameters): length). """ - target: Span - task: Primer3TaskType amplicon_sizes: MinOptMax[int] amplicon_tms: MinOptMax[float] primer_sizes: MinOptMax[int] @@ -232,9 +182,6 @@ class AmpliconParameters(Primer3Parameters): primer_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: - if not self.task.is_amplicon_design_task: - raise ValueError(f"Task '{self.task}' must be an amplicon design task.") - if self.primer_max_dinuc_bases % 2 == 1: raise ValueError("Primer Max Dinuc Bases must be an even number of bases") if not isinstance(self.amplicon_sizes.min, int) or not isinstance( @@ -258,7 +205,7 @@ def __post_init__(self) -> None: def max_dinuc_bases(self) -> int: return self.primer_max_dinuc_bases - def _to_input_tags(self) -> dict[Primer3InputTag, Any]: + def to_input_tags(self) -> dict[Primer3InputTag, Any]: """Converts input params to Primer3InputTag to feed directly into Primer3.""" mapped_dict: dict[Primer3InputTag, Any] = { Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE: self.amplicon_sizes.opt, @@ -372,8 +319,6 @@ class ProbeParameters(Primer3Parameters): length). """ - target: Span - task: Primer3TaskType probe_sizes: MinOptMax[int] probe_tms: MinOptMax[float] probe_gcs: MinOptMax[float] @@ -392,9 +337,6 @@ class ProbeParameters(Primer3Parameters): probe_secondary_structure_wt: float = 0.0 def __post_init__(self) -> None: - if not self.task.is_probe_design_task: - raise ValueError(f"Task '{self.task}' must be an probe design task.") - if not isinstance(self.probe_sizes.min, int): raise TypeError("Probe sizes must be integers") if not isinstance(self.probe_tms.min, float) or not isinstance(self.probe_gcs.min, float): @@ -416,7 +358,7 @@ def __post_init__(self) -> None: def max_dinuc_bases(self) -> int: return self.probe_max_dinuc_bases - def _to_input_tags(self) -> dict[Primer3InputTag, Any]: + def to_input_tags(self) -> dict[Primer3InputTag, Any]: """Converts input params to Primer3InputTag to feed directly into Primer3.""" mapped_dict: dict[Primer3InputTag, Any] = { Primer3InputTag.PRIMER_INTERNAL_MIN_SIZE: self.probe_sizes.min, diff --git a/prymer/primer3/primer3_task.py b/prymer/primer3/primer3_task.py index ce6ec2b..8da14e3 100644 --- a/prymer/primer3/primer3_task.py +++ b/prymer/primer3/primer3_task.py @@ -169,31 +169,21 @@ def to_input_tags(self, target: Span, design_region: Span) -> dict[Primer3InputT count_tag: ClassVar[str] = NotImplemented """The tag returned by Primer3 that provides the number of primers returned""" - is_amplicon_design_task: ClassVar[bool] = NotImplemented - """True if this task designs amplicons (primer or primer pairs)""" - - is_probe_design_task: ClassVar[bool] = NotImplemented - """True if this task designs probes""" - @classmethod @abstractmethod def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: """Aligns the set of input parameters specific to primer pair or single primer design""" @classmethod - def __init_subclass__( - cls, task_type: TaskType, is_amplicon_design_task: bool, **kwargs: Any - ) -> None: + def __init_subclass__(cls, task_type: TaskType, **kwargs: Any) -> None: # See: https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__ super().__init_subclass__(**kwargs) cls.task_type = task_type cls.count_tag = f"PRIMER_{task_type}_NUM_RETURNED" - cls.is_amplicon_design_task = is_amplicon_design_task - cls.is_probe_design_task = not is_amplicon_design_task -class DesignPrimerPairsTask(Primer3Task, task_type=TaskType.PAIR, is_amplicon_design_task=True): +class DesignPrimerPairsTask(Primer3Task, task_type=TaskType.PAIR): """Stores task-specific Primer3 settings for designing primer pairs""" @classmethod @@ -208,7 +198,7 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT } -class DesignLeftPrimersTask(Primer3Task, task_type=TaskType.LEFT, is_amplicon_design_task=True): +class DesignLeftPrimersTask(Primer3Task, task_type=TaskType.LEFT): """Stores task-specific characteristics for designing left primers.""" @classmethod @@ -222,7 +212,7 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT } -class DesignRightPrimersTask(Primer3Task, task_type=TaskType.RIGHT, is_amplicon_design_task=True): +class DesignRightPrimersTask(Primer3Task, task_type=TaskType.RIGHT): """Stores task-specific characteristics for designing right primers""" @classmethod @@ -238,7 +228,7 @@ def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputT } -class PickHybProbeOnly(Primer3Task, task_type=TaskType.INTERNAL, is_amplicon_design_task=False): +class PickHybProbeOnly(Primer3Task, task_type=TaskType.INTERNAL): """Stores task-specific characteristics for designing an internal hybridization probe.""" @classmethod diff --git a/tests/api/test_picking.py b/tests/api/test_picking.py index 40b28cb..4099b14 100644 --- a/tests/api/test_picking.py +++ b/tests/api/test_picking.py @@ -11,12 +11,10 @@ from prymer import Oligo from prymer import PrimerPair from prymer import Span -from prymer import Strand from prymer import Thermo from prymer.api import picking from prymer.model import WeightRange from prymer.primer3 import AmpliconParameters -from prymer.primer3 import DesignPrimerPairsTask @pytest.fixture @@ -31,10 +29,7 @@ def amplicon_tms() -> MinOptMax[float]: @pytest.fixture def params() -> AmpliconParameters: - target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) return AmpliconParameters( - target=target, - task=DesignPrimerPairsTask(), amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py index 78e90ef..7be72fc 100644 --- a/tests/primer3/test_primer3.py +++ b/tests/primer3/test_primer3.py @@ -39,10 +39,8 @@ def target() -> Span: @pytest.fixture -def single_primer_params(target: Span) -> AmpliconParameters: +def single_primer_params() -> AmpliconParameters: return AmpliconParameters( - target=target, - task=DesignLeftPrimersTask(), amplicon_sizes=MinOptMax(min=100, max=250, opt=200), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), primer_sizes=MinOptMax(min=29, max=31, opt=30), @@ -54,10 +52,8 @@ def single_primer_params(target: Span) -> AmpliconParameters: @pytest.fixture -def pair_primer_params(target: Span) -> AmpliconParameters: +def pair_primer_params() -> AmpliconParameters: return AmpliconParameters( - target=target, - task=DesignPrimerPairsTask(), amplicon_sizes=MinOptMax(min=100, max=200, opt=150), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=72.5), primer_sizes=MinOptMax(min=20, max=30, opt=25), @@ -69,10 +65,8 @@ def pair_primer_params(target: Span) -> AmpliconParameters: @pytest.fixture -def design_fail_gen_primer3_params(target: Span) -> AmpliconParameters: +def design_fail_gen_primer3_params() -> AmpliconParameters: return AmpliconParameters( - target=target, - task=DesignPrimerPairsTask(), amplicon_sizes=MinOptMax(min=200, max=300, opt=250), amplicon_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), primer_sizes=MinOptMax(min=24, max=27, opt=26), @@ -82,10 +76,8 @@ def design_fail_gen_primer3_params(target: Span) -> AmpliconParameters: @pytest.fixture -def valid_probe_params(target: Span) -> ProbeParameters: +def valid_probe_params() -> ProbeParameters: return ProbeParameters( - target=target, - task=PickHybProbeOnly(), probe_sizes=MinOptMax(min=18, opt=22, max=30), probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), @@ -158,17 +150,15 @@ def test_design_raises( single_primer_params: AmpliconParameters, ) -> None: """Test that design() raises when given an invalid argument.""" - target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - - invalid_design_input = replace( + invalid_params = replace( single_primer_params, - target=target, number_primers_return="invalid", # type: ignore - task=DesignLeftPrimersTask(), ) with pytest.raises(ValueError, match="Illegal PRIMER_NUM_RETURN value: invalid"): - Primer3(genome_fasta=genome_ref).design(design_input=invalid_design_input) + Primer3(genome_fasta=genome_ref).design( + task=DesignLeftPrimersTask(), params=invalid_params, target=target + ) # TODO: add other Value Errors @@ -178,19 +168,16 @@ def test_left_primer_valid_designs( ) -> None: """Test that left primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = replace( - single_primer_params, - target=target, - task=DesignLeftPrimersTask(), - ) expected_thermo_max = single_primer_params.primer_tms.min - 10 - assert design_input.primer_max_homodimer_tm == expected_thermo_max - assert design_input.primer_max_3p_homodimer_tm == expected_thermo_max - assert design_input.primer_max_hairpin_tm == expected_thermo_max + assert single_primer_params.primer_max_homodimer_tm == expected_thermo_max + assert single_primer_params.primer_max_3p_homodimer_tm == expected_thermo_max + assert single_primer_params.primer_max_hairpin_tm == expected_thermo_max with Primer3(genome_fasta=genome_ref) as designer: for _ in range(10): # run many times to ensure we can re-use primer3 - left_result = designer.design(design_input=design_input) + left_result = designer.design( + task=DesignLeftPrimersTask(), params=single_primer_params, target=target + ) designed_lefts: list[Oligo] = left_result.primers() assert all(isinstance(design, Oligo) for design in designed_lefts) for actual_design in designed_lefts: @@ -229,14 +216,11 @@ def test_right_primer_valid_designs( ) -> None: """Test that right primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = replace( - single_primer_params, - target=target, - task=DesignRightPrimersTask(), - ) with Primer3(genome_fasta=genome_ref) as designer: for _ in range(10): # run many times to ensure we can re-use primer3 - right_result: Primer3Result = designer.design(design_input=design_input) + right_result: Primer3Result = designer.design( + task=DesignRightPrimersTask(), params=single_primer_params, target=target + ) designed_rights: list[Oligo] = right_result.primers() assert all(isinstance(design, Oligo) for design in designed_rights) @@ -275,13 +259,10 @@ def test_primer_pair_design(genome_ref: Path, pair_primer_params: AmpliconParame """Test that paired primer design produces left and right primers within design constraints. Additionally, assert that `PrimerPair.amplicon_sequence()` matches reference sequence.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = replace( - pair_primer_params, - target=target, - task=DesignPrimerPairsTask(), - ) with Primer3(genome_fasta=genome_ref) as designer: - pair_result: Primer3Result = designer.design(design_input=design_input) + pair_result: Primer3Result = designer.design( + task=DesignPrimerPairsTask(), params=pair_primer_params, target=target + ) designed_pairs: list[PrimerPair] = pair_result.primer_pairs() assert all(isinstance(design, PrimerPair) for design in designed_pairs) lefts = [primer_pair.left_primer for primer_pair in designed_pairs] @@ -359,10 +340,8 @@ def test_fasta_close_valid(genome_ref: Path, single_primer_params: AmpliconParam designer.close() assert designer._fasta.closed target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = replace(single_primer_params, target=target, task=DesignLeftPrimersTask()) - with pytest.raises(ValueError, match="I/O operation on closed file"): - designer.design(design_input=design_input) + designer.design(task=DesignLeftPrimersTask(), params=single_primer_params, target=target) @pytest.mark.parametrize( @@ -423,44 +402,34 @@ def test_screen_pair_results( run in a primer (high_threshold = 6, low_threshold = 2). If one primer of a primer pair should have a dinucleotide run above the set threshold, then the pair is considered invalid.""" - target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - design_input = replace( - pair_primer_params, - target=target, - task=DesignPrimerPairsTask(), - ) - - altered_design_input = replace( + altered_params = replace( pair_primer_params, - target=target, primer_max_dinuc_bases=2, # lower from 6 to 2 - task=DesignPrimerPairsTask(), ) with Primer3(genome_fasta=genome_ref) as designer: # all PrimerPairs have acceptable dinucleotide run lengths (threshold = 6) base_primer_pair_designs, base_dinuc_pair_failures = designer._screen_pair_results( - design_input=design_input, designed_primer_pairs=valid_primer_pairs + params=pair_primer_params, designed_primer_pairs=valid_primer_pairs ) assert len(base_dinuc_pair_failures) == 0 - assert design_input is not None for primer_pair in base_primer_pair_designs: assert ( primer_pair.left_primer.longest_dinucleotide_run_length - <= design_input.max_dinuc_bases + <= pair_primer_params.max_dinuc_bases ) assert ( primer_pair.right_primer.longest_dinucleotide_run_length - <= design_input.max_dinuc_bases + <= pair_primer_params.max_dinuc_bases ) # 1 primer from every pair will fail lowered dinuc threshold of 2 # As a result, no valid primer pairs will be emitted altered_designs, altered_dinuc_failures = designer._screen_pair_results( - design_input=altered_design_input, designed_primer_pairs=valid_primer_pairs + params=altered_params, designed_primer_pairs=valid_primer_pairs ) - assert altered_design_input is not None + assert altered_params is not None assert [ - design.longest_dinucleotide_run_length > altered_design_input.primer_max_dinuc_bases + design.longest_dinucleotide_run_length > altered_params.primer_max_dinuc_bases for design in altered_dinuc_failures ] assert len(altered_designs) == 0 @@ -473,17 +442,13 @@ def test_build_failures( ) -> None: """Test that `build_failures()` parses Primer3 `failure_strings` correctly and includes failures related to long dinucleotide runs.""" - target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - - altered_design_input = replace( + altered_params = replace( pair_primer_params, - target=target, primer_max_dinuc_bases=2, # lower from 6 to 2 - task=DesignPrimerPairsTask(), ) designer = Primer3(genome_fasta=genome_ref) primer_pair_designs, dinuc_pair_failures = designer._screen_pair_results( - design_input=altered_design_input, designed_primer_pairs=valid_primer_pairs + params=altered_params, designed_primer_pairs=valid_primer_pairs ) # 3 primers fail for dinucleotide runs that are longer than `primer_max_dinuc_bases` assert len(dinuc_pair_failures) == 3 @@ -513,16 +478,9 @@ def test_build_failures_debugs( ) -> None: """Test that we log a debug message in the event of an unknown Primer3Failure reason.""" caplog.set_level(logging.DEBUG) - target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) - - design_input = replace( - pair_primer_params, - target=target, - task=DesignPrimerPairsTask(), - ) designer = Primer3(genome_fasta=genome_ref) primer_pair_designs, dinuc_pair_failures = designer._screen_pair_results( - design_input=design_input, designed_primer_pairs=valid_primer_pairs + params=pair_primer_params, designed_primer_pairs=valid_primer_pairs ) test_failure_strings = ["fabricated fail reason 1"] designer._build_failures(dinuc_pair_failures, test_failure_strings) @@ -624,13 +582,8 @@ def test_create_design_region_raises_when_primers_would_not_fit_in_design_region def test_probe_design_raises(genome_ref: Path, valid_probe_params: ProbeParameters) -> None: """Test that we raise an error when the target region is smaller than the minimal probe size.""" target = Span(refname="chr1", start=201, end=217, strand=Strand.POSITIVE) - design_input = replace( - valid_probe_params, - target=target, - task=PickHybProbeOnly(), - ) with Primer3(genome_fasta=genome_ref) as designer: with pytest.raises( ValueError, match="Target region required to be at least as large as the" ): - designer.design(design_input=design_input) + designer.design(task=PickHybProbeOnly(), params=valid_probe_params, target=target) diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py index 809bbd6..ff2eab3 100644 --- a/tests/primer3/test_primer3_parameters.py +++ b/tests/primer3/test_primer3_parameters.py @@ -12,7 +12,6 @@ from prymer.primer3.primer3_input_tag import Primer3InputTag from prymer.primer3.primer3_parameters import AmpliconParameters from prymer.primer3.primer3_parameters import ProbeParameters -from prymer.primer3.primer3_task import PickHybProbeOnly from prymer.primer3.primer3_task import Primer3TaskType @@ -22,15 +21,8 @@ def target() -> Span: @pytest.fixture -def design_region() -> Span: - return Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) - - -@pytest.fixture -def valid_primer_amplicon_params(target: Span) -> AmpliconParameters: +def valid_primer_amplicon_params() -> AmpliconParameters: return AmpliconParameters( - target=target, - task=DesignLeftPrimersTask(), amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), @@ -40,10 +32,8 @@ def valid_primer_amplicon_params(target: Span) -> AmpliconParameters: @pytest.fixture -def valid_probe_params(target: Span) -> ProbeParameters: +def valid_probe_params() -> ProbeParameters: return ProbeParameters( - target=target, - task=PickHybProbeOnly(), probe_sizes=MinOptMax(min=18, opt=22, max=30), probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), @@ -62,41 +52,16 @@ def test_primer_design_only_valid( valid_primer_amplicon_params: AmpliconParameters, task_type: Primer3TaskType, ) -> None: - test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - test_input = replace( - valid_primer_amplicon_params, - target=test_target, - task=task_type, - ) - mapped_dict = test_input.to_input_tags(design_region=test_design_region) - assert len(mapped_dict.keys()) == 44 + mapped_dict = valid_primer_amplicon_params.to_input_tags() + assert len(mapped_dict.keys()) == 39 def test_probe_design_only_valid( valid_probe_params: ProbeParameters, ) -> None: - test_design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) - test_target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) - test_input = replace( - valid_probe_params, - target=test_target, - task=PickHybProbeOnly(), - ) - mapped_dict = test_input.to_input_tags(design_region=test_design_region) - assert mapped_dict[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 1 + mapped_dict = valid_probe_params.to_input_tags() assert Primer3InputTag.PRIMER_NUM_RETURN in mapped_dict - - assert len(mapped_dict.keys()) == 28 - - # test instantiation of default `ProbeParameters` when they are not provided - altered_input = replace( - valid_probe_params, - target=test_target, - task=PickHybProbeOnly(), - ) - altered_mapped_dict = altered_input.to_input_tags(design_region=test_target) - assert altered_mapped_dict[Primer3InputTag.PRIMER_INTERNAL_WT_GC_PERCENT_GT] == 0.0 + assert len(mapped_dict.keys()) == 24 def test_primer_amplicon_param_construction_valid( @@ -168,10 +133,9 @@ def test_primer_probe_param_construction_raises( def test_primer_amplicon_params_to_input_tags( valid_primer_amplicon_params: AmpliconParameters, - design_region: Span, ) -> None: """Test that to_input_tags() works as expected""" - test_dict = valid_primer_amplicon_params.to_input_tags(design_region=design_region) + test_dict = valid_primer_amplicon_params.to_input_tags() assert test_dict[Primer3InputTag.PRIMER_NUM_RETURN] == 5 assert test_dict[Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE] == "200-300" assert test_dict[Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE] == 250 @@ -193,7 +157,7 @@ def test_primer_amplicon_params_to_input_tags( assert test_dict[Primer3InputTag.PRIMER_MAX_NS_ACCEPTED] == 1 assert test_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 1 ambiguous_primer_design = replace(valid_primer_amplicon_params, avoid_masked_bases=False) - ambiguous_dict = ambiguous_primer_design.to_input_tags(design_region=design_region) + ambiguous_dict = ambiguous_primer_design.to_input_tags() assert ambiguous_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 0 @@ -216,10 +180,10 @@ def test_max_primer_length(valid_primer_amplicon_params: AmpliconParameters) -> def test_primer_weights_valid( - design_region: Span, valid_primer_amplicon_params: AmpliconParameters + valid_primer_amplicon_params: AmpliconParameters, ) -> None: """Test instantiation of `AmpliconParameters` object with valid input""" - test_dict = valid_primer_amplicon_params.to_input_tags(design_region=design_region) + test_dict = valid_primer_amplicon_params.to_input_tags() assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT] == 1 assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT] == 0.0 @@ -233,11 +197,11 @@ def test_primer_weights_valid( assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_GT] == 0.1 assert test_dict[Primer3InputTag.PRIMER_WT_TM_LT] == 1.0 assert test_dict[Primer3InputTag.PRIMER_WT_TM_GT] == 1.0 - assert len((test_dict.values())) == 44 + assert len((test_dict.values())) == 39 -def test_probe_weights_valid(design_region: Span, valid_probe_params: ProbeParameters) -> None: - test_dict = valid_probe_params.to_input_tags(design_region=design_region) +def test_probe_weights_valid(valid_probe_params: ProbeParameters) -> None: + test_dict = valid_probe_params.to_input_tags() assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_LT] == 1.0 assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SIZE_GT] == 1.0 assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_TM_LT] == 1.0 @@ -247,16 +211,14 @@ def test_probe_weights_valid(design_region: Span, valid_probe_params: ProbeParam assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_ANY_TH] == 0.0 assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_SELF_END_TH] == 0.0 assert test_dict[Primer3InputTag.PRIMER_INTERNAL_WT_HAIRPIN_TH] == 0.0 - assert len(test_dict) == 28 + assert len(test_dict) == 24 -def test_primer_weights_to_input_tags( - design_region: Span, valid_primer_amplicon_params: AmpliconParameters -) -> None: +def test_primer_weights_to_input_tags(valid_primer_amplicon_params: AmpliconParameters) -> None: """Test results from to_input_tags() with and without default values""" - default_map = valid_primer_amplicon_params.to_input_tags(design_region) + default_map = valid_primer_amplicon_params.to_input_tags() assert default_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 customized_map = replace( valid_primer_amplicon_params, amplicon_size_wt=WeightRange(5.0, 1.0) - ).to_input_tags(design_region=design_region) + ).to_input_tags() assert customized_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 5