Particle collisions by linear solvers - ProkopHapala/FireCore GitHub Wiki
Particle collisions by linear solvers
Position based dynamics and projective dynamics are very efficient methods for solving hard-degrees of freedom in molecules like bonds ( i.e. stiff ineraction between particles with fixed topology.
Advantage of these schemes is that they are unconditionaly stable, which allows to significantly increase time-step of molecule (or other dynamical) simulations, since the hard dregrees of freedom are treated by implicit solver, rather than explicitly propagated by dynamics throu force. In force-based dynamics simulation the time step, which is generally inversly proportional to highest vibration frequency $f \approx \sqrt{k/m}$ is no loger limited by stiffnes of these hard degrees for freedom. The limitation of these methods is that linear solver can efficiently solve only linear constrains. Therefore additional non-linear (anharmonic) forces, or non-conservative forces (e.g. air drag) must be added externlly. These forces than determine the required time step.
Here we try to extend the method to collisions between the particles (i.e. stiff short range interactions with rapidly channging topology).
Problems:
-
The interactions are not-linear (or harmonic) unlike bonds, but are anharmonic and dissociative (i.e. like Leanrd-Jones). This means we can use linear solver only approximatively arround the minimum.
-
Due to rapid changes of the interaction pairs, it does not make much sense to explicitly construct matrixes (like in Choolesky factorization) as these matrixes would rapidly change with rapidly changing interaction topology. Therefore it makes much more sense to use iterative methods, like Jacobi or Gauss Seidel.
Split Quasi Linear Poential
To efficiently use linear solver like Jacobi or Gauss-Seidel we will split our anharmonic potential (like Lenard-Jones) to two parts.
- Linea (harmonic) part
- non-linear dissociative part
Within the framework of projective dynamics we can than implement the harmonic part in the linear solver and the non-linear part as external force. The importaint assumption is that the non-linear part is small and soft (much smaller stiffness) than the harmonic part.
The Linear poential cutoff
The linear solver tries to find such position of each particle which minimize the potential energy form all linear (harmonic) constrains. In order to function properly the linear constrains must be defined in some reasonable range around the minimum (where force from this constrain is zero). although for collision is most importaint just the repulsive part, for numerical stability and convenience we typically extend the linear region beyond the minimum to some $R_{cut} > R_{min}$. Therefore the particles-particle force can be expressed as $F=k(r_{ij}-R_{min})$ with constant stiffness $k$ along the whole interval of distance $r<R_{cut}$.
This potential is implemented into the linear solver (Jacobi or Gauss seidel).
- At every iteration of the algorithm we check which constrains are within the range $r<R_{cut}$. Those that are we include into the solver trying to place them within distance $R_{min}$. That means if they are closer they are pushed away, if they are further, they are pulled closer.
- If the particles are beyond $r<R_{cut}$ linear solver ignores them.
The smoothening potential
The linear poential has serious problem: I is swithing abruptly between zero force for $r>R_{cut}$ and non-zero force $f=-k(R_{cut}-R_{min})$ when the particles cross $R_cut$ boundary. This will lead to unstable simulations, dificult convergence and non-physical behaviour.
For this reason we need to implement additional correction poential which transition smoothly between the harmonic one, and zero at distance. In particular we define anther cutof $R_{cut2}>R_{cut}$ for distance where the particles should not iteract by any means.
By definition this transition smoothening poential should be resposnible for disociative anharmonic nature of the non-covalent interactions, and therefore by definition must be anharmonic and concave. For this reason we cannot integrate this potential easily into the linear constrain solver. And we need to consider it as soft external poential applied to the dynamics before the constrains are solved. For this reason this poential should be much weaked and softer than the linear part of the collision potential.
There are many functional forms how to define such correction poential. For computational performance we can limit oursefl to ponlynomical and reciprocal functions.
In following python code we implement (as an example) the simplist formula for smoothening, which is actually linear (resp. harmonic) just concave rather than convex (i.e. parabola is inverted, with negative stiffnes). Such parabola is matched to the original linar poential at $R_{cut}$ to eliminate any discontinuity in force and then original linear poential is shifted by constant $E_0$ to match the energy.
- To ensure the force continuity at $R_{cut}$ we simply use the relation $f=-k(R_{cut}-R_{min}) = k_2(R_{cut2}-R_{cut})$
- The energy shift $E_0$ is than defined as $E_0=(k_2/2) (R_{cut2}-R_{cut})^2$
The functions are implemented in this google colab
import numpy as np
import matplotlib.pyplot as plt
def getSR_r2(r, R_min, R_cut):
"""
Calculates the Lennard-Jones potential and force between spherical atoms.
Args:
r: A NumPy array of distances between atoms.
R_min: The distance at which the potential is minimum.
R_cut: The cutoff distance beyond which the potential is zero.
Returns:
A tuple containing the potential and force arrays.
"""
# Calculate the potential using a shifted parabola.
x = r - R_min
mask = r < R_cut
E = np.zeros_like(r, dtype=float)
E[mask] = (x**2)[mask] - (R_cut - R_min)**2
# Calculate the force as the negative derivative of the potential.
F = np.zeros_like(r, dtype=float)
F[mask] = -2 * x[mask]
return E, F
def getSR_r2_smooth(r, R_min, R_cut, R_cut2):
"""
Calculates the short-range potential and force with a smoother transition.
Args:
r: A NumPy array of distances between atoms.
R_min: The distance at which the potential is minimum.
R_cut: The distance at which the first parabola ends.
R_cut2: The distance at which the potential becomes zero.
Returns:
A tuple containing the potential and force arrays.
"""
# Calculate the potential using two shifted parabolas.
x = r - R_min
mask1 = r < R_cut
mask2 = np.logical_and(r >= R_cut, r < R_cut2)
E = np.zeros_like(r, dtype=float)
F = np.zeros_like(r, dtype=float)
# First parabola (r < R_cut)
E[mask1] = (x**2)[mask1] # No shift yet
# Second parabola (R_cut <= r < R_cut2)
# Match derivative (force) at R_cut
k1 = 2 * (R_cut - R_min)
E[mask2] = k1 * (r[mask2] - R_cut2)
# Make the second parabola with negative curvature and go to zero at R_cut2
k2 = -k1 / (2 * (R_cut2-R_cut)) # curvature must be negative
E[mask2] = E[mask2] + k2*((r[mask2]-R_cut)**2) - k2*((R_cut2-R_cut)**2)
# Enforce to be zero at R_cut2
# Calculate the force as the negative derivative of the potential.
F[mask1] = -2 * x[mask1]
F[mask2] = -k1 - 2*k2*(r[mask2] - R_cut)
# Shift the first parabola to match the value of the second parabola at R_cut
C = (R_cut - R_min)**2 - k2*((R_cut2-R_cut)**2) # Find the constant C
E[mask1] = E[mask1] - C # Apply the shift
return E, F
def getSR_r2_smoothing(r, R_min, R_cut, R_cut2):
"""
Computes the smoothening potential and force for the transition region.
Args:
r (numpy.ndarray): Array of distances between atoms.
R_min (float): Distance at which the potential is minimum.
R_cut (float): End of the first parabola and start of the smoothing region.
R_cut2 (float): Distance where the potential becomes zero.
Returns:
tuple: Arrays of the smoothening potential and force.
"""
x = r - R_min
mask1 = r < R_cut
mask2 = np.logical_and(r >= R_cut, r < R_cut2)
E = np.zeros_like(r, dtype=float)
F = np.zeros_like(r, dtype=float)
# Match derivative (force) at R_cut
k1 = 2 * (R_cut - R_min)
k2 = -k1 / (2 * (R_cut2-R_cut)) # curvature must be negative
E[mask2] = k1 * (r[mask2] - R_cut2) + k2*((r[mask2]-R_cut)**2) - k2*((R_cut2-R_cut)**2)
F[mask2] = -k1 - 2*k2*(r[mask2] - R_cut)
# Shift the first parabola to match the value of the second parabola at R_cut
C = k2*((R_cut2-R_cut)**2) # Find the constant C
F[mask1] = 0
E[mask1] = C
return E,F
# ======== Main
# Set the parameters.
R_min = 2.0 # Distance at which the potential is minimum.
R_cut = 2.5 # Cutoff distance.
R_cut2 = 3.5
# Create an array of distances.
r = np.linspace(0, 5.0, 1000)
# Calculate the potential and force.
E,F = getSR_r2 (r, R_min, R_cut)
E2,F2 = getSR_r2_smooth (r, R_min, R_cut, R_cut2)
E3,F3 = getSR_r2_smoothing(r, R_min, R_cut, R_cut2)
# Plot the potential and force.
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
ax1.plot(r, E , label="SR_x2" )
ax1.plot(r, E2, label="SR_x2_smooth", lw=3.0)
ax1.plot(r, E3, label="SR_x2_smoothening")
ax1.axvline(x=R_min, color='b', linestyle='--')
ax1.axvline(x=R_cut, color='r', linestyle='--')
ax1.axvline(x=R_cut2, color='g', linestyle='--')
ax1.set_xlabel('Distance [A]')
ax1.set_ylabel('Energy [eV]')
ax1.set_title('Short-Range Potential and Force')
ax1.legend()
ax1.grid(True)
ax2.plot(r, F , label="SR_x2")
ax2.plot(r, F2, label="SR_x2_smooth", lw=3.0)
ax2.plot(r, F3, label="SR_x2_smoothening")
ax2.axvline(x=R_min, color='b', linestyle='--')
ax2.axvline(x=R_cut, color='r', linestyle='--')
ax2.axvline(x=R_cut2, color='g', linestyle='--')
ax2.set_ylabel
ax2.set_xlabel('Distance [A]')
ax1.set_ylabel('Force [eV/A]')
ax2.grid(True)
plt.tight_layout()
plt.show()