Ferrofluid - Part 2

Remark: The equilibration and sampling times used in this tutorial would be not sufficient for scientific purposes, but they are long enough to get at least a qualitative insight of the behaviour of ferrofluids. They have been shortened so we achieve reasonable computation times for the purpose of a tutorial.

Applying an external magnetic field

In this part we want to investigate the influence of a homogeneous external magnetic field exposed to a ferrofluid system.

We import all necessary packages and check for the required ESPResSo features

In [1]:
import espressomd
import espressomd.magnetostatics

espressomd.assert_features(['DIPOLES', 'DP3M', 'LENNARD_JONES'])

%matplotlib inline
import matplotlib.pyplot as plt
In [2]:
plt.rcParams.update({'font.size': 18})
import numpy as np
import tqdm

and set up the simulation parameters where we introduce a new dimensionless parameter

\begin{equation} \alpha = \frac{\mu B}{k_{\text{B}}T} = \frac{\mu \mu_0 H}{k_{\text{B}}T} \end{equation}

which is called Langevin parameter. We intentionally choose a relatively high volume fraction $\phi$ and dipolar interaction parameter $\lambda$ to clearly see the influence of the dipole-dipole interaction

In [3]:
# Lennard-Jones parameters
LJ_SIGMA = 1.
LJ_EPSILON = 1.
LJ_CUT = 2**(1. / 6.) * LJ_SIGMA

# Particles
N_PART = 700

# Area fraction of the mono-layer
PHI = 0.06

# Dipolar interaction parameter lambda = MU_0 m^2 /(4 pi sigma^3 kT)
DIP_LAMBDA = 4.

# Temperature
KT = 1.0

# Friction coefficient
GAMMA = 1.0

# Time step
TIME_STEP = 0.01

# Langevin parameter ALPHA = MU_0 m H / kT
ALPHA = 10.

# vacuum permeability
MU_0 = 1.

Now we set up the system. As in part I, the orientation of the dipole moments is set directly on the particles, whereas the magnitude of the moments is taken into account when determining the prefactor of the dipolar P3M (for more details see part I).

Hint: It should be noted that we seed both the Langevin thermostat and the random number generator of numpy. The latter means that the initial configuration of our system is the same every time this script will be executed. As the time evolution of the system depends not solely on the Langevin thermostat but also on the numeric accuracy and DP3M as well as DLC (the tuned parameters are slightly different every time) it is only partly predefined. You can change the seeds to simulate with a different initial configuration and a guaranteed different time evolution.

In [4]:
# System setup
box_size = (N_PART * np.pi * (LJ_SIGMA / 2.)**2. / PHI)**0.5

print("Box size", box_size)
# Note that the dipolar P3M and dipolar layer correction need a cubic
# simulation box for technical reasons.
system = espressomd.System(box_l=(box_size, box_size, box_size))
system.time_step = TIME_STEP

# Lennard-Jones interaction
system.non_bonded_inter[0, 0].lennard_jones.set_params(epsilon=LJ_EPSILON, sigma=LJ_SIGMA, cutoff=LJ_CUT, shift="auto")

# Random dipole moments
np.random.seed(seed=1)
dip_phi = 2. * np.pi * np.random.random((N_PART, 1))
dip_cos_theta = 2 * np.random.random((N_PART, 1)) - 1
dip_sin_theta = np.sin(np.arccos(dip_cos_theta))
dip = np.hstack((
    dip_sin_theta * np.sin(dip_phi),
    dip_sin_theta * np.cos(dip_phi),
    dip_cos_theta))

# Random positions in the monolayer
pos = box_size * np.hstack((np.random.random((N_PART, 2)), np.zeros((N_PART, 1))))

# Add particles
particles = system.part.add(pos=pos, rotation=N_PART * [(True, True, True)], dip=dip, fix=N_PART * [(False, False, True)])

# Remove overlap between particles by means of the steepest descent method
system.integrator.set_steepest_descent(
    f_max=0, gamma=0.1, max_displacement=0.05)

while system.analysis.energy()["total"] > 5 * KT * N_PART:
    system.integrator.run(20)

# Switch to velocity Verlet integrator
system.integrator.set_vv()
system.thermostat.set_langevin(kT=KT, gamma=GAMMA, seed=1)

# tune verlet list skin
system.cell_system.tune_skin(min_skin=0.4, max_skin=2., tol=0.2, int_steps=100)

# Setup dipolar P3M and dipolar layer correction (DLC)
dp3m = espressomd.magnetostatics.DipolarP3M(accuracy=5E-4, prefactor=DIP_LAMBDA * LJ_SIGMA**3 * KT)
mdlc = espressomd.magnetostatics.DLC(actor=dp3m, maxPWerror=1E-4, gap_size=box_size - LJ_SIGMA)
system.actors.add(mdlc)

# tune verlet list skin again
system.cell_system.tune_skin(min_skin=0.4, max_skin=2., tol=0.2, int_steps=100)

# print skin value
print(f'tuned skin = {system.cell_system.skin:.2f}')
Box size 95.72344839677595
tuned skin = 0.55

We now apply the external magnetic field which is

\begin{equation} B = \mu_0 H = \frac{\alpha~k_{\text{B}}T}{\mu} \end{equation}

As only the current orientation of the dipole moments, i.e. the unit vector of the dipole moments, is saved in the particle list but not their magnitude, we have to use $B\cdot \mu$ as the strength of the external magnetic field. We will apply the field in x-direction using the class constraints of ESPResSo

In [5]:
# magnetic field times dipole moment
H_dipm = ALPHA * KT
H_field = [H_dipm, 0, 0]

Exercise:

Define a homogenous magnetic field constraint using H_field and add it to system's contraints.

In [6]:
H_constraint = espressomd.constraints.HomogeneousMagneticField(H=H_field)
system.constraints.add(H_constraint)
Out[6]:
<espressomd.constraints.HomogeneousMagneticField at 0x7f117d178e50>

Equilibrate the system.

In [7]:
# Equilibrate
print("Equilibration...")
equil_rounds = 10
equil_steps = 100
for i in tqdm.trange(equil_rounds):
    system.integrator.run(equil_steps)
Equilibration...
100%|██████████| 10/10 [00:01<00:00,  5.34it/s]

Now we can visualize the current state and see that the particles mostly create chains oriented in the direction of the external magnetic field. Also some monomers should be present.

In [8]:
plt.figure(figsize=(10, 10))
plt.xlim(0, box_size)
plt.ylim(0, box_size)
plt.xlabel('x-position', fontsize=20)
plt.ylabel('y-position', fontsize=20)
plt.plot(particles.pos_folded[:, 0], particles.pos_folded[:, 1], 'o')
plt.show()

Video of the development of the system

You may want to get an insight of how the system develops in time. Thus we now create a function which will save a video and embed it in an html string to create a video of the systems development

In [9]:
import matplotlib.pyplot as plt
In [10]:
import matplotlib.animation as animation
In [ ]:
import tempfile
import base64

VIDEO_TAG = """<video controls>
 <source src="data:video/x-m4v;base64,{0}" type="video/mp4">
 Your browser does not support the video tag.
</video>"""


def anim_to_html(anim):
    if not hasattr(anim, '_encoded_video'):
        with tempfile.NamedTemporaryFile(suffix='.mp4') as f:
            anim.save(f.name, fps=20, extra_args=['-vcodec', 'libx264'])
            with open(f.name, "rb") as g:
                video = g.read()
        anim._encoded_video = base64.b64encode(video).decode('ascii')
        plt.close(anim._fig)
    return VIDEO_TAG.format(anim._encoded_video)


animation.Animation._repr_html_ = anim_to_html


def init():
    # Set x and y range
    ax.set_ylim(0, box_size)
    ax.set_xlim(0, box_size)
    xdata, ydata = [], []
    part.set_data(xdata, ydata)
    return part,


def run(i):
    system.integrator.run(50)

    # Save current system state as a plot
    xdata, ydata = particles.pos_folded[:, 0], particles.pos_folded[:, 1]
    ax.figure.canvas.draw()
    part.set_data(xdata, ydata)
    print(f'progress: {(i + 1):3.0f}%', end='\r')
    return part,

We now can start the sampling over the animation class of matplotlib

In [11]:
fig, ax = plt.subplots(figsize=(10, 10))
part, = ax.plot([], [], 'o')

animation.FuncAnimation(fig, run, frames=100, blit=True, interval=0, repeat=False, init_func=init)
progress: 100%
Out[11]:

In the visualization video we can see that the single chains break and connect to each other during time. Also some monomers are present which break from and connect to chains. If you want to have some more frames, i.e. a longer video, just adjust the frames parameter in FuncAnimation.

Magnetization curve

An important observable of a ferrofluid system is the magnetization $M$ of the system in direction of an external magnetic field $H$

\begin{equation} M = \frac{\sum_i \mu_i^H}{V} \end{equation}

where the index $H$ means the component of $\mu_i$ in direction of the external magnetic field $H$ and the sum runs over all particles. $V$ is the system's volume.

The magnetization plotted over the external field $H$ is called magnetization curve. For particles with non-interacting dipole moments there is an analytical solution

\begin{equation} M = M_{\text{sat}}\cdot L(\alpha) \end{equation}

with $L(\alpha)$ the Langevin function

\begin{equation} L(\alpha) = \coth(\alpha)-\frac{1}{\alpha} \end{equation}

and $\alpha$ the Langevin parameter

\begin{equation} \alpha=\frac{\mu_0\mu}{k_{\text{B}}T}H = \frac{\mu}{k_{\text{B}}T}B \end{equation}

$M_{sat}$ is the so called saturation magnetization which is the magnetization of a system where all dipole moments are aligned to each other. Thus it is the maximum of the magnetization. In our case all dipole moments are equal, thus

\begin{equation} M_{\text{sat}} = \frac{N_{\text{part}}\cdot\mu}{V} \end{equation}

For better comparability we now introduce a dimensionless magnetization

\begin{equation} M^* = \frac{M}{M_{sat}} = \frac{\sum_i \mu_i^H}{N_{\text{part}}\cdot \mu} \end{equation}

Thus the analytical solution for non-interacting dipole moments $M^*$ is simply the Langevin function.

For interacting dipole moments there are only approximations for the magnetization curve available.

Here we want to use the approximation of Ref. [1] for a quasi two dimensional system, which reads with adjusted coefficients (Ref. [1] used a different dipole-dipole interaction prefactor $\gamma = 1$)

\begin{equation} M_{\parallel}^{\text{q2D}} = M_{\text{sat}} L(\alpha) \left( 1 + \mu_0\frac{1}{8} M_{\text{sat}} \frac{\mathrm{d} L(\alpha)}{\mathrm{d}B} \right) \end{equation}

and

\begin{equation} M_{\perp}^{\text{q2D}} = M_{\text{sat}} L(\alpha) \left( 1 - \mu_0\frac{1}{4} M_{\text{sat}} \frac{\mathrm{d} L(\alpha)}{\mathrm{d}B} \right) \end{equation}

for the magnetization with an external magnetic field parallel and perpendicular to the monolayer plane, respectively. Here the dipole-dipole interaction is approximated as a small perturbation and

\begin{equation} \frac{\mathrm{d} L(\alpha)}{\mathrm{d}B} = \left( \frac{1}{\alpha^2} - \frac{1}{\sinh^2(\alpha)} \right) \cdot \frac{\mu}{k_{\text{B}}T} \end{equation}

By comparing the magnetization curve parallel $M_{\parallel}^{\text{q2D}}$ and perpendicular $M_{\perp}^{\text{q2D}}$ to the monolayer plane we can see that the magnetization is increased in the case of an external field parallel to the monolayer plane and decreased in the case of an external field perpendicular to the monolayer plane. The latter can be explained by the fact that an orientation of all single dipole moments perpendicular to the monolayer plane results in a configuration with a repulsive dipole-dipole interaction as the particles have no freedom of movement in the direction perpendicular to the monolayer plane. This counteracts the magnetization perpendicular to the monolayer plane.

We now want to use ESPResSo to get an estimation of how the magnetization curve is affected by the dipole-dipole interaction parallel and perpendicular to the monolayer plane and compare the results with the Langevin curve and the magnetization curves of Ref. [1].

For the sampling of the magnetization curve we set up a new system, where we decrease the dipolar interaction parameter $\lambda$ drastically. We do this as we want to compare our results with the approximation of Ref. [1] which is only valid for small dipole-dipole interaction between the particles (decreasing the volume fraction $\phi$ would also be an appropriate choice). For smaller dipolar interaction parameters it is possible to increase the time step. We do this to get more uncorrelated measurements.

In [12]:
# Dipolar interaction parameter lambda = MU_0 m^2 /(4 pi sigma^3 kT)
DIP_LAMBDA = 1.

# increase time step
TIME_STEP = 0.02

# dipole moment
dipm = np.sqrt(4 * np.pi * DIP_LAMBDA * LJ_SIGMA**3. * KT / MU_0)
In [13]:
# remove all particles
system.part.clear()
system.actors.clear()
system.thermostat.turn_off()

# Random dipole moments
dip_phi = 2. * np.pi * np.random.random((N_PART, 1))
dip_cos_theta = 2 * np.random.random((N_PART, 1)) - 1
dip_sin_theta = np.sin(np.arccos(dip_cos_theta))
dip = np.hstack((
    dip_sin_theta * np.sin(dip_phi),
    dip_sin_theta * np.cos(dip_phi),
    dip_cos_theta))

# Random positions in the monolayer
pos = box_size * np.hstack((np.random.random((N_PART, 2)), np.zeros((N_PART, 1))))

# Add particles
particles = system.part.add(pos=pos, rotation=N_PART * [(True, True, True)], dip=dip, fix=N_PART * [(False, False, True)])

# Remove overlap between particles by means of the steepest descent method
system.integrator.set_steepest_descent(f_max=0, gamma=0.1, max_displacement=0.05)

while system.analysis.energy()["total"] > 5 * KT * N_PART:
    system.integrator.run(20)

# Switch to velocity Verlet integrator
system.integrator.set_vv()
system.thermostat.set_langevin(kT=KT, gamma=GAMMA, seed=1)

# tune verlet list skin
system.cell_system.tune_skin(min_skin=0.4, max_skin=2., tol=0.2, int_steps=100)

# Setup dipolar P3M and dipolar layer correction
dp3m = espressomd.magnetostatics.DipolarP3M(accuracy=5E-4, prefactor=DIP_LAMBDA * LJ_SIGMA**3 * KT)
mdlc = espressomd.magnetostatics.DLC(actor=dp3m, maxPWerror=1E-4, gap_size=box_size - LJ_SIGMA)
system.actors.add(mdlc)

# tune verlet list skin again
system.cell_system.tune_skin(min_skin=0.4, max_skin=2., tol=0.2, int_steps=100)
Out[13]:
0.55

To increase the performance we use the built-in function MagneticDipoleMoment to calculate the dipole moment of the whole system. In our case this is only the orientation as we never set the strength of the dipole moments on our particles.

Exercise:

Import the magnetic dipole moment observable and define an observable object dipm_tot. Use particle slicing to pass all particle ids.

In [14]:
import espressomd.observables
dipm_tot = espressomd.observables.MagneticDipoleMoment(ids=particles.id)

We use the dimensionless Langevin parameter $\alpha$ as the parameter for the external magnetic field. As the interesting part of the magnetization curve is the one for small external magnetic field strengths—for large external magnetic fields the magnetization goes into saturation in all cases—we increase the spacing between the Langevin parameters $\alpha$ up to higher values and write them into a list

In [15]:
alphas = [0,1,2,3,4.5,8]

For both the magnetization perpendicular and parallel to the monolayer plane we use the same system for every value of the Langevin parameter $\alpha$. Thus we use that the system is already more or less equilibrated from the previous run so we save some equilibration time. For scientific purposes one would use a new system for every value for the Langevin parameter to ensure that the systems are independent and no correlation effects are measured. Also one would perform more than just one simulation for each value of $\alpha$ to increase the precision of the results.

Now we sample the magnetization for increasing $\alpha$ (increasing magnetic field strength) in direction perpendicular to the monolayer plane.

Exercise:

Complete the loop such that for every alpha a magnetic field of strength of the respective H_dipm is applied:

# sampling with magnetic field perpendicular to monolayer plane (in z-direction)

# remove all constraints
system.constraints.clear()

# list of magnetization in field direction
magnetization_perp = np.full_like(alphas, np.nan)

# number of loops for sampling
loops = 500

for ndx, alpha in enumerate(pbar := tqdm.tqdm(alphas)):
    pbar.set_description(f"Sampling for α={alpha:.2f}")
    # set magnetic field constraint
    H_dipm = alpha * KT
    # < exercise >

    # equilibration
    for i in range(equil_rounds):
        system.integrator.run(equil_steps)

    # sampling
    magn_temp = 0.
    for i in range(loops):
        system.integrator.run(20)
        magn_temp += dipm_tot.calculate()[2]

    # save average magnetization
    magnetization_perp[ndx] = magn_temp / loops

    # remove constraint
    system.constraints.clear()
In [16]:
# sampling with magnetic field perpendicular to monolayer plane (in z-direction)

# remove all constraints
system.constraints.clear()

# list of magnetization in field direction
magnetization_perp = np.full_like(alphas, np.nan)

# number of loops for sampling
loops = 100

for ndx, alpha in enumerate(pbar := tqdm.tqdm(alphas)):
    pbar.set_description(f"Sampling for α={alpha:.2f}")
    # set magnetic field constraint
    H_dipm = alpha * KT
    H_field = [0, 0, H_dipm]
    H_constraint = espressomd.constraints.HomogeneousMagneticField(H=H_field)
    system.constraints.add(H_constraint)

    # equilibration
    for i in range(equil_rounds):
        system.integrator.run(equil_steps)

    # sampling
    magn_temp = 0.
    for i in range(loops):
        system.integrator.run(20)
        magn_temp += dipm_tot.calculate()[2]

    # save average magnetization
    magnetization_perp[ndx] = magn_temp / loops

    # remove constraint
    system.constraints.clear()
Sampling for α=8.00: 100%|██████████| 6/6 [00:32<00:00,  5.48s/it]

and now we sample the magnetization for increasing $\alpha$ or increasing magnetic field in direction parallel to the monolayer plane.

Exercise:

Use the code from the previous exercise as a template. Now sample the magnetization curve for a magnetic field parallel to the quasi-2D layer and store the calculated magnetizations in a list named magnetization_para (analogous to magnetization_perp).

Hint: Set up the field in $x$- or $y$-direction and sample the magnetization along the same axis.

In [17]:
# sampling with magnetic field parallel to monolayer plane (in x-direction)

# remove all constraints
system.constraints.clear()

# list of magnetization in field direction
magnetization_para = np.full_like(alphas, np.nan)

# number of loops for sampling
loops = 100

for ndx, alpha in enumerate(pbar := tqdm.tqdm(alphas)):
    pbar.set_description(f"Sampling for α={alpha:.2f}")
    # set magnetic field constraint
    H_dipm = alpha * KT
    H_field = [H_dipm, 0, 0]
    H_constraint = espressomd.constraints.HomogeneousMagneticField(H=H_field)
    system.constraints.add(H_constraint)

    # equilibration
    for i in range(equil_rounds):
        system.integrator.run(equil_steps)

    # sampling
    magn_temp = 0
    for i in range(loops):
        system.integrator.run(20)
        magn_temp += dipm_tot.calculate()[0]

    # save average magnetization
    magnetization_para[ndx] = magn_temp / loops

    # remove constraint
    system.constraints.clear()
Sampling for α=8.00: 100%|██████████| 6/6 [00:32<00:00,  5.43s/it]

Now we can compare the resulting magnetization curves with the Langevin curve and the more advanced ones of Ref. [1] by plotting all of them in one figure.

For the approximations of $M_{\parallel}^{\text{q2D}}$ and $M_{\perp}^{\text{q2D}}$ of Ref. [1] we need the dipole moment of a single particle. Thus we calculate it from our dipolar interaction parameter $\lambda$

In [18]:
# dipole moment
dipm = np.sqrt(DIP_LAMBDA * 4 * np.pi * LJ_SIGMA**3. * KT / MU_0)
print(f'dipole moment = {dipm:.4f}')
dipole moment = 3.5449

and the saturation magnetization by using

\begin{equation} M_{\text{sat}} = \rho \mu = \phi \frac{4}{\pi \sigma^2} \mu \end{equation}

thus

In [19]:
M_sat = PHI * 4. / np.pi * 1. / (LJ_SIGMA**2.) * dipm

Further we need the derivation of the Langevin function after the external field $B$ thus we define the function

In [20]:
def dL_dB(alpha):
    return (1. / (alpha**2.) - 1. / ((np.sinh(alpha))**2.)) * dipm / (KT)

Now we define the approximated magnetization curves parallel and perpendicular to the monolayer plane

In [21]:
# approximated magnetization curve for a field parallel to the monolayer plane
def magnetization_approx_para(alpha):
    return L(alpha) * (1. + MU_0 / 8. * M_sat * dL_dB(alpha))
In [22]:
# approximated magnetization curve for a field perpendicular to the monolayer plane
def magnetization_approx_perp(alpha):
    return L(alpha) * (1. - MU_0 / 4. * M_sat * dL_dB(alpha))

Now we define the Langevin function

In [23]:
# Langevin function
def L(x):
    return (np.cosh(x) / np.sinh(x)) - 1. / x

and plot the three theoretical curves together with our simulation results

Exercise:

The following listing plots the analytical models for the magnetization. Add your sampled magnetization curves (for parallel and perpendicular fields) each normalized by the number of particles $N_{\text{part}}$.

# list of the values for alpha (x-axis)
x = np.arange(0.01,9, 0.1, dtype=float)

plt.figure(figsize=(10,10))
plt.xlabel(r'$\alpha$', fontsize=20)
plt.ylabel(r'$M^*$', fontsize=20)
plt.plot(x, L(x), label='Langevin function')
plt.plot(x, magnetization_approx_perp(x), label='q2D approximation $\perp$')
plt.plot(x, magnetization_approx_para(x), label='q2D approximation $\parallel$')
# < exercise >
plt.legend(fontsize=20)
plt.show()
In [24]:
# list of the values for alpha (x-axis)
x = np.arange(0.01, 9, 0.1, dtype=float)

plt.figure(figsize=(10, 10))
plt.xlabel(r'$\alpha$', fontsize=20)
plt.ylabel(r'$M^*$', fontsize=20)
plt.plot(x, L(x), label='Langevin function')
plt.plot(x, magnetization_approx_perp(x), label='q2D approximation $\perp$')
plt.plot(x, magnetization_approx_para(x), label='q2D approximation $\parallel$')
plt.plot(alphas, magnetization_perp / N_PART, 'o', label='simulation results $\perp$')
plt.plot(alphas, magnetization_para / N_PART, 'o', label='simulation results $\parallel$')
plt.legend(fontsize=20)
plt.show()

We can see that the simulation results are better represented by the curves of Ref. [1] compared to the Langevin function. This was to be expected as the Langevin function is the magnetization curve of the real three dimensional system without dipole-dipole interaction. We can also see that the magnetization is smaller in the case of an external magnetic field perpendicular to the monolayer plane compared to the parallel case.

Feel free to experiment with different dipolar interaction parameters $\lambda$ up to around 4 and different area fractions $\phi$ up to around 0.4. For higher values the here used simple sampling method is not applicable as the particles form clusters of very high relaxation times exceeding normal simulation times by far. Therefore more advanced methods are necessary to increase the sampling performance.

It should also be noted that perhaps thereby one has to decrease the time step as for higher values of the dipolar interaction parameter and the area fraction the interaction between the particles is stronger.

References

[1] Tamás Kristóf and István Szalai. Magnetic properties in monolayers of a model polydisperse ferrofluid. Phys. Rev. E 72: 041105, 2005. DOI:10.1103/PhysRevE.72.041105