Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add probe-specific logic to Primer3 #48

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ amplicons prior to primer design.
Designing primers (left or right) or primer pairs using Primer3 is primarily performed using the
[`Primer3`][prymer.primer3.primer3.Primer3] class, which wraps the
[`primer3` command line tool](https://github.com/primer3-org/primer3). The
[`design_primers()`][prymer.primer3.primer3.Primer3.design_primers] facilitates the design of single and paired primers
[`design_oligos()`][prymer.primer3.primer3.Primer3.design_oligos] facilitates the design of primers (single and paired) and/or internal probes
for a single target. The `Primer3` instance is intended to be re-used to design primers across multiple targets, or
re-design (after changing parameters) for the same target, or both!

Expand Down
22 changes: 22 additions & 0 deletions prymer/api/probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from dataclasses import dataclass

from fgpyo.util.metric import Metric

from prymer.api.primer import Primer


@dataclass(frozen=True, init=True, kw_only=True, slots=True)
class Probe(Primer, Metric["Probe"]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue Per discussion on the working group call today, we are going to add these fields directly to Primer (and maybe rename the class to Oligo or similar)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have documented this because I just bumped up against why I defaulted them both to None in the first place-- if you specify the default weights for either task, then Primer3 uses those weights as part of your scoring function, and it will complain if the corresponding params are not there, because it has no delta-off-optimal to score.

"""Stores the properties of the designed Probe. Inherits `tm`, `penalty`,
`span`, `bases`, and `tail` from `Primer`.

Attributes:
self_any_th: self-complementarity throughout the probe as calculated by Primer3
self_end_th: 3' end complementarity of the probe as calculated by Primer3
hairpin_th: hairpin formation thermodynamics of the probe as calculated by Primer3

"""

self_any_th: float
self_end_th: float
hairpin_th: float
166 changes: 113 additions & 53 deletions prymer/primer3/primer3.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
primer_and_amplicon_params=params, \
task=DesignLeftPrimersTask(), \
)
>>> left_result = designer.design_primers(design_input=design_input)
>>> left_result = designer.design_oligos(design_input=design_input)

```

Expand Down Expand Up @@ -142,6 +142,7 @@
from prymer.api.primer import Primer
from prymer.api.primer_like import PrimerLike
from prymer.api.primer_pair import PrimerPair
from prymer.api.probe import Probe
from prymer.api.span import Span
from prymer.api.span import Strand
from prymer.api.variant_lookup import SimpleVariant
Expand All @@ -152,6 +153,7 @@
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.util.executable_runner import ExecutableRunner


Expand Down Expand Up @@ -308,13 +310,6 @@
hard_masked = "".join(soft_masked_list)
return soft_masked, hard_masked

@staticmethod
def _is_valid_primer(design_input: Primer3Input, primer_design: Primer) -> bool:
return (
primer_design.longest_dinucleotide_run_length()
<= design_input.primer_and_amplicon_params.primer_max_dinuc_bases
)

@staticmethod
def _screen_pair_results(
design_input: Primer3Input, designed_primer_pairs: list[PrimerPair]
Expand Down Expand Up @@ -349,8 +344,8 @@
valid_primer_pair_designs.append(primer_pair)
return valid_primer_pair_designs, dinuc_pair_failures

def design_primers(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901
"""Designs primers or primer pairs given a target region.
def design_oligos(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901
"""Designs primers, primer pairs, and/or internal probes given a target region.

Args:
design_input: encapsulates the target region, design task, specifications, and scoring
Expand All @@ -371,12 +366,16 @@
f"Error, trying to use a subprocess that has already been "
f"terminated, return code {self._subprocess.returncode}"
)

design_region: Span = 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,
)
design_region: Span
match design_input.task:
case PickHybProbeOnly():
design_region = design_input.target
case _:
emmcauley marked this conversation as resolved.
Show resolved Hide resolved
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,
)

soft_masked, hard_masked = self.get_design_sequences(design_region)
global_primer3_params = {
Expand All @@ -389,7 +388,6 @@
**global_primer3_params,
**design_input.to_input_tags(design_region=design_region),
}

# Submit inputs to primer3
for tag, value in assembled_primer3_tags.items():
self._subprocess.stdin.write(f"{tag}={value}")
Expand Down Expand Up @@ -441,6 +439,20 @@
primer3_error("Primer3 failed")

match design_input.task:
case PickHybProbeOnly(): # Probe design
all_probe_results: list[Probe] = Primer3._build_probes(
design_input=design_input,
design_results=primer3_results,
design_region=design_region,
unmasked_design_seq=soft_masked,
)

return Primer3._assemble_single_designs(
design_input=design_input,
design_results=primer3_results,
unfiltered_designs=all_probe_results,
)

case DesignPrimerPairsTask(): # Primer pair design
all_pair_results: list[PrimerPair] = Primer3._build_primer_pairs(
design_input=design_input,
Expand All @@ -462,7 +474,7 @@
design_task=design_input.task,
unmasked_design_seq=soft_masked,
)
return Primer3._assemble_primers(
return Primer3._assemble_single_designs(
design_input=design_input,
design_results=primer3_results,
unfiltered_designs=all_single_results,
Expand All @@ -471,6 +483,46 @@
case _ as unreachable:
assert_never(unreachable)

@staticmethod
def _build_probes(
design_input: Primer3Input,
design_results: dict[str, str],
design_region: Span,
unmasked_design_seq: str,
) -> list[Probe]:
count: int = _check_design_results(design_input, design_results)
task_key = design_input.task.task_type
probes: list[Probe] = []
for idx in range(count):
key = f"PRIMER_{task_key}_{idx}"
str_position, str_length = design_results[key].split(",", maxsplit=1)
position, length = int(str_position), int(str_length) # position is 1-based

span = design_region.get_subspan(
offset=position - 1, subspan_length=length, strand=design_region.strand
)

slice_offset = design_region.get_offset(span.start)
slice_end = design_region.get_offset(span.end) + 1

# remake the primer sequence from the un-masked genome sequence just in case
bases = unmasked_design_seq[slice_offset:slice_end]
if span.strand == Strand.NEGATIVE:
bases = reverse_complement(bases)

Check warning on line 511 in prymer/primer3/primer3.py

View check run for this annotation

Codecov / codecov/patch

prymer/primer3/primer3.py#L511

Added line #L511 was not covered by tests

probes.append(
Probe(
bases=bases,
tm=float(design_results[f"{key}_TM"]),
penalty=float(design_results[f"{key}_PENALTY"]),
span=span,
self_any_th=float(design_results[f"{key}_SELF_ANY_TH"]),
self_end_th=float(design_results[f"{key}_SELF_END_TH"]),
hairpin_th=float(design_results[f"{key}_HAIRPIN_TH"]),
)
)
return probes

@staticmethod
def _build_primers(
design_input: Primer3Input,
Expand All @@ -495,18 +547,9 @@
Raises:
ValueError: if Primer3 does not return primer designs
"""
count_tag = design_input.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:
primer_error = design_results["PRIMER_ERROR"]
raise ValueError(f"Primer3 returned an error: {primer_error}")
else:
raise ValueError(f"Primer3 did not return the count tag: {count_tag}")
count: int = int(maybe_count)

primers = []
count: int = _check_design_results(design_input, design_results)

primers: list[Primer] = []
for idx in range(count):
key = f"PRIMER_{design_task.task_type}_{idx}"
str_position, str_length = design_results[key].split(",", maxsplit=1)
Expand Down Expand Up @@ -544,41 +587,31 @@
return primers

@staticmethod
def _assemble_primers(
design_input: Primer3Input, design_results: dict[str, str], unfiltered_designs: list[Primer]
def _assemble_single_designs(
design_input: Primer3Input,
design_results: dict[str, str],
unfiltered_designs: Union[list[Primer], list[Probe]],
) -> Primer3Result:
"""Helper function to organize primer designs into valid and failed designs.

Wraps `Primer3._is_valid_primer()` and `Primer3._build_failures()` to filter out designs
with dinucleotide runs that are too long and extract additional failure reasons emitted by
Primer3.
"""Screens oligo designs (primers or probes) emitted by Primer3 for acceptable dinucleotide
runs and extracts failure reasons for failed designs."""

Args:
design_input: encapsulates the target region, design task, specifications,
and scoring penalties
unfiltered_designs: list of primers emitted from Primer3
design_results: key-value pairs of results reported by Primer3

Returns:
primer_designs: a `Primer3Result` that encapsulates valid and failed designs
"""
valid_primer_designs = [
valid_oligo_designs = [
design
for design in unfiltered_designs
if Primer3._is_valid_primer(primer_design=design, design_input=design_input)
if _has_acceptable_dinuc_run(oligo_design=design, design_input=design_input)
]
dinuc_failures = [
design
for design in unfiltered_designs
if not Primer3._is_valid_primer(primer_design=design, design_input=design_input)
if not _has_acceptable_dinuc_run(oligo_design=design, design_input=design_input)
]

failure_strings = [design_results[f"PRIMER_{design_input.task.task_type}_EXPLAIN"]]
failures = Primer3._build_failures(dinuc_failures, failure_strings)
primer_designs: Primer3Result = Primer3Result(
filtered_designs=valid_primer_designs, failures=failures
design_candidates: Primer3Result = Primer3Result(
filtered_designs=valid_oligo_designs, failures=failures
)
return primer_designs
return design_candidates

@staticmethod
def _build_primer_pairs(
Expand Down Expand Up @@ -684,7 +717,7 @@

@staticmethod
def _build_failures(
dinuc_failures: list[Primer],
dinuc_failures: Union[list[Primer], list[Probe]],
failure_strings: list[str],
) -> list[Primer3Failure]:
"""Extracts the reasons why designs that were considered by Primer3 failed
Expand Down Expand Up @@ -760,3 +793,30 @@
)

return design_region


def _check_design_results(design_input: Primer3Input, 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)
if maybe_count is None: # no count tag was found
if "PRIMER_ERROR" in design_results:
primer_error = design_results["PRIMER_ERROR"]
raise ValueError(f"Primer3 returned an error: {primer_error}")

Check warning on line 805 in prymer/primer3/primer3.py

View check run for this annotation

Codecov / codecov/patch

prymer/primer3/primer3.py#L804-L805

Added lines #L804 - L805 were not covered by tests
else:
raise ValueError(f"Primer3 did not return the count tag: {count_tag}")

Check warning on line 807 in prymer/primer3/primer3.py

View check run for this annotation

Codecov / codecov/patch

prymer/primer3/primer3.py#L807

Added line #L807 was not covered by tests
count: int = int(maybe_count)

return count


def _has_acceptable_dinuc_run(
design_input: Primer3Input, oligo_design: Union[Primer, Probe]
) -> bool:
emmcauley marked this conversation as resolved.
Show resolved Hide resolved
max_dinuc_bases: int
if type(oligo_design) is Primer:
max_dinuc_bases = design_input.primer_and_amplicon_params.primer_max_dinuc_bases
elif type(oligo_design) is Probe:
max_dinuc_bases = design_input.probe_params.probe_max_dinuc_bases

return oligo_design.longest_dinucleotide_run_length() <= max_dinuc_bases
19 changes: 12 additions & 7 deletions prymer/primer3/primer3_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,18 @@
"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())
elif self.task.requires_primer_amplicon_params:
if self.primer_and_amplicon_params is None:
raise ValueError(f"Primer3 task {self.task} requires `PrimerAndAmpliconParams`")

Check warning on line 127 in prymer/primer3/primer3_input.py

View check run for this annotation

Codecov / codecov/patch

prymer/primer3/primer3_input.py#L127

Added line #L127 was not covered by tests
else:
if self.primer_weights is None:
object.__setattr__(self, "primer_weights", PrimerAndAmpliconWeights())
emmcauley marked this conversation as resolved.
Show resolved Hide resolved
elif self.task.requires_probe_params:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question Is it ever possible for a task to require both probe and primer params?

If so, you may want to consider checking both and then reporting all collected errors. But if they're mutually exclusive it's unnecessary

if self.probe_params is None:
raise ValueError(f"Primer3 task {self.task} requires `ProbeParameters`")

Check warning on line 133 in prymer/primer3/primer3_input.py

View check run for this annotation

Codecov / codecov/patch

prymer/primer3/primer3_input.py#L133

Added line #L133 was not covered by tests
else:
if 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`
Expand All @@ -154,5 +160,4 @@
for settings in optional_attributes.values():
if settings is not None:
assembled_tags.update(settings.to_input_tags())

return assembled_tags
Loading
Loading