Source code for schrodinger.structutils.createfragments

"""
This script will break up a set of input molecules into fragments
based on some simple rules, similar to those described in the original
RECAP paper. Run with -h to see the basic rules.

If -murcko option is specified, then Murco Assembly scaffolds are generated
instead of fragments. This is done by removing terminal from each "branch"
until reacing a ring.

If -recap option is specified, then rules are as follows:

I. Only break the bonds matching the RECAP SMARTS patterns.
    1. Amide
    2. Ester
    3. Amine
    4. Urea
    5. Ether
    6. Olefin
    7. Quarternary nitrogen
    8. Aromatic nitrogen - aliphatic carbon
    9. Lactam nitrogen - aliphatic carbon
    10. Aromatic carbon - aromatic carbon
    11. Sulphonamide
II. Do not break ring bonds
III. Do not break bonds that would yield one of the following fragments:
     hydrogen, methane, ethane, prone, butane.

NOTE: -recap_use can be used to specify which RECAP rules to use. The value
must be a list of comma-separated numbers (no spaces).

Example usage::

    from schrodinger.structutils import createfragments
    frag_creator = createfragments.FragmentCreator(atoms=options.atoms,
                            bonds=options.bonds, smarts=options.smarts,
                            carbon_hetero=options.carbon_hetero,
                            maxatoms=options.maxatoms,
                            removedup=options.removedup, murcko=options.murcko,
                            recap=options.recap, recap_use=recap_list,
                            complete=options.complete,
                            add_h=options.add_h, verbose=True)
    for struct in my_structures:
        # fragments is a list of Structure objects
        fragments = frag_creator.fragmentStructure(struct)

Copyright Schrodinger, LLC. All rights reserved.
"""

import sys
import warnings

import schrodinger.utils.log as log
from schrodinger.structutils import analyze
from schrodinger.structutils import build

logger = log.get_output_logger(__file__)

ATTACHPROP = 'b_fragmol_attachment'
RECAP_PROP_PREFIX = 'b_fragmol_recap_'


[docs]def are_sts_the_same(st1, st2): """ Checks coordinates of each atom in the specified structures and returns true if they are the same :type st1: `structure.Structure` :param st1: The first structure for comparison :type st2: `structure.Structure` :param st2: The second structure for comparison :rtype: bool :return: True if the structures have the same coordinates, False if not """ if st1.atom_total != st2.atom_total: return False for i in range(1, st1.atom_total + 1): a1 = st1.atom[i] a2 = st2.atom[i] if a1.xyz != a2.xyz: return False # All coordinates match; same fragment: return True
# RECAP rules. Note that "!@" must be used in order for in-ring bonds to not # get broken. RECAP_PATTERNS = [ ("[#6][C;X3](=O)!@[N;X3]", 2, 4, "Amide"), ("[!#1][O;X2]!@[C;X3](=O)[C;X4;H2,H3]", 2, 3, "Ester"), ("[#7;X3](!@[C,N&X3,#1])([C,N&X3,#1])[C,N&X3,#1]", 1, 2, "Amine"), ("[#7;X3]!@[C;X3](=O)[#7;X3]", 1, 2, "Urea"), ("[#6]!@[O;X2][#6]", 1, 2, "Ether"), ("[#6]=!@[#6]", 1, 2, "Olefin"), ("[#7;X4]!@[#6]", 1, 2, "Quarternary nitrogen"), ("[n]!@[C]", 1, 2, "Aromatic nitrogen - aliphatic carbon"), ("C!@N@C=O", 1, 2, "Lactam nitrogen - aliphatic carbon"), ("c!@c", 1, 2, "Aromatic carbon - aromatic carbon"), ("[#7][S;X4](=O)(=O)", 1, 2, "Sulphonamide"), ] # Patters for fragments that should NOT be produced according to the # RECAP rule. First atom is the bonding atom. NOTE: Hydrogens are excluded # by different means in the code, so the SMARTS pattern is not included here. TERMINAL_PATTERNS = [ "[C;X4;H3]", # Methyl "[O][C;X4;H3]", # Methoxy "[C;X4;H2][C;X4;H3]", # Ethyl "[O][C;X4;H2][C;X4;H3]", # Ethoxy "[C;X4;H2][C;X4;H2][C;X4;H3]", # Propyl "[C;X4;H2][C;X4;H2][C;X4;H2][C;X4;H3]", # Butyl ]
[docs]def find_recap_bonds(st, options): """ Find all bonds that match the RECAP rules described in J. Chem. Inf. Comput. Sci. 1998, 38, 511-522 Will return a list of [(atom1, atom2, rule_num)]. :type st: `schrodinger.structure.Structure` :param st: The structure to search for RECAP rule bonds :param options: The object holding the compute options :rtype: set of tuples :return: Each tuple is (atom1, atom2, rule_num) where atom1 and atom2 are the atoms involved in the bond and rule_num is the recap rule that the bond matches. atom1 and atom2 are sorted. """ # Ev:80526 # FIXME Add ability to use only certain RECAP rules (Ev:90142) recap_bonds = set() # set of sets of 2 atoms each. # sets are used in order to avoid listing the same bond more than once. if options.recap_use: use_rules = [int(x) for x in options.recap_use] else: use_rules = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] # Remove bonds that would produce small, "uninteresting" fragments # (hydrogens, methyls, ethyls, propyls, and butyls): small_terminal_groups = analyze.evaluate_multiple_smarts( st, TERMINAL_PATTERNS) # this is a list of atom lists. First atom is the bonded atom # Make a list of first (bonded) atoms: small_terminal_bond_atoms = [atoms[0] for atoms in small_terminal_groups] logger.debug(' SMALL TERMINAL BOND ATOMS: %s' % small_terminal_bond_atoms) for num, (pattern, pos1, pos2, doc) in enumerate(RECAP_PATTERNS, start=1): if num not in use_rules: continue logger.debug(" using pattern: %s" % pattern) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning, message="analyze.evaluate_smarts") atom_sets = analyze.evaluate_smarts(st, pattern) for atoms in atom_sets: atom1 = atoms[pos1 - 1] atom2 = atoms[pos2 - 1] if st.atom[atom1].element == 'H' or st.atom[atom2].element == 'H': logger.debug(' will not break bond because it is to a ' 'hydrogen') continue # Do not break this bond if atom1 in small_terminal_bond_atoms or \ atom2 in small_terminal_bond_atoms: logger.debug(' will not break bond because it will produce ' 'a small molecule') continue # Make a list of (atom1, atom2, rule_num): bond = sorted([atom1, atom2]) + [num] recap_bonds.add(tuple(bond)) logger.debug(' DEBUG recap_bonds: %s' % recap_bonds) return recap_bonds
class _Options(object): """ A class to hold a list of options. Mimics the object returned by parseargs. """ def __init__(self, atoms=2, bonds=100, smarts=None, carbon_hetero=False, maxatoms=200, removedup=False, murcko=False, recap=False, recap_use=None, complete=False, add_h=True, verbose=False): """ Create an _Options object :type atoms: int :param atoms: Minimum number of non-hydrogen atoms in a fragment (default=2) :type bonds: int :param bonds: Maximum number of broken bonds allowed in creating a fragment (default=100) :type smarts: str :param smarts: Path to a file of SMARTS patterns (one per line) to identify and eliminate fragments containing undesired features. :type carbon_hetero: bool :param carbon_hetero: Allows cutting of bonds between ring-carbon and hetero atoms (default=False) :type maxatoms: int :param maxatoms: Maximum number of atoms allowed in molecule to be fragmented (default=200). :type removedup: bool :param removedup: Keep only one copy of identical fragment but with different coordinates. (default=False) :type murcko: bool :param murcko: Generate Murco Assembly scaffold instead of regular fragments. (default=False) :type recap: bool :param recap: Use RECAP rules to find bonds to break. :type recap_use: list of int :param recap_use: Specify which RECAP rules to use (default is to use all). Value must be a list of integers (example: [1, 2, 10, 11]). :type complete: bool :param complete: Retain the complete list of fragments created, including intermediates. Only works with RECAP rules. (Default: False) :type add_h: bool :param complete: Add hydrogens to complete lewis structures. I some calses, you do not want to do this (Default: True) :type verbose: bool :param verbose: True if progress should be logged, False if not """ self.min_out_size = atoms self.max_broken_bonds = bonds self.smarts_file = smarts self.cut_hetero = carbon_hetero self.max_size = maxatoms self.removedup = removedup self.murcko = murcko self.recap = recap self.recap_use = recap_use self.complete = complete self.verbose = verbose self.add_h = add_h
[docs]class FragmentCreator(object): """ This class will break up input structures into fragments based on some simple rules. The basic rules are: 1. Cut one of these 2 types of bonds I. Bonds to rings a. Do not cut bonds to hydrogens b. Do not cut any in-ring bonds c. Do not cut bonds between ring-carbon and a non-carbon atoms (allowed with -c option) II. Carbon-carbon bonds a. Do not cut any bonds if either atom is in any ring 2. Refuse a fragment if any part of it matches a line in the SMARTS file specified by -smarts. 3. Refuse a fragment if the number of broken bonds exceeds -bonds. 4. Refuse a fragment if the number of atoms is less than -atoms. 5. Do not attempt to fragment molecules with more than -maxatoms atoms. For -recap option, here are the rules that are used: 1. [#6][C;X3](=O)!@[N;X3] Amide 2. [!#1][O;X2]!@[C;X3](=O)[C;X4;H2,H3] Ester 3. [#7;X3](!@[C,N&X3,#1])([C,N&X3,#1])[C,N&X3,#1] Amine 4. [#7;X3]!@[C;X3](=O)[#7;X3] Urea 5. [#6]!@[O;X2][#6] Ether 6. [#6]=!@[#6] Olefin 7. [#7;X4]!@[#6] Quarternary nitrogen 8. [n]!@[C] Aromatic nitrogen - aliphatic carbon 9. C!@N@C=O Lactam nitrogen - aliphatic carbon 10. c!@c Aromatic carbon - aromatic carbon 11. [#7][S;X4](=O)(=O) Sulphonamide """
[docs] def __init__(self, **kwargs): """ Create a FragmentCreator object. :type atoms: int :param atoms: Minimum number of non-hydrogen atoms in a fragment (default=2) :type bonds: int :param bonds: Maximum number of broken bonds allowed in creating a fragment (default=100) :type smarts: str :param smarts: Path to a file of SMARTS patterns (one per line) to identify and eliminate fragments containing undesired features. :type carbon_hetero: bool :param carbon_hetero: Allows cutting of bonds between ring-carbon and hetero atoms (default=False) :type maxatoms: int :param maxatoms: Maximum number of atoms allowed in molecule to be fragmented (default=200). :type removedup: bool :param removedup: Keep only one copy of identical fragment but with different coordinates. (default=False) :type murcko: bool :param murcko: Generate Murco Assembly scaffold instead of regular fragments. (default=False) :type recap: bool :param recap: Use RECAP rules to find bonds to break. :type recap_use: list of int :param recap_use: Specify which RECAP rules to use (default is to use all). Value must be a list of integers (example: [1, 2, 10, 11]). :type complete: bool :param complete: Retain the complete list of fragments created, including intermediates. Only works with RECAP rules. (Default: False) :type add_h: bool :param complete: Add hydrogens to complete lewis structures. I some calses, you do not want to do this (Default: True) :type verbose: bool :param verbose: True if progress should be logged, False if not """ self.options = _Options(**kwargs) self.checkArgs()
[docs] def getPatternString(self): """ Get a string that describes the RECAP patterns :rtype: str :return: A string describing the RECAP patterns. """ pring = ('\nRECAP patterns (SMARTS pattern, and positions of the ' 'bond atoms):\n') for num, (pattern, pos1, pos2, doc) in enumerate(RECAP_PATTERNS, start=1): pring += " %2d: %s:\n" % (num, doc) pring += " %s %i-%i\n\n" % (pattern, pos1, pos2) return pring
[docs] def checkArgs(self): """ Check the input arguments for appropriate usage """ options = self.options if options.min_out_size < 1: raise RuntimeError("Number of non-hydrogen atoms in output fragment" " cannot be less than one.") if options.max_broken_bonds < 0: raise RuntimeError("Maximum number of broken bonds cannot be less " "than zero.") if options.max_size < 10: raise RuntimeError("Maximum number of atoms in a structure to be " "fragmented cannot be less than 10.") if options.murcko and options.recap: raise RuntimeError("murcko and recap options are mutually " "exclusive.") if options.complete and not (options.recap or options.recap_use): raise RuntimeError("complete can only be used with the recap or " "recap_use options.") if options.recap_use: try: use_rules = [int(x) for x in options.recap_use] except ValueError: raise RuntimeError("Invalid value for recap_use option - must " "be a list of ints.") else: if options.verbose: logger.info("Using RECAP rules: %s" % use_rules) # Ev:92083 and Ev:92081 options.recap = True elif options.recap: if options.verbose: logger.info("Using all RECAP rules") if options.murcko: if options.verbose: logger.info("Using murcko mode") if options.verbose: logger.info('Requested maximum broken bonds: %i' % options.max_broken_bonds) # FIXME does not apply to -recap logger.info('Requested cut hetero: %s' % options.cut_hetero)
[docs] def fragmentStructure(self, struct): """ Break the input structure into fragments. :type struct: `schrodinger.structure.Structure` :param struct: The structure to break into fragments :rtype: list :return: list of `schrodinger.structure.Structure` objects for the fragments. """ if len(struct.atom) > self.options.max_size: raise RuntimeError('Skipping ligand %s because it has more than' ' %d atoms.' % (struct.title, self.options.max_size)) fragmenter = _StructureFragmenter(struct, self.options) return fragmenter.fragments[:]
[docs] def fragmentStructureForEA(self, struct): """ Break the input structure into fragments. Used for Event Analyis (EA) :type struct: `schrodinger.structure.Structure` :return: list of `schrodinger.structure.Structure` objects for the fragments """ if len(struct.atom) > self.options.max_size: raise RuntimeError('Skipping ligand %s because it has more than' ' %d atoms.' % (struct.title, self.options.max_size)) fragmenter = _StructureFragmenter(struct, self.options) return fragmenter.getStOutput()
class _StructureFragmenter(object): """ Class to create fragments from a given set of `_Options` and a given `schrodinger.structure.Structure`. """ def __init__(self, st, options): """ Create a StructureFragmenter object. :type st: `schrodinger.structure.Structure` :param st: The structure to fragment :type options: `_Options` :param options: The options to use during fragmenting """ self.st = st self.options = options self.fragments = [] # Label the original atom numbers: for atom in st.atom: atom.label_user_text = str(int(atom)) self.poss_new_mols = [] self.breakable_bonds = [] #List of possible bonds to be broken. if self.options.murcko: logger.debug(" Using murcko mode") ok = self.generateMurckoScaffold() if ok: self.storeFragments(self.options) elif self.options.recap: logger.debug(" Using RECAP rules") # NOTE: May return same bond more than once as part of a different # RECAP rule: recap_bonds_set = find_recap_bonds(self.st, options) recap_bonds_str = ", ".join( ['%i-%i' % (a1, a2) for (a1, a2, num) in recap_bonds_set]) if not recap_bonds_str: recap_bonds_str = 'None' if self.options.verbose: logger.info(" Bonds matching the RECAP rules: %s" % recap_bonds_str) # EV:96032 if self.options.complete: all_bonds = self.getCombinations(recap_bonds_set) #-- Break each combination from a single copy of the molecule for combinations in all_bonds: for bond_set in combinations: self._makeFragmentsUsingBondsSet(bond_set) else: self._makeFragmentsUsingBondsSet(recap_bonds_set) self.generateFinalMolecules() self.storeFragments(self.options) else: if self.options.verbose: logger.info("Using standard mode") # Get a list of C-C bonds that can be broken: self.findBreakableBonds() if self.options.verbose: logger.info("Possible breaks: %i" % len(self.breakable_bonds)) if self.breakable_bonds: self.breakBonds() self.generateFinalMolecules() self.storeFragments(self.options) def findBreakableBonds(self): """ Gets the list of those C-C bonds that should be broken. """ self.breakable_bonds = [] # Any heavy atom bound via a (non-ring, single-order bond) to any other # heavy atom within a ring: for c1, c2 in analyze.evaluate_smarts(self.st, "[!#1]!@;-[!#1;r]"): # NOTE it is possible that both atoms are in rings if not self.options.cut_hetero: # element of (possibly) non-ring atom e1 = self.st.atom[c1].element # element of ring atom e2 = self.st.atom[c2].element if e1 != 'C' and e2 == 'C': continue bond = self.st.getBond(c1, c2) if bond not in self.breakable_bonds: self.breakable_bonds.append(bond) # Find all carbon-carbon bonds (except those in rings): for c1, c2 in analyze.evaluate_smarts(self.st, "[C;!r][C;!r]"): bond = self.st.getBond(c1, c2) if bond not in self.breakable_bonds: self.breakable_bonds.append(bond) def breakBonds(self): """ Breaks the C-C bonds that should be broken and returns a list of new molecules """ # Algorithm: for each bond, with it broken, alternate all other bonds; # then with it unbroken alternate all bonds (numbered above it) self.poss_new_mols = [] self.curr_bond_index = 0 # key: bond index in breakable_bonds; value: boolean self.break_nobreak_dict = {} if self.options.verbose: logger.info(' Building fragments') self.alternateFurthurBonds() def generateFinalMolecules(self): """ Create a list of fragments that meet all the criteria """ self.final_molecules = [] for i, mol in enumerate(self.poss_new_mols): # Determine if molecule matches the following criteria: # 1. More than <min> # of heavy atoms # 2. Doens't match user-specified SMARTS patterns # 3. Number of broken bonds is fewer than user-specified value if self._checkMolecule(mol): self.final_molecules.append(mol) def removeTerminalAtoms(self): """ Remove atoms that are bound to only one other atom. Returns True if such atoms were found. """ terminal_atoms = [] for atom in self.st.atom: if len(atom.bond) in [0, 1]: terminal_atoms.append(atom.index) if terminal_atoms: self.st.deleteAtoms(terminal_atoms) return True else: return False def generateMurckoScaffold(self): self.final_molecules = [] done = False while not done: removed = self.removeTerminalAtoms() if not removed: done = True if len(self.st.atom) == 0: # No ring atoms in this st return False # ERROR else: self.final_molecules = [self.st] return True # OK def storeFragments(self, options): """ Creates the final fragment list - also adds hydrogen atoms and sets the title """ for index, mol_st in enumerate(self.final_molecules): if options.add_h: build.add_hydrogens(mol_st) mol_st.title = "%s (frag %i)" % (self.st.title, index + 1) self.fragments.append(mol_st) def getStOutput(self): return self.poss_new_mols def _checkMolecule(self, mol): """ Check to see if a fragment structure passes all the criteria :type mol: `schrodinger.structure.Structure` :param mol: The fragment structure to check :rtype: bool :return: True if structure passes the criteria, False if not """ if not self.checkBrokenBonds(mol): logger.debug(' DID NOT PASS checkMolecule - too many broken bonds') return False if not self.hasEnoughHeavyAtoms(mol): logger.debug(' DID NOT PASS checkMolecule - ' 'not enough heavy atoms') return False if not self.isAllowedSmartsPattern(mol): logger.debug(' DID NOT PASS checkMolecule - ' 'not allowed SMARTS pattern') return False logger.debug(' PASSED checkMolecule') return True def hasEnoughHeavyAtoms(self, mol): """ Calculates how many non-hydgrogen atoms the molecule contains and returns False if less than user-specified minimum. :type mol: `schrodinger.structure.Structure` :param mol: The fragment structure to check :rtype: bool :return: True if the fragment has enough heavy atoms, False if not """ num_heavy_atoms = 0 for atom in mol.atom: if atom.element != "H": num_heavy_atoms += 1 return (num_heavy_atoms >= self.options.min_out_size) def isAllowedSmartsPattern(self, mol): """ Checks if any prohibited SMARTS pattern matches fragment :type mol: `schrodinger.structure.Structure` :param mol: The fragment structure to check :rtype: bool :return: True if the fragment has an allowed SMARTS pattern, False if not """ # FIXME read this file at the start of the script. if self.options.smarts_file is None: return True try: file_handle = open(self.options.smarts_file) except: logger.error("SMARTS pattern file not valid. Please try again.") sys.exit() for s in file_handle: smarts_pattern = s.strip() mol_atoms = analyze.evaluate_smarts(mol, smarts_pattern) if (len(mol_atoms) > 0): return False return True def checkBrokenBonds(self, mol): """ Checks if molecule has more than the cutoff number of broken bonds :type mol: `schrodinger.structure.Structure` :param mol: The structure to check :rtype: bool :return: True if the molecule has fewer than the maximum number of cutoff bonds, False if not """ num_broken_bonds = 0 for atom in mol.atom: if ATTACHPROP in atom.property: num_broken_bonds += 1 return (num_broken_bonds <= self.options.max_broken_bonds) def alternateFurthurBonds(self): """ Recursive function for breaking any combination of brekable bonds. For example, if there are 3 bonds, then the following breaks will be produced:: 1 2 3 12 23 13 123 For 4 bonds the following:: 1 2 3 4 12 13 14 23 24 14 123 124 134 234 1234 etc. """ # Number of the bond we are about to alternate: sys.stdout.write(".") sys.stdout.flush() if self.curr_bond_index > 100: if self.options.verbose: logger.info(' breaking') return # Check if we are at the last bond: if self.curr_bond_index + 1 == len( self.breakable_bonds): # on last bond self.break_nobreak_dict[self.curr_bond_index] = True self.makeFragments( ) # Use self.break_nobreak_dict to make fragments self.break_nobreak_dict[self.curr_bond_index] = False self.makeFragments( ) # Use self.break_nobreak_dict to make fragments # Go back in the branch: self.break_nobreak_dict.pop(self.curr_bond_index) return # Follow one branch: self.curr_bond_index += 1 self.break_nobreak_dict[self.curr_bond_index] = True self.alternateFurthurBonds() # After the branch reached end, break_nobreak_dict will be reversed # so follow the next branch: self.break_nobreak_dict[self.curr_bond_index] = False self.alternateFurthurBonds() self.curr_bond_index -= 1 def makeFragments(self): """ Generate fragments by breaking bonds that have True set for them in self.break_nobreak_dict. """ bonds_set = set() for bond_index, do_break in self.break_nobreak_dict.items(): if do_break: bond = self.breakable_bonds[bond_index] a1 = bond.atom1.index a2 = bond.atom2.index bonds_set.add(tuple(sorted([a1, a2]))) self._makeFragmentsUsingBondsSet(bonds_set) def _makeFragmentsUsingBondsSet(self, bonds_set): """ Generate fragments by breaking the bonds in the bonds_set. Each bond is a set of 2 atoms. """ # Keep track of bonds that we already broke: # Set of sorted (atom1, atom2): broken_bonds = set() st = self.st.copy() num_broken = 0 for bond in bonds_set: if len(bond) == 3: # Using -recap option index1, index2, recap_num = tuple(bond) else: # -recap option not used index1, index2 = tuple(bond) recap_num = None a1 = st.atom[index1] a2 = st.atom[index2] # index1 is already less than index2; no need to sort bond_index = (index1, index2) if bond_index not in broken_bonds: st.deleteBond(a1, a2) logger.debug(' BROKE BOND: %i-%i' % (index1, index2)) a1.property[ATTACHPROP] = True a2.property[ATTACHPROP] = True num_broken += 1 broken_bonds.add(bond_index) # Whether or not we broke this bond before, we need to mark it for # this specific RECAP rule (Ev:80527): if recap_num: a1.property[RECAP_PROP_PREFIX + str(recap_num)] = True a2.property[RECAP_PROP_PREFIX + str(recap_num)] = True if num_broken == 0: return for mol in st.molecule: molst = mol.extractStructure() # Compare the new fragment to all existing fragments, # and exclude it if its not unique: unique = True for othermolst in self.poss_new_mols: if self.options.removedup: if molst.isEquivalent(othermolst): unique = False else: if are_sts_the_same(molst, othermolst): unique = False if not unique: break if unique: logger.debug(' Fragment is unique') self.poss_new_mols.append(molst) else: logger.debug(' Fragment is not unique (discarded)') def getCombinations(self, bonds): """ Get all possible combinations of bonds to break that will return all possible fragments, including intermediates. :rtype: list of lists :return: List of lists where each list contains the bonds to be cleaved for one copy of the molecule. """ #-- Get the max number of breaks needed to cover all # possible bond cleavage combinations max_breaks = (len(bonds) // 2) + 1 #-- Change the set to a list of lists bonds = [[b] for b in bonds] bond_groups = [bonds] while max_breaks > 1: new_grp = [] #-- Copy & append to the last group attached for grp in bond_groups[-1]: _grp = [grp + b for b in bonds if not b[0] in grp] new_grp.extend(_grp) bond_groups.append(new_grp) max_breaks -= 1 return bond_groups