diff --git a/arbdmodel/__init__.py b/arbdmodel/__init__.py
index 478d7b5a87960e75d750b2077ea9e58a185f604f..e22e22a59ac9cb2e6484d7ee88789b172735081f 100644
--- a/arbdmodel/__init__.py
+++ b/arbdmodel/__init__.py
@@ -1,4 +1,28 @@
 # -*- coding: utf-8 -*-
+
+## Set up loggers
+import logging
+def _get_username():
+    import sys
+    try:
+        return sys.environ['USER']
+    except:
+        return None
+
+logging.basicConfig(format='%(name)s: %(levelname)s: %(message)s')
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+_ch = logging.StreamHandler()
+_ch.setFormatter(logging.Formatter('%(name)s: %(levelname)s: %(message)s'))
+logger.addHandler(_ch)
+logger.propagate = False
+
+devlogger = logging.getLogger(__name__+'.dev')
+# devlogger.setLevel(logging.DEBUG)
+if _get_username() not in ('cmaffeo2',):
+    devlogger.addHandler(logging.NullHandler())
+
+## Import resources
 from pathlib import Path
 from abc import abstractmethod, ABCMeta
 
@@ -1138,9 +1162,9 @@ class SimEngine(metaclass=ABCMeta):
             cmd = self._generate_command_string(binary, output_name, output_directory, num_procs, gpu, replicas)
 
             if dry_run:
-                print("Run with: %s" % " ".join(cmd))
+                logger.info(f'Run with: {" ".join(cmd)}')
             else:
-                print("Running {} with: {}".format(binary," ".join(cmd)))
+                logger.info(f'Running {self.default_binary} with: {" ".join(cmd)}')
                 if log_file is None or (hasattr(log_file,'write') and callable(log_file.write)):
                     fd = sys.stdout if log_file is None else log_file
                     process = subprocess.Popen(cmd, stdout=subprocess.PIPE, universal_newlines=True)
@@ -1346,6 +1370,7 @@ class ArbdEngine(SimEngine):
         for i,j,t1,t2 in model._particleTypePairIter():
             f = "%s.%s-%s.dat" % (prefix, t1.name, t2.name)
             interaction = model._get_nonbonded_interaction(t1,t2)
+            if interaction is None: continue
             old_range = interaction.range_
 
             interaction.range_ = [0, configuration.cutoff]
@@ -1377,7 +1402,11 @@ class ArbdEngine(SimEngine):
 
         with open(self._bond_filename,'w') as fh:
             for i,j,b,ex in model.get_bonds():
-                item = (i.idx, j.idx, str(b))
+                try:
+                    bfile = b.filename()
+                except:
+                    bfile = str(b)
+                item = (i.idx, j.idx, bfile)
                 if ex:
                     fh.write("BOND REPLACE %d %d %s\n" % item)
                 else:
@@ -1391,7 +1420,11 @@ class ArbdEngine(SimEngine):
 
         with open(self._angle_filename,'w') as fh:
             for b in model.get_angles():
-                item = tuple([p.idx for p in b[:-1]] + [str(b[-1])])
+                try:
+                    bfile = b[-1].filename()
+                except:
+                    bfile = str(b[-1])
+                item = tuple([p.idx for p in b[:-1]] + [bfile])
                 fh.write("ANGLE %d %d %d %s\n" % item)
 
     def _write_dihedral_file( self, model, filename ):
@@ -1402,7 +1435,11 @@ class ArbdEngine(SimEngine):
 
         with open(self._dihedral_filename,'w') as fh:
             for b in model.get_dihedrals():
-                item = tuple([p.idx for p in b[:-1]] + [str(b[-1])])
+                try:
+                    bfile = b[-1].filename()
+                except:
+                    bfile = str(b[-1])
+                item = tuple([p.idx for p in b[:-1]] + [bfile])
                 fh.write("DIHEDRAL %d %d %d %d %s\n" % item)
 
     def _write_exclusion_file( self, model, filename ):
@@ -1417,7 +1454,14 @@ class ArbdEngine(SimEngine):
         if len(model.bond_angles) > 0:
             with open(self._bond_angle_filename,'w') as fh:
                 for b in model.get_bond_angles():
-                    item = tuple([p.idx for p in b[:-1]] + [str(p) for p in b[-1]])
+                    bfiles = []
+                    for p in b[-1]:
+                        try:
+                            bfile = p.filename()
+                        except:
+                            bfile = str(p)
+                        bfiles.append(bfile)
+                    item = tuple([p.idx for p in b[:-1]] + bfiles)
                     fh.write("BONDANGLE %d %d %d %d %s %s %s\n" % item)
 
     def _write_product_potential_file( self, model, filename ):
@@ -1438,8 +1482,12 @@ class ArbdEngine(SimEngine):
                             b = tb
                         if type(b) is not str and not isinstance(b, Path):
                             b.write_file()
+                        try:
+                            bfile = b.filename()
+                        except:
+                            bfile = str(b)
                         line = line+" ".join([str(x.idx) for x in ijk])+" "
-                        line = line+" ".join([str(x) for x in [type_,b] if x != ""])+" "
+                        line = line+" ".join([str(x) for x in [type_,bfile] if x != ""])+" "
                     fh.write(line)
 
     def _write_group_sites_file( self, model, filename ):
@@ -1638,15 +1686,27 @@ tabulatedPotential  1
 
             if len(bonds) > 0:
                 for b in model._get_bond_potentials():
-                    fh.write("tabulatedBondFile %s\n" % b)
+                    try:
+                        bfile = b.filename()
+                    except:
+                        bfile = str(b)
+                    fh.write("tabulatedBondFile %s\n" % bfile)
 
             if len(angles) > 0:
                 for b in model._get_angle_potentials():
-                    fh.write("tabulatedAngleFile %s\n" % b)
+                    try:
+                        bfile = b.filename()
+                    except:
+                        bfile = str(b)
+                    fh.write("tabulatedAngleFile %s\n" % bfile)
 
             if len(dihedrals) > 0:
                 for b in list(set([b for i,j,k,l,b in dihedrals])):
-                    fh.write("tabulatedDihedralFile %s\n" % b)
+                    try:
+                        bfile = b.filename()
+                    except:
+                        bfile = str(b)
+                    fh.write("tabulatedDihedralFile %s\n" % bfile)
 
             if len(restraints) > 0:
                 fh.write("inputRestraints %s\n" % self._restraint_filename)
diff --git a/arbdmodel/coords.py b/arbdmodel/coords.py
index 52b8f899186a77c00fce6e810b6571193d2bd66c..34e704685f49b0d3c4b30ed2826f5fc2cea82cbe 100644
--- a/arbdmodel/coords.py
+++ b/arbdmodel/coords.py
@@ -1,3 +1,4 @@
+from . import logger
 import numpy as np
 from scipy.optimize import newton
 
@@ -157,13 +158,21 @@ def rotationAboutAxis(axis,angle, normalizeAxis=True):
     return quaternion_to_matrix(q)
 
 def readArbdCoords(fname):
+    logger.warning('readArbdCoords is deprecated. Please update your code to use read_arbd_coordinates')
+    return read_arbd_coordinates(fname)
+
+def read_arbd_coordinates(fname):
     coords = []
     with open(fname) as fh:
         for line in fh:
             coords.append([float(x) for x in line.split()[1:]])
     return np.array(coords)
 
-def readAvgArbdCoords(psf,pdb,dcd,rmsdThreshold=3.5, first_frame=0, last_frame=-1, stride=1):
+def readAvgArbdCoords(*args,**kwargs):
+    logger.warning('readAvgArbdCoords is deprecated. Please update your code to use read_average_arbd_coordinates')
+    return read_average_arbd_coordinates(*args,**kwargs)
+    
+def read_average_arbd_coordinates(psf,pdb,dcd,rmsd_threshold=3.5, first_frame=0, last_frame=-1, stride=1):
     import MDAnalysis as mda
 
     usel = mda.Universe(psf, dcd)
@@ -180,11 +189,11 @@ def readAvgArbdCoords(psf,pdb,dcd,rmsdThreshold=3.5, first_frame=0, last_frame=-
         rmsd = np.mean( (r-r0)**2 )
         r = np.array( [(r-comA).dot(R)+comB for r in usel.atoms.positions] )
         pos.append( r )
-        if rmsd > rmsdThreshold**2:
+        if rmsd > rmsd_threshold**2:
             break
     t0=t+1
-    print( "Averaging coordinates in %s after frame %d" % (dcd, t0) )
-
+    logger.info("Averaging coordinates in %s after frame %d" % (dcd, t0) )
+    
     pos = np.mean(pos, axis=0)
     return pos
 
diff --git a/arbdmodel/fjc_polymer_model.py b/arbdmodel/fjc_polymer_model.py
index ee63f7f745380ec55270cfed374ee29a609eb353..abbff5d2d1b08b9d17cf188ede2d9f8a634dc00a 100644
--- a/arbdmodel/fjc_polymer_model.py
+++ b/arbdmodel/fjc_polymer_model.py
@@ -4,11 +4,10 @@
 import numpy as np
 import sys
 
-
 ## Local imports
-from . import ArbdModel, ParticleType, PointParticle, Group, get_resource_path    
-from .polymer import PolymerSection, PolymerGroup
-from .interactions import NonbondedScheme, HarmonicBond, HarmonicPotential
+from . import logger, ArbdModel, ParticleType, PointParticle, Group, get_resource_path    
+from .polymer import PolymerSection, PolymerGroup, PolymerBeads, PolymerModel
+from .interactions import AbstractPotential, HarmonicBond
 from .coords import quaternion_to_matrix
 
 
@@ -18,81 +17,48 @@ type_ = ParticleType("X",
                      mass=120
 )
 
-## Bonded potentials
-class FjcNonbonded(NonbondedScheme):
-    def __init__(self, resolution=0.1, rMin=0):
-        NonbondedScheme.__init__(self, typesA=None, typesB=None, resolution=resolution, rMin=rMin)
+# ## Bonded potentials
+# class FjcNonbonded(AbstractPotential):
+#     """ This potential should apply zero force; however, it is required for the arbd engine """
+#     def __init__(self, resolution=0.1, range_=(0,1)):
+#         AbstractPotential.__init__(self, resolution=resolution, range_=range_)
 
-    def potential(self, r, typeA, typeB):
-        """ Constant force excluded volume """
-        force = 10              # kcal_mol/AA
-        radius = 6
-        
-        u = np.zeros(r.shape)
-        # s = r < 2*radius
-        # u[s] = (2*radius - r[s]) * force            
-        return u
+#     def potential(self, r, types):
+#         u = np.zeros(r.shape)
+#         return u
 
-class FjcBeadsFromPolymer(Group):
+class FjcBeadsFromPolymer(PolymerBeads):
 
     def __init__(self, polymer, sequence=None, 
-                 rest_length = 3.8, spring_constant = 25,
+                 rest_length = None, spring_constant = 25, monomers_per_bead_group = 1,
                  **kwargs):
 
-        # if sequence is None:
-        #     raise NotImplementedError
-        #     # ... set random sequence
-
-        self.polymer = polymer
-        self.sequence = sequence
-        self.spring_constant = 25
-        self.rest_length = 3.8
-
-        for prop in ('segname','chain'):
-            if prop not in kwargs:
-                # import pdb
-                # pdb.set_trace()
-                try:
-                    self.__dict__[prop] = polymer.__dict__[prop]
-                except:
-                    pass
-
-        # if len(sequence) != polymer.num_monomers:
-        #     raise ValueError("Length of sequence does not match length of polymer")
-        Group.__init__(self, **kwargs)
+        self.spring_constant = spring_constant
+        PolymerBeads.__init__(self, polymer, sequence, monomers_per_bead_group, rest_length, **kwargs)
         
+    def _generate_ith_bead_group(self, i, r, o):
+        return PointParticle(type_, r,
+                             resid = i+1)
 
-    def _clear_beads(self):
-        ...
-        
-    def _generate_beads(self):
-        # beads = self.children
-        nb = self.polymer.num_monomers
-        
-        for i in range(nb):
-            c = self.polymer.monomer_index_to_contour(i)
-            r = self.polymer.contour_to_position(c)
-
-            bead = PointParticle(type_, r,
-                                 resid = i+1)
-            self.add(bead)
-
-        ## Two consecutive nts 
-        for i in range(len(self.children)-1):
-            b1 = self.children[i]
-            b2 = self.children[i+1]
+    def _join_adjacent_bead_groups(self, ids):
+        if len(ids) == 2:
+            b1,b2 = [self.children[i] for i in ids]
             """ units "10 kJ/N_A" kcal_mol """
             bond = HarmonicBond(k = self.spring_constant,
                                 r0 = self.rest_length,
-                                rRange = (0,500),
+                                range_ = (0,500),
                                 resolution = 0.01,
-                                maxForce = 50)
+                                max_force = 50)
+            logger.info(f'Adding bond to {b1} {b2} with k={self.spring_constant} and r0={self.rest_length}')
             self.add_bond( i=b1, j=b2, bond = bond, exclude=True )
+        else:
+            pass
 
-class FjcModel(ArbdModel):
+class FjcModel(PolymerModel):
     def __init__(self, polymers,
                  sequences = None,
-                 rest_length = 3.8,
+                 rest_length = None,
+                 monomers_per_bead_group = 1,
                  spring_constant = 25,
                  damping_coefficient = 40.9,
                  DEBUG=False,
@@ -101,55 +67,30 @@ class FjcModel(ArbdModel):
         """ 
         [damping_coefficient]: ns
         """
-
-        print("WARNING: diffusion coefficient arbitrarily set to 100 AA**2/ns in FjcModel")
         
-        kwargs['timestep'] = 50e-6
-        kwargs['cutoff'] = 10
-
-        if 'decompPeriod' not in kwargs:
-            kwargs['decompPeriod'] = 100000
-
-        """ Assign sequences """
-        if sequences is None:
-            # raise NotImplementedError("HpsModel must be provided a sequences argument")
-            sequences = [None for i in range(len(polymers))]
+        if 'timestep' not in kwargs: kwargs['timestep'] = 50e-6
+        if 'cutoff' not in kwargs: kwargs['cutoff'] = 5 # no non-bonded interactions, so set this small
+        if 'decomp_period' not in kwargs:
+            kwargs['decomp_period'] = 100000 # Make it huge because we never need to compute FJC NB forces
 
-        self.polymer_group = PolymerGroup(polymers)
-        self.sequences = sequences
         self.rest_length = rest_length
         self.spring_constant = spring_constant
-        ArbdModel.__init__(self, [], **kwargs)
+        PolymerModel.__init__(self, polymers, sequences, monomers_per_bead_group, **kwargs)
 
         """ Update type diffusion coefficients """
+        logger.warning("Diffusion coefficient arbitrarily set to 100 AA**2/ns in FjcModel")
         self.set_damping_coefficient( damping_coefficient )
 
         """ Set up nonbonded interactions """
-        nonbonded = FjcNonbonded()
-        self.useNonbondedScheme( nonbonded, typeA=type_, typeB=type_ )
-                
-        """ Generate beads """
-        self.generate_beads()
-
-    def update_splines(self, coords):
-        i = 0
-        for p,c in zip(self.polymer_group.polymers,self.children):
-            n = len(c.children)
-            p.set_splines(np.linspace(0,1,n), coords[i:i+n])
-            i += n
-
-        self.clear_all()
-        self.generate_beads()
-        ## TODO Apply restraints, etc
-
-    def generate_beads(self):
-        self.peptides = [FjcBeadsFromPolymer(p,s, rest_length = self.rest_length,
-                                             spring_constant = self.spring_constant )
-                         for p,s in zip(self.polymer_group.polymers,self.sequences)]
-
-        for s in self.peptides:
-            self.add(s)
-            s._generate_beads()
+        # nonbonded = FjcNonbonded()
+        #  self.add_nonbonded_interaction( nonbonded, typeA=type_, typeB=type_ )
+
+    def _generate_polymer_beads(self, polymer, sequence):
+        return FjcBeadsFromPolymer(polymer, sequence,
+                                   rest_length = self.rest_length,
+                                   spring_constant = self.spring_constant,
+                                   monomers_per_bead_group = self.monomers_per_bead_group,
+                                   )
 
     def set_damping_coefficient(self, damping_coefficient):
         for t in [type_]:
diff --git a/arbdmodel/hps_polymer_model.py b/arbdmodel/hps_polymer_model.py
index 4d4dbfccd03a5b84e0792cc3464212958ccbee75..e3ff157270ef85dd274550d21e42795eceef2c66 100644
--- a/arbdmodel/hps_polymer_model.py
+++ b/arbdmodel/hps_polymer_model.py
@@ -8,7 +8,7 @@ import sys
 ## Local imports
 from . import ArbdModel, ParticleType, PointParticle, Group, get_resource_path    
 from .polymer import PolymerSection, PolymerGroup
-from .interactions import NonbondedScheme, HarmonicBond, HarmonicAngle, HarmonicDihedral
+from .interactions import AbstractPotential, HarmonicBond, HarmonicAngle, HarmonicDihedral
 from .coords import quaternion_to_matrix
 
 """Define particle types"""
@@ -137,14 +137,15 @@ _types = dict(
 for k,t in _types.items():
     t.resname = t.name
 
-class HpsNonbonded(NonbondedScheme):
-    def __init__(self, debye_length=10, resolution=0.1, rMin=0):
-        NonbondedScheme.__init__(self, typesA=None, typesB=None, resolution=resolution, rMin=rMin)
+class HpsNonbonded(AbstractPotential):
+    def __init__(self, debye_length=10, resolution=0.1, range_=(0,None)):
+        AbstractPotential.__init__(self, resolution=resolution, range_=range_)
         self.debye_length = debye_length
         self.maxForce = 50
 
-    def potential(self, r, typeA, typeB):
+    def potential(self, r, types):
         """ Electrostatics """
+        typeA,typeB = types
         ld = self.debye_length 
         q1 = typeA.charge
         q2 = typeB.charge
diff --git a/arbdmodel/interactions.py b/arbdmodel/interactions.py
index db3a7da3586c4d273c8eae7644687fd01f7b3a8b..2017b4d2b5f14ebdfde4ff40c6ea6f0316a3ee73 100644
--- a/arbdmodel/interactions.py
+++ b/arbdmodel/interactions.py
@@ -6,14 +6,17 @@ from shutil import copyfile
 """ Module providing classes used to describe potentials in ARBD """
 
 
-""" Abstract classes """
+## Abstract classes that others inherit from
 class AbstractPotential(metaclass=ABCMeta):
     """ Abstract class for writing potentials """
 
     def __init__(self, range_=(0,None), resolution=0.1, 
-                 max_force = None, max_potential = None):
-        self.resolution = resolution
+                 max_force = None, max_potential = None, zero='last'):
         self.range_ = range_
+        self.resolution = resolution
+        self.max_force = max_force
+        self.max_potential = max_potential
+        self.zero = zero
 
     @property
     def range_(self):
@@ -23,8 +26,12 @@ class AbstractPotential(metaclass=ABCMeta):
         assert(len(value) == 2)
         self.__range_ = tuple(value)
 
+    @property
+    def periodic(self):
+        return False
+
     @abstractmethod
-    def potential(self, r, types):
+    def potential(self, r, types=None):
         raise NotImplementedError
     
     def __remove_nans(self, u):
@@ -34,26 +41,60 @@ class AbstractPotential(metaclass=ABCMeta):
             right[np.isnan(u[right])] += 1
         u[:-1][left] = u[right]
 
-    def write_file(self, filename, types=None):
+    def filename(self, types=None):
+        raise NotImplementedError('Inherited potential objects should overload this function')
+        
+    def write_file(self, filename=None, types=None):
+        if filename is None:
+            filename = self.filename(types)
         rmin,rmax = self.range_
         r = np.arange(rmin, rmax+self.resolution, self.resolution)
         with np.errstate(divide='ignore',invalid='ignore'):
             u = self.potential(r, types)
         self.__remove_nans(u)
-        np.savetxt(filename, np.array([r,u]).T)
 
-class NonbondedPotential(AbstractPotential):
-    """ Abstract class for writing nonbonded interactions """
-    ## No specialization needed so far
+        if self.zero == 'min':
+            u = u - np.min(u)
+        elif self.zero == 'first':
+            u = u - u[0]
+        elif self.zero == 'last':
+            u = u - u[-1]
+        else:
+            raise ValueError('Unrecognized option for "zero" argument')
+        
+        max_force = self.max_force
+        if max_force is not None:
+            assert(max_force > 0)
+            f = np.diff(u)/np.diff(r)
+            f[f>max_force] = max_force
+            f[f<-max_force] = -max_force
+            u[0] = 0
+            u[1:] = np.cumsum(f*np.diff(r))
+
+        if self.max_potential is not None:
+            f = np.diff(u)/np.diff(r)
+            ids = np.where( 0.5*(u[1:]+u[:-1]) > self.max_potential )[0]
 
-class BondedPotential(AbstractPotential):
-    """ Abstract class for writing bonded interactions """
+            # w = np.sqrt(2*self.max_potential/self.k)
+            # drAvg = 0.5*(np.abs(dr[ids]) + np.abs(dr[ids+1]))
+            # f[ids] = f[ids] * np.exp(-(drAvg-w)/(w))
+            f[ids] = self.max_potential
+            
+            u[0] = 0
+            u[1:] = np.cumsum(f*np.diff(r))
 
-    # def add_sim_system(self, simSystem):
-    #     rmin,rmax = self.range_
-    #     self.r = np.arange(rmin,rmax,resolution)
+        if self.zero == 'min':
+            u = u - np.min(u)
+        elif self.zero == 'first':
+            u = u - u[0]
+        elif self.zero == 'last':
+            u = u - u[-1]
+        else:
+            raise ValueError('Unrecognized option for "zero" argument')
 
-""" Concrete nonbonded pontentials """
+        np.savetxt(filename, np.array([r,u]).T)
+    
+## Concrete nonbonded pontentials
 class LennardJones(AbstractPotential):
     def potential(self, r, types):
         typeA, typeB = types
@@ -81,139 +122,143 @@ class TabulatedNonbonded(AbstractPotential):
 
         ## TODO: check that tableFile exists and is regular file
         
-    def write_file(self, filename):
+    def write_file(self, filename, types):
         if filename != self.tableFile:
             copyfile(self.tableFile, filename)
 
-""" Concrete bonded potentials """
-class BaseBondedPotential():
-    def __init__(self, k, r0, rRange=(0,50), resolution=0.1, maxForce=None, max_potential=None, zero='min', prefix="potentials/"):
+## Bonded potentials            
+class HarmonicBondedPotential(AbstractPotential):
+    def __init__(self, k, r0, filename_prefix='./potentials/', *args, **kwargs):
         self.k = k
         self.r0 = r0
-        self.rRange = rRange
-        self.resolution = resolution
-        self.maxForce = maxForce
-        self.prefix = prefix
-        self.zero = zero
-        self.periodic = False
-        self.type_ = "None"
-        self.max_potential = max_potential
-        self.kscale_ = None     # only used for 
-
-    def filename(self):
-        raise NotImplementedError("Not implemented")
-
-    def potential(self,dr):
-        raise NotImplementedError("Not implemented")
-
-    def __str__(self):
-        return self.filename()
+        self.kscale_ = None
+        self.filename_prefix = filename_prefix
+        if 'zero' not in kwargs: kwargs['zero'] = 'min'
+        AbstractPotential.__init__(self, *args, **kwargs)
 
-    def write_file(self):
-        r = np.arange( self.rRange[0], 
-                       self.rRange[1]+self.resolution, 
-                       self.resolution )
+    @property
+    def kscale(self):
+        return 1.0
+ 
+    @property
+    @abstractmethod
+    def type_(self):
+        return "none"
+   
+    def filename(self, types=None):
+        assert(types is None)
+        return "%s%s-%.3f-%.3f.dat" % (self.filename_prefix, self.type_,
+                                       self.k*self.kscale, self.r0)
+
+    def potential(self, r, types=None):
         dr = r-self.r0
-
         if self.periodic == True:
-            rSpan = self.rRange[1]-self.rRange[0]
-            assert(rSpan > 0)
-            dr = np.mod( dr+0.5*rSpan, rSpan) - 0.5*rSpan 
-
-        u = self.potential(dr)
-
-        maxForce = self.maxForce
-        if maxForce is not None:
-            assert(maxForce > 0)
-            f = np.diff(u)/np.diff(r)
-            f[f>maxForce] = maxForce
-            f[f<-maxForce] = -maxForce
-            u[0] = 0
-            u[1:] = np.cumsum(f*np.diff(r))
-
-        if self.max_potential is not None:
-            f = np.diff(u)/np.diff(r)
-            ids = np.where( 0.5*(u[1:]+u[:-1]) > self.max_potential )[0]
-
-            w = np.sqrt(2*self.max_potential/self.k)
-            drAvg = 0.5*(np.abs(dr[ids]) + np.abs(dr[ids+1]))
-
-            f[ids] = f[ids] * np.exp(-(drAvg-w)/(w))
-            u[0] = 0
-            u[1:] = np.cumsum(f*np.diff(r))
-
-        if self.zero == "min":
-            u = u - np.min(u)
-
-        np.savetxt( self.filename(), np.array([r, u]).T, fmt="%f" )
-
-class HarmonicPotential(BaseBondedPotential):
-    def __init__(self, k, r0, rRange, resolution, maxForce, max_potential, zero="min", correct_geometry=False, prefix='./'):
-        self.k = k
-        self.kscale_ = None
-        BaseBondedPotential.__init__(self, k, r0, rRange, resolution, maxForce, max_potential, zero, prefix)
-
-    def filename(self):
-        return "%s%s-%.3f-%.3f.dat" % (self.prefix, self.type_,
-                                       self.k*self.kscale_, self.r0)
-    def potential(self,dr):
+            r_span = self.range_[1]-self.range_[0]
+            assert(r_span > 0)
+            dr = np.mod( dr+0.5*r_span, r_span) - 0.5*r_span 
         return 0.5*self.k*dr**2
 
     def __hash__(self):
         assert(self.type_ != "None")
-        return hash((self.type_, self.k, self.r0, self.rRange, self.resolution, self.maxForce, self.max_potential, self.prefix, self.periodic))
+        return hash((self.type_, self.k, self.r0, self.range_, self.resolution, self.max_force, self.max_potential, self.zero, self.filename_prefix, self.periodic)) # 
 
     def __eq__(self, other):
-        for a in ("type_", "k", "r0", "rRange", "resolution", "maxForce", "max_potential", "prefix", "periodic"):
-            if self.__dict__[a] != other.__dict__[a]:
+        for a in ("type_", "k", "r0", "range_", "resolution", "max_force", "max_potential", "filename_prefix", "periodic"):
+            if getattr(self,a) != getattr(other,a):
                 return False
         return True
-
-class HarmonicBond(HarmonicPotential):
-    def __init__(self, k, r0, rRange=(0,50), resolution=0.1, maxForce=None, max_potential=None, zero='min', correct_geometry=False, temperature=295, prefix="potentials/"):
-        HarmonicPotential.__init__(self, k, r0, rRange, resolution, maxForce, max_potential, zero=zero, correct_geometry=correct_geometry, prefix=prefix)
-        self.type_ = "gbond" if correct_geometry else "bond"
-        self.kscale_ = 1.0
+    
+class HarmonicBond(HarmonicBondedPotential):
+    def __init__(self, k, r0, correct_geometry=False, temperature=295, *args, **kwargs):
+        if 'range_' not in kwargs: kwargs['range_'] = (0,50)
+        HarmonicBondedPotential.__init__(self, k, r0, *args, **kwargs)
         self.correct_geometry = correct_geometry
         self.temperature = temperature
 
-    def potential(self,dr):
-        u = HarmonicPotential.potential(self,dr)
+    @property
+    def kscale(self):
+        return 1.0
+ 
+    @property
+    def type_(self):
+        return 'gbond' if self.correct_geometry else 'bond'
+
+    def potential(self, r, types=None):
+        u = HarmonicBondedPotential.potential(self, r, types)
         if self.correct_geometry:
             with np.errstate(divide='ignore',invalid='ignore'):
-                du = 2*0.58622592*np.log(dr+self.r0) * self.temperature/295
+                du = 2*0.58622592*np.log(r) * self.temperature/295
             du[np.logical_not(np.isfinite(du))] = 0
             u = u+du
         return u
 
+class HarmonicAngle(HarmonicBondedPotential):
+    def __init__(self, *args, **kwargs):
+        if 'range_' not in kwargs: kwargs['range_'] = (0,181)
+        HarmonicBondedPotential.__init__(self, *args, **kwargs)
+
+    @property
+    def kscale_(self):
+        return (180.0/np.pi)**2
+
+    @property
+    def type_(self):
+        return 'angle'
+
+class HarmonicDihedral(HarmonicBondedPotential):
+    def __init__(self, *args, **kwargs):
+        if 'range_' not in kwargs: kwargs['range_'] = (-180,180)
+        HarmonicBondedPotential.__init__(self, *args, **kwargs)
 
-class HarmonicAngle(HarmonicPotential):
-    def __init__(self, k, r0, rRange=(0,181), resolution=0.1, maxForce=None, max_potential=None, prefix="potentials/"):
-        HarmonicPotential.__init__(self, k, r0, rRange, resolution, maxForce, max_potential, zero=zero, prefix=prefix)
-        self.type_ = "angle"
-        self.kscale_ = (180.0/np.pi)**2
+    @property
+    def kscale_(self):
+        return (180.0/np.pi)**2
 
-class HarmonicDihedral(HarmonicPotential):
-    def __init__(self, k, r0, rRange=(-180,180), resolution=0.1, maxForce=None, max_potential=None, prefix="potentials/"):
-        HarmonicPotential.__init__(self, k, r0, rRange, resolution, maxForce, max_potential, zero, prefix)
-        self.periodic = True
-        self.type_ = "dihedral"
-        self.kscale_ = (180.0/np.pi)**2
+    @property
+    def type_(self):
+        return 'dihedral'
 
-class WLCSKBond(BaseBondedPotential):
+    @property
+    def periodic(self):
+        return True
+
+class WLCSKPotential(HarmonicBondedPotential, metaclass=ABCMeta):
     """ ## https://aip.scitation.org/doi/full/10.1063/1.4968020 """
-    def __init__(self, d, lp, kT, rRange=(0,50), resolution=0.02, maxForce=100, max_potential=None, zero="min", prefix="potentials/"):
-        ## Note, we set k to lp here, but it's not proper and may affect results from max_potential
-        BaseBondedPotential.__init__(self, lp, 0, rRange, resolution, maxForce, max_potential, zero, prefix)
-        self.type_ = "wlcbond"
+    def __init__(self, d, lp, kT, **kwargs):
+        ## Note, we're leveraging the HarmonicBondedPotential base class and set k to lp here, but it isn't proper
+        HarmonicBondedPotential.__init__(self, d, lp, **kwargs)
         self.d = d          # separation
         self.lp = lp            # persistence length
         self.kT = kT
 
-    def filename(self):
-        return "%s%s-%.3f-%.3f.dat" % (self.prefix, self.type_,
+    def filename(self,types=None):
+        assert(types is None)
+        return "%s%s-%.3f-%.3f.dat" % (self.filename_prefix, self.type_,
                                        self.d, self.lp)
-    def potential(self, dr):
+
+    def __hash__(self):
+        return hash((self.d, self.lp, self.kT, HarmonicBondedPotential.__hash__(self)))
+
+    def __eq__(self, other):
+        for a in ("d", "lp", "kT"):
+            if self.__dict__[a] != other.__dict__[a]:
+                return False
+        return HarmonicBondedPotential.__eq__(self,other)
+    
+class WLCSKBond(WLCSKPotential):
+    """ ## https://aip.scitation.org/doi/full/10.1063/1.4968020 """
+    def __init__(self, d, lp, kT, **kwargs):
+        if 'range_' not in kwargs: kwargs['range_'] = (0,50)
+        if 'resolution' not in kwargs: kwargs['resolution'] = 0.02
+        if 'max_force' not in kwargs: kwargs['max_force'] = 100
+        WLCSKPotential.__init__(self, d, lp, kT, **kwargs)
+
+    @property
+    def type_(self):
+        return "wlcbond"
+
+    def potential(self, r, types=None):
+        dr = r - self.d
         nk = self.d / (2*self.lp)
         q2 = (dr / self.d)**2
         a1,a2 = 1, -7.0/(2*nk)
@@ -222,48 +267,23 @@ class WLCSKBond(BaseBondedPotential):
         a4 = (p0 + p1/(2*nk) + p2*(2*nk)**-2) / (1+p3/(2*nk)+p4*(2*nk)**-2)
         with np.errstate(divide='ignore',invalid='ignore'):
             u = self.kT * nk * ( a1/(1-q2) - a2*np.log(1-q2) + a3*q2 - 0.5*a4*q2*(q2-2) )
-        max_force = np.diff(u[q2<1][-2:]) / np.diff(dr).mean()
-        max_u = u[q2<1][-1]
-        max_dr = dr[q2<1][-2]
-        assert( max_force >= 0 )
-        u[q2>=1] = (dr[q2>=1]-max_dr)*max_force + max_u
         return u
 
-    def __hash__(self):
-        assert(self.type_ != "None")
-        return hash((self.type_, self.d, self.lp, self.kT, self.rRange, self.resolution, self.maxForce, self.max_potential, self.prefix, self.periodic))
-
-    def __eq__(self, other):
-        for a in ("type_", "d", "lp", "kT" "rRange", "resolution", "maxForce", "max_potential", "prefix", "periodic"):
-            if self.__dict__[a] != other.__dict__[a]:
-                return False
-        return True
+class WLCSKAngle(WLCSKPotential):
+    """ ## https://aip.scitation.org/doi/full/10.1063/1.4968020 """
+    def __init__(self, d, lp, kT, **kwargs):
+        if 'range_' not in kwargs: kwargs['range_'] = (0,181)
+        if 'resolution' not in kwargs: kwargs['resolution'] = 0.5
+        WLCSKPotential.__init__(self, d, lp, kT, **kwargs)
 
-class WLCSKAngle(BaseBondedPotential):
-    ## https://aip.scitation.org/doi/full/10.1063/1.4968020
-    def __init__(self, d, lp, kT, rRange=(0,181), resolution=0.5, maxForce=None, max_potential=None, zero="min", prefix="potentials/"):
-        BaseBondedPotential.__init__(self, 180, rRange, resolution, maxForce, max_potential, zero, prefix)
-        self.type_ = "wlcangle"
-        self.d = d          # separation
-        self.lp = lp            # persistence length
-        self.kT = kT
+    @property
+    def type_(self):
+        return "wlcangle"
 
-    def filename(self):
-        return "%s%s-%.3f-%.3f.dat" % (self.prefix, self.type_,
-                                       self.d, self.lp)
-    def potential(self,dr):
+    def potential(self, r, types=None):
+        dr = r - self.d
         nk = self.d / (2*self.lp)
         p1,p2,p3,p4 = -1.237, 0.8105, -1.0243, 0.4595
         C = (1 + p1*(2*nk) + p2*(2*nk)**2) / (2*nk+p3*(2*nk)**2+p4*(2*nk)**3)
         u = self.kT * C * (1-np.cos(dr * np.pi / 180))
         return u
-
-    def __hash__(self):
-        assert(self.type_ != "None")
-        return hash((self.type_, self.d, self.lp, self.kT, self.rRange, self.resolution, self.maxForce, self.max_potential, self.prefix, self.periodic))
-
-    def __eq__(self, other):
-        for a in ("type_", "d", "lp", "kT" "rRange", "resolution", "maxForce", "max_potential", "prefix", "periodic"):
-            if self.__dict__[a] != other.__dict__[a]:
-                return False
-        return True
diff --git a/arbdmodel/polymer.py b/arbdmodel/polymer.py
index c121dd24d8db184af6b8bcc46ce2a2c9b679ccb3..c6f0244a499ff4f78df8067288c4fc994d4bc9d6 100644
--- a/arbdmodel/polymer.py
+++ b/arbdmodel/polymer.py
@@ -1,14 +1,16 @@
+from abc import abstractmethod, ABCMeta
+
+from . import logger
+
 from pathlib import Path
 from copy import copy, deepcopy
 
 import numpy as np
 from scipy import interpolate
 
-from . import ArbdModel
+from . import ArbdModel, Group
 from .coords import rotationAboutAxis, quaternion_from_matrix, quaternion_to_matrix
 
-
-
 """
 TODO:
  - test for large systems
@@ -158,7 +160,6 @@ class ConnectableElement():
         l = B.parent.locations
         if B not in l: l.append(B)
 
-
 class PolymerSection(ConnectableElement):
     """ Base class that describes a linear section of a polymer """
 
@@ -401,7 +402,7 @@ class PolymerSection(ConnectableElement):
             
         for c in cl:
             yield c
-            
+
 class PolymerGroup():
     def __init__(self, polymers=[],
                  **kwargs):
@@ -546,3 +547,125 @@ class PolymerGroup():
             ## iterate through the model polymers
             for s in self.polymers:
                 draw_cylinder(s,radius,"cyan")
+
+class PolymerBeads(Group, metaclass=ABCMeta):
+    """ Abstract class for bead-based models that are built from Polymer objects """
+
+    def __init__(self, polymer, sequence=None, monomers_per_bead_group = 1, rest_length = None, **kwargs):
+        self.polymer = polymer
+        self.sequence = sequence
+        # self.spring_constant = spring_constant
+        self.monomers_per_bead_group = monomers_per_bead_group
+        
+        if rest_length is None:
+            rest_length = polymer.monomer_length * self.monomers_per_bead_group
+
+        self.rest_length = rest_length
+        
+        for prop in ('segname','chain'):
+            if prop not in kwargs:
+                try:
+                    self.__dict__[prop] = polymer.__dict__[prop]
+                except:
+                    pass
+
+        Group.__init__(self, **kwargs)
+
+    @property
+    def monomers_per_bead_group(self):
+        return self.__monomers_per_bead_group
+
+    @property
+    def num_bead_groups(self):
+        return self.__num_bead_groups    
+    
+    @monomers_per_bead_group.setter
+    def monomers_per_bead_group(self,value):
+        if value <= 0:
+            raise ValueError("monomers_per_bead_group must be positive")
+        self.num_bead_groups = int(np.ceil(self.polymer.num_monomers/value))
+
+    @num_bead_groups.setter
+    def num_bead_groups(self,value):
+        if value <= 0:
+            raise ValueError("num_bead_groups must be positive")
+        self.__num_bead_groups = value
+        self.__monomers_per_bead_group = self.polymer.num_monomers/value # get exact ratio
+            
+    def _clear_beads(self):
+        ## self.children = []
+        self.clear_all()
+
+    @abstractmethod
+    def _generate_ith_bead_group(self, i, r, o):
+        """ Normally a bead, but sometimes a group of beads """
+        bead = PointParticle(type_, r,
+                             resid = i+1)
+        pass
+
+    @abstractmethod
+    def _join_adjacent_bead_groups(self, ids):
+        if len(ids) == 2:
+            b1,b2 = [self.children[i] for i in ids]
+            """ units "10 kJ/N_A" kcal_mol """
+            bond = HarmonicBond(k = self.spring_constant,
+                                r0 = self.rest_length,
+                                rRange = (0,500),
+                                resolution = 0.01,
+                                maxForce = 50)
+            self.add_bond( i=b1, j=b2, bond = bond, exclude=True )
+        else:
+            pass
+        
+    def _generate_beads(self):
+        for i in range(self.num_bead_groups):
+            c = self.polymer.monomer_index_to_contour(i * self.monomers_per_bead_group)
+            r = self.polymer.contour_to_position(c)
+            o = self.polymer.contour_to_orientation(c)
+            
+            obj = self._generate_ith_bead_group(i, r, o)
+            self.add(obj)
+
+        for di in range(1,4):
+            for i in range(len(self.children)-di):
+                ids = tuple(i+_di for _di in range(di+1)) 
+                self._join_adjacent_bead_groups(ids)
+
+class PolymerModel(ArbdModel, metaclass=ABCMeta):
+    def __init__(self, polymers,
+                 sequences = None,
+                 monomers_per_bead_group = 1,
+                 **kwargs):
+
+        """ Assign sequences """
+        if sequences is None:
+            sequences = [None for i in range(len(polymers))]
+
+        self.polymer_group = PolymerGroup(polymers)
+        self.sequences = sequences
+        self.monomers_per_bead_group = monomers_per_bead_group
+        ArbdModel.__init__(self, [], **kwargs)
+                
+        """ Generate beads """
+        self.generate_beads()
+
+    def update_splines(self, coords):
+        i = 0
+        for p,c in zip(self.polymer_group.polymers,self.children):
+            n = len(c.children)
+            p.set_splines(np.linspace(0,1,n), coords[i:i+n])
+            i += n
+
+    @abstractmethod
+    def _generate_polymer_beads(self, polymer, sequence):
+        pass
+            
+    def generate_beads(self):
+        self.clear_all()
+        self.peptides = [self._generate_polymer_beads(p,s)
+                         for p,s in zip(self.polymer_group.polymers,self.sequences)]
+
+        for s in self.peptides:
+            self.add(s)
+            s._generate_beads()
+