"""
Module handling the potential class.
"""
from copy import deepcopy
from fmm3dpy import hfmm3d, lfmm3d
from numpy import array, int64, ndarray, pi, sqrt, tanh
from warnings import warn
from ..utilities.exceptions import AlgorithmWarning
from ..utilities.fdints import fdm1h, invfd1h
from .force_pm import force_optimized_green_function as gf_opt
from .force_pm import update as pm_update
from .force_pp import update as pp_update
from .force_pp import update_0D as pp_update_0D
[docs]class Potential:
r"""
Parameters specific to potential choice.
Attributes
----------
a_rs : float
Short-range cutoff to deal with divergence of the potential for r -> 0.
box_lengths : array
Pointer to :attr:`sarkas.core.Parameters.box_lengths`.
box_volume : float
Pointer to :attr:`sarkas.core.Parameters.box_volume`.
force_error : float
Force error due to the choice of the algorithm.
fourpie0 : float
Coulomb constant :math:`4 \pi \epsilon_0`.
kappa : float
Inverse screening length.
linked_list_on : bool
Flag for choosing the Linked cell list algorithm.
matrix : numpy.ndarray
Matrix of potential's parameters.
measure : bool
Flag for calculating the histogram for the radial distribution function.
It is set to `False` during equilibration phase and changed to `True` during production phase.
method : str
Algorithm method. Choices = `["PP", "PPPM", "FMM", "Brute"]`. \n
`"PP"` = Linked Cell List (default).
`"PPPM"` = Particle-Particle Particle-Mesh.
`"FMM"` = Fast Multipole Method.
`"Brute"` = corresponds to calculating the distance between all pair of particles within a distance :math:`L/2`.
pbox_lengths : numpy.ndarray
Pointer to :attr:`sarkas.core.Parameters.pbox_lengths`
pbox_volume : float
Pointer to :attr:`sarkas.core.Parameters.pbox_lengths`
pppm_on : bool
Flag for turning on the PPPM algorithm.
QFactor : float
Sum of the squared of the charges.
rc : float
Cutoff radius for the Linked Cell List algorithm.
screening_length_type : str
Choice of ways to calculate the screening length. \n
Choices = `[thomas-fermi, tf, debye, debye-huckel, db, moliere, custom, unscreened]`. \n
Default = thomas-fermi
screening_length : float
Value of the screening length.
total_net_charge : float
Sum of all the charges.
type : str
Type of potential. \n
Choices = [`"coulomb"`, `"egs"`, `"lennardjones"`, `"moliere"`, `"qsp"`].
"""
a_rs: float = 0.0
box_lengths: ndarray = None
box_volume: float = 0.0
force_error: float = 0.0
fourpie0: float = 0.0
kappa: float = None
linked_list_on: bool = True
matrix: ndarray = None
measure: bool = False
method: str = "pp"
pbox_lengths: ndarray = None
pbox_volume: float = 0.0
pppm_on: bool = False
pppm_aliases: ndarray = array([3, 3, 3], dtype=int64)
pppm_alpha_ewald: float = 0.0
pppm_cao: ndarray = array([3, 3, 3], dtype=int64)
pppm_mesh: ndarray = array([8, 8, 8], dtype=int64)
pppm_h_array: ndarray = array([1.0, 1.0, 1.0], dtype=float)
pppm_pm_err: float = 0.0
pppm_pp_err: float = 0.0
QFactor: float = 0.0
rc: float = None
num_species: ndarray = None
screening_length_type: str = "thomas-fermi"
screening_length: float = None
species_charges: ndarray = None
species_masses: ndarray = None
total_net_charge: float = 0.0
total_num_density: float = 0.0
total_num_ptcls: float = 0.0
type: str = "yukawa"
def __copy__(self):
"""
Make a shallow copy of the object using copy by creating a new instance of the object and copying its __dict__.
"""
# Create a new object
_copy = type(self)()
# copy the dictionary
_copy.from_dict(input_dict=self.__dict__)
return _copy
def __deepcopy__(self, memodict={}):
"""
Make a deepcopy of the object.
Parameters
----------
memodict: dict
Dictionary of id's to copies
Returns
-------
_copy: :class:`sarkas.potentials.core.Potential`
A new Potential class.
"""
id_self = id(self) # memorization avoids unnecessary recursion
_copy = memodict.get(id_self)
if _copy is None:
_copy = type(self)()
# Make a deepcopy of the mutable arrays using numpy copy function
for k, v in self.__dict__.items():
_copy.__dict__[k] = deepcopy(v, memodict)
return _copy
def __repr__(self):
sortedDict = dict(sorted(self.__dict__.items(), key=lambda x: x[0].lower()))
disp = "Potential( \n"
for key, value in sortedDict.items():
disp += "\t{} : {}\n".format(key, value)
disp += ")"
return disp
[docs] @staticmethod
def calc_electron_properties(params):
"""Calculate electronic parameters.
See Electron Properties webpage in documentation website.
Parameters
----------
params : :class:`sarkas.core.Parameters`
Simulation's parameters.
"""
warn(
"Deprecated feature. It will be removed in the v2.0.0 release. \n"
"Use parameters.calc_electron_properties(species). You need to pass the species list.",
category=DeprecationWarning,
)
twopi = 2.0 * pi
spin_degeneracy = 2.0 # g in the notes
# Inverse temperature for convenience
beta_e = 1.0 / (params.kB * params.electron_temperature)
# Plasma frequency
params.electron_plasma_frequency = sqrt(
4.0 * pi * params.qe**2 * params.electron_number_density / (params.fourpie0 * params.me)
)
params.electron_debye_length = sqrt(
params.fourpie0 / (4.0 * pi * params.qe**2 * params.electron_number_density * beta_e)
)
# de Broglie wavelength
params.electron_deBroglie_wavelength = sqrt(twopi * params.hbar2 * beta_e / params.me)
lambda3 = params.electron_deBroglie_wavelength**3
# Landau length 4pi e^2 beta. The division by fourpie0 is needed for MKS units
params.electron_landau_length = 4.0 * pi * params.qe**2 * beta_e / params.fourpie0
# chemical potential of electron gas/(kB T), obtained by inverting the density equation.
params.electron_dimensionless_chemical_potential = invfd1h(
lambda3 * sqrt(pi) * params.electron_number_density / 4.0
)
# Thomas-Fermi length obtained from compressibility. See eq.(10) in Ref. [3]_
lambda_TF_sq = lambda3 / params.electron_landau_length
lambda_TF_sq /= spin_degeneracy / sqrt(pi) * fdm1h(params.electron_dimensionless_chemical_potential)
params.electron_TF_wavelength = sqrt(lambda_TF_sq)
# Electron WS radius
params.electron_WS_radius = (3.0 / (4.0 * pi * params.electron_number_density)) ** (1.0 / 3.0)
# Brueckner parameters
params.electron_rs = params.electron_WS_radius / params.a0
# Fermi wave number
params.electron_Fermi_wavenumber = (3.0 * pi**2 * params.electron_number_density) ** (1.0 / 3.0)
# Fermi energy
params.electron_Fermi_energy = params.hbar2 * params.electron_Fermi_wavenumber**2 / (2.0 * params.me)
# Other electron parameters
params.electron_degeneracy_parameter = params.kB * params.electron_temperature / params.electron_Fermi_energy
params.electron_relativistic_parameter = params.hbar * params.electron_Fermi_wavenumber / (params.me * params.c0)
# Eq. 1 in Murillo Phys Rev E 81 036403 (2010)
params.electron_coupling = params.qe**2 / (
params.fourpie0
* params.electron_Fermi_energy
* params.electron_WS_radius
* sqrt(1 + params.electron_degeneracy_parameter**2)
)
# Warm Dense Matter Parameter, Eq.3 in Murillo Phys Rev E 81 036403 (2010)
params.wdm_parameter = 2.0 / (params.electron_degeneracy_parameter + 1.0 / params.electron_degeneracy_parameter)
params.wdm_parameter *= 2.0 / (params.electron_coupling + 1.0 / params.electron_coupling)
if params.magnetized:
b_mag = sqrt((params.magnetic_field**2).sum()) # magnitude of B
if params.units == "cgs":
params.electron_cyclotron_frequency = params.qe * b_mag / params.c0 / params.me
else:
params.electron_cyclotron_frequency = params.qe * b_mag / params.me
params.electron_magnetic_energy = params.hbar * params.electron_cyclotron_frequency
tan_arg = 0.5 * params.hbar * params.electron_cyclotron_frequency * beta_e
# Perpendicular correction
params.horing_perp_correction = (params.electron_plasma_frequency / params.electron_cyclotron_frequency) ** 2
params.horing_perp_correction *= 1.0 - tan_arg / tanh(tan_arg)
params.horing_perp_correction += 1
# Parallel correction
params.horing_par_correction = 1 - (params.hbar * beta_e * params.electron_plasma_frequency) ** 2 / 12.0
# Quantum Anisotropy Parameter
params.horing_delta = params.horing_perp_correction - 1
params.horing_delta += (params.hbar * beta_e * params.electron_cyclotron_frequency) ** 2 / 12
params.horing_delta /= params.horing_par_correction
[docs] def calc_screening_length(self, species):
# Consistency
self.screening_length_type = self.screening_length_type.lower()
if self.screening_length_type in ["thomas-fermi", "tf"]:
# Check electron properties
if hasattr(self, "electron_temperature_eV"):
self.electron_temperature = self.eV2K * self.electron_temperature_eV
else:
self.electron_temperature = species[-1].temperature
self.screening_length = species[-1].ThomasFermi_wavelength
elif self.screening_length_type in ["debye", "debye-huckel", "dh"]:
self.screening_length = species[-1].debye_length
elif self.screening_length_type in ["kappa", "from_kappa"]:
self.screening_length = self.a_ws / self.kappa
elif self.screening_length_type in ["custom"]:
if self.screening_length is None:
raise AttributeError("potential.screening_length not defined!")
if not self.screening_length and not self.kappa:
warn("You have not defined the screening_length nor kappa. I will use the Thomas-Fermi length")
self.screening_length_type = "thomas-fermi"
self.screening_length = species[-1].ThomasFermi_wavelength
[docs] def copy_params(self, params):
"""
Copy necessary parameters.
Parameters
----------
params: :class:`sarkas.core.Parameters`
Simulation's parameters.
"""
self.measure = params.measure
self.units = params.units
self.dimensions = params.dimensions
# Copy needed parameters
self.box_lengths = params.box_lengths.copy()
self.pbox_lengths = params.pbox_lengths.copy()
self.box_volume = params.box_volume
self.pbox_volume = params.pbox_volume
# Needed physical constants
self.fourpie0 = params.fourpie0
self.a_ws = params.a_ws
self.kB = params.kB
self.eV2K = params.eV2K
self.eV2J = params.eV2J
self.hbar = params.hbar
self.QFactor = params.QFactor
self.T_desired = params.T_desired
self.coupling_constant = params.coupling_constant
self.total_num_ptcls = params.total_num_ptcls
self.total_net_charge = params.total_net_charge
self.total_num_density = params.total_num_density
self.num_species = params.num_species
self.species_charges = params.species_charges.copy()
self.species_masses = params.species_masses.copy()
if self.type == "lj":
self.species_lj_sigmas = params.species_lj_sigmas.copy()
[docs] def from_dict(self, input_dict: dict) -> None:
"""
Update attributes from input dictionary.
Parameters
----------
input_dict: dict
Dictionary to be copied.
"""
self.__dict__.update(input_dict)
[docs] def method_pretty_print(self):
"""Print algorithm information."""
print("\nALGORITHM: ", self.method)
# PP section
if self.method != "fmm":
print(f"rcut = {self.rc / self.a_ws:.4f} a_ws = {self.rc:.6e} ", end="")
print("[cm]" if self.units == "cgs" else "[m]")
pp_cells = (self.box_lengths / self.rc).astype(int)
print(f"No. of PP cells per dimension = {pp_cells}")
ptcls_in_loop = int(self.total_num_density * (self.dimensions * self.rc) ** self.dimensions)
print(f"No. of particles in PP loop = {ptcls_in_loop}")
dim_const = (self.dimensions + 1) / 3.0 * pi
pp_neighbors = int(self.total_num_density * dim_const * self.rc**self.dimensions)
print(f"No. of PP neighbors per particle = {pp_neighbors}")
if self.method == "pppm":
# PM Section
print(f"Charge assignment orders: {self.pppm_cao}")
print(f"FFT aliases: {self.pppm_aliases}")
print(f"Mesh: {self.pppm_mesh}")
print(
f"Ewald parameter alpha = {self.pppm_alpha_ewald * self.a_ws:.4f} / a_ws = {self.pppm_alpha_ewald:.6e} ",
end="",
)
print("[1/cm]" if self.units == "cgs" else "[1/m]")
h_a = self.pppm_h_array / self.a_ws
print(f"Mesh width = {h_a[0]:.4f}, {h_a[1]:.4f}, {h_a[2]:.4f} a_ws")
print(
f" = {self.pppm_h_array[0]:.4e}, {self.pppm_h_array[1]:.4e}, {self.pppm_h_array[2]:.4e} ",
end="",
)
print("[cm]" if self.units == "cgs" else "[m]")
halpha = self.pppm_h_array * self.pppm_alpha_ewald
inv_halpha = (1.0 / halpha).astype(int)
print(f"Mesh size * Ewald_parameter (h * alpha) = {halpha[0]:.4f}, {halpha[1]:.4f}, {halpha[2]:.4f} ")
print(f" ~ 1/{inv_halpha[0]}, 1/{inv_halpha[1]}, 1/{inv_halpha[2]}")
print(f"PP Force Error = {self.pppm_pp_err:.6e}")
print(f"PM Force Error = {self.pppm_pm_err:.6e}")
print(f"Tot Force Error = {self.force_error:.6e}")
[docs] def method_setup(self):
"""Setup algorithm's specific parameters."""
# Check for cutoff radius
if not self.method == "fmm":
self.linked_list_on = True # linked list on
mask = self.box_lengths > 0.0
min_length = self.box_lengths[mask].min()
if not self.rc:
warn(
f"\nThe cut-off radius is not defined. I will use the brute force method.",
category=AlgorithmWarning,
)
self.rc = min_length / 2.0
self.linked_list_on = False # linked list off
if self.rc > min_length / 2.0:
warn(
f"\nThe cut-off radius is larger than half of the minimum box length. "
f"I will use the brute force method.",
# f"L_min/ 2 = {0.5 * min_length:.4e} will be used as rc",
category=AlgorithmWarning,
)
self.rc = min_length / 2.0
self.linked_list_on = False # linked list off
if self.a_rs != 0.0:
warn("\nShort-range cut-off enabled. Use this feature with care!", category=AlgorithmWarning)
# renaming
if self.method == "p3m":
self.method == "pppm"
# Compute pppm parameters
if self.method == "pppm":
self.pppm_on = True
self.pppm_setup()
else:
self.linked_list_on = False
self.pppm_on = False
if self.type == "coulomb":
self.force_error = self.fmm_precision
else:
self.force_error = self.fmm_precision
[docs] def pppm_setup(self):
"""Calculate the pppm parameters."""
# Change lists to numpy arrays for Numba compatibility
if isinstance(self.pppm_mesh, list):
self.pppm_mesh = array(self.pppm_mesh, dtype=int64)
elif not isinstance(self.pppm_mesh, ndarray):
raise TypeError(f"pppm_mesh is a {type(self.pppm_mesh)}. Please pass a list or numpy array.")
# Mesh array should be 3 even in 2D
if not len(self.pppm_mesh) == 3:
raise AlgorithmWarning(
f"len(potential.pppm_mesh) = {len(self.pppm_mesh)}.\n"
f"The PPPM mesh array should be of length 3 even in non 3D simulations."
)
if isinstance(self.pppm_aliases, list):
self.pppm_aliases = array(self.pppm_aliases, dtype=int64)
elif not isinstance(self.pppm_aliases, ndarray):
raise TypeError(f"pppm_aliases is a {type(self.pppm_aliases)}. Please pass a list or numpy array.")
# In case you pass one number and not a list
if isinstance(self.pppm_cao, int):
caos = array([1, 1, 1], dtype=int64) * self.pppm_cao
self.pppm_cao = caos.copy()
elif isinstance(self.pppm_cao, list):
self.pppm_cao = array(self.pppm_cao, dtype=int64)
elif not isinstance(self.pppm_cao, ndarray):
raise TypeError(f"pppm_cao is a {type(self.pppm_cao)}. Please pass a list or numpy array.")
if self.pppm_cao.max() > 7:
raise AttributeError("\nYou have chosen a charge assignment order bigger than 7. Please choose a value <= 7")
# pppm parameters
self.pppm_h_array = self.box_lengths / self.pppm_mesh
# To avoid division by zero
mask = self.pppm_h_array == 0.0
self.pppm_h_array[mask] = 1.0
self.pppm_h_volume = self.pppm_h_array.prod()
# To avoid unnecessary loops
self.pppm_aliases[mask] = 0
# Pack constants together for brevity in input list
kappa = 1.0 / self.screening_length if self.type == "yukawa" else 0.0
constants = array([kappa, self.pppm_alpha_ewald, self.fourpie0])
# Calculate the Optimized Green's Function
self.pppm_green_function, self.pppm_kx, self.pppm_ky, self.pppm_kz, self.pppm_pm_err = gf_opt(
self.box_lengths, self.pppm_h_array, self.pppm_mesh, self.pppm_aliases, self.pppm_cao, constants
)
# Complete PM Force error calculation
self.pppm_pm_err *= sqrt(self.total_num_ptcls) * self.a_ws**2 * self.fourpie0
self.pppm_pm_err /= self.box_volume ** (2.0 / 3.0)
# Total Force Error
self.force_error = sqrt(self.pppm_pm_err**2 + self.pppm_pp_err**2)
[docs] def pretty_print(self):
"""Print potential information in a user-friendly way."""
print("\nPOTENTIAL: ", self.type)
self.pot_pretty_print(potential=self)
self.method_pretty_print()
[docs] def setup(self, params, species) -> None:
"""Setup the potential class.
Parameters
----------
params : :class:`sarkas.core.Parameters`
Simulation's parameters.
"""
# Enforce consistency
self.type = self.type.lower()
self.method = self.method.lower()
self.copy_params(params)
self.type_setup(species)
self.method_setup()
[docs] def type_setup(self, species):
# Update potential-specific parameters
# Coulomb potential
if self.type == "coulomb":
if self.method == "pp":
warn("Use the PP method with care for pure Coulomb interactions.", category=AlgorithmWarning)
from .coulomb import pretty_print_info, update_params
self.pot_update_params = update_params
update_params(self)
elif self.type == "yukawa":
# Yukawa potential
from .yukawa import pretty_print_info, update_params
self.calc_screening_length(species)
self.pot_update_params = update_params
update_params(self)
elif self.type == "egs":
# exact gradient-corrected screening (EGS) potential
from .egs import pretty_print_info, update_params
self.calc_screening_length(species)
self.pot_update_params = update_params
update_params(self)
elif self.type == "lj":
# Lennard-Jones potential
from .lennardjones import pretty_print_info, update_params
self.pot_update_params = update_params
update_params(self)
elif self.type == "moliere":
# Moliere potential
from .moliere import pretty_print_info, update_params
self.pot_update_params = update_params
update_params(self)
elif self.type == "qsp":
# QSP potential
from .qsp import pretty_print_info, update_params
self.pot_update_params = update_params
update_params(self, species)
elif self.type == "hs_yukawa":
# Hard-Sphere Yukawa
from .hs_yukawa import update_params
self.calc_screening_length(species)
self.pot_update_params = update_params
update_params(self)
self.pot_pretty_print = pretty_print_info
[docs] def update_linked_list(self, ptcls):
"""
Calculate the pp part of the acceleration.
Parameters
----------
ptcls : :class:`sarkas.particles.Particles`
Particles data.
"""
ptcls.potential_energy, ptcls.acc, ptcls.virial = pp_update(
ptcls.pos,
ptcls.id,
ptcls.masses,
self.box_lengths,
self.rc,
self.matrix,
self.force,
self.measure,
ptcls.rdf_hist,
)
if self.type != "lj":
# Mie Energy of charged systems
# J-M.Caillol, J Chem Phys 101 6080(1994) https: // doi.org / 10.1063 / 1.468422
dipole = ptcls.charges @ ptcls.pos
ptcls.potential_energy += 2.0 * pi * (dipole**2).sum() / (3.0 * self.box_volume * self.fourpie0)
[docs] def update_brute(self, ptcls):
"""
Calculate particles' acceleration and potential brutally.
Parameters
----------
ptcls: :class:`sarkas.particles.Particles`
Particles data.
"""
ptcls.potential_energy, ptcls.acc, ptcls.virial = pp_update_0D(
ptcls.pos,
ptcls.id,
ptcls.masses,
self.box_lengths,
self.rc,
self.matrix,
self.force,
self.measure,
ptcls.rdf_hist,
)
if self.type != "lj":
# Mie Energy of charged systems
# J-M.Caillol, J Chem Phys 101 6080(1994) https: // doi.org / 10.1063 / 1.468422
dipole = ptcls.charges @ ptcls.pos
ptcls.potential_energy += 2.0 * pi * (dipole**2).sum() / (3.0 * self.box_volume * self.fourpie0)
[docs] def update_pm(self, ptcls):
"""Calculate the pm part of the potential and acceleration.
Parameters
----------
ptcls : :class:`sarkas.particles.Particles`
Particles' data
"""
U_long, acc_l_r = pm_update(
ptcls.pos,
ptcls.charges,
ptcls.masses,
self.pppm_mesh,
self.pppm_h_array,
self.pppm_h_volume,
self.box_volume,
self.pppm_green_function,
self.pppm_kx,
self.pppm_ky,
self.pppm_kz,
self.pppm_cao,
)
# Ewald Self-energy
U_long += self.QFactor * self.pppm_alpha_ewald / sqrt(pi)
# Neutrality condition
U_long += -pi * self.total_net_charge**2.0 / (2.0 * self.box_volume * self.pppm_alpha_ewald**2)
ptcls.potential_energy += U_long
ptcls.acc += acc_l_r
[docs] def update_pppm(self, ptcls):
"""Calculate particles' potential and accelerations using pppm method.
Parameters
----------
ptcls : :class:`sarkas.particles.Particles`
Particles' data.
"""
self.update_linked_list(ptcls)
self.update_pm(ptcls)
[docs] def update_fmm_coulomb(self, ptcls):
"""Calculate particles' potential and accelerations using FMM method.
Parameters
----------
ptcls : sarkas.core.Particles
Particles' data
"""
out_fmm = lfmm3d(eps=self.fmm_precision, sources=ptcls.pos.transpose(), charges=ptcls.charges, pg=2)
potential_energy = ptcls.charges @ out_fmm.pot.real / self.fourpie0
acc = -(ptcls.charges * out_fmm.grad.real / ptcls.masses) / self.fourpie0
ptcls.acc = acc.transpose()
return potential_energy
[docs] def update_fmm_yukawa(self, ptcls):
"""Calculate particles' potential and accelerations using FMM method.
Parameters
----------
ptcls : sarkas.core.Particles
Particles' data
"""
out_fmm = hfmm3d(
eps=self.fmm_precision,
zk=1j / self.screening_length,
sources=ptcls.pos.transpose(),
charges=ptcls.charges,
pg=2,
)
potential_energy = ptcls.charges @ out_fmm.pot.real / self.fourpie0
acc = -(ptcls.charges * out_fmm.grad.real / ptcls.masses) / self.fourpie0
ptcls.acc = acc.transpose()
return potential_energy