In this tutorial, we simulate a charged system consisting of a fixed charged rod with ions around it. This setup represents a simplified model for polyelectrolyte gels. We will investigate the condensation of ions onto the oppositely charged rod and compare the results to a meanfield analytical solution obtained from Poisson−Boltzmann (PB) theory. Finally we will go beyond the expected applicability of PB and add concentrated additional salt ions to observe an overcharging effect.
The tutorial follows "Deserno, Markus, Christian Holm, and Sylvio May. "Fraction of condensed counterions around a charged rod: Comparison of Poisson−Boltzmann theory and computer simulations. Macromolecules 33.1 (2000): 199-206, 10.1021/ma990897o". We refer to that publication for further reading.
import espressomd
import espressomd.electrostatics
import espressomd.observables
import espressomd.accumulators
import espressomd.math
import espressomd.zn
espressomd.assert_features(['ELECTROSTATICS', 'P3M', 'WCA'])
import tqdm
import numpy as np
import scipy.optimize
%matplotlib inline
import matplotlib.pyplot as plt
np.random.seed(41)
plt.rcParams.update({'font.size': 18})
After importing the necessary ESPResSo features and external modules, we define a cubic system geometry and some physical parameters (which define our unit system).
# system parameters
ROD_LENGTH = 50
BJERRUM_LENGTH = 1.0
# we assume a unit system where the elementary charge and the thermal energy are both 1
system = espressomd.System(box_l=3 * [ROD_LENGTH])
KT = 1.
Q_E = 1.
system.time_step = 0.01
system.cell_system.skin = 0.4
We will build the charged rod from individual particles that are fixed in space. With this, we can use the particle-based electrostatics methods of ESPResSo. For analysis, we give the rod particles a different type than the counterions.
# interaction parameters
WCA_EPSILON = 1.0
ION_DIAMETER = 1.0
ROD_RADIUS = 1.0
MASS=1.0
# particle types
ROD_TYPE = 1
COUNTERION_TYPE = 2
Exercise:
Hints:
sigma
parameter of the interaction between the rod particles and the ions# SOLUTION CELL
# ion-ion interaction
system.non_bonded_inter[COUNTERION_TYPE, COUNTERION_TYPE].wca.set_params(
epsilon=WCA_EPSILON, sigma=ION_DIAMETER)
# ion-rod interaction
system.non_bonded_inter[COUNTERION_TYPE, ROD_TYPE].wca.set_params(
epsilon=WCA_EPSILON, sigma=ION_DIAMETER / 2. + ROD_RADIUS)
Now we need to place the particles in the box
Exercise:
setup_rod_and_counterions(system, ion_valency, counterion_type, rod_charge_dens, N_rod_beads, rod_type)
type
q
according to their ion_valency
rod_charge_dens
is uniformly distributed along the N_rod_beads
individual particlesHints:
# SOLUTION CELL
def setup_rod_and_counterions(system, ion_valency, counterion_type,
rod_charge_dens, N_rod_beads, rod_type):
# calculate charge of the single rod beads
rod_length = system.box_l[2]
total_rod_charge = rod_charge_dens * rod_length
rod_charge_per_bead = total_rod_charge / N_rod_beads
# number of counterions
N_ions = int(total_rod_charge / ion_valency)
rod_zs = np.linspace(0, rod_length, num=N_rod_beads, endpoint=False)
rod_positions = np.column_stack(([system.box_l[0] / 2.] * N_rod_beads,
[system.box_l[1] / 2.] * N_rod_beads,
rod_zs))
system.part.add(pos=rod_positions, type=[rod_type] * N_rod_beads,
q=[rod_charge_per_bead] * N_rod_beads,
fix=[3 * [True]] * N_rod_beads)
ion_positions = np.random.random((N_ions, 3)) * system.box_l
counter_ions = system.part.add(pos=ion_positions, type=[
counterion_type] * N_ions, q=[-ion_valency] * N_ions)
return counter_ions
COUNTERION_VALENCY = 1
ROD_CHARGE_DENS = 2
# number of beads that make up the rod
N_rod_beads = int(ROD_LENGTH / ROD_RADIUS)
setup_rod_and_counterions(system, COUNTERION_VALENCY, COUNTERION_TYPE,
ROD_CHARGE_DENS, N_rod_beads, ROD_TYPE)
# check that the particle setup was done correctly
assert abs(sum(system.part.all().q)) < 1e-10
assert np.all(system.part.select(type=ROD_TYPE).fix)
Now we set up the electrostatics method to calculate the forces and energies from the long-range Coulomb interaction. ESPResSo uses so-called solvers for electrostatics, magnetostatics and hydrodynamics. They are attached to system slots in such a way that unphysical combinations of algorithms are avoided, for example simultaneous usage of two electrostatics solvers. Adding an actor to the system also activates the method and calls necessary initialization routines. Here, we define a P$^3$M object using the Bjerrum length and rms force error. This automatically starts a tuning function which tries to find optimal parameters for P$^3$M and prints them to the screen. For more details, see the user guide chapter on Electrostatics.
p3m_params = {'prefactor': KT * BJERRUM_LENGTH * Q_E**2,
'accuracy': 1e-3}
For the accuracy, ESPResSo estimates the relative error in the force calculation introduced by the approximations of $P^3M$. We choose a relatively poor accuracy (large value) for this tutorial to make it run faster. For your own production simulations you should reduce that number.
Exercise:
p3m
instance and add it to the electrostatics
slot of the system# SOLUTION CELL
p3m = espressomd.electrostatics.P3M(**p3m_params)
system.electrostatics.solver = p3m
Before we can start the simulation, we need to remove the overlap between particles to avoid large forces which would crash the simulation. For this, we use the steepest descent integrator with a relative convergence criterion for forces and energies.
def remove_overlap(system):
FMAX = 0.01 * ION_DIAMETER * MASS / system.time_step**2
system.integrator.set_steepest_descent(
f_max=FMAX,
gamma=10,
max_displacement=0.01)
system.integrator.run(5000)
assert np.all(np.abs(system.part.all().f) < FMAX), "Overlap removal did not converge!"
system.integrator.set_vv()
remove_overlap(system)
After the overlap is removed, we activate a thermostat to simulate the system at a given temperature.
LANGEVIN_PARAMS = {'kT': KT,
'gamma': 0.5,
'seed': 42}
system.thermostat.set_langevin(**LANGEVIN_PARAMS)
Before running the simulations to obtain the histograms, we need to decide how long we need to equilibrate the system. For this we plot the total energy vs the time steps.
energies = []
STEPS_PER_SAMPLE_FIRST_RUN = 10
N_SAMPLES_FIRST_RUN = 1000
for i in range(N_SAMPLES_FIRST_RUN):
system.integrator.run(STEPS_PER_SAMPLE_FIRST_RUN)
energies.append(system.analysis.energy()['total'])
# plot time in time_steps so we can judge the number of warmup steps
ts = np.arange(0, N_SAMPLES_FIRST_RUN) * STEPS_PER_SAMPLE_FIRST_RUN
plt.figure(figsize=(10, 7))
plt.plot(ts, energies)
plt.xlabel('time steps')
plt.ylabel('system total energy')
plt.show()
WARMUP_STEPS = 5000
STEPS_PER_SAMPLE = 100
Now we are ready to implement the observable calculation. As we are interested in the condensation of counterions on the rod, the physical quantity of interest is the density of charges $\rho(r)$ around the rod, where $r$ is the distance from the rod. We need many samples to calculate the density from histograms.
From the last tutorial you should already be familiar with the concepts of observables and accumulators in ESPResSo. We will use the CylindricalDensityProfile observable and the MeanVarianceCalculator accumulator
Exercise:
setup_profile_calculation(system, delta_N, ion_types, r_min, n_radial_bins)
to create observables for $\rho(r)$delta_N
is the number of integration steps between observable calculationion_types
is a list of types for which the radial distances should be calculated. For the moment we only have counterions, but later we will also add additional salt ions for which we would also like to calculate the densityradial_distances[counterion_type] = <accumulator>
and the edges of the binsHints:
system.part.select(type=...)
to get only the particles of a specific type# SOLUTION CELL
def setup_profile_calculation(system, delta_N, ion_types, r_min, n_radial_bins):
radial_profile_accumulators = {}
ctp = espressomd.math.CylindricalTransformationParameters(center = np.array(system.box_l) / 2.,
axis = [0, 0, 1],
orientation = [1, 0, 0])
for ion_type in ion_types:
ion_ids = system.part.select(type=ion_type).id
radial_profile_obs = espressomd.observables.CylindricalDensityProfile(
ids=ion_ids,
transform_params = ctp,
n_r_bins=n_radial_bins,
min_r=r_min,
min_z=-system.box_l[2] / 2.,
max_r=system.box_l[0] / 2.,
max_z=system.box_l[2] / 2.)
bin_edges = radial_profile_obs.bin_edges()
radial_profile_acc = espressomd.accumulators.MeanVarianceCalculator(
obs=radial_profile_obs, delta_N=delta_N)
system.auto_update_accumulators.add(radial_profile_acc)
radial_profile_accumulators[ion_type] = radial_profile_acc
return radial_profile_accumulators, bin_edges
r_min = ROD_RADIUS + ION_DIAMETER / 2.
r_max = system.box_l[0] / 2.
N_RADIAL_BINS = 200
radial_profile_accs, bin_edges = setup_profile_calculation(
system, STEPS_PER_SAMPLE, [COUNTERION_TYPE], r_min, N_RADIAL_BINS)
assert isinstance(
radial_profile_accs[COUNTERION_TYPE], espressomd.accumulators.MeanVarianceCalculator)
assert len(bin_edges) == N_RADIAL_BINS + 1
To run the simulation with different parameters, we need a way to reset the system and return it to an empty state before setting it up again.
Exercise:
clear_system(system)
thatHints:
# SOLUTION CELL
def clear_system(system):
system.thermostat.turn_off()
system.part.clear()
system.electrostatics.clear()
system.auto_update_accumulators.clear()
system.time = 0.
clear_system(system)
# Visualizer Parameters
color = {ROD_TYPE: "#0911e8", COUNTERION_TYPE: "#f70519"} # Particle color by type
radii = {ROD_TYPE: ROD_RADIUS, COUNTERION_TYPE: ION_DIAMETER/2.0} # Particle size by type
print("System visualization for case (a) counterion valency: 2, rod_charge_density: 1")
# Initializing Visualizer
vis = espressomd.zn.Visualizer(system, colors=color, radii=radii, bonds=True)
System visualization for case (a) counterion valency: 2, rod_charge_density: 1
Now we are finally ready to run the simulations and produce the data we can compare to the Poisson-Boltzmann predictions. First we define the parameters and then loop over them.
runs = [{'params': {'counterion_valency': 2, 'rod_charge_dens': 1},
'histogram': None},
{'params': {'counterion_valency': 1, 'rod_charge_dens': 2},
'histogram': None}
]
N_SAMPLES = 1500
Exercise:
Hints:
p3m
instance after each change of parameters. If we reuse the p3m that was tuned before, likely the desired accuracy will not be achieved. .mean()
# SOLUTION CELL
for run in runs:
clear_system(system)
setup_rod_and_counterions(
system, run['params']['counterion_valency'], COUNTERION_TYPE,
run['params']['rod_charge_dens'], N_rod_beads, ROD_TYPE)
p3m = espressomd.electrostatics.P3M(**p3m_params)
system.electrostatics.solver = p3m
remove_overlap(system)
system.thermostat.set_langevin(**LANGEVIN_PARAMS)
print('', end='', flush=True)
# For longer simulation runs it will be convenient to have a progress bar
for i in tqdm.trange(100):
system.integrator.run(WARMUP_STEPS // 100)
radial_profile_accs, bin_edges = setup_profile_calculation(
system, STEPS_PER_SAMPLE, [COUNTERION_TYPE], r_min, N_RADIAL_BINS)
for i in tqdm.trange(100):
system.integrator.run(N_SAMPLES * STEPS_PER_SAMPLE // 100)
# Updating frames for the visualizer
if(run['params']['counterion_valency']==2):
vis.update()
run['histogram'] = radial_profile_accs[COUNTERION_TYPE].mean()
print(f'simulation for parameters {run["params"]} done\n')
CoulombP3M tune parameters: Accuracy goal = 1.00000e-03 prefactor = 1.00000e+00 System: box_l = 5.00000e+01 # charged part = 150 Sum[q_i^2] = 3.00000e+02 mesh cao r_cut_iL alpha_L err rs_err ks_err time [ms] 6 7 cao too large for this mesh 10 7 2.37352e-01 8.46940e+00 9.881e-04 7.071e-04 6.902e-04 0.26 10 6 2.42156e-01 8.29105e+00 9.864e-04 7.071e-04 6.878e-04 0.25 10 5 2.50805e-01 7.98771e+00 9.948e-04 7.071e-04 6.997e-04 0.24 10 4 2.68102e-01 7.44127e+00 1.000e-03 7.071e-04 7.071e-04 0.23 10 3 3.10383e-01 6.36820e+00 9.996e-04 7.071e-04 7.065e-04 0.26 10 2 4.92000e-01 3.89721e+00 1.029e-03 7.071e-04 7.471e-04 accuracy not achieved 16 7 1.57091e-01 1.31192e+01 9.917e-04 7.071e-04 6.953e-04 0.35 16 6 1.61280e-01 1.27587e+01 9.899e-04 7.071e-04 6.927e-04 0.32 16 5 1.67563e-01 1.22525e+01 9.943e-04 7.071e-04 6.990e-04 0.30 16 4 1.79607e-01 1.13837e+01 9.919e-04 7.071e-04 6.955e-04 0.29 16 3 2.09978e-01 9.64580e+00 9.968e-04 7.071e-04 7.026e-04 0.30 16 2 2.68102e-01 7.44127e+00 1.588e-03 7.071e-04 1.422e-03 accuracy not achieved 20 7 1.29093e-01 1.61480e+01 9.981e-04 7.071e-04 7.043e-04 0.40 20 6 1.32601e-01 1.56965e+01 9.956e-04 7.071e-04 7.009e-04 0.35 20 5 1.38213e-01 1.50230e+01 9.886e-04 7.071e-04 6.910e-04 0.34 20 4 1.48036e-01 1.39703e+01 9.934e-04 7.071e-04 6.977e-04 0.33 20 3 1.74696e-01 1.17231e+01 9.915e-04 7.071e-04 6.951e-04 0.32 20 2 1.79607e-01 1.13837e+01 2.836e-03 7.071e-04 2.746e-03 accuracy not achieved 26 7 1.03043e-01 2.04908e+01 9.806e-04 7.071e-04 6.794e-04 0.88 26 6 1.05773e-01 1.99328e+01 9.811e-04 7.071e-04 6.801e-04 0.82 26 5 1.09867e-01 1.91489e+01 9.884e-04 7.071e-04 6.906e-04 0.83 26 4 1.18056e-01 1.77481e+01 9.900e-04 7.071e-04 6.929e-04 0.78 26 3 1.39893e-01 1.48322e+01 9.984e-04 7.071e-04 7.049e-04 0.78 26 2 1.74696e-01 1.17231e+01 1.809e-03 7.071e-04 1.665e-03 accuracy not achieved 30 7 9.07120e-02 2.34421e+01 9.858e-04 7.071e-04 6.869e-04 1.19 30 6 9.28979e-02 2.28604e+01 9.954e-04 7.071e-04 7.005e-04 1.12 30 5 9.67231e-02 2.19071e+01 9.948e-04 7.071e-04 6.998e-04 1.13 30 4 1.04373e-01 2.02151e+01 9.867e-04 7.071e-04 6.882e-04 1.09 30 3 1.24046e-01 1.68435e+01 1.000e-03 7.071e-04 7.071e-04 1.06 30 2 1.39893e-01 1.48322e+01 2.396e-03 7.071e-04 2.290e-03 accuracy not achieved 36 7 7.75287e-02 2.76654e+01 9.703e-04 7.071e-04 6.644e-04 1.95 36 6 7.94670e-02 2.69544e+01 9.770e-04 7.071e-04 6.742e-04 1.92 36 5 8.23743e-02 2.59521e+01 9.954e-04 7.071e-04 7.006e-04 1.91 36 4 8.91580e-02 2.38735e+01 9.838e-04 7.071e-04 6.840e-04 1.81 36 3 1.06602e-01 1.97691e+01 9.982e-04 7.071e-04 7.046e-04 1.91 36 2 1.24046e-01 1.68435e+01 2.275e-03 7.071e-04 2.162e-03 accuracy not achieved resulting parameters: mesh: (10, 10, 10), cao: 4, r_cut_iL: 2.6810e-01, alpha_L: 7.4413e+00, accuracy: 9.9996e-04, time: 0.23 CoulombP3M tune parameters: Accuracy goal = 1.00000e-03 prefactor = 1.00000e+00 System: box_l = 5.00000e+01 # charged part = 75 Sum[q_i^2] = 1.50000e+02 mesh cao r_cut_iL alpha_L err rs_err ks_err time [ms] 4 7 cao too large for this mesh 10 7 2.17172e-01 8.90360e+00 9.870e-04 7.071e-04 6.886e-04 0.11 10 6 2.21016e-01 8.73849e+00 9.866e-04 7.071e-04 6.881e-04 0.10 10 5 2.28703e-01 8.42538e+00 9.868e-04 7.071e-04 6.883e-04 0.09 10 4 2.43117e-01 7.89317e+00 9.929e-04 7.071e-04 6.970e-04 0.09 10 3 2.76750e-01 6.87266e+00 9.955e-04 7.071e-04 7.007e-04 0.09 10 2 4.20891e-01 4.38613e+00 9.980e-04 7.071e-04 7.043e-04 0.16 10 1 4.92000e-01 3.70898e+00 5.274e-03 7.071e-04 5.226e-03 accuracy not achieved 14 7 1.61445e-01 1.22121e+01 9.942e-04 7.071e-04 6.989e-04 0.17 14 6 1.65244e-01 1.19135e+01 9.958e-04 7.071e-04 7.012e-04 0.15 14 5 1.71891e-01 1.14236e+01 9.888e-04 7.071e-04 6.912e-04 0.14 14 4 1.83288e
100%|██████████| 100/100 [00:00<00:00, 189.95it/s] 100%|██████████| 100/100 [00:16<00:00, 6.18it/s] WARNING: Statistics of tuning samples is very bad. in function void check_statistics(Utils::Statistics::RunningAverage<double>&) (/builds/espressomd/espresso/src/core/tuning.cpp:62) on node 0
simulation for parameters {'counterion_valency': 2, 'rod_charge_dens': 1} done -01 1.06686e+01 9.853e-04 7.071e-04 6.862e-04 0.14 14 3 2.08929e-01 9.27880e+00 9.991e-04 7.071e-04 7.059e-04 0.13 14 2 2.43117e-01 7.89317e+00 1.736e-03 7.071e-04 1.585e-03 accuracy not achieved 20 7 1.19155e-01 1.68665e+01 9.781e-04 7.071e-04 6.758e-04 0.33 20 6 1.21603e-01 1.65061e+01 9.955e-04 7.071e-04 7.007e-04 0.31 20 5 1.26500e-01 1.58282e+01 9.889e-04 7.071e-04 6.913e-04 0.29 20 4 1.34661e-01 1.48109e+01 9.938e-04 7.071e-04 6.983e-04 0.29 20 3 1.55880e-01 1.26765e+01 9.895e-04 7.071e-04 6.922e-04 0.28 20 2 2.08929e-01 9.27880e+00 1.320e-03 7.071e-04 1.115e-03 accuracy not achieved 24 7 1.01688e-01 1.99567e+01 9.790e-04 7.071e-04 6.770e-04 0.57 24 6 1.04123e-01 1.94618e+01 9.853e-04 7.071e-04 6.862e-04 0.55 24 5 1.07777e-01 1.87626e+01 9.963e-04 7.071e-04 7.019e-04 0.54 24 4 1.15084e-01 1.75007e+01 9.945e-04 7.071e-04 6.993e-04 0.52 24 3 1.33351e-01 1.49656e+01 9.990e-04 7.071e-04 7.056e-04 0.51 24 2 1.55880e-01 1.26765e+01 1.871e-03 7.071e-04 1.733e-03 accuracy not achieved 30 7 8.33443e-02 2.46412e+01 9.943e-04 7.071e-04 6.991e-04 1.13 30 6 8.54279e-02 2.40050e+01 9.973e-04 7.071e-04 7.033e-04 1.09 30 5 8.90742e-02 2.29651e+01 9.841e-04 7.071e-04 6.844e-04 1.08 30 4 9.48042e-02 2.14966e+01 9.987e-04 7.071e-04 7.053e-04 1.05 30 3 1.10952e-01 1.81932e+01 9.937e-04 7.071e-04 6.981e-04 1.07 30 2 1.33351e-01 1.49656e+01 1.803e-03 7.071e-04 1.659e-03 accuracy not achieved 34 7 7.48064e-02 2.76289e+01 9.890e-04 7.071e-04 6.914e-04 3.33 34 6 7.70284e-02 2.67859e+01 9.759e-04 7.071e-04 6.726e-04 3.28 34 5 7.99910e-02 2.57366e+01 9.783e-04 7.071e-04 6.761e-04 3.35 34 4 8.51756e-02 2.40804e+01 9.953e-04 7.071e-04 7.004e-04 3.24 34 3 9.48042e-02 2.14966e+01 1.139e-03 7.071e-04 8.928e-04 accuracy not achieved resulting parameters: mesh: (10, 10, 10), cao: 4, r_cut_iL: 2.4312e-01, alpha_L: 7.8932e+00, accuracy: 9.9287e-04, time: 0.09 CoulombP3M tune parameters: Accuracy goal = 1.00000e-03 prefactor = 1.00000e+00 System: box_l = 5.00000e+01 # charged part = 150 Sum[q_i^2] = 3.00000e+02 mesh cao r_cut_iL alpha_L err rs_err ks_err time [ms] 6 7 cao too large for this mesh 10 7 2.37352e-01 8.46940e+00 9.881e-04 7.071e-04 6.902e-04 0.20 10 6 2.42156e-01 8.29105e+00 9.864e-04 7.071e-04 6.878e-04 0.19 10 5 2.50805e-01 7.98771e+00 9.948e-04 7.071e-04 6.997e-04 0.18 10 4 2.68102e-01 7.44127e+00 1.000e-03 7.071e-04 7.071e-04 0.18 10 3 3.10383e-01 6.36820e+00 9.996e-04 7.071e-04 7.065e-04 0.19 10 2 4.92000e-01 3.89721e+00 1.029e-03 7.071e-04 7.471e-04 accuracy not achieved 16 7 1.57091e-01 1.31192e+01 9.917e-04 7.071e-04 6.953e-04 0.28 16 6 1.61280e-01 1.27587e+01 9.899e-04 7.071e-04 6.927e-04 0.26 16 5 1.67563e-01 1.22525e+01 9.943e-04 7.071e-04 6.990e-04 0.25 16 4 1.79607e-01 1.13837e+01 9.919e-04 7.071e-04 6.955e-04 0.24 16 3 2.09978e-01 9.64580e+00 9.968e-04 7.071e-04 7.026e-04 0.24 16 2 2.68102e-01 7.44127e+00 1.588e-03 7.071e-04 1.422e-03 accuracy not achieved 20 7 1.29093e-01 1.61480e+01 9.981e-04 7.071e-04 7.043e-04 0.38 20 6 1.32601e-01 1.56965e+01 9.956e-04 7.071e-04 7.009e-04 0.36 20 5 1.38213e-01 1.50230e+01 9.886e-04 7.071e-04 6.910e-04 0.34 20 4 1.48036e-01 1.39703e+01 9.934e-04 7.071e-04 6.977e-04 0.33 20 3 1.74696e-01 1.17231e+01 9.915e-04 7.071e-04 6.951e-04 0.34 20 2 1.79607e-01 1.13837e+01 2.836e-03 7.071e-04 2.746e-03 accuracy not achieved 26 7 1.02931e-01 2.05145e+01 9.847e-04 7.071e-04 6.853e-04 0.86 26 6 1.05244e-01 2.00386e+01 9.999e-04 7.071e-04 7.070e-04 0.81 26 5 1.09870e-01 1.91484e+01 9.883e-04 7.071e-04 6.905e-04 0.78 26 4 1.17966e-01 1.77625e+01 9.924e-04 7.071e-04 6.963e-04 0.76 26 3 1.39940e-01 1.48270e+01 9.977e-04 7.071e-04 7.038e-04 0.75 26 2 1.48036e-01 1.39703e+01 2.764e-03 7.071e-04 2.673e-03 accuracy not achieved 30 7 9.07422e-02 2.34339e+01 9.846e-04 7.071e-04 6.851e-04 1.25 30 6 9.29288e-02 2.28524e+01 9.941e-04 7.071e-04 6.987e-04 1.27 30 5 9.67553e-02 2.18994e+01 9.936e-04 7.071e-04 6.981e-04 1.09 30 4 1.04408e-01 2.02080e+01 9.857e-04 7.071e-04 6.867e-04 1.08 30 3 1.24087e-01 1.68375e+01 9.992e-04 7.071e-04 7.060e-04 1.10 30 2 1.39940e-01 1.48270e+01 2.394e-03 7.071e-04 2.287e-03 accuracy not achieved 36 7 7.74905e-02 2.76798e+01 9.721e-04 7.071e-04 6.671e-04 2.00 36 6 7.91219e-02 2.70784e+01 9.936e-04 7.071e-04 6.980e-04 1.99 36 5 8.23846e-02 2.59487e+01 9.950e-04 7.071e-04 7.000e-04 1.94 36 4 8.89101e-02 2.39437e+01 9.924e-04 7.071e-04 6.964e-04 1.91 36 3 1.04408e-01 2.02080e+01 1.048e-03 7.071e-04 7.739e-04 accuracy not achieved resulting parameters: mesh: (10, 10, 10), cao: 4, r_cut_iL: 2.6810e-01, alpha_L: 7.4413e+00, accuracy: 9.9996e-04, time: 0.18
100%|██████████| 100/100 [00:01<00:00, 96.62it/s] 100%|██████████| 100/100 [00:38<00:00, 2.60it/s]
simulation for parameters {'counterion_valency': 1, 'rod_charge_dens': 2} done
Question
The rod charge density is doubled, so the total charge of the counterions needs to be doubled, too. Since their valency is only half of the one in the first run, there will be four times more counterions in the second run.
We plot the density of counterions around the rod as the normalized integrated radial counterion charge distribution function $P(r)$, meaning the integrated probability to find an amount of charge within the radius $r$. We express the rod charge density $\lambda$ in terms of the dimensionless Manning parameter $\xi = \lambda l_B / e$ where $l_B$ is the Bjerrum length and $e$ the elementary charge
# With the notion of P(r) the probability to find the charge up to r,
# we only use the right side of the bin edges for plotting
rs = bin_edges[1:, 0, 0, 0]
fig, ax = plt.subplots(figsize=(10, 7))
for run in runs:
hist = np.array(run['histogram'][:, 0, 0])
# The CylindricalDensityProfile normalizes the bin values by the bin size.
# We want the 'raw' distribution (number of ions within a radius)
# so we need to multiply by the radii
hist = hist * rs
cum_hist = np.cumsum(hist)
cum_hist /= cum_hist[-1]
manning_xi = run['params']['rod_charge_dens'] * BJERRUM_LENGTH / Q_E
ax.plot(rs, cum_hist, label=rf'$\xi ={manning_xi}, \nu = {run["params"]["counterion_valency"]}$')
ax.set_xscale('log')
ax.legend()
plt.xlabel('r')
plt.ylabel('P(r)')
plt.show()
In the semilogarithmic plot we see an inflection point of the cumulative charge distribution which is the indicator for ion condensation. To compare to the meanfield approach of PB, we calculate the solution of the analytical expressions given in 10.1021/ma990897o
def eq_to_solve_for_gamma(gamma, manning_parameter, rod_radius, max_radius):
# eq 7 - eq 6 from 10.1021/ma990897o
return gamma * np.log(max_radius / rod_radius) - np.arctan(1 / gamma) + np.arctan((1 - manning_parameter) / gamma)
def calc_manning_radius(gamma, max_radius):
# eq 7 from 10.1021/ma990897o
return max_radius * np.exp(-np.arctan(1. / gamma) / gamma)
def calc_PB_probability(r, manning_parameter, gamma, manning_radius):
# eq 8 and 9 from 10.1021/ma990897o
return 1. / manning_parameter + gamma / manning_parameter * np.tan(gamma * np.log(r / manning_radius))
For multivalent counterions, the manning parameter $\xi$ has to be multiplied by the valency $\nu$. The result depends only on the product of rod_charge_dens
and ion_valency
, so we only need one curve
rod_charge_density = runs[0]['params']['rod_charge_dens']
ion_valency = runs[0]['params']['counterion_valency']
manning_parameter_times_valency = BJERRUM_LENGTH * rod_charge_density * ion_valency
gamma = scipy.optimize.fsolve(eq_to_solve_for_gamma, 1, args=(
manning_parameter_times_valency, r_min, r_max))
manning_radius = calc_manning_radius(gamma, r_max)
PB_probability = calc_PB_probability(
rs, manning_parameter_times_valency, gamma, manning_radius)
ax.plot(rs, PB_probability, label=rf'PB $\xi \cdot \nu$ = {manning_parameter_times_valency}')
ax.legend()
ax.set_xscale('log')
fig
We see that overall agreement is quite good, but the deviations from the PB solution get stronger the more charged the ions are. Poisson Boltzmann makes two simplifying assumptions: Particles are points and there are no correlations between the particles. Both is not given in the simulation. Excluded volume effects can only lower the density, but we see in the figure that the simulated density is always larger that the calculated one. This means that correlation effects cause the discrepancy.
Above simulations were performed for a system where all ions come from dissociation off the polyelectrolyte. We can also investigate systems where there are additional salt ions present.
def add_salt(system, anion_params, cation_params):
N_anions = anion_params['number']
N_cations = cation_params['number']
anion_positions = np.random.random((N_anions, 3)) * system.box_l
cation_positions = np.random.random((N_cations, 3)) * system.box_l
anions = system.part.add(pos=anion_positions, type=[anion_params['type']] * N_anions,
q=[-anion_params['valency']] * N_anions)
cations = system.part.add(pos=cation_positions, type=[cation_params['type']] * N_cations,
q=[cation_params['valency']] * N_cations)
return anions, cations
ANION_PARAMS = {'type': 3,
'valency': 2,
'number': 150}
CATION_PARAMS = {'type': 4,
'valency': 2,
'number': 150}
ROD_LENGTH = 10
N_rod_beads = int(ROD_LENGTH / ROD_RADIUS)
ROD_CHARGE_DENS = 1
COUNTERION_VALENCY = 1
STEPS_PER_SAMPLE_SALT = 20
N_SAMPLES_SALT = 1500
N_RADIAL_BINS = 100
all_ion_types = [COUNTERION_TYPE, ANION_PARAMS['type'], CATION_PARAMS['type']]
# set interactions of salt with the rod and all ions
for salt_type in [ANION_PARAMS['type'], CATION_PARAMS['type']]:
system.non_bonded_inter[salt_type, ROD_TYPE].wca.set_params(
epsilon=WCA_EPSILON, sigma=ION_DIAMETER / 2. + ROD_RADIUS)
for ion_type in all_ion_types:
system.non_bonded_inter[salt_type, ion_type].wca.set_params(
epsilon=WCA_EPSILON, sigma=ION_DIAMETER)
# Resetting system with salt ions
clear_system(system)
system.box_l = 3 * [ROD_LENGTH]
counterions = setup_rod_and_counterions(
system, COUNTERION_VALENCY, COUNTERION_TYPE,
ROD_CHARGE_DENS, N_rod_beads, ROD_TYPE)
anions, cations = add_salt(system, ANION_PARAMS, CATION_PARAMS)
assert abs(sum(anions.q) + sum(cations.q)) < 1e-10
p3m = espressomd.electrostatics.P3M(**p3m_params)
system.electrostatics.solver = p3m
remove_overlap(system)
system.thermostat.set_langevin(**LANGEVIN_PARAMS)
print('', end='', flush=True)
CoulombP3M tune parameters: Accuracy goal = 1.00000e-03 prefactor = 1.00000e+00 System: box_l = 1.00000e+01 # charged part = 320 Sum[q_i^2] = 1.22000e+03 mesh cao r_cut_iL alpha_L err rs_err ks_err time [ms] 8 7 4.60000e-01 6.13067e+00 2.012e-02 7.071e-04 2.010e-02 accuracy not achieved 8 7 4.60000e-01 6.13067e+00 2.012e-02 7.071e-04 2.010e-02 accuracy not achieved 10 7 4.60000e-01 6.13067e+00 2.984e-03 7.071e-04 2.899e-03 accuracy not achieved 10 7 4.60000e-01 6.13067e+00 2.984e-03 7.071e-04 2.899e-03 accuracy not achieved 12 7 4.43828e-01 6.36120e+00 9.961e-04 7.071e-04 7.016e-04 1.65 12 6 4.60000e-01 6.13067e+00 1.144e-03 7.071e-04 8.991e-04 accuracy not achieved 12 7 4.43828e-01 6.36120e+00 9.961e-04 7.071e-04 7.016e-04 1.75 12 6 4.43828e-01 6.36120e+00 1.453e-03 7.071e-04 1.269e-03 accuracy not achieved 14 7 3.84882e-01 7.36815e+00 9.975e-04 7.071e-04 7.036e-04 1.38 14 6 4.10021e-01 6.90279e+00 9.944e-04 7.071e-04 6.991e-04 1.43 14 5 4.43828e-01 6.36120e+00 1.171e-03 7.071e-04 9.339e-04 accuracy not achieved 14 7 3.84882e-01 7.36815e+00 9.975e-04 7.071e-04 7.036e-04 1.32 14 6 3.84882e-01 7.36815e+00 1.465e-03 7.071e-04 1.283e-03 accuracy not achieved 16 7 3.40531e-01 8.35944e+00 9.919e-04 7.071e-04 6.957e-04 1.09 16 6 3.62331e-01 7.84143e+00 1.000e-03 7.071e-04 7.071e-04 1.13 16 5 3.84882e-01 7.36815e+00 1.307e-03 7.071e-04 1.100e-03 accuracy not achieved 16 7 3.40531e-01 8.35944e+00 9.919e-04 7.071e-04 6.957e-04 1.05 16 6 3.40531e-01 8.35944e+00 1.463e-03 7.071e-04 1.281e-03 accuracy not achieved 18 7 3.05280e-01 9.35607e+00 9.936e-04 7.071e-04 6.980e-04 0.99 18 6 3.25233e-01 8.76502e+00 9.999e-04 7.071e-04 7.070e-04 1.04 18 5 3.40531e-01 8.35944e+00 1.427e-03 7.071e-04 1.240e-03 accuracy not achieved 18 7 3.05280e-01 9.35607e+00 9.936e-04 7.071e-04 6.980e-04 0.98 18 6 3.05280e-01 9.35607e+00 1.476e-03 7.071e-04 1.295e-03 accuracy not achieved 20 7 2.76660e-01 1.03550e+01 9.986e-04 7.071e-04 7.051e-04 0.84 20 6 2.95740e-01 9.66727e+00 9.925e-04 7.071e-04 6.964e-04 0.95 20 5 3.05280e-01 9.35607e+00 1.548e-03 7.071e-04 1.378e-03 accuracy not achieved 20 7 2.76660e-01 1.03550e+01 9.986e-04 7.071e-04 7.051e-04 1.24 20 6 2.76660e-01 1.03550e+01 1.495e-03 7.071e-04 1.317e-03 accuracy not achieved 22 7 2.53425e-01 1.13346e+01 9.953e-04 7.071e-04 7.005e-04 0.97 22 6 2.70716e-01 1.05894e+01 9.971e-04 7.071e-04 7.029e-04 0.96 22 5 2.76660e-01 1.03550e+01 1.668e-03 7.071e-04 1.511e-03 accuracy not achieved 22 7 2.53268e-01 1.13418e+01 9.989e-04 7.071e-04 7.055e-04 0.94 22 6 2.70716e-01 1.05894e+01 9.971e-04 7.071e-04 7.029e-04 0.95 22 5 2.70716e-01 1.05894e+01 1.915e-03 7.071e-04 1.780e-03 accuracy not achieved 24 7 2.34471e-01 1.22797e+01 9.794e-04 7.071e-04 6.776e-04 0.95 24 6 2.50300e-01 1.14804e+01 9.906e-04 7.071e-04 6.937e-04 0.96 24 5 2.53268e-01 1.13418e+01 1.772e-03 7.071e-04 1.625e-03 accuracy not achieved 24 7 2.34471e-01 1.22797e+01 9.794e-04 7.071e-04 6.776e-04 0.95 24 6 2.34471e-01 1.22797e+01 1.472e-03 7.071e-04 1.291e-03 accuracy not achieved 26 7 2.17069e-01 1.32950e+01 9.955e-04 7.071e-04 7.008e-04 1.15 26 6 2.32639e-01 1.23793e+01 9.894e-04 7.071e-04 6.921e-04 1.14 26 5 2.34471e-01 1.22797e+01 1.826e-03 7.071e-04 1.684e-03 accuracy not achieved 26 7 2.17190e-01 1.32873e+01 9.924e-04 7.071e-04 6.963e-04 1.16 26 6 2.32639e-01 1.23793e+01 9.894e-04 7.071e-04 6.921e-04 1.12 26 5 2.32639e-01 1.23793e+01 1.921e-03 7.071e-04 1.786e-03 accuracy not achieved 28 7 2.02650e-01 1.42702e+01 9.957e-04 7.071e-04 7.010e-04 1.18 28 6 2.17190e-01 1.32873e+01 9.928e-04 7.071e-04 6.968e-04 1.27 28 5 2.32639e-01 1.23793e+01 1.296e-03 7.071e-04 1.086e-03 accuracy not achieved 28 7 2.02650e-01 1.42702e+01 9.957e-04 7.071e-04 7.010e-04 1.26 28 6 2.02650e-01 1.42702e+01 1.519e-03 7.071e-04 1.345e-03 accuracy not achieved 30 7 1.89985e-01 1.52509e+01 9.989e-04 7.071e-04 7.055e-04 1.50 30 6 2.02650e-01 1.42702e+01 1.021e-03 7.071e-04 7.370e-04 accuracy not achieved 30 7 1.89985e-01 1.52509e+01 9.989e-04 7.071e-04 7.055e-04 1.52 30 6 1.89985e-01 1.52509e+01 1.533e-03 7.071e-04 1.360e-03 accuracy not achieved 32 7 1.79595e-01 1.61602e+01 9.789e-04 7.071e-04 6.769e-04 1.75 32 6 1.89985e-01 1.52509e+01 1.049e-03 7.071e-04 7.746e-04 accuracy not achieved 32 7 1.79595e-01 1.61602e+01 9.789e-04 7.071e-04 6.769e-04 1.77 32 6 1.79595e-01 1.61602e+01 1.498e-03 7.071e-04 1.320e-03 accuracy not achieved 34 7 1.69773e-01 1.71236e+01 9.789e-04 7.071e-04 6.770e-04 3.65 34 6 1.79595e-01 1.61602e+01 1.052e-03 7.071e-04 7.790e-04 accuracy not achieved resulting parameters: mesh: (20, 20, 20), cao: 7, r_cut_iL: 2.7666e-01, alpha_L: 1.0355e+01, accuracy: 9.9856e-04, time: 0.84
WARNING: Statistics of tuning samples is very bad. in function void check_statistics(Utils::Statistics::RunningAverage<double>&) (/builds/espressomd/espresso/src/core/tuning.cpp:62) on node 0
for i in tqdm.trange(100):
system.integrator.run(WARMUP_STEPS // 100)
radial_profile_accs, bin_edges = setup_profile_calculation(
system, STEPS_PER_SAMPLE_SALT, all_ion_types, r_min, N_RADIAL_BINS)
for i in tqdm.trange(100):
system.integrator.run(N_SAMPLES_SALT * STEPS_PER_SAMPLE_SALT // 100)
100%|██████████| 100/100 [00:04<00:00, 21.84it/s] 100%|██████████| 100/100 [00:26<00:00, 3.80it/s]
rs = bin_edges[1:, 0, 0, 0]
cum_hists = {}
for ion_type in all_ion_types:
hist = radial_profile_accs[ion_type].mean()
hist = hist[:, 0, 0] * rs
cum_hist = np.cumsum(hist)
cum_hist /= cum_hist[-1]
cum_hists[ion_type] = cum_hist
Exercise:
Hints
# SOLUTION CELL
counterion_charge = sum(counterions.q)
anion_charge = sum(anions.q)
cation_charge = sum(cations.q)
charge_hist = counterion_charge * cum_hists[COUNTERION_TYPE] + \
anion_charge * cum_hists[ANION_PARAMS['type']] + \
cation_charge * cum_hists[CATION_PARAMS['type']]
charge_hist /= charge_hist[-1]
fig2, ax2 = plt.subplots(figsize=(10, 7))
ax2.plot(rs, charge_hist)
ax2.set_xscale('linear')
plt.xlabel('r')
plt.ylabel('P(r)')
plt.show()
You should observe a strong overcharging effect, where ions accumulate close to the rod.