class Oligo(ProxyObject): """ Oligo is a group of Strands that are connected via 5' and/or 3' connections. It corresponds to the physical DNA strand, and is thus used tracking and storing properties that are common to a single strand, such as its color. Commands that affect Strands (e.g. create, remove, merge, split) are also responsible for updating the affected Oligos. """ def __init__(self, part, color=None): # self.__class__.__base__.__init__(self, part) super(Oligo, self).__init__(part) self._part = part self._strand5p = None self._length = 0 self._is_loop = False self._color = color if color else "#0066cc" # end def def __repr__(self): cls_name = self.__class__.__name__ olg_id = str(id(self))[-4:] if self._strand5p is not None: strand_type = "Stap" if self.isStaple() else "Scaf" vh_num = self._strand5p.strandSet().virtualHelix().number() idx = self._strand5p.idx5Prime() else: strand_type = "None" vh_num = -1 idx = -1 return "<%s %s>(%s %d[%d])" % (cls_name, olg_id, strand_type, vh_num, idx) def shallowCopy(self): olg = Oligo(self._part) olg._strand5p = self._strand5p olg._length = self._length olg._is_loop = self._is_loop olg._color = self._color return olg # end def def deepCopy(self, part): olg = Oligo(part) olg._strand5p = None olg._length = self._length olg._is_loop = self._is_loop olg._color = self._color return olg # end def ### SIGNALS ### oligoIdentityChangedSignal = ProxySignal( ProxyObject, name='oligoIdentityChangedSignal') # new oligo oligoAppearanceChangedSignal = ProxySignal( ProxyObject, name='oligoAppearanceChangedSignalpyqtSignal') # self oligoSequenceAddedSignal = ProxySignal( ProxyObject, name='oligoSequenceAddedSignal') # self oligoSequenceClearedSignal = ProxySignal( ProxyObject, name='oligoSequenceClearedSignal') # self ### SLOTS ### ### ACCESSORS ### def color(self): return self._color # end def def locString(self): vh_num = self._strand5p.strandSet().virtualHelix().number() idx = self._strand5p.idx5Prime() return "%d[%d]" % (vh_num, idx) # end def def part(self): return self._part # end def def strand5p(self): return self._strand5p # end def def setStrand5p(self, strand): self._strand5p = strand # end def def undoStack(self): return self._part.undoStack() # end def ### PUBLIC METHODS FOR QUERYING THE MODEL ### def isLoop(self): return self._is_loop def isStaple(self): if self._strand5p is not None: return self._strand5p.isStaple() else: return False def length(self): return self._length # end def def sequence(self): temp = self.strand5p() if not temp: return None if temp.sequence(): return ''.join([Strand.sequence(strand) \ for strand in self.strand5p().generator3pStrand()]) else: return None # end def def sequenceExport(self): part = self.part() vh_num5p = self.strand5p().virtualHelix().number() strand5p = self.strand5p() idx5p = strand5p.idx5Prime() seq = '' if self.isLoop(): # print("A loop exists") raise Exception for strand in strand5p.generator3pStrand(): seq = seq + Strand.sequence(strand, for_export=True) if strand.connection3p() == None: # last strand in the oligo vhNum3p = strand.virtualHelix().number() idx3p = strand.idx3Prime() modseq5p, modseq5p_name = part.getModSequence(strand5p, idx5p, 0) modseq3p, modseq3p_name = part.getModSequence(strand, idx3p, 1) seq = modseq5p + seq + modseq3p output = "%d[%d],%d[%d],%s,%s,%s,%s,%s\n" % \ (vhNum5p, idx5p, vhNum3p, idx3p, seq, len(seq), self._color, modseq5p_name, modseq3p_name) return output # end def def shouldHighlight(self): if not self._strand5p: return False if self._strand5p.isScaffold(): return False if self.length() < 18: return True if self.length() > 50: return True return False # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def remove(self, use_undostack=True): c = RemoveOligoCommand(self) util.execCommandList(self, [c], desc="Color Oligo", use_undostack=use_undostack) # end def def applyColor(self, color, use_undostack=True): if color == self._color: return # oligo already has color c = ApplyColorCommand(self, color) util.execCommandList(self, [c], desc="Color Oligo", use_undostack=use_undostack) # end def def applySequence(self, sequence, use_undostack=True): c = ApplySequenceCommand(self, sequence) util.execCommandList(self, [c], desc="Apply Sequence", use_undostack=use_undostack) # end def def applySequenceCMD(self, sequence, use_undostack=True): return ApplySequenceCommand(self, sequence) # end def def setLoop(self, bool): self._is_loop = bool ### PUBLIC SUPPORT METHODS ### def addToPart(self, part): self._part = part self.setParent(part) part.addOligo(self) # end def def destroy(self): # QObject also emits a destroyed() Signal self.setParent(None) self.deleteLater() # end def def decrementLength(self, delta): self.setLength(self._length - delta) # end def def incrementLength(self, delta): self.setLength(self._length + delta) # end def def refreshLength(self): temp = self.strand5p() if not temp: return length = 0 for strand in temp.generator3pStrand(): length += strand.totalLength() self.setLength(length) # end def def removeFromPart(self): """ This method merely disconnects the object from the model. It still lives on in the undoStack until clobbered Note: don't set self._part = None because we need to continue passing the same reference around. """ self._part.removeOligo(self) self.setParent(None) # end def def setColor(self, color): self._color = color # end def def setLength(self, length): before = self.shouldHighlight() self._length = length if before != self.shouldHighlight(): self.oligoSequenceClearedSignal.emit(self) self.oligoAppearanceChangedSignal.emit(self) # end def def strandMergeUpdate(self, old_strand_low, old_strand_high, new_strand): """ This method sets the isLoop status of the oligo and the oligo's 5' strand. """ # check loop status if old_strand_low.oligo() == old_strand_high.oligo(): self._is_loop = True self._strand5p = new_strand return # leave the _strand5p as is? # end if # Now get correct 5p end to oligo if old_strand_low.isDrawn5to3(): if old_strand_low.connection5p() is not None: self._strand5p = old_strand_low.oligo()._strand5p else: self._strand5p = new_strand else: if old_strand_high.connection5p() is not None: self._strand5p = old_strand_high.oligo()._strand5p else: self._strand5p = new_strand # end if # end def def strandResized(self, delta): """ Called by a strand after resize. Delta is used to update the length, which may case an appearance change. """ pass # end def def strandSplitUpdate(self, new_strand5p, new_strand3p, oligo3p, old_merged_strand): """ If the oligo is a loop, splitting the strand does nothing. If the oligo isn't a loop, a new oligo must be created and assigned to the new_strand and everything connected to it downstream. """ # if you split it can't be a loop self._is_loop = False if old_merged_strand.oligo().isLoop(): self._strand5p = new_strand3p return else: if old_merged_strand.connection5p() == None: self._strand5p = new_strand5p else: self._strand5p = old_merged_strand.oligo()._strand5p oligo3p._strand5p = new_strand3p
class StrandSet(CNObject): """:class:`StrandSet` is a container class for :class:`Strands`, and provides the several publicly accessible methods for editing strands, including operations for creation, destruction, resizing, splitting, and merging strands. Views may also query :class:`StrandSet` for information that is useful in determining if edits can be made, such as the bounds of empty space in which a strand can be created or resized. Internally :class:`StrandSet` uses redundant heap and a list data structures to track :class:`Strands` objects, with the list of length of a virtual helix looking like:: strand_array = [strandA, strandA, strandA, ..., None, strandB, strandB, ...] Where every index strandA spans has a reference to strandA and strand_heap:: strand_heap = [strandA, strandB, strandC, ...] is merely a sorted list from low index to high index of strand objects Args: is_fwd (bool): is this a forward or reverse StrandSet? id_num (int): ID number of the virtual helix this is on part (Part): Part object this is a child of initial_size (int): initial_size to allocate """ def __init__(self, is_fwd, id_num, part, initial_size): self._document = part.document() super(StrandSet, self).__init__(part) self._is_fwd = is_fwd self._strand_type = StrandType.FWD if self._is_fwd else StrandType.REV self._id_num = id_num self._part = part self._reset(initial_size) self._undo_stack = None # end def def simpleCopy(self, part): """Create an empty copy (no strands) of this strandset with the only a new virtual_helix_group parent TODO: consider renaming this method Args: part (Part): part to copy this into """ return StrandSet(self._is_fwd, self._id_num, part, len(self.strand_array)) # end def def __iter__(self): """Iterate over each strand in the strands list. Yields: Strand: :class:`Strand` in order from low to high index """ return self.strands().__iter__() # end def def __repr__(self): if self._is_fwd: st = 'fwd' else: st = 'rev' num = self._id_num return "<%s_StrandSet(%d)>" % (st, num) # end def ### SIGNALS ### strandsetStrandAddedSignal = ProxySignal(CNObject, CNObject, name='strandsetStrandAddedSignal') """pyqtSignal(QObject, QObject): strandset, strand""" ### SLOTS ### ### ACCESSORS ### def part(self): """Get model :class:`Part` Returns: Part: the :class:`Part` """ return self._part # end def def document(self): """Get model :class:`Document` Returns: Document: the :class:`Document` """ return self._document # end def def strands(self): """Get raw reference to the strand_heap of this :class:`StrandSet` Returns: list: the list of strands """ return self.strand_heap def _reset(self, initial_size): """Reset this object clearing out references to all :class:`Strand` objects. Exceptional private method to be only used by Parts Args: initial_size (int): size to revert to """ self.strand_array = [None] * (initial_size) self.strand_heap = [] # end def def resize(self, delta_low, delta_high): """Resize this StrandSet. Pad each end when growing otherwise don't do anything Args: delta_low (int): amount to resize the low index end delta_high (int): amount to resize the high index end """ if delta_low < 0: self.strand_array = self.strand_array[delta_low:] if delta_high < 0: self.strand_array = self.strand_array[:delta_high] self.strand_array = [None] * delta_low + self.strand_array + [ None ] * delta_high # end def ### PUBLIC METHODS FOR QUERYING THE MODEL ### def isForward(self): """Is the set 5' to 3' (forward) or is it 3' to 5' (reverse) Returns: bool: True if is forward, False otherwise """ return self._is_fwd # end def def strandType(self): """Store the enum of strand type Returns: int: :class:`StrandType.FWD` if is forward, otherwise :class:`StrandType.REV` """ return self._strand_type def isReverse(self): """Is the set 3' to 5' (reverse) or 5' to 3' (forward)? Returns: bool: True if is reverse, False otherwise """ return not self._is_fwd # end def def length(self): """ length of the :class:`StrandSet` and therefore also the associated virtual helix in bases Returns: int: length of the set """ return len(self.strand_array) def idNum(self): """Get the associated virtual helix ID number Returns: int: virtual helix ID number """ return self._id_num # end def def getNeighbors(self, strand): """Given a :class:`Strand` in this :class:`StrandSet` find it's internal neighbors Args: strand (Strand): Returns: tuple: (low neighbor, high neighbor) of types :class:`Strand` or :obj:`None` """ sh = self.strand_heap i = bisect_left(sh, strand) if sh[i] != strand: raise ValueError("getNeighbors: strand not in set") if i == 0: low_strand = None else: low_strand = sh[i - 1] if i == len(sh) - 1: high_strand = None else: high_strand = sh[i + 1] return low_strand, high_strand # end def def complementStrandSet(self): """Returns the complementary strandset. Used for insertions and sequence application. Returns: StrandSet: the complementary :class:`StrandSet` """ fwd_ss, rev_ss = self._part.getStrandSets(self._id_num) return rev_ss if self._is_fwd else fwd_ss # end def def getBoundsOfEmptyRegionContaining(self, base_idx): """Return the bounds of the empty region containing base index <base_idx>. Args: base_idx (int): the index of interest Returns: tuple: of :obj:`int` of form:: (low_idx, high_idx) """ class DummyStrand(object): _base_idx_low = base_idx def __lt__(self, other): return self._base_idx_low < other._base_idx_low ds = DummyStrand() sh = self.strand_heap lsh = len(sh) if lsh == 0: return 0, len(self.strand_array) - 1 # the i-th index is the high-side strand and the i-1 index # is the low-side strand since bisect_left gives the index # to insert the dummy strand at i = bisect_left(sh, ds) if i == 0: low_idx = 0 else: low_idx = sh[i - 1].highIdx() + 1 # would be an append to the list effectively if inserting the dummy strand if i == lsh: high_idx = len(self.strand_array) - 1 else: high_idx = sh[i].lowIdx() - 1 return (low_idx, high_idx) # end def def indexOfRightmostNonemptyBase(self): """Returns the high base_idx of the last strand, or 0.""" sh = self.strand_heap if len(sh) > 0: return sh[-1].highIdx() else: return 0 # end def def strandCount(self): """Getter for the number of :class:`Strands` in the set Returns: int: the number of :class:`Strands` in the set """ return len(self.strand_heap) # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def createStrand(self, base_idx_low, base_idx_high, color=None, use_undostack=True): """Assumes a strand is being created at a valid set of indices. Args: base_idx_low (int): low index of strand base_idx_high (int): high index of strand color (:obj:`str`, optional): default=True use_undostack (:obj:`bool`, optional): default=True Returns: object: Strand if successful, None otherwise """ # print("sss creating strand") part = self._part # NOTE: this color defaulting thing is problematic for tests if color is None: color = part.getProperty('color') bounds_low, bounds_high = self.getBoundsOfEmptyRegionContaining( base_idx_low) if bounds_low is not None and bounds_low <= base_idx_low and \ bounds_high is not None and bounds_high >= base_idx_high: c = CreateStrandCommand(self, base_idx_low, base_idx_high, color, update_segments=use_undostack) x, y = part.getVirtualHelixOrigin(self._id_num) d = "%s:(%0.2f,%0.2f).%d^%d" % (self.part().getName(), x, y, self._is_fwd, base_idx_low) # print("strand", d) util.execCommandList(self, [c], desc=d, use_undostack=use_undostack) return c.strand() else: # print("could not create strand", bounds_low, bounds_high, base_idx_low, base_idx_high) return None # end def def createDeserializedStrand(self, base_idx_low, base_idx_high, color, use_undostack=False): """Passes a strand to AddStrandCommand that was read in from file input. Omits the step of checking _couldStrandInsertAtLastIndex, since we assume that deserialized strands will not cause collisions. """ c = CreateStrandCommand(self, base_idx_low, base_idx_high, color, update_segments=use_undostack) x, y = self._part.getVirtualHelixOrigin(self._id_num) d = "(%0.2f,%0.2f).%d^%d" % (x, y, self._is_fwd, base_idx_low) # print("strand", d) util.execCommandList(self, [c], desc=d, use_undostack=use_undostack) return 0 # end def def isStrandInSet(self, strand): sl = self.strand_array if sl[strand.lowIdx()] == strand and sl[strand.highIdx()] == strand: return True else: return False # end def def removeStrand(self, strand, use_undostack=True, solo=True): """Remove a :class:`Strand` from the set Args: strand (Strand): the :class:`Strand` to remove use_undostack (:obj:`bool`, optional): default=True solo (:obj:`bool`, optional): solo is an argument to enable limiting signals emiting from the command in the case the command is instantiated part of a larger command, default=True """ cmds = [] if not self.isStrandInSet(strand): raise IndexError("Strandset.removeStrand: strand not in set") if strand.sequence() is not None: cmds.append(strand.oligo().applySequenceCMD(None)) cmds += strand.clearDecoratorCommands() cmds.append(RemoveStrandCommand(self, strand, solo=solo)) util.execCommandList(self, cmds, desc="Remove strand", use_undostack=use_undostack) # end def def oligoStrandRemover(self, strand, cmds, solo=True): """Used for removing all :class:`Strand`s from an :class:`Oligo` Args: strand (Strand): a strand to remove cmds (list): a list of :class:`UndoCommand` objects to append to solo (:obj:`bool`, optional): to pass on to RemoveStrandCommand, default=True """ if not self.isStrandInSet(strand): raise IndexError("Strandset.oligoStrandRemover: strand not in set") cmds += strand.clearDecoratorCommands() cmds.append(RemoveStrandCommand(self, strand, solo=solo)) # end def def removeAllStrands(self, use_undostack=True): """Remove all :class:`Strand` objects in the set Args: use_undostack (:obj:`bool`, optional): default=True """ for strand in list(self.strand_heap): self.removeStrand(strand, use_undostack=use_undostack, solo=False) # end def def mergeStrands(self, priority_strand, other_strand, use_undostack=True): """Merge the priority_strand and other_strand into a single new strand. The oligo of priority should be propagated to the other and all of its connections. Args: priority_strand (Strand): priority strand other_strand (Strand): other strand use_undostack (:obj:`bool`, optional): default=True Returns: bool: True if strands were merged, False otherwise """ low_and_high_strands = self.strandsCanBeMerged(priority_strand, other_strand) if low_and_high_strands: strand_low, strand_high = low_and_high_strands if self.isStrandInSet(strand_low): c = MergeCommand(strand_low, strand_high, priority_strand) util.doCmd(self, c, use_undostack=use_undostack) return True else: return False # end def def strandsCanBeMerged(self, strandA, strandB): """Only checks that the strands are of the same StrandSet and that the end points differ by 1. DOES NOT check if the Strands overlap, that should be handled by addStrand Returns: tuple: empty :obj:`tuple` if the strands can't be merged if the strands can be merged it returns the strand with the lower index in the form:: (strand_low, strand_high) """ if strandA.strandSet() != strandB.strandSet(): return () if abs(strandA.lowIdx() - strandB.highIdx()) == 1 or \ abs(strandB.lowIdx() - strandA.highIdx()) == 1: if strandA.lowIdx() < strandB.lowIdx(): if not strandA.connectionHigh() and not strandB.connectionLow( ): return strandA, strandB else: if not strandB.connectionHigh() and not strandA.connectionLow( ): return strandB, strandA else: return None # end def def splitStrand(self, strand, base_idx, update_sequence=True, use_undostack=True): """Break strand into two strands. Reapply sequence by default. Args: strand (Strand): the :class:`Strand` base_idx (int): the index update_sequence (:obj:`bool`, optional): whether to emit signal, default=True use_undostack (:obj:`bool`, optional): default=True Returns: bool: True if successful, False otherwise TODO consider return strands instead """ if self.strandCanBeSplit(strand, base_idx): if self.isStrandInSet(strand): c = SplitCommand(strand, base_idx, update_sequence) util.doCmd(self, c, use_undostack=use_undostack) return True else: return False else: return False # end def def strandCanBeSplit(self, strand, base_idx): """Make sure the base index is within the strand Don't split right next to a 3Prime end Don't split on endpoint (AKA a crossover) Args: strand (Strand): the :class:`Strand` base_idx (int): the index to split at Returns: bool: True if can be split, False otherwise """ # no endpoints lo, hi = strand.idxs() if base_idx == lo or base_idx == hi: return False # make sure the base index within the strand elif lo > base_idx or base_idx > hi: return False elif base_idx - lo > 1 and hi - base_idx > 1: return True else: return False # end def def destroy(self): """Destroy this object """ self.setParent(None) self.deleteLater() # QObject will emit a destroyed() Signal # end def ### PUBLIC SUPPORT METHODS ### def strandFilter(self): """Get the filter type for this set Returns: str: 'forward' if is_fwd else 'reverse' """ return "forward" if self._is_fwd else "reverse" # end def def hasStrandAt(self, idx_low, idx_high): """Check if set has a strand on the interval given Args: idx_low (int): low index idx_high (int): high index Returns: bool: True if strandset has a strand in the region between idx_low and idx_high (both included). False otherwise """ sa = self.strand_array sh = self.strand_heap lsh = len(sh) strand = sa[idx_low] if strand is None: class DummyStrand(object): _base_idx_low = idx_low def __lt__(self, other): return self._base_idx_low < other._base_idx_low ds = DummyStrand() i = bisect_left(sh, ds) while i < lsh: strand = sh[i] if strand.lowIdx() > idx_high: return False elif idx_low <= strand.highIdx(): return True else: i += 1 return False else: return True # end def def getOverlappingStrands(self, idx_low, idx_high): """Gets :class:`Strand` list that overlap the given range. Args: idx_low (int): low index of overlap region idx_high (int): high index of overlap region Returns: :obj:`list` of :class:`Strand`: all :class:`Strand` objects in range """ sa = self.strand_array sh = self.strand_heap lsh = len(sh) strand = sa[idx_low] out = [] if strand is None: class DummyStrand(object): _base_idx_low = idx_low def __lt__(self, other): return self._base_idx_low < other._base_idx_low ds = DummyStrand() i = bisect_left(sh, ds) else: out.append(strand) i = bisect_left(sh, strand) + 1 while i < lsh: strand = sh[i] if strand.lowIdx() > idx_high: break elif idx_low <= strand.highIdx(): out.append(strand) i += 1 else: i += 1 return out # end def # def hasStrandAtAndNoXover(self, idx): # """Name says it all # Args: # idx (int): index # Returns: # bool: True if hasStrandAtAndNoXover, False otherwise # """ # sl = self.strand_array # strand = sl[idx] # if strand is None: # return False # elif strand.hasXoverAt(idx): # return False # else: # return True # # end def # def hasNoStrandAtOrNoXover(self, idx): # """Name says it all # Args: # idx (int): index # Returns: # bool: True if hasNoStrandAtOrNoXover, False otherwise # """ # sl = self.strand_array # strand = sl[idx] # if strand is None: # return True # elif strand.hasXoverAt(idx): # return False # else: # return True # # end def def getStrand(self, base_idx): """Returns the :class:`Strand` that overlaps with `base_idx` Args: base_idx (int): Returns: Strand: :class:`Strand` at `base_idx` if it exists """ try: return self.strand_array[base_idx] except: print(self.strand_array) raise # end def def dump(self, xover_list): """ Serialize a StrandSet, and append to a xover_list of xovers adding a xover if the 3 prime end of it is founds TODO update this to support strand properties Args: xover_list (list): A list to append xovers to Returns: :obj:`list` of :obj:`tuple`: indices low and high of each strand in the :class:`StrandSet` """ sh = self.strand_heap idxs = [strand.idxs() for strand in sh] colors = [strand.getColor() for strand in sh] is_fwd = self._is_fwd for strand in sh: s3p = strand.connection3p() if s3p is not None: xover = (strand.idNum(), is_fwd, strand.idx3Prime()) + s3p.dump5p() xover_list.append(xover) return idxs, colors # end def ### PRIVATE SUPPORT METHODS ### def _addToStrandList(self, strand, update_segments=True): """Inserts strand into the strand_array at idx Args: strand (Strand): the strand to add update_segments (:obj:`bool`, optional): whether to signal default=True """ # print("Adding to strandlist") idx_low, idx_high = strand.idxs() for i in range(idx_low, idx_high + 1): self.strand_array[i] = strand insort_left(self.strand_heap, strand) if update_segments: self._part.refreshSegments(self._id_num) def _updateStrandIdxs(self, strand, old_idxs, new_idxs): """update indices in the strand array/list of an existing strand Args: strand (Strand): the strand old_idxs (tuple): range (:obj:`int`) to clear new_idxs (tuple): range (:obj:`int`) to set to `strand` """ for i in range(old_idxs[0], old_idxs[1] + 1): self.strand_array[i] = None for i in range(new_idxs[0], new_idxs[1] + 1): self.strand_array[i] = strand def _removeFromStrandList(self, strand, update_segments=True): """Remove strand from strand_array. Args: strand (Strand): the strand update_segments (:obj:`bool`, optional): whether to signal default=True """ self._document.removeStrandFromSelection( strand) # make sure the strand is no longer selected idx_low, idx_high = strand.idxs() for i in range(idx_low, idx_high + 1): self.strand_array[i] = None i = bisect_left(self.strand_heap, strand) self.strand_heap.pop(i) if update_segments: self._part.refreshSegments(self._id_num) def getStrandIndex(self, strand): """Get the 5' end index of strand if it exists for forward strands and the 3' end index of the strand for reverse strands Returns: tuple: (:obj:`bool`, :obj:`int`) """ try: ind = self.strand_array.index(strand) return (True, ind) except ValueError: return (False, 0) # end def def _deepCopy(self, virtual_helix): """docstring for deepCopy""" raise NotImplementedError
class Strand(CNObject): """A Strand is a continuous stretch of bases that are all in the same StrandSet (recall: a VirtualHelix is made up of two StrandSets). Every Strand has two endpoints. The naming convention for keeping track of these endpoints is based on the relative numeric value of those endpoints (low and high). Thus, Strand has a '_base_idx_low', which is its index with the lower numeric value (typically positioned on the left), and a '_base_idx_high' which is the higher-value index (typically positioned on the right) Strands can be linked to other strands by "connections". References to connected strands are named "_strand5p" and "_strand3p", which correspond to the 5' and 3' phosphate linkages in the physical DNA strand, respectively. Since Strands can point 5'-to-3' in either the low-to-high or high-to-low directions, connection accessor methods (connectionLow and connectionHigh) are bound during the init for convenience. Args: strandset (StrandSet): base_idx_low (int): low index base_idx_high (int): high index oligo (cadnano.oligo.Oligo): optional, defaults to None. """ def __init__(self, strandset, base_idx_low, base_idx_high, oligo=None): self._document = strandset.document() super(Strand, self).__init__(strandset) self._strandset = strandset self._id_num = strandset.idNum() """Keep track of its own segments. Updated on creation and resizing """ self._base_idx_low = base_idx_low # base index of the strand's left bound self._base_idx_high = base_idx_high # base index of the right bound self._oligo = oligo self._strand5p = None # 5' connection to another strand self._strand3p = None # 3' connection to another strand self._sequence = None self.segments = [] self.abstract_sequence = [] # dynamic methods for mapping high/low connection /indices # to corresponding 3Prime 5Prime is_forward = strandset.isForward() if is_forward: self.idx5Prime = self.lowIdx self.idx3Prime = self.highIdx self.connectionLow = self.connection5p self.connectionHigh = self.connection3p self.setConnectionLow = self.setConnection5p self.setConnectionHigh = self.setConnection3p else: self.idx5Prime = self.highIdx self.idx3Prime = self.lowIdx self.connectionLow = self.connection3p self.connectionHigh = self.connection5p self.setConnectionLow = self.setConnection3p self.setConnectionHigh = self.setConnection5p self._is_forward = is_forward # end def def __repr__(self): s = "%s.<%s(%s, %s)>" % (self._strandset.__repr__(), self.__class__.__name__, self._base_idx_low, self._base_idx_high) return s # end def def __lt__(self, other): return self._base_idx_low < other._base_idx_low def generator5pStrand(self): """Iterate from self to the final _strand5p is None 3' to 5' Includes originalCount to check for circular linked list Yields: Strand: 5' connected :class:`Strand` """ node0 = node = self f = attrgetter('_strand5p') while node: yield node # equivalent to: node = node._strand5p node = f(node) if node0 == node: break # end def def generator3pStrand(self): """Iterate from self to the final _strand3p is None 5prime to 3prime Includes originalCount to check for circular linked list Yields: Strand: 3' connected :class:`Strand` """ node0 = node = self f = attrgetter('_strand3p') while node: yield node # equivalent to: node = node._strand5p node = f(node) if node0 == node: break # end def def strandFilter(self): return self._strandset.strandFilter() ### SIGNALS ### strandHasNewOligoSignal = ProxySignal(CNObject, name='strandHasNewOligoSignal') """pyqtSignal(QObject): strand""" strandRemovedSignal = ProxySignal(CNObject, name='strandRemovedSignal') """pyqtSignal(QObject): strand""" strandResizedSignal = ProxySignal(CNObject, tuple, name='strandResizedSignal') """pyqtSignal(QObject, tuple)""" strandXover5pRemovedSignal = ProxySignal(CNObject, CNObject, name='strandXover5pRemovedSignal') """pyqtSignal(QObject, QObject): (strand3p, strand5p)""" strandUpdateSignal = ProxySignal(CNObject, name='strandUpdateSignal') """pyqtSignal(QObject): strand""" strandInsertionAddedSignal = ProxySignal(CNObject, object, name='strandInsertionAddedSignal') """pyqtSignal(QObject, object): (strand, insertion object)""" strandInsertionChangedSignal = ProxySignal( CNObject, object, name='strandInsertionChangedSignal') """#pyqtSignal(QObject, object): (strand, insertion object)""" strandInsertionRemovedSignal = ProxySignal( CNObject, int, name='strandInsertionRemovedSignal') """#pyqtSignal(QObject, int): # Parameters: (strand, insertion index)""" strandModsAddedSignal = ProxySignal(CNObject, CNObject, str, int, name='strandModsAddedSignal') """pyqtSignal(QObject, object, str, int): (strand, document, mod_id, idx)""" strandModsChangedSignal = ProxySignal(CNObject, CNObject, str, int, name='strandModsChangedSignal') """pyqtSignal(QObject, object, str, int): (strand, document, mod_id, idx)""" strandModsRemovedSignal = ProxySignal(CNObject, CNObject, str, int, name='strandModsRemovedSignal') """pyqtSignal(QObject, object, str, int): (strand, document, mod_id, idx)""" strandSelectedChangedSignal = ProxySignal( CNObject, tuple, name='strandSelectedChangedSignal') """pyqtSignal(QObject, tuple): (strand, value)""" ### SLOTS ### ### ACCESSORS ### def part(self): return self._strandset.part() # end def def idNum(self): return self._id_num # end def def document(self): return self._document # end def def oligo(self): return self._oligo # end def def getColor(self): return self._oligo.getColor() # end def def sequence(self, for_export=False): seq = self._sequence if seq: return util.markwhite(seq) if for_export else seq elif for_export: return ''.join(['?' for x in range(self.totalLength())]) return '' # end def def abstractSeq(self): return ','.join([str(i) for i in self.abstract_sequence]) def strandSet(self): return self._strandset # end def def strandType(self): return self._strandset.strandType() def isForward(self): return self._strandset.isForward() def setSequence(self, sequence_string): """Applies sequence string from 5' to 3' return the tuple (used, unused) portion of the sequence_string Args: sequence_string (str): Returns: tuple: of :obj:`str` of form:: (used, unused) """ if sequence_string is None: self._sequence = None return None, None length = self.totalLength() if len(sequence_string) < length: bonus = length - len(sequence_string) sequence_string += ''.join([' ' for x in range(bonus)]) temp = sequence_string[0:length] self._sequence = temp return temp, sequence_string[length:] # end def def reapplySequence(self): """ """ comp_ss = self.strandSet().complementStrandSet() # the strand sequence will need to be regenerated from scratch # as there are no guarantees about the entirety of the strand moving # i.e. both endpoints thanks to multiple selections so just redo the # whole thing self._sequence = None for comp_strand in comp_ss.getOverlappingStrands( self._base_idx_low, self._base_idx_high): comp_seq = comp_strand.sequence() used_seq = util.comp(comp_seq) if comp_seq else None used_seq = self.setComplementSequence(used_seq, comp_strand) # end for # end def def getComplementStrands(self): """Return the list of complement strands that overlap with this strand. """ comp_ss = self.strandSet().complementStrandSet() return [ comp_strand for comp_strand in comp_ss.getOverlappingStrands( self._base_idx_low, self._base_idx_high) ] # end def def setComplementSequence(self, sequence_string, strand): """This version takes anothers strand and only sets the indices that align with the given complimentary strand. As it depends which direction this is going, and strings are stored in memory left to right, we need to test for is_forward to map the reverse compliment appropriately, as we traverse overlapping strands. We reverse the sequence ahead of time if we are applying it 5' to 3', otherwise we reverse the sequence post parsing if it's 3' to 5' Again, sequences are stored as strings in memory 5' to 3' so we need to jump through these hoops to iterate 5' to 3' through them correctly Perhaps it's wiser to merely store them left to right and reverse them at draw time, or export time Args: sequence_string (str): strand (Strand): Returns: str: the used portion of the sequence_string """ s_low_idx, s_high_idx = self._base_idx_low, self._base_idx_high c_low_idx, c_high_idx = strand.idxs() is_forward = self._is_forward self_seq = self._sequence # get the ovelap low_idx, high_idx = util.overlap(s_low_idx, s_high_idx, c_low_idx, c_high_idx) # only get the characters we're using, while we're at it, make it the # reverse compliment total_length = self.totalLength() # see if we are applying if sequence_string is None: # clear out string for in case of not total overlap use_seq = ''.join([' ' for x in range(total_length)]) else: # use the string as is use_seq = sequence_string[::-1] if is_forward else sequence_string temp = array(ARRAY_TYPE, sixb(use_seq)) if self_seq is None: temp_self = array( ARRAY_TYPE, sixb(''.join([' ' for x in range(total_length)]))) else: temp_self = array( ARRAY_TYPE, sixb(self_seq) if is_forward else sixb(self_seq[::-1])) # generate the index into the compliment string a = self.insertionLengthBetweenIdxs(s_low_idx, low_idx - 1) b = self.insertionLengthBetweenIdxs(low_idx, high_idx) c = strand.insertionLengthBetweenIdxs(c_low_idx, low_idx - 1) start = low_idx - c_low_idx + c end = start + b + high_idx - low_idx + 1 temp_self[low_idx - s_low_idx + a:high_idx - s_low_idx + 1 + a + b] = temp[start:end] # print("old sequence", self_seq) self._sequence = tostring(temp_self) # if we need to reverse it do it now if not is_forward: self._sequence = self._sequence[::-1] # test to see if the string is empty(), annoyingly expensive # if len(self._sequence.strip()) == 0: if not self._sequence: self._sequence = None # print("new sequence", self._sequence) return self._sequence # end def def clearAbstractSequence(self): self.abstract_sequence = [] # end def def applyAbstractSequence(self): """Assigns virtual index from 5' to 3' on strand and it's complement location. """ abstract_seq = [] part = self.part() segment_dict = part.segment_dict[self._id_num] # make sure we apply numbers from 5' to 3' strand_order = 1 if self._is_forward else -1 for segment in self.segments[::strand_order]: if segment in segment_dict: seg_id, offset, length = segment_dict[segment] else: seg_id, offset, length = part.getNewAbstractSegmentId(segment) segment_dict[segment] = (seg_id, offset, length) for i in range(length)[::strand_order]: abstract_seq.append(offset + i) self.abstract_sequence = abstract_seq # end def def copyAbstractSequenceToSequence(self): abstract_seq = self.abstract_sequence # self._sequence = ''.join([ascii_letters[i % 52] for i in abstract_seq]) self._sequence = ''.join(['|' for i in abstract_seq]) # end def ### PUBLIC METHODS FOR QUERYING THE MODEL ### def connection3p(self): return self._strand3p # end def def connection5p(self): return self._strand5p # end def def idxs(self): return (self._base_idx_low, self._base_idx_high) # end def def lowIdx(self): return self._base_idx_low # end def def highIdx(self): return self._base_idx_high # end def def idx3Prime(self): """Returns the absolute base_idx of the 3' end of the strand. overloaded in __init__ """ pass # return self.idx3Prime def idx5Prime(self): """Returns the absolute base_idx of the 5' end of the strand. overloaded in __init__ """ pass # return self.idx5Prime def dump5p(self): return self._id_num, self._is_forward, self.idx5Prime() # def def getSequenceList(self): """return the list of sequences strings comprising the sequence and the inserts as a tuple with the index of the insertion [(idx, (strandItemString, insertionItemString), ...] This takes advantage of the fact the python iterates a dictionary by keys in order so if keys are indices, the insertions will iterate out from low index to high index """ seqList = [] is_forward = self._is_forward seq = self._sequence if is_forward else self._sequence[::-1] # assumes a sequence has been applied correctly and is up to date tL = self.totalLength() offsetLast = 0 lengthSoFar = 0 iLength = 0 lI, hI = self.idxs() for insertion in self.insertionsOnStrand(): iLength = insertion.length() index = insertion.idx() offset = index + 1 - lI + lengthSoFar if iLength < 0: offset -= 1 # end if lengthSoFar += iLength seqItem = seq[offsetLast:offset] # the stranditem seq # Because skips literally skip displaying a character at a base # position, this needs to be accounted for seperately if iLength < 0: seqItem += ' ' offsetLast = offset else: offsetLast = offset + iLength seqInsertion = seq[offset:offsetLast] # the insertions sequence seqList.append((index, (seqItem, seqInsertion))) # end for # append the last bit of the strand seqList.append((lI + tL, (seq[offsetLast:tL], ''))) if not is_forward: # reverse it again so all sub sequences are from 5' to 3' for i in range(len(seqList)): index, temp = seqList[i] seqList[i] = (index, (temp[0][::-1], temp[1][::-1])) return seqList # end def def canResizeTo(self, new_low, new_high): """Checks to see if a resize is allowed. Similar to getResizeBounds but works for two bounds at once. """ part = self.part() id_num = self._id_num low_neighbor, high_neighbor = self._strandset.getNeighbors(self) low_bound = low_neighbor.highIdx() if low_neighbor else 0 high_bound = high_neighbor.lowIdx( ) if high_neighbor else part.maxBaseIdx(id_num) if new_low > low_bound and new_high < high_bound: return True return False def getResizeBounds(self, idx): """Determines (inclusive) low and high drag boundaries resizing from an endpoint located at idx. When resizing from _base_idx_low:: low bound is determined by checking for lower neighbor strands. high bound is the index of this strand's high cap, minus 1. When resizing from _base_idx_high:: low bound is the index of this strand's low cap, plus 1. high bound is determined by checking for higher neighbor strands. When a neighbor is not present, just use the Part boundary. """ part = self.part() neighbors = self._strandset.getNeighbors(self) if idx == self._base_idx_low: if neighbors[0] is not None: low = neighbors[0].highIdx() + 1 else: low = 0 # print("A", low, self._base_idx_high - 1 ) return low, self._base_idx_high - 1 else: # self._base_idx_high if neighbors[1] is not None: high = neighbors[1].lowIdx() - 1 else: high = part.maxBaseIdx(self._id_num) # print("B", self._base_idx_low+1, high) return self._base_idx_low + 1, high # end def def hasXoverAt(self, idx): """An xover is necessarily at an enpoint of a strand """ if idx == self.highIdx(): return True if self.connectionHigh() is not None else False elif idx == self.lowIdx(): return True if self.connectionLow() is not None else False else: return False # end def def canInstallXoverAt(self, idx, from_strand, from_idx): """Assumes idx is: self.lowIdx() <= idx <= self.highIdx() """ if self.hasXoverAt(idx): return False ss = self.strandSet() is_same_strand = from_strand == self is_forward = ss.isForward() index_diff_H = self.highIdx() - idx index_diff_L = idx - self.lowIdx() idx3p = self.idx3Prime() idx5p = self.idx5Prime() # ensure 2 bps from 3p end if not the 3p end index3_lim = idx3p - 1 if is_forward else idx3p + 1 if is_same_strand: index_diff_strands = from_idx - idx if idx == idx5p or idx == index3_lim: return True elif index_diff_strands > -3 and index_diff_strands < 3: return False # end if for same Strand else: from_idx3p = from_strand.idx3Prime() from_idx5p = from_strand.idx5Prime() if idx == idx5p or idx == index3_lim: if from_idx3p == from_idx: return True elif (abs(from_idx3p - from_idx) > 1 and abs(from_idx5p - from_idx) > 1): return True else: # print("this:", idx, idx3p, idx5p) # print("from:", from_idx, from_idx3p, from_idx5p) return False elif index_diff_H > 2 and index_diff_L > 1: if from_idx3p == from_idx: return True elif (abs(from_idx3p - from_idx) > 1 and abs(from_idx5p - from_idx) > 1): return True else: return False else: print("default", index_diff_H, index_diff_L) return False # end def def insertionLengthBetweenIdxs(self, idxL, idxH): """includes the length of insertions in addition to the bases """ tL = 0 insertions = self.insertionsOnStrand(idxL, idxH) for insertion in insertions: tL += insertion.length() return tL # end def def insertionsOnStrand(self, idxL=None, idxH=None): """if passed indices it will use those as a bounds """ insertions = [] insertionsDict = self.part().insertions()[self._id_num] sortedIndices = sorted(insertionsDict.keys()) if idxL is None: idxL, idxH = self.idxs() for index in sortedIndices: insertion = insertionsDict[index] if idxL <= insertion.idx() <= idxH: insertions.append(insertion) # end if # end for return insertions # end def def modifersOnStrand(self): """ """ mods = [] modsDict = self.part().mods()['ext_instances'] id_num = self._id_num isstaple = True # self.isStaple() idxL, idxH = self.idxs() keyL = "{},{},{}".format(id_num, isstaple, idxL) keyH = "{},{},{}".format(id_num, isstaple, idxH) if keyL in modsDict: mods.append(modsDict[keyL]) if keyH in modsDict: mods.append(modsDict[keyH]) return mods # end def def length(self): return self._base_idx_high - self._base_idx_low + 1 # end def def totalLength(self): """includes the length of insertions in addition to the bases """ tL = 0 insertions = self.insertionsOnStrand() for insertion in insertions: tL += insertion.length() return tL + self.length() # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def addMods(self, document, mod_id, idx, use_undostack=True): """Used to add mods during a merge operation.""" cmds = [] idx_low, idx_high = self.idxs() if idx_low == idx or idx == idx_high: check_mid1 = self.part().getModID(self, idx) check_mid2 = document.getMod(mod_id) print("strand.addMods:", check_mid1, check_mid2) if check_mid2 is not None: if check_mid1 != mod_id: if check_mid1 is not None: cmds.append( RemoveModsCommand(document, self, idx, check_mid1)) # print("adding a {} modification at {}".format(mod_id, idx)) cmds.append(AddModsCommand(document, self, idx, mod_id)) util.execCommandList(self, cmds, desc="Add Modification", use_undostack=use_undostack) else: print(check_mid1, mod_id) # end if # end def def removeMods(self, document, mod_id, idx, use_undostack=True): """Used to add mods during a merge operation.""" idx_low, idx_high = self.idxs() print("attempting to remove") if idx_low == idx or idx == idx_high: print("removing a modification at {}".format(idx)) c = RemoveModsCommand(document, self, idx, mod_id) util.doCmd(self, c, use_undostack=use_undostack) # end if # end def def addInsertion(self, idx, length, use_undostack=True): """Adds an insertion or skip at idx. length should be:: >0 for an insertion -1 for a skip Args: idx (int): length (int): use_undostack (bool): optional, default is True """ cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if not self.hasInsertionAt(idx): # make sure length is -1 if a skip if length < 0: length = -1 if use_undostack: # on import no need to blank sequences cmds.append(self.oligo().applySequenceCMD(None)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None)) cmds.append(AddInsertionCommand(self, idx, length)) util.execCommandList(self, cmds, desc="Add Insertion", use_undostack=use_undostack) # end if # end if # end def def changeInsertion(self, idx, length, use_undostack=True): """ Args: idx (int): length (int): use_undostack (bool): optional, default is True """ cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if self.hasInsertionAt(idx): if length == 0: self.removeInsertion(idx) else: # make sure length is -1 if a skip if length < 0: length = -1 cmds.append(self.oligo().applySequenceCMD(None)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None)) cmds.append(ChangeInsertionCommand(self, idx, length)) util.execCommandList(self, cmds, desc="Change Insertion", use_undostack=use_undostack) # end if # end if # end def def removeInsertion(self, idx, use_undostack=True): """ Args: idx (int): use_undostack (bool): optional, default is True """ cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if self.hasInsertionAt(idx): if use_undostack: cmds.append(self.oligo().applySequenceCMD(None)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None)) cmds.append(RemoveInsertionCommand(self, idx)) util.execCommandList(self, cmds, desc="Remove Insertion", use_undostack=use_undostack) # end if # end if # end def def destroy(self): self.setParent(None) self.deleteLater() # QObject also emits a destroyed() Signal # end def def merge(self, idx): """Check for neighbor, then merge if possible. Args: idx (int): Raises: IndexError: """ low_neighbor, high_neighbor = self._strandset.getNeighbors(self) # determine where to check for neighboring endpoint if idx == self._base_idx_low: if low_neighbor: if low_neighbor.highIdx() == idx - 1: self._strandset.mergeStrands(self, low_neighbor) elif idx == self._base_idx_high: if high_neighbor: if high_neighbor.lowIdx() == idx + 1: self._strandset.mergeStrands(self, high_neighbor) else: raise IndexError # end def def resize(self, new_idxs, use_undostack=True, update_segments=True): cmds = [] cmds += self.getRemoveInsertionCommands(new_idxs) cmds.append( ResizeCommand(self, new_idxs, update_segments=update_segments)) util.execCommandList(self, cmds, desc="Resize strand", use_undostack=use_undostack) # end def def setConnection3p(self, strand): self._strand3p = strand # end def def setConnection5p(self, strand): self._strand5p = strand # end def def setIdxs(self, idxs): self._base_idx_low = idxs[0] self._base_idx_high = idxs[1] # end def def setOligo(self, new_oligo, emit_signals=False): self._oligo = new_oligo if emit_signals: self.strandHasNewOligoSignal.emit(self) # end def def split(self, idx, update_sequence=True): """Called by view items to split this strand at idx.""" self._strandset.splitStrand(self, idx, update_sequence) ### PUBLIC SUPPORT METHODS ### def getRemoveInsertionCommands(self, new_idxs): """Removes Insertions, Decorators, and Modifiers that have fallen out of range of new_idxs. For insertions, it finds the ones that have neither Staple nor Scaffold strands at the insertion idx as a result of the change of this strand to new_idxs """ cIdxL, cIdxH = self.idxs() nIdxL, nIdxH = new_idxs # low_out, high_out = False, False insertions = [] if cIdxL < nIdxL < cIdxH: idxL, idxH = cIdxL, nIdxL - 1 insertions += self.insertionsOnStrand(idxL, idxH) else: low_out = True if cIdxL < nIdxH < cIdxH: idxL, idxH = nIdxH + 1, cIdxH insertions += self.insertionsOnStrand(idxL, idxH) else: high_out = True # this only called if both the above aren't true # if low_out and high_out: # if we move the whole strand, just clear the insertions out if nIdxL > cIdxH or nIdxH < cIdxL: idxL, idxH = cIdxL, cIdxH insertions += self.insertionsOnStrand(idxL, idxH) # we stretched in this direction return self.clearInsertionsCommands(insertions, cIdxL, cIdxH) # end def def clearInsertionsCommands(self, insertions, idxL, idxH): """clear out insertions in this range """ commands = [] comp_ss = self.strandSet().complementStrandSet() overlappingStrandList = comp_ss.getOverlappingStrands(idxL, idxH) for insertion in insertions: idx = insertion.idx() removeMe = True for strand in overlappingStrandList: overLapIdxL, overLapIdxH = strand.idxs() if overLapIdxL <= idx <= overLapIdxH: removeMe = False # end if # end for if removeMe: commands.append(RemoveInsertionCommand(self, idx)) else: pass # print "keeping %s insertion at %d" % (self, key) # end for ### ADD CODE HERE TO HANDLE DECORATORS AND MODIFIERS return commands # end def def clearDecoratorCommands(self): insertions = self.insertionsOnStrand() return self.clearInsertionsCommands(insertions, *self.idxs()) # end def def hasInsertionAt(self, idx): insts = self.part().insertions()[self._id_num] return idx in insts # end def def shallowCopy(self): """ """ new_s = Strand(self._strandset, *self.idxs()) new_s._oligo = self._oligo new_s._strand5p = self._strand5p new_s._strand3p = self._strand3p # required to shallow copy the dictionary new_s._sequence = None # self._sequence return new_s # end def def _deepCopy(self, strandset, oligo): """ """ new_s = Strand(strandset, *self.idxs()) new_s._oligo = oligo new_s._sequence = self._sequence return new_s
class Document(ProxyObject): """ The Document class is the root of the model. It has two main purposes: 1. Serve as the parent all Part objects within the model. 2. Track all sub-model actions on its undoStack. """ def __init__(self, parent=None): super(Document, self).__init__(parent) self._undostack = UndoStack() self._parts = [] self._assemblies = [] self._controller = None self._selected_part = None # the dictionary maintains what is selected self._selection_dict = {} # the added list is what was recently selected or deselected self._selected_changed_dict = {} app().documentWasCreatedSignal.emit(self) # end def ### SIGNALS ### documentPartAddedSignal = ProxySignal( object, ProxyObject, name='documentPartAddedSignal') # doc, part # dict of tuples of objects using the reference as the key, # and the value is a tuple with meta data # in the case of strands the metadata would be which endpoints of selected # e.g. { objectRef: (value0, value1), ...} documentSelectedChangedSignal = ProxySignal( dict, name='documentSelectedChangedSignal') # tuples of items + data documentSelectionFilterChangedSignal = ProxySignal( list, name='documentSelectionFilterChangedSignal') documentViewResetSignal = ProxySignal(ProxyObject, name='documentViewResetSignal') documentClearSelectionsSignal = ProxySignal( ProxyObject, name='documentClearSelectionsSignal') ### SLOTS ### ### ACCESSORS ### def undoStack(self): """ This is the actual undoStack to use for all commands. Any children needing to perform commands should just ask their parent for the undoStack, and eventually the request will get here. """ return self._undostack def parts(self): """Returns a list of parts associated with the document.""" return self._parts def assemblies(self): """Returns a list of assemblies associated with the document.""" return self._assemblies ### PUBLIC METHODS FOR QUERYING THE MODEL ### def selectedPart(self): return self._selected_part def addToSelection(self, obj, value): self._selection_dict[obj] = value self._selected_changed_dict[obj] = value # end def def removeFromSelection(self, obj): if obj in self._selection_dict: del self._selection_dict[obj] self._selected_changed_dict[obj] = (False, False) return True else: return False # end def def clearSelections(self): """ Only clear the dictionary """ self._selection_dict = {} # end def def addStrandToSelection(self, strand, value): ss = strand.strandSet() if ss in self._selection_dict: self._selection_dict[ss][strand] = value else: self._selection_dict[ss] = {strand: value} self._selected_changed_dict[strand] = value # end def def removeStrandFromSelection(self, strand): ss = strand.strandSet() if ss in self._selection_dict: temp = self._selection_dict[ss] if strand in temp: del temp[strand] if len(temp) == 0: del self._selection_dict[ss] self._selected_changed_dict[strand] = (False, False) return True else: return False else: return False # end def def selectionDict(self): return self._selection_dict # end def def selectedOligos(self): """ as long as one endpoint of a strand is in the selection, then the oligo is considered selected """ s_dict = self._selection_dict selected_oligos = set() for ss in s_dict.keys(): for strand in ss: selected_oligos.add(strand.oligo()) # end for # end for return selected_oligos if len(selected_oligos) > 0 else None #end def def clearAllSelected(self): self._selection_dict = {} # the added list is what was recently selected or deselected self._selected_changed_dict = {} self.documentClearSelectionsSignal.emit(self) # end def def isModelSelected(self, obj): return obj in self._selection_dict # end def def isModelStrandSelected(self, strand): ss = strand.strandSet() if ss in self._selection_dict: if strand in self._selection_dict[ss]: return True else: return False else: return False # end def def getSelectedValue(self, obj): """ obj is an objects to look up it is prevetted to be in the dictionary """ return self._selection_dict[obj] def getSelectedStrandValue(self, strand): """ strand is an objects to look up it is prevetted to be in the dictionary """ return self._selection_dict[strand.strandSet()][strand] # end def def sortedSelectedStrands(self, strandset): # outList = self._selection_dict[strandset].keys() # outList.sort(key=Strand.lowIdx) out_list = [x for x in self._selection_dict[strandset].items()] getLowIdx = lambda x: Strand.lowIdx(itemgetter(0)(x)) out_list.sort(key=getLowIdx) return out_list # end def def determineStrandSetBounds(self, selected_strand_list, strandset): min_low_delta = strandset.partMaxBaseIdx() min_high_delta = strandset.partMaxBaseIdx() # init the return values ss_dict = self._selection_dict[strandset] # get the StrandSet index of the first item in the list ss_idx = strandset._findIndexOfRangeFor(selected_strand_list[0][0])[2] ss_list = strandset._strand_list len_ss_list = len(ss_list) max_ss_idx = len_ss_list - 1 i = 0 for strand, value in selected_strand_list: while strand != ss_list[ss_idx]: # incase there are gaps due to double xovers ss_idx += 1 # end while idxL, idxH = strand.idxs() if value[0]: # the end is selected if ss_idx > 0: low_neighbor = ss_list[ss_idx - 1] if low_neighbor in ss_dict: valueN = ss_dict[low_neighbor] # we only care if the low neighbor is not selected temp = min_low_delta if valueN[1] \ else idxL - low_neighbor.highIdx() - 1 # end if else: # not selected temp = idxL - low_neighbor.highIdx() - 1 # end else else: temp = idxL - 0 # end else if temp < min_low_delta: min_low_delta = temp # end if # check the other end of the strand if not value[1]: temp = idxH - idxL - 1 if temp < min_high_delta: min_high_delta = temp # end if if value[1]: if ss_idx < max_ss_idx: high_neighbor = ss_list[ss_idx + 1] if high_neighbor in ss_dict: valueN = ss_dict[high_neighbor] # we only care if the low neighbor is not selected temp = min_high_delta if valueN[0] \ else high_neighbor.lowIdx() - idxH - 1 # end if else: # not selected temp = high_neighbor.lowIdx() - idxH - 1 # end else else: temp = strandset.partMaxBaseIdx() - idxH # end else if temp < min_high_delta: min_high_delta = temp # end if # check the other end of the strand if not value[0]: temp = idxH - idxL - 1 if temp < min_low_delta: min_low_delta = temp # end if # increment counter ss_idx += 1 # end for return (min_low_delta, min_high_delta) # end def def getSelectionBounds(self): min_low_delta = -1 min_high_delta = -1 for strandset in self._selection_dict.keys(): selected_list = self.sortedSelectedStrands(strandset) temp_low, temp_high = self.determineStrandSetBounds( selected_list, strandset) if temp_low < min_low_delta or min_low_delta < 0: min_low_delta = temp_low if temp_high < min_high_delta or min_high_delta < 0: min_high_delta = temp_high # end for Mark train bus to metro return (min_low_delta, min_high_delta) # end def # def operateOnStrandSelection(self, method, arg, both=False): # pass # # end def def deleteSelection(self, use_undostack=True): """ Delete selected strands. First iterates through all selected strands and extracts refs to xovers and strands. Next, calls removeXover on xoverlist as part of its own macroed command for isoluation purposes. Finally, calls removeStrand on all strands that were fully selected (low and high), or had at least one non-xover endpoint selected. """ xoList = [] strand_dict = {} for strandset_dict in self._selection_dict.values(): for strand, selected in strandset_dict.items(): part = strand.virtualHelix().part() idxL, idxH = strand.idxs() strand5p = strand.connection5p() strand3p = strand.connection3p() # both ends are selected strand_dict[strand] = selected[0] and selected[1] # only look at 3' ends to handle xover deletion sel3p = selected[0] if idxL == strand.idx3Prime( ) else selected[1] if sel3p: # is idx3p selected? if strand3p: # is there an xover xoList.append((part, strand, strand3p, use_undostack)) else: # idx3p is a selected endpoint strand_dict[strand] = True else: if not strand5p: # idx5p is a selected endpoint strand_dict[strand] = True if use_undostack and xoList: self.undoStack().beginMacro("Delete xovers") for part, strand, strand3p, useUndo in xoList: Part.removeXover(part, strand, strand3p, useUndo) self.removeStrandFromSelection(strand) self.removeStrandFromSelection(strand3p) self._selection_dict = {} self.documentClearSelectionsSignal.emit(self) if use_undostack: if xoList: # end xover macro if it was started self.undoStack().endMacro() if True in strand_dict.values(): self.undoStack().beginMacro("Delete selection") else: return # nothing left to do for strand, delete in strand_dict.items(): if delete: strand.strandSet().removeStrand(strand) if use_undostack: self.undoStack().endMacro() def paintSelection(self, scafColor, stapColor, use_undostack=True): """Delete xovers if present. Otherwise delete everything.""" scaf_oligos = {} stap_oligos = {} for strandset_dict in self._selection_dict.values(): for strand, value in strandset_dict.items(): if strand.isScaffold(): scaf_oligos[strand.oligo()] = True else: stap_oligos[strand.oligo()] = True if use_undostack: self.undoStack().beginMacro("Paint strands") for olg in scaf_oligos.keys(): olg.applyColor(scafColor) for olg in stap_oligos.keys(): olg.applyColor(stapColor) if use_undostack: self.undoStack().endMacro() def resizeSelection(self, delta, use_undostack=True): """ Moves the selected idxs by delta by first iterating over all strands to calculate new idxs (method will return if snap-to behavior would create illegal state), then applying a resize command to each strand. """ resize_list = [] # calculate new idxs for strandset_dict in self._selection_dict.values(): for strand, selected in strandset_dict.items(): part = strand.virtualHelix().part() idxL, idxH = strand.idxs() newL, newH = strand.idxs() deltaL = deltaH = delta # process xovers to get revised delta if selected[0] and strand.connectionLow(): newL = part.xoverSnapTo(strand, idxL, delta) if newL == None: return deltaH = newL - idxL if selected[1] and strand.connectionHigh(): newH = part.xoverSnapTo(strand, idxH, delta) if newH == None: return deltaL = newH - idxH # process endpoints if selected[0] and not strand.connectionLow(): newL = idxL + deltaL if selected[1] and not strand.connectionHigh(): newH = idxH + deltaH if newL > newH: # check for illegal state return resize_list.append((strand, newL, newH)) # end for # end for # execute the resize commands if use_undostack: self.undoStack().beginMacro("Resize Selection") for strand, idxL, idxH in resize_list: Strand.resize(strand, (idxL, idxH), use_undostack) if use_undostack: self.undoStack().endMacro() # end def def updateSelection(self): """ do it this way in the future when we have a better signaling architecture between views """ # self.documentSelectedChangedSignal.emit(self._selected_changed_dict) """ For now, individual objects need to emit signals """ for obj, value in self._selected_changed_dict.items(): obj.selectedChangedSignal.emit(obj, value) # end for self._selected_changed_dict = {} # for ss in self._selection_dict: # print self.sortedSelectedStrands(ss) # end def def resetViews(self): # This is a fast way to clear selections and the views. # We could manually deselect each item from the Dict, but we'll just # let them be garbage collect # the dictionary maintains what is selected self._selection_dict = {} # the added list is what was recently selected or deselected self._selected_changed_dict = {} self.documentViewResetSignal.emit(self) # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def addHoneycombPart(self, max_row=prefs.HONEYCOMB_PART_MAXROWS, max_col=prefs.HONEYCOMB_PART_MAXCOLS, max_steps=prefs.HONEYCOMB_PART_MAXSTEPS): """ Create and store a new DNAPart and instance, and return the instance. """ dnapart = None if len(self._parts) == 0: dnapart = HoneycombPart(document=self, max_row=max_row, max_col=max_col, max_steps=max_steps) self._addPart(dnapart) return dnapart def addSquarePart(self, max_row=prefs.SQUARE_PART_MAXROWS, max_col=prefs.SQUARE_PART_MAXCOLS, max_steps=prefs.SQUARE_PART_MAXSTEPS): """ Create and store a new DNAPart and instance, and return the instance. """ dnapart = None if len(self._parts) == 0: dnapart = SquarePart(document=self, max_row=max_row, max_col=max_col, max_steps=max_steps) self._addPart(dnapart) return dnapart def removeAllParts(self): """Used to reset the document. Not undoable.""" self.documentClearSelectionsSignal.emit(self) for part in self._parts: part.remove(use_undostack=False) # end def def removePart(self, part): self.documentClearSelectionsSignal.emit(self) self._parts.remove(part) ### PUBLIC SUPPORT METHODS ### def controller(self): return self._controller def setController(self, controller): """Called by DocumentController setDocument method.""" self._controller = controller # end def def setSelectedPart(self, newPart): if self._selected_part == newPart: return self._selected_part = newPart # end def ### PRIVATE SUPPORT METHODS ### def _addPart(self, part, use_undostack=True): """Add part to the document via AddPartCommand.""" c = self.AddPartCommand(self, part) util.execCommandList(self, [c], desc="Add part", use_undostack=use_undostack) return c.part() # end def ### COMMANDS ### class AddPartCommand(UndoCommand): """ Undo ready command for deleting a part. """ def __init__(self, document, part): super(Document.AddPartCommand, self).__init__("add part") self._doc = document self._part = part # end def def part(self): return self._part # end def def redo(self): if len(self._doc._parts) == 0: self._doc._parts.append(self._part) self._part.setDocument(self._doc) self._doc.setSelectedPart(self._part) self._doc.documentPartAddedSignal.emit(self._doc, self._part) # end def def undo(self): self._doc.removePart(self._part) self._part.setDocument(None) self._doc.setSelectedPart(None) self._part.partRemovedSignal.emit(self._part)
class Part(CNObject): """A Part is a group of VirtualHelix items that are on the same lattice. Parts are the model component that most directly corresponds to a DNA origami design. Parts are always parented to the document. Parts know about their oligos, and the internal geometry of a part Copying a part recursively copies all elements in a part: StrandSets, Strands, etc PartInstances are parented to either the document or an assembly PartInstances know global position of the part Copying a PartInstance only creates a new PartInstance with the same Part(), with a mutable parent and position field. """ editable_properties = ['name', 'color', 'is_visible', 'grid_type'] def __init__(self, *args, **kwargs): """Sets the parent document, sets bounds for part dimensions, and sets up bookkeeping for partInstances, Oligos, VirtualHelix's, and helix ID number assignment. """ self._document = document = kwargs.get('document', None) super(Part, self).__init__(document) self._instance_count = 0 self._instances = set() # Properties # TODO document could be None self._group_properties = {'name': "Part%d" % len(document.children()), 'color': "#cc0000", # outlinerview will override from styles 'is_visible': True } self.uuid = kwargs['uuid'] if 'uuid' in kwargs else uuid4().hex # Selections self._selections = {} if self.__class__ == Part: e = "This class is abstract. Perhaps you want HoneycombPart." raise NotImplementedError(e) # end def def __repr__(self): cls_name = self.__class__.__name__ return "<%s %s>" % (cls_name, str(id(self))[-4:]) ### SIGNALS ### partZDimensionsChangedSignal = ProxySignal(CNObject, int, int, bool, name='partZDimensionsChangedSignal') """self, id_min, id_max, zoom_to_fit""" partInstanceAddedSignal = ProxySignal(CNObject, name='partInstanceAddedSignal') """self""" partParentChangedSignal = ProxySignal(CNObject, name='partParentChangedSignal') """self""" partRemovedSignal = ProxySignal(CNObject, name='partRemovedSignal') """self""" partPropertyChangedSignal = ProxySignal(CNObject, object, object, name='partPropertyChangedSignal') """self, property_name, new_value""" partSelectedChangedSignal = ProxySignal(CNObject, object, name='partSelectedChangedSignal') """self, is_selected""" partActiveChangedSignal = ProxySignal(CNObject, bool, name='partActiveChangedSignal') """self, is_active""" partInstancePropertySignal = ProxySignal(CNObject, str, str, object, name='partInstancePropertySignal') """self, view, key, val""" partDocumentSettingChangedSignal = ProxySignal(object, str, object, name='partDocumentSettingChangedSignal') """self, key, value""" ### SLOTS ### ### ACCESSORS ### def document(self): """Get this objects Document Returns: Document """ return self._document # end def def setDocument(self, document): """set this object's Document Args: document (Document): """ self._document = document # end def def _canRemove(self): """If _instance_count == 1 you could remove the part Returns: bool """ return self._instance_count == 1 # end def def _canReAdd(self): """If _instance_count == 0 you could re-add the part Returns: bool """ return self._instance_count == 0 # end def def _incrementInstance(self, document, obj_instance): """Increment the instances of this reference object Args: document (Document): obj_instance (ObjectInstance): """ self._instance_count += 1 self._instances.add(obj_instance) self._document = document document.addInstance(obj_instance) if self._instance_count == 1: document.addRefObj(self) # end def def _decrementInstance(self, obj_instance): """Decrement the instances of this reference object Args: obj_instance (ObjectInstance): """ ic = self._instance_count self._instances.remove(obj_instance) document = self._document document.removeInstance(obj_instance) if ic == 0: raise IndexError("Can't have less than zero instance of a Part") ic -= 1 if ic == 0: document.removeRefObj(self) self._document = None self._instance_count = ic return ic # end def def instanceProperties(self): """ Generator yielding all instance properties """ for instance in self._instances: yield instance.properties() # end def def setInstanceProperty(self, part_instance, key, value): """Set an instance property Args: part_instance (ObjectInstance): key (str): value (object): """ part_instance.setProperty(key, value) # end def def getInstanceProperty(self, part_instance, key): """Get an instance property Args: part_instance (ObjectInstance): key (str): Returns: object: """ return part_instance.getProperty(key) # end def def changeInstanceProperty(self, part_instance, view, key, value, use_undostack=True): c = ChangeInstancePropertyCommand(self, part_instance, view, key, value) util.doCmd(self, c, use_undostack=use_undostack) # end def def getModelProperties(self): """ Get the dictionary of model properties Returns: dict: group properties """ return self._group_properties # end def def getProperty(self, key): """ Args: key (str): """ return self._group_properties[key] # end def def getOutlineProperties(self): """Convenience method for getting the properties used in the outlinerview Returns: tuple: (<name>, <color>, <is_visible>) """ props = self._group_properties return props['name'], props['color'], props['is_visible'] # end def def setProperty(self, key, value, use_undostack=True): """ Get the value of the key model properties Args: key (str): value (object): use_undostack (bool, optional): default True """ if key == 'is_visible': self._document.clearAllSelected() if use_undostack: c = SetPropertyCommand([self], key, value) self.undoStack().push(c) else: self._setProperty(key, value) # end def def _setProperty(self, key, value, emit_signals=False): self._group_properties[key] = value if emit_signals: self.partPropertyChangedSignal.emit(self, key, value) # end def def getName(self): return self._group_properties['name'] # end def def getColor(self): return self._group_properties['color'] # end def def destroy(self): self.setParent(None) self.deleteLater() # QObject also emits a destroyed() Signal
class ObjectInstance(CNObject): def __init__(self, reference_object, parent=None): super(ObjectInstance, self).__init__(reference_object) self._parent = parent # parent is either a document or assembly self._ref_object = reference_object # self._position = [0, 0, 0, 0, 0, 0] # x, y, z, phi, theta, psi self._properties = {} # end def # SIGNALS # instanceDestroyedSignal = ProxySignal(CNObject, name="instanceDestroyedSignal") instancePropertyChangedSignal = ProxySignal( CNObject, name="instancePropertyChangedSignal") instanceParentChangedSignal = ProxySignal( CNObject, name="instanceParentChangedSignal") # SLOTS # # METHODS # def destroy(self): self.setParent(None) self.deleteLater() # end def def reference(self): return self._ref_object # end def def parent(self): return self._parent # end def def properties(self): return self._properties.copy() # end def def setProperty(self, key, val): self._properties[key] = val # end def def getProperty(self, key): return self._properties[key] # end def def shallowCopy(self): oi = ObjectInstance(self._ref_object, self._parent) oi._properties = oi._properties.copy() return oi # end def def deepCopy(self, reference_object, parent): oi = ObjectInstance(reference_object, parent) oi._properties = oi._properties.copy() return oi
class Oligo(CNObject): """ Oligo is a group of Strands that are connected via 5' and/or 3' connections. It corresponds to the physical DNA strand, and is thus used tracking and storing properties that are common to a single strand, such as its color. Commands that affect Strands (e.g. create, remove, merge, split) are also responsible for updating the affected Oligos. Args: part (Part): the model :class:`Part` color (str): optional, color property of the :class:`Oligo` """ editable_properties = ['name', 'color'] def __init__(self, part, color=None, length=0): super(Oligo, self).__init__(part) self._part = part self._strand5p = None self._is_circular = False self._props = { 'name': "oligo%s" % str(id(self))[-4:], 'color': "#cc0000" if color is None else color, 'length': 0, 'is_visible': True } # end def def __repr__(self): cls_name = self.__class__.__name__ olg_id = str(id(self))[-4:] if self._strand5p is not None: vh_num = self._strand5p.idNum() idx = self._strand5p.idx5Prime() ss_type = self._strand5p.strandType() else: vh_num = -1 idx = -1 ss_type = -1 return "<%s %s>(%d.%d[%d])" % (cls_name, olg_id, vh_num, ss_type, idx) # end def def __lt__(self, other): return self.length() < other.length() # end def def shallowCopy(self): olg = Oligo(self._part) olg._strand5p = self._strand5p olg._is_circular = self._is_circular olg._props = self._props.copy() return olg # end def def dump(self): """ Return dictionary of this oligo and its properties. It's expected that caller will copy the properties if mutating Returns: dict: """ s5p = self._strand5p key = { 'id_num': s5p.idNum(), 'idx5p': s5p.idx5Prime(), 'is_5p_fwd': s5p.isForward(), 'is_circular': self._is_circular, 'sequence': self.sequence() } key.update(self._props) return key # end def ### SIGNALS ### oligoRemovedSignal = ProxySignal(CNObject, CNObject, name='oligoRemovedSignal') """part, self""" oligoSequenceAddedSignal = ProxySignal(CNObject, name='oligoSequenceAddedSignal') """self""" oligoSequenceClearedSignal = ProxySignal(CNObject, name='oligoSequenceClearedSignal') """self""" oligoPropertyChangedSignal = ProxySignal(CNObject, object, object, name='oligoPropertyChangedSignal') """self, property_name, new_value""" ### SLOTS ### ### ACCESSORS ### def getProperty(self, key): return self._props[key] # end def def getOutlineProperties(self): """Convenience method for the outline view Returns: tuple: (<name>, <color>, <is_visible>) """ props = self._props return props['name'], props['color'], props['is_visible'] # end def def getModelProperties(self): """Return a reference to the property dictionary Returns: dict: """ return self._props # end def def setProperty(self, key, value, use_undostack=True): if use_undostack: c = SetPropertyCommand([self], key, value) self.undoStack().push(c) else: self._setProperty(key, value) # end def def _setProperty(self, key, value, emit_signals=False): self._props[key] = value if emit_signals: self.oligoPropertyChangedSignal.emit(self, key, value) # end def def getName(self): return self._props['name'] # end def def getColor(self): color = self._props['color'] try: if color is None: print(self._props) raise ValueError("Whhat Got None???") except: exc_type, exc_value, exc_traceback = sys.exc_info() traceback.print_tb(exc_traceback, limit=5, file=sys.stdout) traceback.print_stack() sys.exit(0) return color # end def def _setColor(self, color): """Set this oligo color. Args: color (str): format '#ffffff' """ if color is None: raise ValueError("Whhat None???") self._props['color'] = color # end def def _setLength(self, length, emit_signals): before = self.shouldHighlight() key = 'length' self._props[key] = length if emit_signals and before != self.shouldHighlight(): self.oligoSequenceClearedSignal.emit(self) self.oligoPropertyChangedSignal.emit(self, key, length) # end def def locString(self): vh_num = self._strand5p.idNum() idx = self._strand5p.idx5Prime() return "%d[%d]" % (vh_num, idx) # end def def part(self): return self._part # end def def strand5p(self): """The 5' strand is the first contiguous segment of the oligo, representing the region between the 5' base to the first crossover or 3' end. Returns: Strand: the 5'-most strand of the oligo. """ return self._strand5p # end def def strand3p(self): """The 3' strand. The last strand in the oligo. See strand5p(). Returns: Strand: the 3'-most strand of the oligo. """ s5p = self._strand5p if self._is_circular: return s5p._strand5p for strand in s5p.generator3pStrand(): pass return strand # end def def setStrand5p(self, strand): self._strand5p = strand # end def def undoStack(self): return self._part.undoStack() # end def ### PUBLIC METHODS FOR QUERYING THE MODEL ### def isCircular(self): """Used for checking if an oligo is circular, or has a 5' and 3' end. Sequences cannot be exported of oligos for which isCircular returns True. See also: Oligo.sequenceExport(). Returns: bool: True if the strand3p is connected to strand5p, else False. """ return self._is_circular # end def def length(self): """ The oligo length in bases. Returns: int: value from the oligo's property dict. """ return self._props['length'] # end def def sequence(self): """Get the sequence applied to this `Oligo` Returns: str or None """ temp = self.strand5p() if not temp: return None if temp.sequence(): return ''.join([ Strand.sequence(strand) for strand in self.strand5p().generator3pStrand() ]) else: return None # end def def sequenceExport(self, output): """ Iterative appending to argument `output` which is a dictionary of lists Args: output (dict): dictionary with keys given in `NucleicAcidPart.getSequences` Returns: dict: output with this oligo's values appended for each key """ part = self.part() vh_num5p = self.strand5p().idNum() strand5p = self.strand5p() idx5p = strand5p.idx5Prime() seq = [] a_seq = [] if self.isCircular(): # print("A loop exists") raise CircularOligoException("Cannot export circular oligo " + self.getName()) for strand in strand5p.generator3pStrand(): seq.append(Strand.sequence(strand, for_export=True)) a_seq.append(Strand.abstractSeq(strand)) if strand.connection3p() is None: # last strand in the oligo vh_num3p = strand.idNum() idx3p = strand.idx3Prime() a_seq = ','.join(a_seq) a_seq = "(%s)" % (a_seq) modseq5p, modseq5p_name = part.getStrandModSequence( strand5p, idx5p, ModType.END_5PRIME) modseq3p, modseq3p_name = part.getStrandModSequence( strand, idx3p, ModType.END_3PRIME) seq = ''.join(seq) seq = modseq5p + seq + modseq3p # output = "%d[%d]\t%d[%d]\t%s\t%s\t%s\t%s\t(%s)\n" % \ # (vh_num5p, idx5p, vh_num3p, idx3p, self.getColor(), # modseq5p_name, seq, modseq3p_name, a_seq) # these are the keys # keys = ['Start','End','Color', 'Mod5', # 'Sequence','Mod3','AbstractSequence'] output['Start'].append("%d[%d]" % (vh_num5p, idx5p)) output['End'].append("%d[%d]" % (vh_num3p, idx3p)) output['Color'].append(self.getColor()) output['Mod5'].append(modseq5p_name) output['Sequence'].append(seq) output['Mod3'].append(modseq3p_name) output['AbstractSequence'].append(a_seq) return output # end def def shouldHighlight(self): """ Checks if oligo's length falls within the range specified by OLIGO_LEN_BELOW_WHICH_HIGHLIGHT and OLIGO_LEN_BELOW_WHICH_HIGHLIGHT. Returns: bool: True if circular or length outside acceptable range, otherwise False. """ if not self._strand5p: return True if self.length() > MAX_HIGHLIGHT_LENGTH: return False if self.length() < OLIGO_LEN_BELOW_WHICH_HIGHLIGHT: return True if self.length() > OLIGO_LEN_ABOVE_WHICH_HIGHLIGHT: return True return False # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def remove(self, use_undostack=True): c = RemoveOligoCommand(self) util.doCmd(self, c, use_undostack=use_undostack) # end def def applyAbstractSequences(self): temp = self.strand5p() if not temp: return for strand in temp.generator3pStrand(): strand.applyAbstractSequence() # end def def clearAbstractSequences(self): temp = self.strand5p() if not temp: return for strand in temp.generator3pStrand(): strand.clearAbstractSequence() # end def def displayAbstractSequences(self): temp = self.strand5p() if not temp: return for strand in temp.generator3pStrand(): strand.copyAbstractSequenceToSequence() # end def def applyColor(self, color, use_undostack=True): if color == self.getColor(): return # oligo already has this color c = ApplyColorCommand(self, color) util.doCmd(self, c, use_undostack=use_undostack) # end def def applySequence(self, sequence, use_undostack=True): c = ApplySequenceCommand(self, sequence) util.doCmd(self, c, use_undostack=use_undostack) # end def def applySequenceCMD(self, sequence): return ApplySequenceCommand(self, sequence) # end def def _setLoop(self, bool): self._is_circular = bool # end def def getStrandLengths(self): """ Traverses the oligo and builds up a list of each strand's total length, which also accounts for any insertions, deletions, or modifications. Returns: list: lengths of individual strands in the oligo """ strand5p = self.strand5p() strand_lengths = [] for strand in strand5p.generator3pStrand(): strand_lengths.append(strand.totalLength()) return strand_lengths def getNumberOfBasesToEachXover(self, use_3p_idx=False): """ Convenience method to get a list of absolute distances from the 5' end of the oligo to each of the xovers in the oligo. Args: use_3p_idx: Adds a 1-base office to return 3' xover idx instead of """ strand5p = self.strand5p() num_bases_to_xovers = [] offset = 1 if use_3p_idx else 0 # 3p xover idx is always the next base i = 0 for strand in strand5p.generator3pStrand(): if strand.connection3p(): i = i + strand.totalLength() num_bases_to_xovers.append(i + offset) return num_bases_to_xovers def splitAtAbsoluteLengths(self, len_list): self._part.splitOligoAtAbsoluteLengths(self, len_list) # end def ### PUBLIC SUPPORT METHODS ### def addToPart(self, part, emit_signals=False): self._part = part self.setParent(part) part._addOligoToSet(self, emit_signals) # end def def getAbsolutePositionAtLength(self, len_for_pos): """ Convenience method convert the length in bases from the 5' end of the oligo to the absolute position (vh, strandset, baseidx). Args: len_for_pos: length in bases for position lookup """ strand5p = self.strand5p() temp_len = 0 for strand in strand5p.generator3pStrand(): if (temp_len + strand.totalLength()) > len_for_pos: vh = strand.idNum() strandset = strand.strandType() baseidx = strand.idx5Prime() + len_for_pos - temp_len return (vh, strandset, baseidx) else: temp_len += strand.totalLength() return None # end def def setPart(self, part): self._part = part self.setParent(part) # end def def destroy(self): # QObject also emits a destroyed() Signal # self.setParent(None) # self.deleteLater() cmds = [] s5p = self._strand5p for strand in s5p.generator3pStrand(): cmds += strand.clearDecoratorCommands() # end for cmds.append(RemoveOligoCommand(self)) return cmds # end def def _decrementLength(self, delta, emit_signals): self._setLength(self.length() - delta, emit_signals) # end def def _incrementLength(self, delta, emit_signals): self._setLength(self.length() + delta, emit_signals) # end def def refreshLength(self, emit_signals=False): temp = self.strand5p() if not temp: return length = 0 for strand in temp.generator3pStrand(): length += strand.totalLength() self._setLength(length, emit_signals) # end def def removeFromPart(self, emit_signals=False): """This method merely disconnects the object from the model. It still lives on in the undoStack until clobbered Note: don't set self._part = None because we need to continue passing the same reference around. """ self._part._removeOligoFromSet(self, emit_signals) self.setParent(None) # end def def _strandMergeUpdate(self, old_strand_low, old_strand_high, new_strand): """This method sets the isCircular status of the oligo and the oligo's 5' strand. """ # check loop status if old_strand_low.oligo() == old_strand_high.oligo(): self._is_circular = True self._strand5p = new_strand return # leave the _strand5p as is? # end if # Now get correct 5p end to oligo if old_strand_low.isForward(): if old_strand_low.connection5p() is not None: self._strand5p = old_strand_low.oligo()._strand5p else: self._strand5p = new_strand else: if old_strand_high.connection5p() is not None: self._strand5p = old_strand_high.oligo()._strand5p else: self._strand5p = new_strand # end if # end def def _strandSplitUpdate(self, new_strand5p, new_strand3p, oligo3p, old_merged_strand): """If the oligo is a loop, splitting the strand does nothing. If the oligo isn't a loop, a new oligo must be created and assigned to the new_strand and everything connected to it downstream. """ # if you split it can't be a loop self._is_circular = False if old_merged_strand.oligo().isCircular(): self._strand5p = new_strand3p return else: if old_merged_strand.connection5p() is None: self._strand5p = new_strand5p else: self._strand5p = old_merged_strand.oligo()._strand5p oligo3p._strand5p = new_strand3p
class Strand(ProxyObject): """ A Strand is a continuous stretch of bases that are all in the same StrandSet (recall: a VirtualHelix is made up of two StrandSets). Every Strand has two endpoints. The naming convention for keeping track of these endpoints is based on the relative numeric value of those endpoints (low and high). Thus, Strand has a '_base_idx_low', which is its index with the lower numeric value (typically positioned on the left), and a '_base_idx_high' which is the higher-value index (typically positioned on the right) Strands can be linked to other strands by "connections". References to connected strands are named "_strand5p" and "_strand3p", which correspond to the 5' and 3' phosphate linkages in the physical DNA strand, respectively. Since Strands can point 5'-to-3' in either the low-to-high or high-to-low directions, connection accessor methods (connectionLow and connectionHigh) are bound during the init for convenience. """ def __init__(self, strandset, base_idx_low, base_idx_high, oligo=None): self._doc = strandset.document() super(Strand, self).__init__(strandset) self._strandset = strandset self._base_idx_low = base_idx_low # base index of the strand's left bound self._base_idx_high = base_idx_high # base index of the right bound self._oligo = oligo self._strand5p = None # 5' connection to another strand self._strand3p = None # 3' connection to another strand self._sequence = None self._decorators = {} self._modifiers = {} # dynamic methods for mapping high/low connection /indices # to corresponding 3Prime 5Prime is_drawn_5_to_3 = strandset.isDrawn5to3() if is_drawn_5_to_3: self.idx5Prime = self.lowIdx self.idx3Prime = self.highIdx self.connectionLow = self.connection5p self.connectionHigh = self.connection3p self.setConnectionLow = self.setConnection5p self.setConnectionHigh = self.setConnection3p else: self.idx5Prime = self.highIdx self.idx3Prime = self.lowIdx self.connectionLow = self.connection3p self.connectionHigh = self.connection5p self.setConnectionLow = self.setConnection3p self.setConnectionHigh = self.setConnection5p self._is_drawn_5_to_3 = is_drawn_5_to_3 # end def def __repr__(self): clsName = self.__class__.__name__ s = "%s.<%s(%s, %s)>" % (self._strandset.__repr__(), clsName, self._base_idx_low, self._base_idx_high) return s def generator5pStrand(self): """ Iterate from self to the final _strand5p == None 3prime to 5prime Includes originalCount to check for circular linked list """ originalCount = 0 node0 = node = self f = attrgetter('_strand5p') # while node and originalCount == 0: # yield node # equivalent to: node = node._strand5p # node = f(node) # if node0 == self: # originalCount += 1 while node: yield node # equivalent to: node = node._strand5p node = f(node) if node0 == node: break # end def def generator3pStrand(self): """ Iterate from self to the final _strand3p == None 5prime to 3prime Includes originalCount to check for circular linked list """ originalCount = 0 node0 = node = self f = attrgetter('_strand3p') while node: yield node # equivalent to: node = node._strand5p node = f(node) if node0 == node: break # end def def strandFilter(self): return self._strandset.strandFilter() ### SIGNALS ### strandHasNewOligoSignal = ProxySignal(ProxyObject, name='strandHasNewOligoSignal') #pyqtSignal(QObject) # strand strandRemovedSignal = ProxySignal(ProxyObject, name='strandRemovedSignal') #pyqtSignal(QObject) # strand strandResizedSignal = ProxySignal(ProxyObject, tuple, name='strandResizedSignal') #pyqtSignal(QObject, tuple) # Parameters: (strand3p, strand5p) strandXover5pChangedSignal = ProxySignal(ProxyObject, ProxyObject, name='strandXover5pChangedSignal') #pyqtSignal(QObject, QObject) strandXover5pRemovedSignal = ProxySignal(ProxyObject, ProxyObject, name='strandXover5pRemovedSignal') #pyqtSignal(QObject, QObject) # Parameters: (strand) strandUpdateSignal = ProxySignal(ProxyObject, name='strandUpdateSignal') #pyqtSignal(QObject) # Parameters: (strand, insertion object) strandInsertionAddedSignal = ProxySignal(ProxyObject, object, name='strandInsertionAddedSignal') #pyqtSignal(QObject, object) strandInsertionChangedSignal = ProxySignal(ProxyObject, object, name='strandInsertionChangedSignal') #pyqtSignal(QObject, object) # Parameters: (strand, insertion index) strandInsertionRemovedSignal = ProxySignal(ProxyObject, int, name='strandInsertionRemovedSignal') #pyqtSignal(QObject, int) # Parameters: (strand, decorator object) strandModsAddedSignal = ProxySignal(ProxyObject, object, int, name='strandModsAddedSignal') #pyqtSignal(QObject, object) strandModsChangedSignal = ProxySignal(ProxyObject, object, int, name='strandModsChangedSignal') #pyqtSignal(QObject, object) # Parameters: (strand, decorator index) strandModsRemovedSignal = ProxySignal(ProxyObject, object, int, name='strandModsRemovedSignal') #pyqtSignal(QObject, int) # Parameters: (strand, modifier object) strandModifierAddedSignal = ProxySignal(ProxyObject, object, name='strandModifierAddedSignal') #pyqtSignal(QObject, object) strandModifierChangedSignal = ProxySignal(ProxyObject, object, name='strandModifierChangedSignal') #pyqtSignal(QObject, object) # Parameters: (strand, modifier index) strandModifierRemovedSignal = ProxySignal(ProxyObject, int, name='strandModifierRemovedSignal') #pyqtSignal(QObject, int) # Parameters: (strand, value) selectedChangedSignal = ProxySignal(ProxyObject, tuple, name='selectedChangedSignal') #pyqtSignal(QObject, tuple) ### SLOTS ### ### ACCESSORS ### def undoStack(self): return self._strandset.undoStack() def decorators(self): return self._decorators # end def def isStaple(self): return self._strandset.isStaple() def isScaffold(self): return self._strandset.isScaffold() def part(self): return self._strandset.part() # end def def document(self): return self._doc # end def def oligo(self): return self._oligo # end def def sequence(self, for_export=False): seq = self._sequence if seq: return util.markwhite(seq) if for_export else seq elif for_export: return ''.join(['?' for x in range(self.totalLength())]) return '' # end def def strandSet(self): return self._strandset # end def def strandType(self): return self._strandset.strandType() def virtualHelix(self): return self._strandset.virtualHelix() # end def def setSequence(self, sequence_string): """ Applies sequence string from 5' to 3' return the tuple (used, unused) portion of the sequence_string """ if sequence_string == None: self._sequence = None return None, None length = self.totalLength() if len(sequence_string) < length: bonus = length - len(sequence_string) sequence_string += ''.join([' ' for x in range(bonus)]) temp = sequence_string[0:length] self._sequence = temp return temp, sequence_string[length:] # end def def reapplySequence(self): """ """ comp_ss = self.strandSet().complementStrandSet() # the strand sequence will need to be regenerated from scratch # as there are no guarantees about the entirety of the strand moving # i.e. both endpoints thanks to multiple selections so just redo the # whole thing self._sequence = None for comp_strand in comp_ss._findOverlappingRanges(self): compSeq = comp_strand.sequence() usedSeq = util.comp(compSeq) if compSeq else None usedSeq = self.setComplementSequence( usedSeq, comp_strand) # end for # end def def getComplementStrands(self): """ return the list of complement strands that overlap with this strand """ comp_ss = self.strandSet().complementStrandSet() return [comp_strand for comp_strand in comp_ss._findOverlappingRanges(self)] # end def def getPreDecoratorIdxList(self): """ Return positions where predecorators should be displayed. This is just a very simple check for the presence of xovers on the strand. Will refine later by checking for lattice neighbors in 3D. """ return range(self._base_idx_low, self._base_idx_high + 1) # end def # def getPreDecoratorIdxList(self): # """Return positions where predecorators should be displayed.""" # part = self._strandset.part() # validIdxs = sorted([idx[0] for idx in part._stapL + part._stapH]) # lo, hi = self._base_idx_low, self._base_idx_high # start = lo if self.connectionLow() == None else lo+1 # end = hi if self.connectionHigh() == None else hi-1 # ret = [] # for i in range(start, end+1): # if i % part.stepSize() in validIdxs: # ret.append(i) # return ret # # end def def setComplementSequence(self, sequence_string, strand): """ This version takes anothers strand and only sets the indices that align with the given complimentary strand return the used portion of the sequence_string As it depends which direction this is going, and strings are stored in memory left to right, we need to test for is_drawn_5_to_3 to map the reverse compliment appropriately, as we traverse overlapping strands. We reverse the sequence ahead of time if we are applying it 5' to 3', otherwise we reverse the sequence post parsing if it's 3' to 5' Again, sequences are stored as strings in memory 5' to 3' so we need to jump through these hoops to iterate 5' to 3' through them correctly Perhaps it's wiser to merely store them left to right and reverse them at draw time, or export time """ s_low_idx, s_high_idx = self._base_idx_low, self._base_idx_high c_low_idx, c_high_idx = strand.idxs() # get the ovelap low_idx, high_idx = util.overlap(s_low_idx, s_high_idx, c_low_idx, c_high_idx) # only get the characters we're using, while we're at it, make it the # reverse compliment total_length = self.totalLength() # see if we are applying if sequence_string == None: # clear out string for in case of not total overlap use_seq = ''.join([' ' for x in range(total_length)]) else: # use the string as is use_seq = sequence_string[::-1] if self._is_drawn_5_to_3 \ else sequence_string temp = array(array_type, sixb(use_seq)) if self._sequence == None: temp_self = array(array_type, sixb(''.join([' ' for x in range(total_length)]))) else: temp_self = array(array_type, sixb(self._sequence) if self._is_drawn_5_to_3 \ else sixb(self._sequence[::-1])) # generate the index into the compliment string a = self.insertionLengthBetweenIdxs(s_low_idx, low_idx - 1) b = self.insertionLengthBetweenIdxs(low_idx, high_idx) c = strand.insertionLengthBetweenIdxs(c_low_idx, low_idx - 1) start = low_idx - c_low_idx + c end = start + b + high_idx - low_idx + 1 temp_self[low_idx - s_low_idx + a:high_idx - s_low_idx + 1 + a + b] = \ temp[start:end] # print "old sequence", self._sequence self._sequence = tostring(temp_self) # if we need to reverse it do it now if not self._is_drawn_5_to_3: self._sequence = self._sequence[::-1] # test to see if the string is empty(), annoyingly expensive if len(self._sequence.strip()) == 0: self._sequence = None # print "new sequence", self._sequence return self._sequence # end def ### PUBLIC METHODS FOR QUERYING THE MODEL ### def connection3p(self): return self._strand3p # end def def connection5p(self): return self._strand5p # end def def idxs(self): return (self._base_idx_low, self._base_idx_high) # end def def lowIdx(self): return self._base_idx_low # end def def highIdx(self): return self._base_idx_high # end def def idx3Prime(self): """Returns the absolute base_idx of the 3' end of the strand. overloaded in __init__ """ pass # return self.idx3Prime def idx5Prime(self): """Returns the absolute base_idx of the 5' end of the strand. overloaded in __init__ """ pass # return self.idx5Prime def isDrawn5to3(self): return self._strandset.isDrawn5to3() # end def def getSequenceList(self): """ return the list of sequences strings comprising the sequence and the inserts as a tuple with the index of the insertion [(idx, (strandItemString, insertionItemString), ...] This takes advantage of the fact the python iterates a dictionary by keys in order so if keys are indices, the insertions will iterate out from low index to high index """ seqList = [] is_drawn_5_to_3 = self._is_drawn_5_to_3 seq = self._sequence if is_drawn_5_to_3 else self._sequence[::-1] # assumes a sequence has been applied correctly and is up to date tL = self.totalLength() offsetLast = 0 lengthSoFar = 0 iLength = 0 lI, hI = self.idxs() for insertion in self.insertionsOnStrand(): iLength = insertion.length() index = insertion.idx() offset = index + 1 - lI + lengthSoFar if iLength < 0: offset -= 1 # end if lengthSoFar += iLength seqItem = seq[offsetLast:offset] # the stranditem seq # Because skips literally skip displaying a character at a base # position, this needs to be accounted for seperately if iLength < 0: seqItem += ' ' offsetLast = offset else: offsetLast = offset + iLength seqInsertion = seq[offset:offsetLast] # the insertions sequence seqList.append((index, (seqItem, seqInsertion))) # end for # append the last bit of the strand seqList.append((lI + tL, (seq[offsetLast:tL], ''))) if not is_drawn_5_to_3: # reverse it again so all sub sequences are from 5' to 3' for i in range(len(seqList)): index, temp = seqList[i] seqList[i] = (index, (temp[0][::-1], temp[1][::-1])) return seqList # end def def canResizeTo(self, newLow, newHigh): """ Checks to see if a resize is allowed. Similar to getResizeBounds but works for two bounds at once. """ lowNeighbor, highNeighbor = self._strandset.getNeighbors(self) lowBound = lowNeighbor.highIdx() if lowNeighbor \ else self.part().minBaseIdx() highBound = highNeighbor.lowIdx() if highNeighbor \ else self.part().maxBaseIdx() if newLow > lowBound and newHigh < highBound: return True return False def getResizeBounds(self, idx): """ Determines (inclusive) low and high drag boundaries resizing from an endpoint located at idx. When resizing from _base_idx_low: low bound is determined by checking for lower neighbor strands. high bound is the index of this strand's high cap, minus 1. When resizing from _base_idx_high: low bound is the index of this strand's low cap, plus 1. high bound is determined by checking for higher neighbor strands. When a neighbor is not present, just use the Part boundary. """ neighbors = self._strandset.getNeighbors(self) if idx == self._base_idx_low: if neighbors[0]: low = neighbors[0].highIdx() + 1 else: low = self.part().minBaseIdx() return low, self._base_idx_high - 1 else: # self._base_idx_high if neighbors[1]: high = neighbors[1].lowIdx() - 1 else: high = self.part().maxBaseIdx() return self._base_idx_low + 1, high # end def def hasXoverAt(self, idx): """ An xover is necessarily at an enpoint of a strand """ if idx == self.highIdx(): return True if self.connectionHigh() is not None else False elif idx == self.lowIdx(): return True if self.connectionLow() is not None else False else: return False # end def def canInstallXoverAt(self, idx, from_strand, from_idx): """ Assumes idx is: self.lowIdx() <= idx <= self.highIdx() """ if self.hasXoverAt(idx): return False sS = self.strandSet() is_same_strand = from_strand == self is_strand_type_match = \ from_strand.strandSet().strandType() == sS.strandType() \ if from_strand else True if not is_strand_type_match: return False is_drawn_5_to_3 = sS.isDrawn5to3() indexDiffH = self.highIdx() - idx indexDiffL = idx - self.lowIdx() index3Lim = self.idx3Prime() - 1 if is_drawn_5_to_3 \ else self.idx3Prime() + 1 if is_same_strand: indexDiffStrands = from_idx - idx if idx == self.idx5Prime() or idx == index3Lim: return True elif indexDiffStrands > -3 and indexDiffStrands < 3: return False # end if for same Strand if idx == self.idx5Prime() or idx == index3Lim: return True elif indexDiffH > 2 and indexDiffL > 1: return True else: return False #end def def insertionLengthBetweenIdxs(self, idxL, idxH): """ includes the length of insertions in addition to the bases """ tL = 0 insertions = self.insertionsOnStrand(idxL, idxH) for insertion in insertions: tL += insertion.length() return tL # end def def insertionsOnStrand(self, idxL=None, idxH=None): """ if passed indices it will use those as a bounds """ insertions = [] coord = self.virtualHelix().coord() insertionsDict = self.part().insertions()[coord] sortedIndices = sorted(insertionsDict.keys()) if idxL == None: idxL, idxH = self.idxs() for index in sortedIndices: insertion = insertionsDict[index] if idxL <= insertion.idx() <= idxH: insertions.append(insertion) # end if # end for return insertions # end def def modifersOnStrand(self): """ """ mods = [] modsDict = self.part().mods()['ext_instances'] coord = self.virtualHelix().coord() isstaple = self.isStaple() idxL, idxH = self.idxs() keyL = "{},{},{}".format(coord, isstaple, idxL) keyH = "{},{},{}".format(coord, isstaple, idxH) if keyL in modsDict: mods.append(modsDict[keyL]) if keyH in modsDict: mods.append(modsDict[keyH]) return mods # end def def length(self): return self._base_idx_high - self._base_idx_low + 1 # end def def totalLength(self): """ includes the length of insertions in addition to the bases """ tL = 0 insertions = self.insertionsOnStrand() for insertion in insertions: tL += insertion.length() return tL + self.length() # end def ### PUBLIC METHODS FOR EDITING THE MODEL ### def addDecorators(self, additionalDecorators): """Used to add decorators during a merge operation.""" self._decorators.update(additionalDecorators) # end def def addMods(self, mod_id, idx, use_undostack=True): """Used to add mods during a merge operation.""" cmds = [] idx_low, idx_high = self.idxs() if idx_low == idx or idx == idx_high: check_mid1 = self.part().getModID(self, idx) check_mid2 = self.part().getMod(mod_id) if check_mid2 is not None: if check_mid1 != mod_id: if check_mid1 is not None: cmds.append(RemoveModsCommand(self, idx, check_mid1)) # print("adding a {} modification at {}".format(mod_id, idx)) cmds.append(AddModsCommand(self, idx, mod_id)) util.execCommandList( self, cmds, desc="Add Modification", use_undostack=use_undostack) else: print(check_mid, mod_id) # end if # end def def removeMods(self, mod_id, idx, use_undostack=True): """Used to add mods during a merge operation.""" cmds = [] idx_low, idx_high = self.idxs() print("attempting to remove") if idx_low == idx or idx == idx_high: print("removing a modification at {}".format(idx)) cmds.append(RemoveModsCommand(self, idx, mod_id)) util.execCommandList( self, cmds, desc="Remove Modification", use_undostack=use_undostack) # end if # end def def addInsertion(self, idx, length, use_undostack=True): """ Adds an insertion or skip at idx. length should be >0 for an insertion -1 for a skip """ cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if not self.hasInsertionAt(idx): # make sure length is -1 if a skip if length < 0: length = -1 if use_undostack: # on import no need to blank sequences cmds.append(self.oligo().applySequenceCMD(None, use_undostack=use_undostack)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None, use_undostack=use_undostack)) cmds.append(AddInsertionCommand(self, idx, length)) util.execCommandList( self, cmds, desc="Add Insertion", use_undostack=use_undostack) # end if # end if # end def def changeInsertion(self, idx, length, use_undostack=True): cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if self.hasInsertionAt(idx): if length == 0: self.removeInsertion(idx) else: # make sure length is -1 if a skip if length < 0: length = -1 cmds.append(self.oligo().applySequenceCMD(None, use_undostack=use_undostack)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None, use_undostack=use_undostack)) cmds.append( ChangeInsertionCommand(self, idx, length)) util.execCommandList( self, cmds, desc="Change Insertion", use_undostack=use_undostack) # end if # end if # end def def removeInsertion(self, idx, use_undostack=True): cmds = [] idx_low, idx_high = self.idxs() if idx_low <= idx <= idx_high: if self.hasInsertionAt(idx): if use_undostack: cmds.append(self.oligo().applySequenceCMD(None, use_undostack=use_undostack)) for strand in self.getComplementStrands(): cmds.append(strand.oligo().applySequenceCMD(None, use_undostack=use_undostack)) cmds.append(RemoveInsertionCommand(self, idx)) util.execCommandList( self, cmds, desc="Remove Insertion", use_undostack=use_undostack) # end if # end if # end def def destroy(self): self.setParent(None) self.deleteLater() # QObject also emits a destroyed() Signal # end def def merge(self, idx): """Check for neighbor, then merge if possible.""" lowNeighbor, highNeighbor = self._strandset.getNeighbors(self) # determine where to check for neighboring endpoint if idx == self._base_idx_low: if lowNeighbor: if lowNeighbor.highIdx() == idx - 1: self._strandset.mergeStrands(self, lowNeighbor) elif idx == self._base_idx_high: if highNeighbor: if highNeighbor.lowIdx() == idx + 1: self._strandset.mergeStrands(self, highNeighbor) else: raise IndexError # end def def resize(self, new_idxs, use_undostack=True): cmds = [] if self.strandSet().isScaffold(): cmds.append(self.oligo().applySequenceCMD(None)) cmds += self.getRemoveInsertionCommands(new_idxs) cmds.append(ResizeCommand(self, new_idxs)) util.execCommandList( self, cmds, desc="Resize strand", use_undostack=use_undostack) # end def def setConnection3p(self, strand): self._strand3p = strand # end def def setConnection5p(self, strand): self._strand5p = strand # end def def setIdxs(self, idxs): self._base_idx_low = idxs[0] self._base_idx_high = idxs[1] # end def def setOligo(self, new_oligo, emit_signal=True): self._oligo = new_oligo if emit_signal: self.strandHasNewOligoSignal.emit(self) # end def def setStrandSet(self, strandset): self._strandset = strandset # end def def split(self, idx, update_sequence=True): """Called by view items to split this strand at idx.""" self._strandset.splitStrand(self, idx, update_sequence) def updateIdxs(self, delta): self._base_idx_low += delta self._base_idx_high += delta # end def ### PUBLIC SUPPORT METHODS ### def getRemoveInsertionCommands(self, new_idxs): """ Removes Insertions, Decorators, and Modifiers that have fallen out of range of new_idxs. For insertions, it finds the ones that have neither Staple nor Scaffold strands at the insertion idx as a result of the change of this strand to new_idxs """ decs = self._decorators mods = self._modifiers cIdxL, cIdxH = self.idxs() nIdxL, nIdxH = new_idxs low_out, high_out = False, False insertions = [] if cIdxL < nIdxL < cIdxH: idxL, idxH = cIdxL, nIdxL - 1 insertions += self.insertionsOnStrand(idxL, idxH) else: low_out = True if cIdxL < nIdxH < cIdxH: idxL, idxH = nIdxH + 1, cIdxH insertions += self.insertionsOnStrand(idxL, idxH) else: high_out = True # this only called if both the above aren't true # if low_out and high_out: # if we move the whole strand, just clear the insertions out if nIdxL > cIdxH or nIdxH < cIdxL: idxL, idxH = cIdxL, cIdxH insertions += self.insertionsOnStrand(idxL, idxH) # we stretched in this direction return self.clearInsertionsCommands(insertions, cIdxL, cIdxH) # end def def clearInsertionsCommands(self, insertions, idxL, idxH): """ clear out insertions in this range """ commands = [] comp_ss = self.strandSet().complementStrandSet() overlappingStrandList = comp_ss.getOverlappingStrands(idxL, idxH) for insertion in insertions: idx = insertion.idx() removeMe = True for strand in overlappingStrandList: overLapIdxL, overLapIdxH = strand.idxs() if overLapIdxL <= idx <= overLapIdxH: removeMe = False # end if # end for if removeMe: commands.append(RemoveInsertionCommand(self, idx)) else: pass # print "keeping %s insertion at %d" % (self, key) # end for ### ADD CODE HERE TO HANDLE DECORATORS AND MODIFIERS return commands # end def def clearDecoratorCommands(self): insertions = self.insertionsOnStrand() return self.clearInsertionsCommands(insertions, *self.idxs()) # end def def hasDecoratorAt(self, idx): return idx in self._decorators # end def def hasInsertion(self): """ Iterate through dict of insertions for this strand's virtualhelix and return True of any of the indices overlap with the strand. """ coord = self.virtualHelix().coord() insts = self.part().insertions()[coord] for i in range(self._base_idx_low, self._base_idx_high+1): if i in insts: return True return False # end def def hasInsertionAt(self, idx): coord = self.virtualHelix().coord() insts = self.part().insertions()[coord] return idx in insts # end def def hasModifierAt(self, idx): return idx in self._modifiers # end def def shallowCopy(self): """ can't use python module 'copy' as the dictionary _decorators needs to be shallow copied as well, but wouldn't be if copy.copy() is used, and copy.deepcopy is undesired """ nS = Strand(self._strandset, *self.idxs()) nS._oligo = self._oligo nS._strand5p = self._strand5p nS._strand3p = self._strand3p # required to shallow copy the dictionary nS._decorators = dict(self._decorators.items()) nS._sequence = None # self._sequence return nS # end def def deepCopy(self, strandset, oligo): """ can't use python module 'copy' as the dictionary _decorators needs to be shallow copied as well, but wouldn't be if copy.copy() is used, and copy.deepcopy is undesired """ nS = Strand(strandset, *self.idxs()) nS._oligo = oligo decs = nS._decorators for key, decOrig in self._decorators: decs[key] = decOrig.deepCopy() # end fo nS._sequence = self._sequence return nS
class VirtualHelix(ProxyObject): """ VirtualHelix is a container class for two StrandSet objects (one scaffold and one staple). The Strands all share the same helix axis. It is called "virtual" because many different Strands (i.e. sub-oligos) combine to form the "helix", just as many fibers may be braided together to form a length of rope. """ def __init__(self, part, row, col, idnum=0): self._doc = part.document() super(VirtualHelix, self).__init__(part) self._coord = (row, col) # col, row self._part = part self._scaf_strandset = StrandSet(StrandType.SCAFFOLD, self) self._stap_strandset = StrandSet(StrandType.STAPLE, self) # If self._part exists, it owns self._number # in that only it may modify it through the # private interface. The public interface for # setNumber just routes the call to the parent # dnapart if one is present. If self._part == None # the virtualhelix owns self._number and may modify it. self._number = None self.setNumber(idnum) # end def def __repr__(self): return "<%s(%d)>" % (self.__class__.__name__, self._number) ### SIGNALS ### virtualHelixRemovedSignal = ProxySignal( ProxyObject, name='virtualHelixRemovedSignal') #pyqtSignal(QObject) # self virtualHelixNumberChangedSignal = ProxySignal( ProxyObject, int, name='virtualHelixNumberChangedSignal' ) #pyqtSignal(QObject, int) # self, num ### SLOTS ### ### ACCESSORS ### def scaf(self, idx): """ Returns the strand at idx in self's scaffold, if any """ return self._scaf_strandset.getStrand(idx) def stap(self, idx): """ Returns the strand at idx in self's scaffold, if any """ return self._stap_strandset.getStrand(idx) def coord(self): return self._coord # end def def number(self): return self._number # end def def part(self): return self._part # end def def document(self): return self._doc # end def def setNumber(self, number): if self._number != number: num_to_vh_dict = self._part._number_to_virtual_helix # if self._number is not None: num_to_vh_dict[self._number] = None self._number = number self.virtualHelixNumberChangedSignal.emit(self, number) num_to_vh_dict[number] = self # end def def setPart(self, new_part): self._part = new_part self.setParent(new_part) # end def def scaffoldStrandSet(self): return self._scaf_strandset # end def def stapleStrandSet(self): return self._stap_strandset # end def def undoStack(self): return self._part.undoStack() # end def ### METHODS FOR QUERYING THE MODEL ### def scaffoldIsOnTop(self): return self.isEvenParity() def getStrandSetByIdx(self, idx): """ This is a path-view-specific accessor idx == 0 means top strand idx == 1 means bottom strand """ if idx == 0: if self.isEvenParity(): return self._scaf_strandset else: return self._stap_strandset else: if self.isEvenParity(): return self._stap_strandset else: return self._scaf_strandset # end def def getStrandSetByType(self, strand_type): if strand_type == StrandType.SCAFFOLD: return self._scaf_strandset else: return self._stap_strandset # end def def getStrandSets(self): """Return a tuple of the scaffold and staple StrandSets.""" return self._scaf_strandset, self._stap_strandset # end def def hasStrandAtIdx(self, idx): """Return a tuple for (Scaffold, Staple). True if a strand is present at idx, False otherwise.""" return (self._scaf_strandset.hasStrandAt(idx, idx),\ self._stap_strandset.hasStrandAt(idx, idx)) # end def def indexOfRightmostNonemptyBase(self): """Returns the rightmost nonempty base in either scaf of stap.""" return max(self._scaf_strandset.indexOfRightmostNonemptyBase(),\ self._stap_strandset.indexOfRightmostNonemptyBase()) # end def def isDrawn5to3(self, strandset): is_scaf = strandset == self._scaf_strandset is_even = self.isEvenParity() return is_even == is_scaf # end def def isEvenParity(self): return self._part.isEvenParity(*self._coord) # end def def strandSetBounds(self, idx_helix, idx_type): """ forwards the query to the strandset """ return self.strandSet(idx_helix, idx_type).bounds() # end def ### METHODS FOR EDITING THE MODEL ### def destroy(self): # QObject also emits a destroyed() Signal self.setParent(None) self.deleteLater() # end def def remove(self, use_undostack=True): """ Removes a VirtualHelix from the model. Accepts a reference to the VirtualHelix, or a (row,col) lattice coordinate to perform a lookup. """ if use_undostack: self.undoStack().beginMacro("Delete VirtualHelix") self._scaf_strandset.remove(use_undostack) self._stap_strandset.remove(use_undostack) c = RemoveVirtualHelixCommand(self.part(), self) if use_undostack: self.undoStack().push(c) self.undoStack().endMacro() else: c.redo() # end def ### PUBLIC SUPPORT METHODS ### def deepCopy(self, part): """ This only copies as deep as the VirtualHelix strands get copied at the oligo and added to the Virtual Helix """ vh = VirtualHelix(part, self._number) vh._coords = (self._coord[0], self._coord[1]) # If self._part exists, it owns self._number # in that only it may modify it through the # private interface. The public interface for # setNumber just routes the call to the parent # dnapart if one is present. If self._part == None # the virtualhelix owns self._number and may modify it. self._number = idnum # end def def getLegacyStrandSetArray(self, strand_type): """Called by legacyencoder.""" if strand_type == StrandType.SCAFFOLD: return self._scaf_strandset.getLegacyArray() else: return self._stap_strandset.getLegacyArray() def shallowCopy(self): pass # end def # def translateCoords(self, deltaCoords): # """ # for expanding a helix # """ # deltaRow, deltaCol = deltaCoords # row, col = self._coord # self._coord = row + deltaRow, col + deltaCol # # end def # end class
class Assembly(CNObject): """ An Assembly is a collection of components, comprised recursively of various levels of individual parts and sub-assembly modules. The purpose of an Assembly object in radnano is to arrange Parts into larger groups (which may be connected or constrained in specific ways) to facilitate the modeling of more complex designs than a single part. """ def __init__(self, document): super(Assembly, self).__init__(document) self._document = document self._obj_instance_list = [] # This is a list of member parts # This is a list of ObjectInstances of this # particular assembly ONLY # an Assembly can not have an ObjectIntanceList that contains itself # that would be a circular reference self._assembly_instances = [] # end def # SIGNALS # assemblyInstanceAddedSignal = ProxySignal(CNObject, name='assemblyInstanceAddedSignal') assemblyDestroyedSignal = ProxySignal(CNObject, name='assemblyDestroyedSignal') # SLOTS # # METHODS # def undoStack(self): return self._document.undoStack() def destroy(self): # QObject also emits a destroyed() Signal self.setParent(None) self.deleteLater() # end def def document(self): return self._document # end def def objects(self): for obj in self._obj_instance_list: yield obj # end def def instances(self): for inst in self._assembly_instances: yield inst # end def def deepCopy(self): """ Deep copy the assembly by cloning the This leaves alone assemblyInstances, and only To finish the job this deepCopy Assembly should be incorporated into a new ObjectInstance and therefore an assemblyInstance """ doc = self._document asm = Assembly(doc) new_obj_inst_list = asm._obj_instance_list obj_instances = self.objects() # create a dictionary mapping objects (keys) to lists of # ObjectInstances ([value1, value2]) # this uniquifies the creation of new Assemblies object_dict = defaultdict(list) f1 = methodcaller('reference') for x in obj_instances: obj = f1(x) object_dict[obj].append(x) # end for # copy the all the objects f2 = methodcaller('deepCopy') for key, value in object_dict: # create a new object newObj = f2(key) # copy all of the instances relevant to this new object newInsts = [obj_inst.deepCopy(newObj, asm) for obj_inst in value] # add these to the list in the assembly new_obj_inst_list.extend(newInsts) # add Object to the document doc.addObject(newObj) # end for return asm # end def def addInstance(self, assembly_instance): self._assembly_instances.extend(assembly_instance)
class Document(CNObject): """ The Document class is the root of the model. It has two main purposes: 1. Serve as the parent all Part objects within the model. 2. Track all sub-model actions on its undoStack. Args: parent (CNObject): optional, defaults to None Attributes: view_names (list): views the document should support filter_set (set): filters that should be applied when selecting. """ def __init__(self, parent=None): super(Document, self).__init__(parent) self._undostack = us = UndoStack() # notice NO parent, what does this mean? us.setUndoLimit(30) self._children = set() # for storing a reference to Parts (and Assemblies) self._instances = set() # for storing instances of Parts (and Assemblies) self._controller = None self._selected_instance = None # the dictionary maintains what is selected self._selection_dict = {} self._active_part = None self._filename = None # the added list is what was recently selected or deselected self._strand_selected_changed_dict = {} self.view_names = [] self.filter_set = set() self._mods = {} # modifications keyed by mod id this_app = app() this_app.documentWasCreatedSignal.emit(self) # end def # SIGNALS # documentPartAddedSignal = ProxySignal(object, CNObject, name='documentPartAddedSignal') """`Document`, `Part`""" documentAssemblyAddedSignal = ProxySignal(object, CNObject, name='documentAssemblyAddedSignal') """`Document`, `Assembly`""" documentSelectionFilterChangedSignal = ProxySignal(object, name='documentSelectionFilterChangedSignal') documentPreXoverFilterChangedSignal = ProxySignal(str, name='documentPreXoverFilterChangedSignal') documentViewResetSignal = ProxySignal(CNObject, name='documentViewResetSignal') documentClearSelectionsSignal = ProxySignal(CNObject, name='documentClearSelectionsSignal') documentModAddedSignal = ProxySignal(object, object, object, name='documentModAddedSignal') documentModRemovedSignal = ProxySignal(object, object, name='documentModRemovedSignal') documentModChangedSignal = ProxySignal(object, object, object, name='documentModChangedSignal') # SLOTS # # ACCESSORS # def undoStack(self): """This is the actual undoStack to use for all commands. Any children needing to perform commands should just ask their parent for the undoStack, and eventually the request will get here. """ return self._undostack def children(self): """Returns a list of parts associated with the document. Returns: list: list of all child objects """ return self._children def addRefObj(self, child): """For adding Part and Assembly object references Args: child (object): """ self._children.add(child) def addInstance(self, instance): """Add an ObjectInstance to the list of instances Args: instance (ObjectInstance): """ self._instances.add(instance) def removeInstance(self, instance): """ Remove an ObjectInstance from the list of instances Args: instance (ObjectInstance): """ self._instances.remove(instance) self.documentClearSelectionsSignal.emit(self) def removeAllChildren(self): """Used to reset the document. Not undoable.""" self.documentClearSelectionsSignal.emit(self) for child in list(self._children): child.remove(use_undostack=True) self.undoStack().clear() self.deactivateActivePart() # end def def setFilterSet(self, filter_list): """ Set the Document filter list. Emits `documentSelectionFilterChangedSignal` Args: filter_list (list): list of filter key names """ vhkey = 'virtual_helix' fs = self.filter_set if vhkey in filter_list and vhkey not in fs: self.clearAllSelected() if vhkey in fs and vhkey not in filter_list: self.clearAllSelected() self.filter_set = fs = set(filter_list) self.documentSelectionFilterChangedSignal.emit(fs) # end def def removeRefObj(self, child): """ Remove child Part or Assembly Args: child (object): """ self._children.remove(child) # end def def activePart(self): return self._active_part # end def def setActivePart(self, part): # print("DC setActivePart") self._active_part = part # end def def deactivateActivePart(self): # print("DC deactive Part") self._active_part = None # end def def fileName(self): return self._filename # end def def setFileName(self, fname): self._filename = fname # end def def writeToFile(self, filename, legacy=False): """ Convenience wrapper for `encodeToFile` to set the `document` argument to `self` Args: filename (str): full path file name legacy (bool): attempt to export cadnano2 format """ encodeToFile(filename, self, legacy) # end def def readFile(self, filename): """ Convenience wrapper for `decodeFile` to always emit_signals and set the `document` argument to `self` Args: filename (str): full path file name """ return decodeFile(filename, document=self, emit_signals=True) # end def # def assemblies(self): # """Returns a list of assemblies associated with the document.""" # return self._assemblies # PUBLIC METHODS FOR QUERYING THE MODEL # # def selectedInstance(self): # return self._selected_instance def addStrandToSelection(self, strand, value): """ Add `Strand` object to Document selection Args: strand (Strand): value (tuple): of :obj:`bool` of form:: (is low index selected, is high index selected) Returns: bool: True if successful, False otherwise """ ss = strand.strandSet() if ss in self._selection_dict: self._selection_dict[ss][strand] = value else: self._selection_dict[ss] = {strand: value} self._strand_selected_changed_dict[strand] = value # end def def removeStrandFromSelection(self, strand): """Remove `Strand` object from Document selection Args: strand (Strand): Returns: bool: True if successful, False otherwise """ ss = strand.strandSet() if ss in self._selection_dict: temp = self._selection_dict[ss] if strand in temp: del temp[strand] if len(temp) == 0: del self._selection_dict[ss] self._strand_selected_changed_dict[strand] = (False, False) return True else: return False else: return False # end def def addVirtualHelicesToSelection(self, part, id_nums): """If the Part isn't in the _selection_dict its not going to be in the changed_dict either, so go ahead and add Args: part (Part): id_nums (array-like): """ selection_dict = self._selection_dict if part not in selection_dict: selection_dict[part] = s_set = set() else: s_set = selection_dict[part] changed_set = set() for id_num in id_nums: if id_num not in s_set: s_set.add(id_num) changed_set.add(id_num) if len(changed_set) > 0: part.partVirtualHelicesSelectedSignal.emit(part, changed_set, True) # end def def removeVirtualHelicesFromSelection(self, part, id_nums): """Remove from the `Part` selection the `VirtualHelix` objects specified by id_nums. Args: part (Part) id_nums (array-like) """ # print("remove called", id(part), id_nums, self._selection_dict.get(part)) selection_dict = self._selection_dict if part in selection_dict: s_set = selection_dict[part] changed_set = set() for id_num in id_nums: if id_num in s_set: s_set.remove(id_num) if len(s_set) == 0: del selection_dict[part] changed_set.add(id_num) if len(changed_set) > 0: part.partVirtualHelicesSelectedSignal.emit(part, changed_set, False) # end def def selectedOligos(self): """As long as one endpoint of a strand is in the selection, then the oligo is considered selected. Returns: set: or :obj:`None` if nothing is found """ s_dict = self._selection_dict selected_oligos = set() for ss in s_dict.keys(): for strand in ss: selected_oligos.add(strand.oligo()) # end for # end for return selected_oligos if len(selected_oligos) > 0 else None # end def def clearAllSelected(self): """Clear all selections emits documentClearSelectionsSignal """ # print("clearAllSelected") self._selection_dict = {} # the added list is what was recently selected or deselected self._strand_selected_changed_dict = {} self.documentClearSelectionsSignal.emit(self) # end def def isModelStrandSelected(self, strand): ss = strand.strandSet() if ss in self._selection_dict: if strand in self._selection_dict[ss]: return True else: return False else: return False # end def def isVirtualHelixSelected(self, part, id_num): """For a given part Args: part (Part): part in question id_num (int): ID number of a virtual helix Returns: bool: True if id_num is selected else False """ if part in self._selection_dict: return id_num in self._selection_dict[part] else: return False # end def def isOligoSelected(self, oligo): """Determine if given `Oligo` is selected Args: oligo (Oligo) Returns: bool: True if `oligo` is selected otherwise False """ strand5p = oligo.strand5p() return self.isModelStrandSelected(strand5p) # end def def selectOligo(self, oligo): """Select given `Oligo` Args: oligo (Oligo): """ strand5p = oligo.strand5p() both_ends = (True, True) for strand in strand5p.generator3pStrand(): self.addStrandToSelection(strand, both_ends) self.updateStrandSelection() # end def def deselectOligo(self, oligo): """Deselect given `Oligo` Args: oligo (Oligo): """ strand5p = oligo.strand5p() for strand in strand5p.generator3pStrand(): self.removeStrandFromSelection(strand) self.updateStrandSelection() # end def def getSelectedStrandValue(self, strand): """ strand is an objects to look up it is prevetted to be in the dictionary Args: strand (Strand): """ return self._selection_dict[strand.strandSet()][strand] # end def def sortedSelectedStrands(self, strandset): """ Get a list sorted from low to high index of `Strands` in a `StrandSet` that are selected Args: strandset (StrandSet): Returns: list: of :obj: `Strand` """ out_list = [x for x in self._selection_dict[strandset].items()] getLowIdx = lambda x: Strand.lowIdx(itemgetter(0)(x)) out_list.sort(key=getLowIdx) return out_list # end def def determineStrandSetBounds(self, selected_strand_list, strandset): """ Determine the bounds of a :class:`StrandSet` `strandset` among a a list of selected strands in that same `strandset` Args: selected_strand_list (list): strandset (StrandSet): Returns: tuple: of :obj:`int` """ length = strandset.length() min_high_delta = min_low_delta = max_ss_idx = length - 1 # init the return values ss_dict = self._selection_dict[strandset] for strand, value in selected_strand_list: idx_low, idx_high = strand.idxs() low_neighbor, high_neighbor = strandset.getNeighbors(strand) # print(low_neighbor, high_neighbor) if value[0]: # the end is selected if low_neighbor is None: temp = idx_low - 0 else: if low_neighbor in ss_dict: value_N = ss_dict[low_neighbor] # we only care if the low neighbor is not selected temp = min_low_delta if value_N[1] else idx_low - low_neighbor.highIdx() - 1 # end if else: # not selected temp = idx_low - low_neighbor.highIdx() - 1 # end else if temp < min_low_delta: min_low_delta = temp # end if # check the other end of the strand if not value[1]: temp = idx_high - idx_low - 1 if temp < min_high_delta: min_high_delta = temp # end if if value[1]: if high_neighbor is None: temp = max_ss_idx - idx_high else: if high_neighbor in ss_dict: value_N = ss_dict[high_neighbor] # we only care if the low neighbor is not selected temp = min_high_delta if value_N[0] else high_neighbor.lowIdx() - idx_high - 1 # end if else: # not selected temp = high_neighbor.lowIdx() - idx_high - 1 # end else # end else if temp < min_high_delta: min_high_delta = temp # end if # check the other end of the strand if not value[0]: temp = idx_high - idx_low - 1 if temp < min_low_delta: min_low_delta = temp # end if # end for return (min_low_delta, min_high_delta) # end def def getSelectionBounds(self): """ Get the index bounds of a strand selection Returns: tuple: of :obj:`int` """ min_low_delta = -1 min_high_delta = -1 for strandset in self._selection_dict.keys(): selected_list = self.sortedSelectedStrands(strandset) temp_low, temp_high = self.determineStrandSetBounds(selected_list, strandset) if temp_low < min_low_delta or min_low_delta < 0: min_low_delta = temp_low if temp_high < min_high_delta or min_high_delta < 0: min_high_delta = temp_high return (min_low_delta, min_high_delta) # end def def deleteStrandSelection(self, use_undostack=True): """ Delete selected strands. First iterates through all selected strands and extracts refs to xovers and strands. Next, calls removeXover on xoverlist as part of its own macroed command for isoluation purposes. Finally, calls removeStrand on all strands that were fully selected (low and high), or had at least one non-xover endpoint selected. """ xoList = [] strand_dict = {} for strandset_dict in self._selection_dict.values(): for strand, selected in strandset_dict.items(): part = strand.part() idx_low, idx_high = strand.idxs() strand5p = strand.connection5p() strand3p = strand.connection3p() # both ends are selected strand_dict[strand] = selected[0] and selected[1] # only look at 3' ends to handle xover deletion sel3p = selected[0] if idx_low == strand.idx3Prime() else selected[1] if sel3p: # is idx3p selected? if strand3p: # is there an xover xoList.append((part, strand, strand3p, use_undostack)) else: # idx3p is a selected endpoint strand_dict[strand] = True else: if not strand5p: # idx5p is a selected endpoint strand_dict[strand] = True if use_undostack and xoList: self.undoStack().beginMacro("Delete xovers") for part, strand, strand3p, useUndo in xoList: NucleicAcidPart.removeXover(part, strand, strand3p, useUndo) self.removeStrandFromSelection(strand) self.removeStrandFromSelection(strand3p) self._selection_dict = {} self.documentClearSelectionsSignal.emit(self) if use_undostack: if xoList: # end xover macro if it was started self.undoStack().endMacro() if True in strand_dict.values(): self.undoStack().beginMacro("Delete selection") else: return # nothing left to do for strand, delete in strand_dict.items(): if delete: strand.strandSet().removeStrand(strand) if use_undostack: self.undoStack().endMacro() # end def def resizeSelection(self, delta, use_undostack=True): """ Moves the selected idxs by delta by first iterating over all strands to calculate new idxs (method will return if snap-to behavior would create illegal state), then applying a resize command to each strand. Args: delta (float): use_undostack (bool): optional, default is True """ resize_list = [] vh_set = set() # calculate new idxs part = None for strandset_dict in self._selection_dict.values(): for strand, selected in strandset_dict.items(): if part is None: part = strand.part() idx_low, idx_high = strand.idxs() new_low, new_high = strand.idxs() delta_low = delta_high = delta # process xovers to get revised delta if selected[0] and strand.connectionLow(): new_low = part.xoverSnapTo(strand, idx_low, delta) if new_low is None: return delta_high = new_low - idx_low if selected[1] and strand.connectionHigh(): new_high = part.xoverSnapTo(strand, idx_high, delta) if new_high is None: return delta_low = new_high - idx_high # process endpoints if selected[0] and not strand.connectionLow(): new_low = idx_low + delta_low if selected[1] and not strand.connectionHigh(): new_high = idx_high + delta_high if new_low > new_high: # check for illegal state return vh_set.add(strand.idNum()) resize_list.append((strand, new_low, new_high)) # end for # end for # execute the resize commands us = self.undoStack() if use_undostack: us.beginMacro("Resize Selection") for strand, idx_low, idx_high in resize_list: Strand.resize(strand, (idx_low, idx_high), use_undostack, update_segments=False) if resize_list: cmd = RefreshSegmentsCommand(part, vh_set) if use_undostack: us.push(cmd) else: cmd.redo() if use_undostack: us.endMacro() # end def def updateStrandSelection(self): """ do it this way in the future when we have a better signaling architecture between views For now, individual objects need to emit signals """ for obj, value in self._strand_selected_changed_dict.items(): obj.strandSelectedChangedSignal.emit(obj, value) # end for self._strand_selected_changed_dict = {} # end def def resetViews(self): """ This is a fast way to clear selections and the views. We could manually deselect each item from the Dict, but we'll just let them be garbage collect the dictionary maintains what is selected """ # print("reset views") self._selection_dict = {} # the added list is what was recently selected or deselected self._strand_selected_changed_dict = {} self.documentViewResetSignal.emit(self) # end def def makeNew(self, fname=None): self.clearAllSelected() self.resetViews() setBatch(True) self.removeAllChildren() # clear out old parts setBatch(False) self.undoStack().clear() # reset undostack self.deactivateActivePart() self._filename = fname if fname else "untitled.json" # end def def setViewNames(self, view_name_list, do_clear=False): """ Tell the model what views the document should support Allows non-visible views to be used. Intended to be called at application launch only at present. Args: view_name_list (list): do_clear (bool): optional, clear the names or not? defaults to False """ view_names = [] if do_clear else self.view_names for view_name in view_name_list: if view_name not in view_names: view_names.append(view_name) self.view_names = view_names # end def # PUBLIC METHODS FOR EDITING THE MODEL # def createNucleicAcidPart(self, use_undostack=True): """ Create and store a new DnaPart and instance, and return the instance. Args: use_undostack (bool): optional, defaults to True """ dnapart = NucleicAcidPart(document=self) self._addPart(dnapart, use_undostack=use_undostack) return dnapart # end def def getParts(self): """Get all child `Part` in the document Yields: Part: the next Part in the the list of children """ for item in self._children: if isinstance(item, Part): yield item # end def def getPartUUID(self, uuid): """ Get the part given the uuid string Args: uuid (str): Returns: Part or None if no matching part is found """ for item in self._children: if isinstance(item, Part) and item.uuid == uuid: return item # end def # PUBLIC SUPPORT METHODS # def controller(self): return self._controller # end def def setController(self, controller): """Called by DocumentController setDocument method.""" self._controller = controller # end def def setSelectedInstance(self, new_instance): if self._selected_instance == new_instance: return self._selected_instance = new_instance # end def # PRIVATE SUPPORT METHODS # def _addPart(self, part, use_undostack=True): """Add part to the document via AddInstanceCommand. """ c = AddInstanceCommand(self, part) util.doCmd(self, c, use_undostack) # end def def createMod(self, params, mid=None, use_undostack=True): """ Create a modification Args: params (dict): mid (str): optional, modification ID string use_undostack (bool): optional, default is True Returns: tuple: of :obj:`dict`, :obj:`str` of form:: (dictionary of modification paramemters, modification ID string) """ if mid is None: mid = uuid4().hex elif mid in self._mods: raise KeyError("createMod: Duplicate mod id: {}".format(mid)) name = params.get('name', mid) color = params.get('color', '#00FF00') seq5p = params.get('seq5p', '') seq3p = params.get('seq3p', '') seqInt = params.get('seqInt', '') note = params.get('note', '') cmdparams = { 'props': {'name': name, 'color': color, 'note': note, 'seq5p': seq5p, 'seq3p': seq3p, 'seqInt': seqInt, }, 'ext_locations': set(), # external mods, mod belongs to idx outside of strand 'int_locations': set() # internal mods, mod belongs between idx and idx + 1 } item = {'name': name, 'color': color, 'note': note, 'seq5p': seq5p, 'seq3p': seq3p, 'seqInt': seqInt } c = AddModCommand(self, cmdparams, mid) util.doCmd(self, c, use_undostack=use_undostack) return item, mid # end def def modifyMod(self, params, mid, use_undostack=True): """Modify an existing modification Args: params (dict): mid (str): optional, modification ID string use_undostack (bool): optional, default is True """ if mid in self._mods: c = ModifyModCommand(self, params, mid) util.doCmd(self, c, use_undostack=use_undostack) # end def def destroyMod(self, mid, use_undostack=True): """Destroy an existing modification Args: mid (str): optional, modification ID string use_undostack (bool): optional, default is True """ if mid in self._mods: c = RemoveModCommand(self, mid) util.doCmd(self, c, use_undostack=use_undostack) # end def def getMod(self, mid): """Get an existing modification Args: mid (str): optional, modification ID string Returns: dict or None """ return self._mods.get(mid) # end def def getModProperties(self, mid): """Get an existing modification properties Args: mid (str): optional, modification ID string Returns: dict or None """ return self._mods.get(mid)['props'] # end def def getModLocationsSet(self, mid, is_internal): """Get an existing modifications locations in a Document (Part, Virtual Helix ID, Strand) Args: mid (str): optional, modification ID string is_internal (bool): Returns: dict or None """ if is_internal: return self._mods[mid]['int_locations'] else: return self._mods[mid]['ext_locations'] # end def def addModInstance(self, mid, is_internal, part, key): """ Add an instance of a modification to the Document Args: mid (str): modification id string is_internal (bool): part (Part): associated Part key (str): key of the modification at the part level """ location_set = self.getModLocationsSet(mid, is_internal) doc_key = ''.join((part.uuid, ',', key)) location_set.add(doc_key) # end def def removeModInstance(self, mid, is_internal, part, key): """Remove an instance of a modification from the Document Args: mid (str): modification id string is_internal (bool): part (Part): associated Part key (str): key of the modification at the part level """ location_set = self.getModLocationsSet(mid, is_internal) doc_key = ''.join((part.uuid, ',', key)) location_set.remove(doc_key) # end def def modifications(self): """ Get a copy of the dictionary of the modifications in this Document Returns: dict: """ mods = self._mods res = {} for mid in list(mods.keys()): mod_dict = mods[mid] res[mid] = {'props': mod_dict['props'].copy(), 'int_locations': list(mod_dict['int_locations']), 'ext_locations': list(mod_dict['ext_locations']) } return res # end def def getModStrandIdx(self, key): """ Convert a key of a mod instance relative to a part to a part, a strand and an index Args: key (str): Mod key """ keylist = key.split(',') part_uuid = keylist[0] id_num = int(keylist[1]) is_fwd = int(keylist[2]) # enumeration of StrandType.FWD or StrandType.REV idx = int(keylist[3]) part = self.getPartUUID(part_uuid) if part is None: raise KeyError("Part with uuid {} not found".format(part_uuid)) strand = part.getStrand(is_fwd, id_num, idx) return part, strand, idx # end def def getModSequence(self, mid, mod_type): """Getter for the modification sequence give by the arguments Args: mid (str or None): mod id or None mod_type (int): [ModType.END_5PRIME, ModType.END_3PRIME] Returns: tuple: of :obj:`str` of form:: (sequence, name) """ mod_dict = self._mods.get(mid) name = '' if mid is None else mod_dict['name'] if mod_type == ModType.END_5PRIME: seq = '' if mid is None else mod_dict['seq5p'] elif mod_type == ModType.END_3PRIME: seq = '' if mid is None else mod_dict['seq3p'] else: seq = '' if mid is None else mod_dict['seqInt'] return seq, name