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(