c++simulationverlet-integrationleapfrog-integration

Leapfrog integration to update position and velocity


I wrote this minimal example to examine the Leapfrog integration algorithm.

However, I am not sure it is the correct algorithm, and the listing is giving the correct output.

Is this the Leapfrog algorithm?

NOTE: I am using the Lennard-Jones system.

#include "stdafx.h"
#include <cmath>
#include <iostream>

class Vec3 {
public:
    Vec3() {}
    Vec3(double x, double y, double z) : x(x), y(y), z(z) {}
    Vec3(const Vec3& other) : x(other.x), y(other.y), z(other.z) {} // Copy constructor

    Vec3& operator=(const Vec3& other) {
        if (this != &other) {
            x = other.x;
            y = other.y;
            z = other.z;
        }
        return *this;
    } // Copy assignment operator

    Vec3 operator+(const Vec3& b) const {
        return Vec3(x + b.x, y + b.y, z + b.z);
    }

    Vec3 operator*(double scalar) const {
        return Vec3(scalar * x, scalar * y, scalar * z);
    }

    // Friend function for scalar multiplication with scalar on the left-hand side
    friend Vec3 operator*(double scalar, const Vec3& vector) {
        return vector * scalar;
    }

    double getX() const { return x; }
    double getY() const { return y; }
    double getZ() const { return z; }

    static Vec3 Zero() {
        return Vec3(0.0, 0.0, 0.0);
    }

private:
    double x, y, z;
};

class Atom {
public:
    Atom() {}

    Atom(const Vec3& position, const Vec3& velocity)
        : position(position), velocity(velocity) {}

    Atom(const Atom& other)
        : position(other.position), velocity(other.velocity) {}
    // Copy constructor

    Atom& operator=(const Atom& other) {
        if (this != &other) {
            position = other.position;
            velocity = other.velocity;
        }
        return *this;
    }
    // Copy assignment operator

    Vec3 getPosition() const { return position; }
    Vec3 getVelocity() const { return velocity; }

    void setPosition(const Vec3& position) { this->position = position; }
    void setVelocity(const Vec3& velocity) { this->velocity = velocity; }

private:
    Vec3 position;
    Vec3 velocity;
};

class ArgonGasSimulation {
private:
    static constexpr double Sigma = 3.4;
    static constexpr double Epsilon = 1.0;

    static Vec3 ComputeForce(const Vec3& position) {
        double distance = sqrt(pow(position.getX(), 2) + pow(position.getY(), 2) + pow(position.getZ(), 2));
        double r6 = pow(distance, 6);
        double r12 = pow(r6, 2);
        double magnitude = 48 * Epsilon * (pow((Sigma / distance), 6) - 0.5 * pow((Sigma / distance), 4)) / distance;
        return position * magnitude;
    }

    static void Leapfrog(Atom& atom, double timeStep) {
        Vec3 force = ComputeForce(atom.getPosition()); // Compute force based on the current position
        Vec3 newPosition = atom.getPosition() + atom.getVelocity() * timeStep + 0.5 * force * pow(timeStep, 2);

        Vec3 newForce = ComputeForce(newPosition);
        Vec3 newVelocity = atom.getVelocity() + (force + newForce) * timeStep * 0.5;

        // Update state
        atom.setPosition(newPosition);
        atom.setVelocity(newVelocity);
    }

public:
    static void Main() {
        // Initial state
        Vec3 position(1.0, 2.0, 3.0); // initial position
        Vec3 velocity = Vec3::Zero(); // initial velocity

        double timeStep = 0.01;

        Atom atom(position, velocity);

        // Leapfrog integration
        Leapfrog(atom, timeStep);

        // Output new position and velocity
        std::cout << "New Position: (" << atom.getPosition().getX() << ", " << atom.getPosition().getY() << ", " << atom.getPosition().getZ() << ")" << std::endl;
        std::cout << "New Velocity: (" << atom.getVelocity().getX() << ", " << atom.getVelocity().getY() << ", " << atom.getVelocity().getZ() << ")" << std::endl;
    }
};

int main() {
    ArgonGasSimulation::Main();
    return 0;
}

Output:

New Position: (1.00014244382518, 2.00028488765035, 3.00042733147553)
New Velocity: (0.028470372373851, 0.0569407447477019, 0.0854111171215529)
Press any key to continue . . .

EDIT: The following is rewritten in C++ on the basis of the Lutz Lehman's answer.

#include "stdafx.h"
#include <iostream>
#include <vector>
#include <cmath>
#include <random>

// Constants for Argon
constexpr double epsilon = 119.8;   // Depth of the potential well (in K)
constexpr double sigma = 3.405;     // Distance for zero potential (in Angstrom)
constexpr double mass = 39.948;     // Mass of Argon (in amu)

struct Vector3D {
    double x, y, z;
};

// Lennard-Jones potential function
double lj_potential(const Vector3D& r)
{
    double r_mag = std::sqrt(r.x * r.x + r.y * r.y + r.z * r.z);
    double s_over_r = sigma / r_mag;
    double s_over_r6 = pow(s_over_r, 6);
    return 4.0 * epsilon * (s_over_r6 * s_over_r6 - s_over_r6);
}

// Derivative of the Lennard-Jones potential
Vector3D lj_force(const Vector3D& r)
{
    // Define a small distance for the derivative approximation
    double dr = 1e-6;
    Vector3D force;
    std::vector<Vector3D> r_plus_dr = { r, r, r };
    r_plus_dr[0].x += dr;
    r_plus_dr[1].y += dr;
    r_plus_dr[2].z += dr;
    // The force is the negative derivative of the potential energy
    force.x = -(lj_potential(r_plus_dr[0]) - lj_potential(r)) / dr;
    force.y = -(lj_potential(r_plus_dr[1]) - lj_potential(r)) / dr;
    force.z = -(lj_potential(r_plus_dr[2]) - lj_potential(r)) / dr;
    return force;
}

// Update the 'accel' function
void accel(std::vector<Vector3D>& a, const std::vector<Vector3D>& x)
{
    int n = x.size();   // number of points
    for (int i = 0; i < n; i++)
    {
        auto force = lj_force(x[i]);
        // use Lennard-Jones force law
        a[i].x = -force.x / mass;
        a[i].y = -force.y / mass;
        a[i].z = -force.z / mass;
    }
}

void leapfrog_step(std::vector<Vector3D>& x, std::vector<Vector3D>& v, double dt) {
    int n = x.size();       // number of points
    std::vector<Vector3D> a(n);

    accel(a, x);
    for (int i = 0; i < n; i++)
    {
        v[i].x = v[i].x + 0.5 * dt * a[i].x;    // advance vel by half-step
        v[i].y = v[i].y + 0.5 * dt * a[i].y;    // advance vel by half-step
        v[i].z = v[i].z + 0.5 * dt * a[i].z;    // advance vel by half-step
        
        x[i].x = x[i].x + dt * v[i].x;      // advance pos by full-step
        x[i].y = x[i].y + dt * v[i].y;      // advance pos by full-step
        x[i].z = x[i].z + dt * v[i].z;      // advance pos by full-step
    }

    accel(a, x);
    for (int i = 0; i < n; i++)
    {
        v[i].x = v[i].x + 0.5 * dt * a[i].x;    // and complete vel. step
        v[i].y = v[i].y + 0.5 * dt * a[i].y;    // and complete vel. step
        v[i].z = v[i].z + 0.5 * dt * a[i].z;    // and complete vel. step
    }
}

// Initialize positions and velocities of particles
void initialize(std::vector<Vector3D>& x, std::vector<Vector3D>& v, int n_particles, double box_size, double max_vel) {
    // Create a random number generator
    std::default_random_engine generator;
    std::uniform_real_distribution<double> distribution(-0.5, 0.5);

    // Resize the vectors to hold the positions and velocities of all particles
    x.resize(n_particles);
    v.resize(n_particles);

    // Initialize positions and velocities
    for (int i = 0; i < n_particles; i++)
    {
        // Assign random initial positions within the box
        x[i].x = box_size * distribution(generator);
        x[i].y = box_size * distribution(generator);
        x[i].z = box_size * distribution(generator);

        // Assign random initial velocities up to max_vel
        v[i].x = max_vel * distribution(generator);
        v[i].y = max_vel * distribution(generator);
        v[i].z = max_vel * distribution(generator);
    }
}

int main() {
    int n_particles = 100;  // number of particles
    double box_size = 10.0; // size of the simulation box
    double max_vel = 0.1;   // maximum initial velocity
    double dt = 0.01;   // time step
    int n_steps = 10;   // number of time steps

    // Positions and velocities of the particles
    std::vector<Vector3D> x, v;

    // Initialize the particles
    initialize(x, v, n_particles, box_size, max_vel);

    // Run the simulation
    for (int step = 0; step < n_steps; step++) {
        leapfrog_step(x, v, dt);
    }

    return 0;
}

EDIT-2: I wrote the following listing after reading the comments under Lutz Lehman's post.

#include "stdafx.h"
#include <iostream>
#include <vector>
#include <cmath>
#include <random>

// Constants for Argon
constexpr double epsilon = 119.8;   // Depth of the potential well (in K)
constexpr double sigma = 3.405;     // Distance for zero potential (in Angstrom)
constexpr double mass = 39.948;     // Mass of Argon (in amu)

struct Vector3D {
    double x, y, z;
};

// Derivative of the Lennard-Jones potential
Vector3D lj_force(const Vector3D& r)
{
    double r_mag = std::sqrt(r.x * r.x + r.y * r.y + r.z * r.z);
    double s_over_r = sigma / r_mag;
    double s_over_r6 = s_over_r * s_over_r * s_over_r * s_over_r * s_over_r * s_over_r;
    double s_over_r12 = s_over_r6 * s_over_r6;
    double factor = 24.0 * epsilon * (2.0 * s_over_r12 - s_over_r6) / (r_mag * r_mag * r_mag);

    Vector3D force;
    force.x = factor * r.x;
    force.y = factor * r.y;
    force.z = factor * r.z;
    return force;
}

// Update the 'accel' function
void accel(std::vector<Vector3D>& a, const std::vector<Vector3D>& x)
{
    int n = x.size();   // number of points

    // Reset the acceleration to zero
    for (int i = 0; i < n; i++)
    {
        a[i].x = 0.0;
        a[i].y = 0.0;
        a[i].z = 0.0;
    }

    // Compute the acceleration due to each pair of particles
    for (int i = 0; i < n; i++)
    {
        for (int j = i + 1; j < n; j++)
        {
            Vector3D r;
            r.x = x[j].x - x[i].x;
            r.y = x[j].y - x[i].y;
            r.z = x[j].z - x[i].z;
            Vector3D force = lj_force(r);
            // use Lennard-Jones force law
            a[i].x += force.x / mass;
            a[i].y += force.y / mass;
            a[i].z += force.z / mass;
            a[j].x -= force.x / mass;
            a[j].y -= force.y / mass;
            a[j].z -= force.z / mass;
        }
    }
}

void leapfrog_step(std::vector<Vector3D>& x, std::vector<Vector3D>& v, double dt) {
    int n = x.size();       // number of points
    std::vector<Vector3D> a(n);

    accel(a, x);
    for (int i = 0; i < n; i++)
    {
        v[i].x = v[i].x + 0.5 * dt * a[i].x;    // advance vel by half-step
        v[i].y = v[i].y + 0.5 * dt * a[i].y;    // advance vel by half-step
        v[i].z = v[i].z + 0.5 * dt * a[i].z;    // advance vel by half-step

        x[i].x = x[i].x + dt * v[i].x;      // advance pos by full-step
        x[i].y = x[i].y + dt * v[i].y;      // advance pos by full-step
        x[i].z = x[i].z + dt * v[i].z;      // advance pos by full-step
    }

    accel(a, x);
    for (int i = 0; i < n; i++)
    {
        v[i].x = v[i].x + 0.5 * dt * a[i].x;    // and complete vel. step
        v[i].y = v[i].y + 0.5 * dt * a[i].y;    // and complete vel. step
        v[i].z = v[i].z + 0.5 * dt * a[i].z;    // and complete vel. step
    }
}

// Initialize positions and velocities of particles
void initialize(std::vector<Vector3D>& x, std::vector<Vector3D>& v, int n_particles, double box_size, double max_vel) {
    // Create a random number generator
    std::default_random_engine generator;
    std::uniform_real_distribution<double> distribution(-0.5, 0.5);

    // Resize the vectors to hold the positions and velocities of all particles
    x.resize(n_particles);
    v.resize(n_particles);

    // Initialize positions and velocities
    for (int i = 0; i < n_particles; i++)
    {
        // Assign random initial positions within the box
        x[i].x = box_size * distribution(generator);
        x[i].y = box_size * distribution(generator);
        x[i].z = box_size * distribution(generator);

        // Assign random initial velocities up to max_vel
        v[i].x = max_vel * distribution(generator);
        v[i].y = max_vel * distribution(generator);
        v[i].z = max_vel * distribution(generator);
    }
}

int main() {
    int n_particles = 100;  // number of particles
    double box_size = 10.0; // size of the simulation box
    double max_vel = 0.1;   // maximum initial velocity
    double dt = 0.01;   // time step
    int n_steps = 10;   // number of time steps

                        // Positions and velocities of the particles
    std::vector<Vector3D> x, v;

    // Initialize the particles
    initialize(x, v, n_particles, box_size, max_vel);

    // Run the simulation
    for (int step = 0; step < n_steps; step++) {
        leapfrog_step(x, v, dt);
    }

    return 0;
}

Solution

  • On the question of why the code does not implement leapfrog Verlet and what that should be

    The standard Verlet method transforms the central second order difference quotient

    (x(t+h)-2x(t)+x(t-h))/h^2 = x''(t) + O(h^2) = a(x(t)) + O(h^2)
    

    into a time propagator

    x[i+1] = 2*x[i]-x[i-1] + h^2*a[i]
    

    The inherent double summation can be split into two chained summations using the helper variables

    h*v[i+0.5] = x[i+1]-x[i]
    

    These are at first just difference quotients or secant slopes for the point series of the above sequence. They approximate the derivative of the solution best where they are the central difference quotients, that is at the midpoints t[i+0.5]=t[i]+0.5*h. The reduced Verlet recursion is

    h*a[i] = v[i+0.5] - v[i-0.5]
    

    or as algorithm

    a[i] = acc(x[i])
    v[i+0.5] = v[i-0.5] + h*a[i]
    x[i+1] = x[i] + h*v[i+0.5]
    

    Methods that combine values from such staggered grids are given the moniker "leapfrog" or "checkerboard".


    To get the full state of position and velocity at the original grid points t[i] the next-best central difference quotient is

    2*h*v[i] = x[i+1]-x[i-1] = h*(v[i+0.5]+v[i-0.5])
    

    There are now several ways to get the step equation for these velocities. They all end with

    v[i+1]-v[i] = 0.5*h*(a[i+1]+a[i])
    

    As algorithm in explicit steps this is realized as

    v[i+0.5] = v[i] + 0.5*h*a[i]
    x[i+1] = x[i] + h*v[i+0.5]
    a[i+1] = acc(x[i+1])
    v[i+1] = v[i+0.5] + 0.5*h*a[i+1]
    

    This is now the fully formed symplectic method. As a short name it is called "velocity" Verlet method.


    One gets a variant of this by applying a time shift of half a step, so that now the main state sequence is what previously were the states at the half-steps. This gives a different point sequence, but the difference is below the method error, so of no practical relevance

    x[i+0.5] = x[i] + 0.5*h*v[i]
    a[i+0.5] = acc(x[i+0.5])
    v[i+1] = v[i] + h*a[i+0.5]
    x[i+1] = x[i+0.5] + 0.5*h*v[i+1]
    

    The acceleration computation and application can now be seen as one unit, the acceleration vector is only relevant for the current step, it does not need be saved for the next step.