Skip to content
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
9 changes: 6 additions & 3 deletions src/amplifyp/amplicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def __init__(self, template: DNA) -> None:
"""
self.template = template
self.repliconfs: list[Repliconf] = []
self._repliconfs_set: set[Repliconf] = set()

def add_repliconf(self, repliconf: Repliconf) -> None:
"""Add a replication configuration to the generator.
Expand All @@ -149,8 +150,9 @@ def add_repliconf(self, repliconf: Repliconf) -> None:
"AmpliconGenerator."
)

if repliconf not in self.repliconfs:
if repliconf not in self._repliconfs_set:
self.repliconfs.append(repliconf)
self._repliconfs_set.add(repliconf)
else:
raise DuplicateRepliconfError(
"The Repliconf is already in the AmpliconGenerator."
Expand All @@ -166,6 +168,7 @@ def remove_repliconf(self, repliconf: Repliconf) -> None:
ValueError: If the `repliconf` is not present in the generator.
"""
self.repliconfs.remove(repliconf)
self._repliconfs_set.remove(repliconf)

def get_amplicon_quality_score(
self,
Expand Down Expand Up @@ -232,15 +235,15 @@ def _construct_amplicon_sequence(
seq = (
fwd_conf.primer
+ self.template[start:end]
+ rev_conf.primer.reverse_complement()
+ rev_conf.rev_comp_primer
)
elif self.template.type == DNAType.CIRCULAR:
# Circular DNA handling
seq = (
fwd_conf.primer
+ self.template[start:]
+ self.template[:end]
+ rev_conf.primer.reverse_complement()
+ rev_conf.rev_comp_primer
)
circular = True
elif (start > end) and (self.template.type == DNAType.LINEAR):
Expand Down
18 changes: 13 additions & 5 deletions src/amplifyp/dna.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,16 @@ def __init__(
else:
raise TypeError("Invalid DNA type.")

invalid_chars = set(self.__seq.upper()) - set(check_str)
# Optimization: cache the uppercase sequence to avoid repeated
# allocations in _count_bases and other methods.
seq_upper = self.__seq.upper()
if seq_upper == self.__seq:
# If the sequence is already uppercase, avoid duplicating the string.
self._seq_upper = self.__seq
else:
self._seq_upper = seq_upper

invalid_chars = set(self._seq_upper) - set(check_str)
if invalid_chars:
raise ValueError(
f"The DNA sequence contains invalid characters: {
Expand Down Expand Up @@ -255,7 +264,7 @@ def __eq__(self, other: object) -> bool:
if not isinstance(other, DNA):
return NotImplemented
return (
self.seq.upper() == other.seq.upper()
self._seq_upper == other._seq_upper
and self.direction == other.direction
and self.type == other.type
)
Expand All @@ -268,7 +277,7 @@ def __hash__(self) -> int:
Returns:
int: The hash value.
"""
return hash((self.seq.upper(), self.direction, self.type))
return hash((self._seq_upper, self.direction, self.type))

def __len__(self) -> int:
"""Return the length of the DNA sequence.
Expand Down Expand Up @@ -326,8 +335,7 @@ def _count_bases(self, bases: str) -> int:
Returns:
int: The total count of the specified characters in the sequence.
"""
seq_upper = self.seq.upper()
return sum(seq_upper.count(base) for base in bases)
return sum(self._seq_upper.count(base) for base in bases)

def count_at(self) -> int:
"""Count the number of A, T, or W (A/T ambiguous) bases.
Expand Down
67 changes: 49 additions & 18 deletions src/amplifyp/gui2/map_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,29 +142,60 @@ def basex(base_idx: int) -> float:
# or two parts.
# Using simple bar for now.

if amp.circular:
# TODO: visual for circular wrap
pass
# Check for wrapping (start > end implies wrapping in circular DNA)
if end_x < start_x:
# Circular wrapping visualization
# Segment 1: start_x to end of template
right_limit = basex(self.target_length)
self.plotters.append(
RectThing(
start_x,
current_y,
right_limit,
current_y + 10,
fill="black",
outline="",
)
)

width = end_x - start_x
if width < 0: # Wrap around or circular
# Just draw nothing for now or handle later
pass
# Segment 2: start of template to end_x
left_limit = basex(0)
self.plotters.append(
RectThing(
left_limit,
current_y,
end_x,
current_y + 10,
fill="black",
outline="",
)
)

# Bar
self.plotters.append(
RectThing(
start_x,
current_y,
end_x,
current_y + 10,
fill="black",
outline="",
# Label placement on the longer segment
len_seg1 = right_limit - start_x
len_seg2 = end_x - left_limit

if len_seg1 >= len_seg2:
center_x = start_x + len_seg1 / 2
else:
center_x = left_limit + len_seg2 / 2

else:
# Linear visualization
self.plotters.append(
RectThing(
start_x,
current_y,
end_x,
current_y + 10,
fill="black",
outline="",
)
)
)
width = end_x - start_x
center_x = start_x + width / 2

# Size label centered
center_x = start_x + width / 2
self.plotters.append(
StringThing(
str(len(amp.product)) + " bp",
Expand Down
3 changes: 3 additions & 0 deletions src/amplifyp/repliconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ def __init__(
# Pre-calculate reversed primer sequence
self._rev_primer_seq = self.primer.seq[::-1]

# Pre-calculate reverse complement primer for amplicon generation
self.rev_comp_primer = self.primer.reverse_complement()

def range(self) -> range:
"""Return the range of valid starting indices for search.

Expand Down
58 changes: 58 additions & 0 deletions tests/test_amplicon_generator_optimization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest
from amplifyp.dna import DNA, Primer
from amplifyp.amplicon import AmpliconGenerator
from amplifyp.repliconf import Repliconf
from amplifyp.errors import DuplicateRepliconfError

def test_internal_consistency():
"""Verify that repliconfs list and internal set are kept in sync."""
dna = DNA("ATGC" * 10, name="Template")
primer1 = Primer("ATGC", "Primer1")
primer2 = Primer("GCAT", "Primer2")
repliconf1 = Repliconf(dna, primer1)
repliconf2 = Repliconf(dna, primer2)
generator = AmpliconGenerator(dna)

# Initial state
assert len(generator.repliconfs) == 0
assert len(generator._repliconfs_set) == 0

# Add first repliconf
generator.add_repliconf(repliconf1)
assert len(generator.repliconfs) == 1
assert len(generator._repliconfs_set) == 1
assert repliconf1 in generator.repliconfs
assert repliconf1 in generator._repliconfs_set

# Add second repliconf
generator.add_repliconf(repliconf2)
assert len(generator.repliconfs) == 2
assert len(generator._repliconfs_set) == 2
assert repliconf2 in generator.repliconfs
assert repliconf2 in generator._repliconfs_set

# Try duplicate
with pytest.raises(DuplicateRepliconfError):
generator.add_repliconf(repliconf1)

# State should remain unchanged
assert len(generator.repliconfs) == 2
assert len(generator._repliconfs_set) == 2

# Remove repliconf1
generator.remove_repliconf(repliconf1)
assert len(generator.repliconfs) == 1
assert len(generator._repliconfs_set) == 1
assert repliconf1 not in generator.repliconfs
assert repliconf1 not in generator._repliconfs_set
assert repliconf2 in generator.repliconfs
assert repliconf2 in generator._repliconfs_set

# Remove repliconf2
generator.remove_repliconf(repliconf2)
assert len(generator.repliconfs) == 0
assert len(generator._repliconfs_set) == 0

# Try remove non-existent
with pytest.raises(ValueError):
generator.remove_repliconf(repliconf1)