diff --git a/gmso/abc/abstract_site.py b/gmso/abc/abstract_site.py index 9672141a2..3ea20c829 100644 --- a/gmso/abc/abstract_site.py +++ b/gmso/abc/abstract_site.py @@ -137,7 +137,8 @@ def is_valid_position(cls, position): try: position = np.reshape(position, newshape=(3,), order="C") - position.convert_to_units(u.nm) + if position.units != u.dimensionless: + position.convert_to_units(u.nm) except ValueError: raise ValueError( f"Position of shape {position.shape} is not valid. " diff --git a/gmso/core/box.py b/gmso/core/box.py index 20b9f843c..922a6f840 100644 --- a/gmso/core/box.py +++ b/gmso/core/box.py @@ -21,7 +21,8 @@ def _validate_lengths(lengths): np.reshape(lengths, newshape=(3,), order="C") lengths *= input_unit - lengths.convert_to_units(u.nm) + if input_unit != u.Unit("dimensionless"): + lengths.convert_to_units(u.nm) if np.any( np.less( diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 4de86a658..6cebb2772 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -26,6 +26,10 @@ from gmso.utils.connectivity import ( identify_connections as _identify_connections, ) +from gmso.utils.conversions import ( + convert_params_units, + convert_topology_expressions, +) from gmso.utils.units import GMSO_UnitRegsitry as UnitReg scaling_interaction_idxes = {"12": 0, "13": 1, "14": 2} @@ -1693,6 +1697,62 @@ def load(cls, filename, **kwargs): loader = LoadersRegistry.get_callable(filename.suffix) return loader(filename, **kwargs) + def convert_potential_styles(self, expressionMap={}): + """Convert from one parameter form to another. + + Parameters + ---------- + expressionMap : dict, default={} + Map where the keys represent the current potential + type and the corresponding values represent the desired + potential type. The desired potential style can be + either a string with the corresponding name, or + a gmso.utils.expression.PotentialExpression type. + + Examples + ________ + # Convert from RB torsions to OPLS torsions + top.convert_potential_styles({"dihedrals": "OPLSTorsionPotential"}) + # TODO: convert_potential_styles with PotentialExpression + """ + # TODO: raise warnings for improper values or keys in expressionMap + + return convert_topology_expressions(self, expressionMap) + + def convert_unit_styles(self, unitsystem, exp_unitsDict): + """Convert from one set of base units to another. + + Parameters + ---------- + unitsystem : unyt.UnitSystem + set of base units to use for all expressions of the topology + in `unyt package _` + exp_unitsDict : dict + keys with topology attributes that should be converted and + values with dictionary of parameter: expected_dimension + + Examples + ________ + top.convert_unit_styles( + u.UnitSystem( + "lammps_real", "Å", "amu", "fs", "K", "rad", + ), + {"bond":{"k":"energy/length**2", "r_eq":"length"}}, + ) + """ + + ref_values = {"energy": "kJ/mol", "length": "nm", "angle": "radians"} + + # all potContainer ["atom", "bond", "angle", "dihedral", "improper"] + for potStr in exp_unitsDict: + potContainer = getattr(self, potStr + "_types") + convert_params_units( + potContainer, + expected_units_dim=exp_unitsDict[potStr], + base_units=unitsystem, + ref_values=ref_values, + ) + def _return_float_for_unyt(unyt_quant, unyts_bool): try: diff --git a/gmso/core/views.py b/gmso/core/views.py index 007710a58..0ed3be1e4 100644 --- a/gmso/core/views.py +++ b/gmso/core/views.py @@ -57,7 +57,13 @@ def get_sorted_names(potential): else: return potential.member_types elif isinstance(potential, ImproperType): - return (potential.member_types[0], *sorted(potential.member_types[1:])) + return ( + potential.member_types[0], + *potential.member_types[1:], + ) # could sort using `sorted` + return ValueError( + f"Potential {potential} not one of {potential_attribute_map.values()}" + ) def get_parameters(potential): @@ -170,6 +176,13 @@ def index(self, item): for j, potential in enumerate(self.yield_view()): if potential is item: return j + return None + + def equality_index(self, item): + for j, potential in enumerate(self.yield_view()): + if potential == item: + return j + return None def _collect_potentials(self): """Collect potentials from the iterator""" diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 7d77f957d..40d2bcfdf 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -314,7 +314,7 @@ def _parse_molecule_residue(site_map, compound): if molecule_tag.name in molecule_tracker: molecule_tracker[molecule_tag.name] += 1 else: - molecule_tracker[molecule_tag.name] = 0 + molecule_tracker[molecule_tag.name] = 1 molecule_number = molecule_tracker[molecule_tag.name] """End of molecule parsing""" @@ -329,7 +329,7 @@ def _parse_molecule_residue(site_map, compound): residue_tracker[residue_tag.name] ) else: - residue_tracker[residue_tag.name] = {residue_tag: 0} + residue_tracker[residue_tag.name] = {residue_tag: 1} residue_number = residue_tracker[residue_tag.name][residue_tag] site_map[particle]["residue"] = (residue_tag.name, residue_number) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index f8f2fba19..6338c80a6 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -1,4 +1,5 @@ """Module support for converting to/from ParmEd objects.""" +import copy import warnings from operator import attrgetter, itemgetter @@ -7,7 +8,7 @@ from symengine import expand import gmso -from gmso.core.element import element_by_atom_type, element_by_atomic_number +from gmso.core.element import element_by_atomic_number, element_by_symbol from gmso.core.views import PotentialFilters, get_parameters pfilter = PotentialFilters.UNIQUE_PARAMETERS @@ -71,12 +72,12 @@ def from_parmed(structure, refer_type=True): charge=atom.charge * u.elementary_charge, position=[atom.xx, atom.xy, atom.xz] * u.angstrom, atom_type=None, - residue=(residue.name, residue.idx), + residue=(residue.name, residue.idx + 1), element=element, ) - site.molecule = (residue.name, residue.idx) if ind_res else None + site.molecule = (residue.name, residue.idx + 1) if ind_res else None site.atom_type = ( - pmd_top_atomtypes[atom.atom_type] + copy.deepcopy(pmd_top_atomtypes[atom.atom_type]) if refer_type and isinstance(atom.atom_type, pmd.AtomType) else None ) @@ -102,6 +103,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="BondType", + pmd_conn=bond, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -124,11 +126,12 @@ def from_parmed(structure, refer_type=True): ) if refer_type and isinstance(angle.type, pmd.AngleType): conn_params = { - "k": (2 * angle.type.k * u.Unit("kcal / (rad**2 * mol)")), + "k": (2 * angle.type.k * u.Unit("kcal / (radian**2 * mol)")), "theta_eq": (angle.type.theteq * u.degree), } _add_conn_type_from_pmd( connStr="AngleType", + pmd_conn=angle, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -164,6 +167,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="ImproperType", + pmd_conn=dihedral, gmso_conn=top_connection, conn_params=conn_params, name=name_improper, @@ -186,6 +190,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="DihedralType", + pmd_conn=dihedral, gmso_conn=top_connection, conn_params=conn_params, name=name_proper, @@ -221,6 +226,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="DihedralType", + pmd_conn=rb_torsion, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -240,7 +246,7 @@ def from_parmed(structure, refer_type=True): connection_members=_sort_improper_members( top, site_map, - *attrgetter("atom1", "atom2", "atom3", "atom4")(improper), + *attrgetter("atom3", "atom2", "atom1", "atom4")(improper), ) ) if refer_type and isinstance(improper.type, pmd.ImproperType): @@ -250,6 +256,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="ImproperType", + pmd_conn=improper, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -281,11 +288,11 @@ def _atom_types_from_pmd(structure): A dictionary linking a pmd.AtomType object to its corresponding GMSO.AtomType object. """ - unique_atom_types = set() - for atom in structure.atoms: - if isinstance(atom.atom_type, pmd.AtomType): - unique_atom_types.add(atom.atom_type) - unique_atom_types = list(unique_atom_types) + unique_atom_types = [ + atom.atom_type + for atom in structure.atoms + if isinstance(atom.atom_type, pmd.AtomType) + ] pmd_top_atomtypes = {} for atom_type in unique_atom_types: if atom_type.atomic_number: @@ -302,7 +309,7 @@ def _atom_types_from_pmd(structure): "epsilon": atom_type.epsilon * u.Unit("kcal / mol"), }, independent_variables={"r"}, - mass=atom_type.mass, + mass=copy.deepcopy(atom_type.mass), ) pmd_top_atomtypes[atom_type] = top_atomtype return pmd_top_atomtypes @@ -342,7 +349,7 @@ def _sort_improper_members(top, site_map, atom1, atom2, atom3, atom4): def _add_conn_type_from_pmd( - connStr, gmso_conn, conn_params, name, expression, variables + connStr, pmd_conn, gmso_conn, conn_params, name, expression, variables ): """Convert ParmEd dihedral types to GMSO DihedralType. @@ -408,6 +415,9 @@ def to_parmed(top, refer_type=True): msg = "Provided argument is not a topology.Topology." assert isinstance(top, gmso.Topology) + # Copy structure to not overwrite object in memory + top = copy.deepcopy(top) + # Set up Parmed structure and define general properties structure = pmd.Structure() structure.title = top.name @@ -450,7 +460,9 @@ def to_parmed(top, refer_type=True): # Add atom to structure if site.residue: structure.add_atom( - pmd_atom, resname=site.residue.name, resnum=site.residue.number + pmd_atom, + resname=site.residue.name, + resnum=site.residue.number - 1, ) else: structure.add_atom(pmd_atom, resname="RES", resnum=-1) @@ -561,14 +573,20 @@ def _atom_types_from_gmso(top, structure, atom_map): atype_epsilon = float( atom_type.parameters["epsilon"].to("kcal/mol").value ) - atype_element = element_by_atom_type(atom_type) + if atom_type.mass: + atype_mass = atom_type.mass.to("amu").value + else: + atype_mass = element_by_symbol(atom_type.name).mass.to("amu").value + atype_atomic_number = getattr( + element_by_symbol(atom_type.name), "atomic_number", None + ) atype_rmin = atype_sigma * 2 ** (1 / 6) / 2 # to rmin/2 # Create unique Parmed AtomType object atype = pmd.AtomType( atype_name, None, atype_mass, - atype_element.atomic_number, + atype_atomic_number, atype_charge, ) atype.set_lj_params(atype_epsilon, atype_rmin) @@ -645,7 +663,7 @@ def _angle_types_from_gmso(top, structure, angle_map): ), msg # Extract Topology angle_type information agltype_k = 0.5 * float( - angle_type.parameters["k"].to("kcal / (rad**2 * mol)").value + angle_type.parameters["k"].to("kcal / (radian**2 * mol)").value ) agltype_theta_eq = float( angle_type.parameters["theta_eq"].to("degree").value @@ -729,10 +747,17 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): ) # Create unique DihedralType object dtype = pmd.RBTorsionType( - dtype_c0, dtype_c1, dtype_c2, dtype_c3, dtype_c4, dtype_c5 + dtype_c0, + dtype_c1, + dtype_c2, + dtype_c3, + dtype_c4, + dtype_c5, + list=structure.rb_torsion_types, ) # Add RBTorsionType to structure.rb_torsion_types structure.rb_torsion_types.append(dtype) + # dtype._idx = len(structure.rb_torsion_types) - 1 else: raise GMSOError("msg") dtype_map[get_parameters(dihedral_type)] = dtype diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index a702f68d6..bc113a2b8 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -80,8 +80,8 @@ def read_gro(filename): r = re.compile("([0-9]+)([a-zA-Z]+)") m = r.match(res) - site.molecule = (m.group(2), int(m.group(1)) - 1) - site.residue = (m.group(2), int(m.group(1)) - 1) + site.molecule = (m.group(2), int(m.group(1))) + site.residue = (m.group(2), int(m.group(1))) top.add_site(site, update_types=False) top.update_topology() diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index a4d826491..97c865b8d 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -1,14 +1,21 @@ """Read and write LAMMPS data files.""" from __future__ import division +import copy import datetime +import os +import re import warnings +from pathlib import Path import numpy as np import unyt as u -from sympy import simplify, sympify +from sympy import Symbol +from unyt import UnitRegistry from unyt.array import allclose_units +import gmso +from gmso.abc.abstract_site import MoleculeType from gmso.core.angle import Angle from gmso.core.angle_type import AngleType from gmso.core.atom import Atom @@ -16,18 +23,242 @@ from gmso.core.bond import Bond from gmso.core.bond_type import BondType from gmso.core.box import Box +from gmso.core.dihedral import Dihedral from gmso.core.element import element_by_mass +from gmso.core.improper import Improper from gmso.core.topology import Topology +from gmso.core.views import PotentialFilters, get_sorted_names + +pfilter = PotentialFilters.UNIQUE_SORTED_NAMES +from gmso.exceptions import NotYetImplementedWarning from gmso.formats.formats_registry import loads_as, saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.utils.compatibility import check_compatibility from gmso.utils.conversions import ( convert_opls_to_ryckaert, convert_ryckaert_to_opls, ) +# TODO: move this to gmso.utils.units.py +reg = UnitRegistry() +dim = u.dimensions.current_mks * u.dimensions.time +conversion = 1 * getattr(u.physical_constants, "elementary_charge").value +reg.add( + "elementary_charge", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{e}", +) +conversion = 1 * getattr(u.physical_constants, "boltzmann_constant_mks").value +dim = u.dimensions.energy / u.dimensions.temperature +reg.add( + "kb", base_value=conversion, dimensions=dim, tex_repr=r"\rm{kb}" +) # boltzmann temperature +conversion = ( + 4 + * np.pi + * getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + * getattr(u.physical_constants, "eps_0").value + / ( + getattr(u.physical_constants, "electron_charge").value ** 2 + * getattr(u.physical_constants, "electron_mass").value + ) +) +dim = u.dimensions.length +reg.add( + "a0", base_value=conversion, dimensions=dim, tex_repr=r"\rm{a0}" +) # bohr radius +conversion = ( + getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + / u.Unit("a0", registry=reg).base_value ** 2 + / getattr(u.physical_constants, "electron_mass").value +) +dim = u.dimensions.energy +reg.add( + "Ehartree", base_value=conversion, dimensions=dim, tex_repr=r"\rm{Ehartree}" +) # Hartree energy +conversion = np.sqrt( + 10**9 / (4 * np.pi * getattr(u.physical_constants, "eps_0").value) +) +dim = u.dimensions.charge +reg.add( + "Statcoulomb_charge", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{Statcoulomb_charge}", +) # Static charge + + +def _unit_style_factory(style: str): + # NOTE: the when an angle is measured in lammps is not straightforwards. It depends not on the unit_style, but on the + # angle_style, dihedral_style, or improper_style. For examples, harmonic angles, k is specificed in energy/radian, but the + # theta_eq is written in degrees. For fourier dihedrals, d_eq is specified in degrees. When adding new styles, make sure that + # this behavior is accounted for when converting the specific potential_type in the function + # _parameter_converted_to_float + if style == "real": + base_units = u.UnitSystem( + "lammps_real", "Å", "amu", "fs", "K", "rad", registry=reg + ) + base_units["energy"] = "kcal/mol" + base_units["charge"] = "elementary_charge" + elif style == "metal": + base_units = u.UnitSystem( + "lammps_metal", "Å", "amu", "picosecond", "K", "rad", registry=reg + ) + base_units["energy"] = "eV" + base_units["charge"] = "elementary_charge" + elif style == "si": + base_units = u.UnitSystem( + "lammps_si", "m", "kg", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "joule" + base_units["charge"] = "coulomb" + elif style == "cgs": + base_units = u.UnitSystem( + "lammps_cgs", "cm", "g", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "erg" + # Statcoulomb is strange. It is not a 1:1 correspondance to charge, with base units of + # mass**1/2*length**3/2*time**-1. + # However, assuming it is referring to a static charge and not a flux, it can be + # converted to coulomb units. See the registry for the unit conversion to Coulombs + base_units["charge"] = "Statcoulomb_charge" + elif style == "electron": + base_units = u.UnitSystem( + "lammps_electron", "a0", "amu", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "Ehartree" + base_units["charge"] = "elementary_charge" + elif style == "micro": + base_units = u.UnitSystem( + "lammps_micro", "um", "picogram", "us", "K", "rad", registry=reg + ) + base_units["energy"] = "ug*um**2/us**2" + base_units["charge"] = "picocoulomb" + elif style == "nano": + base_units = u.UnitSystem( + "lammps_nano", "nm", "attogram", "ns", "K", "rad", registry=reg + ) + base_units["energy"] = "attogram*nm**2/ns**2" + base_units["charge"] = "elementary_charge" + elif style == "lj": + base_units = ljUnitSystem() + else: + raise NotYetImplementedWarning + + return base_units + + +class ljUnitSystem: + """Use this so the empty unitsystem has getitem magic method.""" + + def __init__(self): + self.registry = reg + self.name = "lj" + + def __getitem__(self, items): + """Return dimensionless units.""" + return "dimensionless" + + +def _parameter_converted_to_float( + parameter, + base_unyts, + conversion_factorDict=None, + n_decimals=3, + name="", +): + """Take a given parameter, and return a float of the parameter in the given style. + + This function will check the base_unyts, which is a unyt.UnitSystem object, + and convert the parameter to those units based on its dimensions. It can + also generate dimensionless units via normalization from conversion_factorsDict. + # TODO: move this to gmso.utils.units.py + """ + # TODO: now I think phi_eq is what is really saved in the improper angle + if name in ["theta_eq", "chieq"]: # eq angle are always in degrees + return round(float(parameter.to("degree").value), n_decimals) + new_dims = _dimensions_to_energy(parameter.units.dimensions) + new_dims = _dimensions_to_charge(new_dims) + if conversion_factorDict and isinstance(base_unyts, ljUnitSystem): + # multiply object -> split into length, mass, energy, charge -> grab conversion factor from dict + # first replace energy for (length)**2*(mass)/(time)**2 u.dimensions.energy. Then iterate through the free symbols + # and figure out a way how to add those to the overall conversion factor + dim_info = new_dims.as_terms() + conversion_factor = 1 + for exponent, ind_dim in zip(dim_info[0][0][1][1], dim_info[1]): + factor = conversion_factorDict.get( + ind_dim.name[1:-1], 1 + ) # replace () in name + conversion_factor *= float(factor) ** exponent + return float( + parameter / conversion_factor + ) # Assuming that conversion factor is in right units + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) + + return round( + float(parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry))), + n_decimals, + ) + + +def _dimensions_to_energy(dims): + """Take a set of dimensions and substitute in Symbol("energy") where possible.""" + # TODO: move this to gmso.utils.units.py + symsStr = str(dims.free_symbols) + energy_inBool = np.all([dimStr in symsStr for dimStr in ["time", "mass"]]) + if not energy_inBool: + return dims + energySym = Symbol("(energy)") # create dummy symbol to replace in equation + dim_info = dims.as_terms() + time_idx = np.where(list(map(lambda x: x.name == "(time)", dim_info[1])))[ + 0 + ][0] + energy_exp = ( + dim_info[0][0][1][1][time_idx] // 2 + ) # energy has 1/time**2 in it, so this is the hint of how many + return ( + dims + * u.dimensions.energy**energy_exp + * energySym ** (-1 * energy_exp) + ) + + +def _dimensions_to_charge(dims): + """Take a set of dimensions and substitute in Symbol("charge") where possible.""" + # TODO: move this to gmso.utils.units.py + symsStr = str(dims.free_symbols) + charge_inBool = np.all([dimStr in symsStr for dimStr in ["current_mks"]]) + if not charge_inBool: + return dims + chargeSym = Symbol("(charge)") # create dummy symbol to replace in equation + dim_info = dims.as_terms() + time_idx = np.where( + list(map(lambda x: x.name == "(current_mks)", dim_info[1])) + )[0][0] + charge_exp = dim_info[0][0][1][1][ + time_idx + ] # charge has (current_mks) in it, so this is the hint of how many + return ( + dims + * u.dimensions.charge ** (-1 * charge_exp) + * chargeSym**charge_exp + ) + @saves_as(".lammps", ".lammpsdata", ".data") -def write_lammpsdata(topology, filename, atom_style="full"): +def write_lammpsdata( + top, + filename, + atom_style="full", + unit_style="real", + strict_potentials=False, + strict_units=False, + lj_cfactorsDict=None, +): """Output a LAMMPS data file. Outputs a LAMMPS data file in the 'full' atom style format. @@ -44,326 +275,127 @@ def write_lammpsdata(topology, filename, atom_style="full"): Defines the style of atoms to be saved in a LAMMPS data file. The following atom styles are currently supported: 'full', 'atomic', 'charge', 'molecular' see http://lammps.sandia.gov/doc/atom_style.html for more information on atom styles. + unit_style : str, optional, default='real' + Can be any of "real", "lj", "metal", "si", "cgs", "electron", "micro", "nano". Otherwise + an error will be thrown. These are defined in _unit_style_factory. See + https://docs.lammps.org/units.html for LAMMPS documentation. + strict_potentials : bool, optional, default False + Tells the writer how to treat conversions. If False, then check for conversions + to usable potential styles found in default_parameterMaps. If True, then error if + potentials are not compatible. + strict_units : bool, optional, default False + Tells the writer how to treat unit conversions. If False, then check for conversions + to unit styles defined in _unit_style_factory. If True, then error if parameter units + do not match. + lj_cfactorsDict : (None, dict), optional, default None + If using unit_style="lj" only, can pass a dictionary with keys of ("mass", "energy", + "length", "charge"), or any combination of these, and they will be used to non- + dimensionalize all values in the topology. If any key is not passed, default values + will be pulled from the topology (see _default_lj_val). These are the largest: sigma, + epsilon, atomtype.mass, and atomtype.charge from the topology. Notes ----- See http://lammps.sandia.gov/doc/2001/data_format.html for a full description of the LAMMPS data format. - This is a work in progress, as only atoms, masses, and atom_type information can be written out. + This is a work in progress, as only a subset of everything LAMMPS supports is currently available. + However, please raise issues as the current writer has been set up to eventually grow to support + all LAMMPS styles. Some of this function has been adopted from `mdtraj`'s support of the LAMMPSTRJ trajectory format. See https://github.com/mdtraj/mdtraj/blob/master/mdtraj/formats/lammpstrj.py for details. """ - if atom_style not in ["atomic", "charge", "molecular", "full"]: + if atom_style not in ["full", "atomic", "molecular", "charge"]: raise ValueError( 'Atom style "{}" is invalid or is not currently supported'.format( atom_style ) ) - # TODO: Support various unit styles - - box = topology.box - - with open(filename, "w") as data: - data.write( - "{} written by topology at {}\n\n".format( - topology.name if topology.name is not None else "", - str(datetime.datetime.now()), + if unit_style not in [ + "real", + "lj", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: + raise ValueError( + 'Unit style "{}" is invalid or is not currently supported'.format( + unit_style ) ) - data.write("{:d} atoms\n".format(topology.n_sites)) - if atom_style in ["full", "molecular"]: - if topology.n_bonds != 0: - data.write("{:d} bonds\n".format(topology.n_bonds)) - else: - data.write("0 bonds\n") - if topology.n_angles != 0: - data.write("{:d} angles\n".format(topology.n_angles)) - else: - data.write("0 angles\n") - if topology.n_dihedrals != 0: - data.write("{:d} dihedrals\n\n".format(topology.n_dihedrals)) - else: - data.write("0 dihedrals\n\n") - - data.write("\n{:d} atom types\n".format(len(topology.atom_types))) - data.write("{:d} bond types\n".format(len(topology.bond_types))) - data.write("{:d} angle types\n".format(len(topology.angle_types))) - data.write("{:d} dihedral types\n".format(len(topology.dihedral_types))) - - data.write("\n") - - # Box data - if allclose_units( - box.angles, - u.unyt_array([90, 90, 90], "degree"), - rtol=1e-5, - atol=1e-8, - ): - warnings.warn("Orthorhombic box detected") - box.lengths.convert_to_units(u.angstrom) - for i, dim in enumerate(["x", "y", "z"]): - data.write( - "{0:.6f} {1:.6f} {2}lo {2}hi\n".format( - 0, box.lengths.value[i], dim - ) - ) - else: - warnings.warn("Non-orthorhombic box detected") - box.lengths.convert_to_units(u.angstrom) - box.angles.convert_to_units(u.radian) - vectors = box.get_vectors() - a, b, c = box.lengths - alpha, beta, gamma = box.angles - - lx = a - xy = b * np.cos(gamma) - xz = c * np.cos(beta) - ly = np.sqrt(b**2 - xy**2) - yz = (b * c * np.cos(alpha) - xy * xz) / ly - lz = np.sqrt(c**2 - xz**2 - yz**2) - - xhi = vectors[0][0] - yhi = vectors[1][1] - zhi = vectors[2][2] - xy = vectors[1][0] - xz = vectors[2][0] - yz = vectors[2][1] - xlo = u.unyt_array(0, xy.units) - ylo = u.unyt_array(0, xy.units) - zlo = u.unyt_array(0, xy.units) - - xlo_bound = xlo + u.unyt_array( - np.min([0.0, xy, xz, xy + xz]), xy.units - ) - xhi_bound = xhi + u.unyt_array( - np.max([0.0, xy, xz, xy + xz]), xy.units - ) - ylo_bound = ylo + u.unyt_array(np.min([0.0, yz]), xy.units) - yhi_bound = yhi + u.unyt_array(np.max([0.0, yz]), xy.units) - zlo_bound = zlo - zhi_bound = zhi - - data.write( - "{0:.6f} {1:.6f} xlo xhi\n".format( - xlo_bound.value, xhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} ylo yhi\n".format( - ylo_bound.value, yhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} zlo zhi\n".format( - zlo_bound.value, zhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} {2:.6f} xy xz yz\n".format( - xy.value, xz.value, yz.value - ) - ) + if unit_style != "lj" and lj_cfactorsDict: + raise ValueError( + "lj_cfactorsDict argument is only used if unit_style is lj." + ) + base_unyts = _unit_style_factory(unit_style) + default_parameterMaps = { # TODO: sites are not checked currently because gmso + # doesn't store pair potential eqn the same way as the connections. + "impropers": "HarmonicImproperPotential", + "dihedrals": "OPLSTorsionPotential", + "angles": "LAMMPSHarmonicAnglePotential", + "bonds": "LAMMPSHarmonicBondPotential", + # "sites":"LennardJonesPotential", + # "sites":"CoulombicPotential" + } - # TODO: Get a dictionary of indices and atom types - if topology.is_typed(): - # Write out mass data - data.write("\nMasses\n\n") - for atom_type in topology.atom_types: - data.write( - "{:d}\t{:.6f}\t# {}\n".format( - topology.atom_types.index(atom_type) + 1, - atom_type.mass.in_units(u.g / u.mol).value, - atom_type.name, - ) - ) + # TODO: Use strict_x, (i.e. x=bonds) to validate what topology attrs to convert - # TODO: Modified cross-interactions - # Pair coefficients - data.write("\nPair Coeffs # lj\n\n") - for idx, param in enumerate(topology.atom_types): - # expected expression for lammps for standard LJ - lj_expression = "4.0 * epsilon * ((sigma/r)**12 - (sigma/r)**6)" - scaling_factor = simplify(lj_expression) / simplify( - param.expression - ) + if strict_potentials: + _validate_potential_compatibility(top) + else: + _try_default_potential_conversions(top, default_parameterMaps) - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - param.parameters["epsilon"] - .in_units(u.Unit("kcal/mol")) - .value - / float(scaling_factor), - param.parameters["sigma"] - .in_units(u.angstrom) - .value, - ) - ) - else: + if strict_units: + _validate_unit_compatibility(top, base_unyts) + else: + if base_unyts and unit_style != "lj": + lj_cfactorsDict = None + else: # LJ unit styles + if lj_cfactorsDict is None: + lj_cfactorsDict = {} + source_factorsList = list(lj_cfactorsDict.keys()) + defaultsList = ["length", "energy", "mass", "charge"] + for source_factor in defaultsList + source_factorsList: + if source_factor not in defaultsList: raise ValueError( - 'Pair Style "{}" is invalid or is not currently supported'.format( - param.expression - ) - ) - if topology.bonds: - data.write("\nBond Coeffs\n\n") - for idx, bond_type in enumerate(topology.bond_types): - # expected harmonic potential expression for lammps - bond_expression = "k * (r-r_eq)**2" - - scaling_factor = simplify(bond_expression) / simplify( - bond_type.expression + f"Conversion factor {source_factor} is not used. Pleas only provide some of {defaultsList}" ) - - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - bond_type.parameters["k"] - .in_units(u.Unit("kcal/mol/angstrom**2")) - .value - / float(scaling_factor), - bond_type.parameters["r_eq"] - .in_units(u.Unit("angstrom")) - .value, - ) - ) - else: - raise ValueError( - 'Bond Style "{}" is invalid or is not currently supported'.format( - bond_type.expression - ) - ) - - if topology.angles: - data.write("\nAngle Coeffs\n\n") - for idx, angle_type in enumerate(topology.angle_types): - # expected lammps harmonic angle expression - angle_expression = "k * (theta - theta_eq)**2" - scaling_factor = simplify(angle_expression) / simplify( - angle_type.expression - ) - - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - angle_type.parameters["k"] - .in_units(u.Unit("kcal/mol/radian**2")) - .value - / float(scaling_factor), - angle_type.parameters["theta_eq"] - .in_units(u.Unit("degree")) - .value, - ) - ) - else: - raise ValueError( - 'Angle Style "{}" is invalid or is not currently supported'.format( - angle_type.expression - ) - ) - # TODO: Write out multiple dihedral styles - if topology.dihedrals: - data.write("\nDihedral Coeffs\n\n") - for idx, dihedral_type in enumerate(topology.dihedral_types): - rbtorsion = PotentialTemplateLibrary()[ - "RyckaertBellemansTorsionPotential" - ] - if ( - dihedral_type.expression - == sympify(rbtorsion.expression) - or dihedral_type.name == rbtorsion.name - ): - dihedral_type = convert_ryckaert_to_opls(dihedral_type) - data.write( - "{}\t{:.5f}\t{:5f}\t{:5f}\t{:.5f}\n".format( - idx + 1, - dihedral_type.parameters["k1"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k2"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k3"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k4"] - .in_units(u.Unit("kcal/mol")) - .value, - ) - ) - - # Atom data - data.write("\nAtoms\n\n") - if atom_style == "atomic": - atom_line = "{index:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "charge": - atom_line = "{index:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "molecular": - atom_line = "{index:d}\t{zero:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "full": - atom_line = "{index:d}\t{zero:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - - for i, site in enumerate(topology.sites): - data.write( - atom_line.format( - index=topology.sites.index(site) + 1, - type_index=topology.atom_types.index(site.atom_type) + 1, - zero=0, - charge=site.charge.to(u.elementary_charge).value, - x=site.position[0].in_units(u.angstrom).value, - y=site.position[1].in_units(u.angstrom).value, - z=site.position[2].in_units(u.angstrom).value, + if lj_cfactorsDict.get(source_factor): + continue + default_val_from_topology = _default_lj_val(top, source_factor) + lj_cfactorsDict[source_factor] = lj_cfactorsDict.get( + source_factor, default_val_from_topology ) - ) - if topology.bonds: - data.write("\nBonds\n\n") - for i, bond in enumerate(topology.bonds): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.bond_types.index(bond.connection_type) + 1, - topology.sites.index(bond.connection_members[0]) + 1, - topology.sites.index(bond.connection_members[1]) + 1, - ) - ) - - if topology.angles: - data.write("\nAngles\n\n") - for i, angle in enumerate(topology.angles): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.angle_types.index(angle.connection_type) + 1, - topology.sites.index(angle.connection_members[0]) + 1, - topology.sites.index(angle.connection_members[1]) + 1, - topology.sites.index(angle.connection_members[2]) + 1, - ) - ) - - if topology.dihedrals: - data.write("\nDihedrals\n\n") - for i, dihedral in enumerate(topology.dihedrals): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.dihedral_types.index(dihedral.connection_type) - + 1, - topology.sites.index(dihedral.connection_members[0]) - + 1, - topology.sites.index(dihedral.connection_members[1]) - + 1, - topology.sites.index(dihedral.connection_members[2]) - + 1, - topology.sites.index(dihedral.connection_members[3]) - + 1, - ) - ) + path = Path(filename) + if not path.parent.exists(): + msg = "Provided path to file that does not exist" + raise FileNotFoundError(msg) + + with open(path, "w") as out_file: + _write_header(out_file, top, atom_style) + _write_box(out_file, top, base_unyts, lj_cfactorsDict) + if top.is_fully_typed(): + _write_atomtypes(out_file, top, base_unyts, lj_cfactorsDict) + _write_pairtypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.bond_types: + _write_bondtypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.angle_types: + _write_angletypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.dihedral_types: + _write_dihedraltypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.improper_types: + _write_impropertypes(out_file, top, base_unyts, lj_cfactorsDict) + + _write_site_data(out_file, top, atom_style, base_unyts, lj_cfactorsDict) + for conn in ["bonds", "angles", "dihedrals", "impropers"]: + connIter = getattr(top, conn) + if connIter: + _write_conn_data(out_file, top, connIter, conn) @loads_as(".lammps", ".lammpsdata", ".data") @@ -377,9 +409,13 @@ def read_lammpsdata( filename : str LAMMPS data file atom_style : str, optional, default='full' - Inferred atom style defined by LAMMPS + Inferred atom style defined by LAMMPS, be certain that this is provided + accurately. + unit_style : str, optional, default='real + LAMMPS unit style used for writing the datafile. Can be "real", "lj", + "metal", "si", "cgs", "electron", "micro", "nano". potential: str, optional, default='lj' - Potential type defined in data file + Potential type defined in data file. Only supporting lj as of now. Returns ------- @@ -394,16 +430,17 @@ def read_lammpsdata( Currently supporting the following atom styles: 'full' - Currently supporting the following unit styles: 'real' + Currently supporting the following unit styles: 'real', "real", "lj", "metal", "si", "cgs", + "electron", "micro", "nano". Currently supporting the following potential styles: 'lj' - Proper dihedrals can be read in but is currently not tested. - - Currently not supporting improper dihedrals. + Currently supporting the following bond styles: 'harmonic' + Currently supporting the following angle styles: 'harmonic' + Currently supporting the following dihedral styles: 'opls' + Currently supporting the following improper styles: 'harmonic' """ - # TODO: Add argument to ask if user wants to infer bond type top = Topology() # Validate 'atom_style' @@ -415,7 +452,16 @@ def read_lammpsdata( ) # Validate 'unit_style' - if unit_style not in ["real"]: + if unit_style not in [ + "real", + "lj", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: raise ValueError( 'Unit Style "{}" is invalid or is not currently supported'.format( unit_style @@ -428,36 +474,39 @@ def read_lammpsdata( top, type_list = _get_ff_information(filename, unit_style, top) # Parse atom information _get_atoms(filename, top, unit_style, type_list) - # Parse connection (bonds, angles, dihedrals) information + # Parse connection (bonds, angles, dihedrals, impropers) information # TODO: Add more atom styles if atom_style in ["full"]: _get_connection(filename, top, unit_style, connection_type="bond") _get_connection(filename, top, unit_style, connection_type="angle") + _get_connection(filename, top, unit_style, connection_type="dihedral") + _get_connection(filename, top, unit_style, connection_type="improper") top.update_topology() return top -def get_units(unit_style): - """Get units for specific LAMMPS unit style.""" +def get_units(unit_style, dimension): + """Get u.Unit for specific LAMMPS unit style with given dimension.""" # Need separate angle units for harmonic force constant and angle - unit_style_dict = { - "real": { - "mass": u.g / u.mol, - "distance": u.angstrom, - "energy": u.kcal / u.mol, - "angle_k": u.radian, - "angle": u.degree, - "charge": u.elementary_charge, - } - } + if unit_style == "lj": + if dimension == "angle": + return u.radian + return u.dimensionless + + usystem = _unit_style_factory(unit_style) + if dimension == "angle_eq": + return ( + u.degree + ) # LAMMPS specifies different units for some angles, such as equilibrium angles - return unit_style_dict[unit_style] + return u.Unit(usystem[dimension], registry=reg) def _get_connection(filename, topology, unit_style, connection_type): """Parse connection types.""" + # TODO: check for other connection types besides the defaults with open(filename, "r") as lammps_file: types = False for i, line in enumerate(lammps_file): @@ -468,38 +517,87 @@ def _get_connection(filename, topology, unit_style, connection_type): break if types == False: return topology + templates = PotentialTemplateLibrary() connection_type_lines = open(filename, "r").readlines()[ i + 2 : i + n_connection_types + 2 ] connection_type_list = list() for line in connection_type_lines: if connection_type == "bond": - c_type = BondType(name=line.split()[0]) + template_potential = templates["LAMMPSHarmonicBondPotential"] # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term - c_type.parameters["k"] = ( - float(line.split()[1]) - * u.Unit( - get_units(unit_style)["energy"] - / get_units(unit_style)["distance"] ** 2 - ) - * 2 - ) - c_type.parameters["r_eq"] = float(line.split()[2]) * ( - get_units(unit_style)["distance"] + conn_params = { + "k": float(line.split()[1]) + * get_units(unit_style, "energy") + / get_units(unit_style, "length") ** 2 + * 2, + "r_eq": float(line.split()[2]) + * get_units(unit_style, "length"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "BondType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) elif connection_type == "angle": - c_type = AngleType(name=line.split()[0]) + template_potential = templates["LAMMPSHarmonicAnglePotential"] # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term - c_type.parameters["k"] = ( - float(line.split()[1]) - * u.Unit( - get_units(unit_style)["energy"] - / get_units(unit_style)["angle_k"] ** 2 - ) - * 2 + conn_params = { + "k": float(line.split()[1]) + * get_units(unit_style, "energy") + / get_units(unit_style, "angle") ** 2 + * 2, + "theta_eq": float(line.split()[2]) + * get_units(unit_style, "angle_eq"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "AngleType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) - c_type.parameters["theta_eq"] = float(line.split()[2]) * u.Unit( - get_units(unit_style)["angle"] + elif connection_type == "dihedral": + template_potential = templates["OPLSTorsionPotential"] + conn_params = { + "k1": float(line.split()[1]) * get_units(unit_style, "energy"), + "k2": float(line.split()[2]) * get_units(unit_style, "energy"), + "k3": float(line.split()[3]) * get_units(unit_style, "energy"), + "k4": float(line.split()[4]) * get_units(unit_style, "energy"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "DihedralType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, + ) + elif connection_type == "improper": + template_potential = templates["HarmonicImproperPotential"] + conn_params = { + "k": float(line.split()[2]) + * get_units(unit_style, "energy") + / get_units(unit_style, "energy") ** 2 + * 2, + "phi_eq": float(line.split()[3]) + * get_units(unit_style, "angle_eq"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "ImproperType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) connection_type_list.append(c_type) @@ -525,15 +623,27 @@ def _get_connection(filename, topology, unit_style, connection_type): for j in range(n_sites): site = topology.sites[int(line.split()[j + 2]) - 1] site_list.append(site) + ctype = copy.copy(connection_type_list[int(line.split()[1]) - 1]) + ctype.member_types = tuple(map(lambda x: x.atom_type.name, site_list)) if connection_type == "bond": connection = Bond( connection_members=site_list, - bond_type=connection_type_list[int(line.split()[1]) - 1], + bond_type=ctype, ) elif connection_type == "angle": connection = Angle( connection_members=site_list, - angle_type=connection_type_list[int(line.split()[1]) - 1], + angle_type=ctype, + ) + elif connection_type == "dihedral": + connection = Dihedral( + connection_members=site_list, + dihedral_type=ctype, + ) + elif connection_type == "improper": + connection = Improper( + connection_members=site_list, + improper_type=ctype, ) topology.add_connection(connection) @@ -553,18 +663,19 @@ def _get_atoms(filename, topology, unit_style, type_list): atom_line = line.split() atom_type = atom_line[2] charge = u.unyt_quantity( - float(atom_line[3]), get_units(unit_style)["charge"] + float(atom_line[3]), get_units(unit_style, "charge") ) - coord = u.angstrom * u.unyt_array( + coord = u.unyt_array( [float(atom_line[4]), float(atom_line[5]), float(atom_line[6])] - ) + ) * get_units(unit_style, "length") site = Atom( charge=charge, position=coord, - atom_type=type_list[int(atom_type) - 1], + atom_type=copy.deepcopy(type_list[int(atom_type) - 1]), # 0-index + molecule=MoleculeType(atom_line[1], int(atom_line[1])), ) element = element_by_mass(site.atom_type.mass.value) - site.name = element.name + site.name = element.name if element else site.atom_type.name site.element = element topology.add_site(site) @@ -612,13 +723,14 @@ def _get_box_coordinates(filename, unit_style, topology): gamma = np.arccos(xy / b) # Box Information - lengths = u.unyt_array([a, b, c], get_units(unit_style)["distance"]) - angles = u.unyt_array([alpha, beta, gamma], u.radian) - angles.to(get_units(unit_style)["angle"]) + lengths = u.unyt_array([a, b, c], get_units(unit_style, "length")) + angles = u.unyt_array( + [alpha, beta, gamma], get_units(unit_style, "angle") + ) topology.box = Box(lengths, angles) else: # Box Information - lengths = u.unyt_array([x, y, z], get_units(unit_style)["distance"]) + lengths = u.unyt_array([x, y, z], get_units(unit_style, "length")) topology.box = Box(lengths) return topology @@ -641,7 +753,7 @@ def _get_ff_information(filename, unit_style, topology): for line in mass_lines: atom_type = AtomType( name=line.split()[0], - mass=float(line.split()[1]) * get_units(unit_style)["mass"], + mass=float(line.split()[1]) * get_units(unit_style, "mass"), ) type_list.append(atom_type) @@ -651,16 +763,517 @@ def _get_ff_information(filename, unit_style, topology): break # Need to figure out if we're going have mixing rules printed out # Currently only reading in LJ params + warn_ljcutBool = False pair_lines = open(filename, "r").readlines()[i + 2 : i + n_atomtypes + 2] for i, pair in enumerate(pair_lines): if len(pair.split()) == 3: - type_list[i].parameters["sigma"] = ( - float(pair.split()[2]) * get_units(unit_style)["distance"] - ) - type_list[i].parameters["epsilon"] = ( - float(pair.split()[1]) * get_units(unit_style)["energy"] - ) + type_list[i].parameters["sigma"] = float( + pair.split()[2] + ) * get_units(unit_style, "length") + type_list[i].parameters["epsilon"] = float( + pair.split()[1] + ) * get_units(unit_style, "energy") elif len(pair.split()) == 4: - warnings.warn("Currently not reading in mixing rules") + warn_ljcutBool = True + + if warn_ljcutBool: + warnings.warn( + "Currently not reading in LJ cutoff values." + "These should be specified in the engine run files." + ) return topology, type_list + + +def _accepted_potentials(): + """List of accepted potentials that LAMMPS can support.""" + templates = PotentialTemplateLibrary() + lennard_jones_potential = templates["LennardJonesPotential"] + harmonic_bond_potential = templates["LAMMPSHarmonicBondPotential"] + harmonic_angle_potential = templates["LAMMPSHarmonicAnglePotential"] + periodic_torsion_potential = templates["PeriodicTorsionPotential"] + harmonic_improper_potential = templates["HarmonicImproperPotential"] + opls_torsion_potential = templates["OPLSTorsionPotential"] + accepted_potentialsList = [ + lennard_jones_potential, + harmonic_bond_potential, + harmonic_angle_potential, + periodic_torsion_potential, + harmonic_improper_potential, + opls_torsion_potential, + ] + return accepted_potentialsList + + +def _validate_potential_compatibility(top): + """Check compatability of topology object potentials with LAMMPSDATA format.""" + pot_types = check_compatibility(top, _accepted_potentials()) + return pot_types + + +def _validate_unit_compatibility(top, base_unyts): + """Check compatability of topology object units with LAMMPSDATA format.""" + for attribute in ["sites", "bonds", "angles", "dihedrals", "impropers"]: + if attribute == "sites": + atype = "atom_types" + else: + atype = attribute[:-1] + "_types" + parametersList = [ + (parameter, name) + for attr_type in getattr(top, atype) + for name, parameter in attr_type.parameters.items() + ] + for parameter, name in parametersList: + assert np.isclose( + _parameter_converted_to_float( + parameter, base_unyts, n_decimals=6, name=name + ), + parameter.value, + atol=1e-3, + ), f"Units System {base_unyts} is not compatible with {atype} with value {parameter}" + + +def _write_header(out_file, top, atom_style): + """Write Lammps file header.""" + out_file.write( + "{} written by {} at {} using the GMSO LAMMPS Writer\n\n".format( + os.environ.get("USER"), + top.name if top.name is not None else "", + str(datetime.datetime.now()), + ) + ) + out_file.write("{:d} atoms\n".format(top.n_sites)) + if atom_style in ["full", "molecular"]: + out_file.write("{:d} bonds\n".format(top.n_bonds)) + out_file.write("{:d} angles\n".format(top.n_angles)) + out_file.write("{:d} dihedrals\n".format(top.n_dihedrals)) + out_file.write("{:d} impropers\n\n".format(top.n_impropers)) + + # TODO: allow users to specify filter_by syntax + out_file.write( + "{:d} atom types\n".format(len(top.atom_types(filter_by=pfilter))) + ) + if top.n_bonds > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} bond types\n".format(len(top.bond_types(filter_by=pfilter))) + ) + if top.n_angles > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} angle types\n".format(len(top.angle_types(filter_by=pfilter))) + ) + if top.n_dihedrals > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} dihedral types\n".format( + len(top.dihedral_types(filter_by=pfilter)) + ) + ) + if top.n_impropers > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} improper types\n".format( + len(top.improper_types(filter_by=pfilter)) + ) + ) + + out_file.write("\n") + + +def _write_box(out_file, top, base_unyts, cfactorsDict): + """Write GMSO Topology box to LAMMPS file.""" + if allclose_units( + top.box.angles, + u.unyt_array([90, 90, 90], "degree"), + rtol=1e-5, + atol=1e-8, + ): + box_lengths = [ + _parameter_converted_to_float( + top.box.lengths[i], base_unyts, cfactorsDict + ) + for i in range(3) + ] + for i, dim in enumerate(["x", "y", "z"]): + out_file.write( + "{0:.6f} {1:.6f} {2}lo {2}hi\n".format(0, box_lengths[i], dim) + ) + out_file.write("0.000000 0.000000 0.000000 xy xz yz\n") + else: + box_lengths = [ + _parameter_converted_to_float( + top.box.lengths[i], base_unyts, cfactorsDict + ) + for i in range(3) + ] + vectors = (box_lengths * top.box.get_unit_vectors().T).T + + xhi = vectors[0][0] + yhi = vectors[1][1] + zhi = vectors[2][2] + xy = vectors[1][0] + xz = vectors[2][0] + yz = vectors[2][1] + xlo = u.unyt_array(0, xy.units) + ylo = u.unyt_array(0, xy.units) + zlo = u.unyt_array(0, xy.units) + + xlo_bound = xlo + u.unyt_array(np.min([0.0, xy, xz, xy + xz]), xy.units) + xhi_bound = xhi + u.unyt_array(np.max([0.0, xy, xz, xy + xz]), xy.units) + ylo_bound = ylo + u.unyt_array(np.min([0.0, yz]), xy.units) + yhi_bound = yhi + u.unyt_array(np.max([0.0, yz]), xy.units) + zlo_bound = zlo + zhi_bound = zhi + + out_file.write( + "{0:.6f} {1:.6f} xlo xhi\n".format(xlo_bound.value, xhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} ylo yhi\n".format(ylo_bound.value, yhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} zlo zhi\n".format(zlo_bound.value, zhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} {2:.6f} xy xz yz\n".format( + xy.value, xz.value, yz.value + ) + ) + + +def _write_atomtypes(out_file, top, base_unyts, cfactorsDict): + """Write out atomtypes in GMSO topology to LAMMPS file.""" + out_file.write("\nMasses\n") + out_file.write(f"#\tmass ({base_unyts['mass']})\n") + atypesView = sorted(top.atom_types(filter_by=pfilter), key=lambda x: x.name) + for atom_type in atypesView: + out_file.write( + "{:d}\t{:.6f}\t# {}\n".format( + atypesView.index(atom_type) + 1, + _parameter_converted_to_float( + atom_type.mass, base_unyts, cfactorsDict + ), + atom_type.name, + ) + ) + + +def _write_pairtypes(out_file, top, base_unyts, cfactorsDict): + """Write out pair interaction to LAMMPS file.""" + # TODO: Handling of modified cross-interactions is not considered from top.pairpotential_types + # Pair coefficients + test_atomtype = top.sites[0].atom_type + out_file.write(f"\nPair Coeffs # {test_atomtype.expression}\n") + nb_style_orderTuple = ( + "epsilon", + "sigma", + ) # this will vary with new pair styles + param_labels = [ + _write_out_parameter_w_units( + key, test_atomtype.parameters[key], base_unyts + ) + for key in nb_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + sorted_atomtypes = sorted( + top.atom_types(filter_by=pfilter), key=lambda x: x.name + ) + for idx, param in enumerate(sorted_atomtypes): + out_file.write( + "{}\t{:7.5f}\t\t{:7.5f}\t\t# {}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + param.parameters[key], + base_unyts, + cfactorsDict, + n_decimals=5, + ) + for key in nb_style_orderTuple + ], + param.name, + ) + ) + + +def _write_bondtypes(out_file, top, base_unyts, cfactorsDict): + """Write out bonds to LAMMPS file.""" + # TODO: Use any accepted lammps styles (only takes harmonic now) + test_bondtype = top.bonds[0].bond_type + out_file.write(f"\nBond Coeffs #{test_bondtype.name}\n") + bond_style_orderTuple = ("k", "r_eq") + param_labels = [ + _write_out_parameter_w_units( + key, test_bondtype.parameters[key], base_unyts + ) + for key in bond_style_orderTuple + ] + + out_file.write("#\t" + "\t".join(param_labels) + "\n") + bond_types = list(top.bond_types(filter_by=pfilter)) + bond_types.sort(key=lambda x: sorted(x.member_types)) + for idx, bond_type in enumerate(bond_types): + member_types = sorted( + [bond_type.member_types[0], bond_type.member_types[1]] + ) + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\t\t# {}\t{}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + bond_type.parameters[key], base_unyts, cfactorsDict + ) + for key in bond_style_orderTuple + ], + *member_types, + ) + ) + + +def _write_angletypes(out_file, top, base_unyts, cfactorsDict): + """Write out angles to LAMMPS file.""" + # TODO: Use any accepted lammps parameters, only harmonic now + test_angletype = top.angles[0].angle_type + out_file.write(f"\nAngle Coeffs #{test_angletype.name}\n") + angle_style_orderTuple = ( + "k", + "theta_eq", + ) # this will vary with new angle styles + param_labels = [ + _write_out_parameter_w_units( + key, test_angletype.parameters[key], base_unyts + ) + for key in angle_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.angle_types(filter_by=pfilter)) + indexList.sort( + key=lambda x: ( + x.member_types[1], + min(x.member_types[::2]), + max(x.member_types[::2]), + ) + ) + for idx, angle_type in enumerate(indexList): + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\t#{:11s}\t{:11s}\t{:11s}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + angle_type.parameters[key], + base_unyts, + cfactorsDict, + name=key, + ) + for key in angle_style_orderTuple + ], + *angle_type.member_types, + ) + ) + + +def _write_dihedraltypes(out_file, top, base_unyts, cfactorsDict): + """Write out dihedrals to LAMMPS file.""" + test_dihedraltype = top.dihedrals[0].dihedral_type + out_file.write(f"\nDihedral Coeffs #{test_dihedraltype.name}\n") + dihedral_style_orderTuple = ( + "k1", + "k2", + "k3", + "k4", + ) # this will vary with new dihedral styles + param_labels = [ + _write_out_parameter_w_units( + key, test_dihedraltype.parameters[key], base_unyts + ) + for key in dihedral_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.dihedral_types(filter_by=pfilter)) + index_membersList = [ + (dihedral_type, get_sorted_names(dihedral_type)) + for dihedral_type in indexList + ] + index_membersList.sort(key=lambda x: ([x[1][i] for i in [1, 2, 0, 3]])) + for idx, (dihedral_type, members) in enumerate(index_membersList): + out_file.write( + "{}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t# {}\t{}\t{}\t{}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + dihedral_type.parameters[parameterStr], + base_unyts, + cfactorsDict, + ) + for parameterStr in dihedral_style_orderTuple + ], + *members, + ) + ) + + +def _write_impropertypes(out_file, top, base_unyts, cfactorsDict): + """Write out impropers to LAMMPS file.""" + # TODO: Use any accepted lammps parameters, only harmonic now + test_impropertype = top.impropers[0].improper_type + out_file.write(f"\nImproper Coeffs #{test_impropertype.name}\n") + improper_style_orderTuple = ( + "k", + "phi_eq", + ) # this will vary with new improper styles + param_labels = [ + _write_out_parameter_w_units( + key, test_impropertype.parameters[key], base_unyts + ) + for key in improper_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.improper_types(filter_by=pfilter)) + index_membersList = [ + (improper_type, get_sorted_names(improper_type)) + for improper_type in indexList + ] + index_membersList.sort(key=lambda x: ([x[1][i] for i in [0, 1, 2, 3]])) + for idx, (improper_type, members) in enumerate(index_membersList): + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + improper_type.parameters[parameterStr], + base_unyts, + cfactorsDict, + name=parameterStr, + ) + for parameterStr in improper_style_orderTuple + ], + *improper_type, + ) + ) + + +def _write_site_data(out_file, top, atom_style, base_unyts, cfactorsDict): + """Write atomic positions and charges to LAMMPS file..""" + out_file.write(f"\nAtoms #{atom_style}\n\n") + if atom_style == "atomic": + atom_line = "{index:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "charge": + atom_line = "{index:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "molecular": + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "full": + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + + unique_sorted_typesList = sorted( + top.atom_types(filter_by=pfilter), key=lambda x: x.name + ) + for i, site in enumerate(top.sites): + out_file.write( + atom_line.format( + index=i + 1, + moleculeid=site.molecule.number, + type_index=unique_sorted_typesList.index(site.atom_type) + 1, + charge=_parameter_converted_to_float( + site.charge, base_unyts, cfactorsDict + ), + x=_parameter_converted_to_float( + site.position[0], base_unyts, cfactorsDict, n_decimals=6 + ), + y=_parameter_converted_to_float( + site.position[1], base_unyts, cfactorsDict, n_decimals=6 + ), + z=_parameter_converted_to_float( + site.position[2], base_unyts, cfactorsDict, n_decimals=6 + ), + ) + ) + + +def _angle_order_sorter(angle_typesList): + return [angle_typesList[i] for i in [1, 0, 2]] + + +def _dihedral_order_sorter(dihedral_typesList): + return [dihedral_typesList[i] for i in [1, 2, 0, 3]] + + +def _improper_order_sorter(improper_typesList): + return [improper_typesList[i] for i in [0, 1, 2, 3]] + + +sorting_funcDict = { + "bonds": None, + "angles": _angle_order_sorter, + "dihedrals": _dihedral_order_sorter, + "impropers": _improper_order_sorter, +} + + +def _write_conn_data(out_file, top, connIter, connStr): + """Write all connections to LAMMPS datafile.""" + out_file.write(f"\n{connStr.capitalize()}\n\n") + indexList = list( + map( + get_sorted_names, + getattr(top, connStr[:-1] + "_types")(filter_by=pfilter), + ) + ) + indexList.sort(key=sorting_funcDict[connStr]) + + for i, conn in enumerate(getattr(top, connStr)): + typeStr = f"{i+1:<6d}\t{indexList.index(get_sorted_names(conn.connection_type))+1:<6d}\t" + indexStr = "\t".join( + map( + lambda x: str(top.sites.index(x) + 1).ljust(6), + conn.connection_members, + ) + ) + out_file.write(typeStr + indexStr + "\n") + + +def _try_default_potential_conversions(top, potentialsDict): + """Take a topology a convert all potentials to the style in potentialDict.""" + for pot_container in potentialsDict: + if getattr(top, pot_container[:-1] + "_types"): + top.convert_potential_styles( + {pot_container: potentialsDict[pot_container]} + ) + elif getattr(top, pot_container): + raise AttributeError( + f"Missing parameters in {pot_container} for {top.get_untyped(pot_container)}" + ) + + +def _default_lj_val(top, source): + """Generate default lj non-dimensional values from topology.""" + if source == "length": + return copy.deepcopy( + max(list(map(lambda x: x.parameters["sigma"], top.atom_types))) + ) + elif source == "energy": + return copy.deepcopy( + max(list(map(lambda x: x.parameters["epsilon"], top.atom_types))) + ) + elif source == "mass": + return copy.deepcopy(max(list(map(lambda x: x.mass, top.atom_types)))) + elif source == "charge": + return copy.deepcopy(max(list(map(lambda x: x.charge, top.atom_types)))) + else: + raise ValueError( + f"Provided {source} for default LJ cannot be found in the topology." + ) + + +def _write_out_parameter_w_units(parameter_name, parameter, base_unyts): + if parameter_name in ["theta_eq", "phi_eq"]: + return f"{parameter_name} ({'degrees'})" + if base_unyts.name == "lj": + return f"{parameter_name} ({'dimensionless'})" + new_dims = _dimensions_to_energy(parameter.units.dimensions) + new_dims = _dimensions_to_charge(new_dims) + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) + + outputUnyt = str( + parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry)).units + ) + return f"{parameter_name} ({outputUnyt})" diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 1067f3684..b3bdd7bc9 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -162,9 +162,14 @@ def _parse_bond(top, section, verbose): if line.strip(): content = line.split() bond = Bond( - connection_members=( - top.sites[int(content[1]) - 1], - top.sites[int(content[2]) - 1], + connection_members=tuple( + sorted( + [ + top.sites[int(content[1]) - 1], + top.sites[int(content[2]) - 1], + ], + key=lambda x: top.get_index(x), + ) ) ) top.add_connection(bond) diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 8ffef07af..24b83b4e6 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -163,7 +163,7 @@ def write_top(top, filename, top_vars=None): "{7:12.5f}\n".format( str(idx + 1), site.atom_type.name, - str(site.molecule.number + 1 if site.molecule else 1), + str(site.molecule.number if site.molecule else 1), tag, site.atom_type.tags["element"], "1", # TODO: care about charge groups diff --git a/gmso/lib/jsons/FourierTorsionPotential.json b/gmso/lib/jsons/FourierTorsionPotential.json new file mode 100644 index 000000000..3f7b1c7a1 --- /dev/null +++ b/gmso/lib/jsons/FourierTorsionPotential.json @@ -0,0 +1,12 @@ +{ + "name": "FourierTorsionPotential", + "expression": "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k0": "energy", + "k1": "energy", + "k2": "energy", + "k3": "energy", + "k4": "energy" + } +} diff --git a/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json b/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json new file mode 100644 index 000000000..cdc6b9bd2 --- /dev/null +++ b/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json @@ -0,0 +1,9 @@ +{ + "name": "LAMMPSHarmonicAnglePotential", + "expression": "k * (theta-theta_eq)**2", + "independent_variables": "theta", + "expected_parameters_dimensions": { + "k":"energy/angle**2", + "theta_eq": "angle" + } +} diff --git a/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json b/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json new file mode 100644 index 000000000..b801de15a --- /dev/null +++ b/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json @@ -0,0 +1,9 @@ +{ + "name": "LAMMPSHarmonicBondPotential", + "expression": "k * (r-r_eq)**2", + "independent_variables": "r", + "expected_parameters_dimensions": { + "k": "energy/length**2", + "r_eq": "length" + } + } diff --git a/gmso/lib/jsons/OPLSTorsionPotential.json b/gmso/lib/jsons/OPLSTorsionPotential.json index 2adb76cc2..0c3ba4bc8 100644 --- a/gmso/lib/jsons/OPLSTorsionPotential.json +++ b/gmso/lib/jsons/OPLSTorsionPotential.json @@ -1,13 +1,11 @@ { "name": "OPLSTorsionPotential", - "expression": "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", + "expression": "0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", "independent_variables": "phi", "expected_parameters_dimensions": { - "k0": "energy", "k1": "energy", "k2": "energy", "k3": "energy", - "k4": "energy", - "k5": "energy" + "k4": "energy" } } diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 1998bada2..32d5ec1a6 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -173,16 +173,9 @@ def _typed_topology(n_sites=100): @pytest.fixture def water_system(self): - water = mb.load(get_path("tip3p.mol2")) - water.name = "water" - water[0].name = "opls_111" - water[1].name = water[2].name = "opls_112" - - packed_system = mb.fill_box( - compound=water, n_compounds=2, box=mb.Box([2, 2, 2]) - ) - - return from_mbuild(packed_system, parse_label=True) + water = Topology(name="water") + water = water.load(get_path("tip3p.mol2")) + return water @pytest.fixture def ethane(self): @@ -202,6 +195,15 @@ def typed_ethane(self): top.name = "ethane" return top + @pytest.fixture + def typed_ethane_opls(self, typed_ethane): + for dihedral in typed_ethane.dihedrals: + dihedral.dihedral_type.name = "RyckaertBellemansTorsionPotential" + top = typed_ethane.convert_potential_styles( + {"dihedrals": "FourierTorsionPotential"} + ) + return top + @pytest.fixture def parmed_ethane(self): from mbuild.lib.molecules import Ethane @@ -241,6 +243,14 @@ def typed_chloroethanol(self): top = from_parmed(pmd_structure) return top + @pytest.fixture + def typed_methaneUA(self): + compound = mb.Compound(name="_CH4", charge=0.0) + trappe = foyer.Forcefield(name="trappe-ua") + pmd_structure = trappe.apply(compound) + top = from_parmed(pmd_structure) + return top + @pytest.fixture def parmed_hexane_box(self): compound = mb.recipes.Alkane(6) @@ -253,31 +263,9 @@ def parmed_hexane_box(self): @pytest.fixture def typed_water_system(self, water_system): top = water_system - + top.identify_connections() ff = ForceField(get_path("tip3p.xml")) - - element_map = {"O": "opls_111", "H": "opls_112"} - - for atom in top.sites: - atom.atom_type = ff.atom_types[atom.name] - - for bond in top.bonds: - bond.bond_type = ff.bond_types["opls_111~opls_112"] - - molecule_tags = top.unique_site_labels( - label_type="molecule", name_only=False - ) - for tag in molecule_tags: - angle = Angle( - connection_members=[ - site for site in top.iter_sites("molecule", tag) - ], - name="opls_112~opls_111~opls_112", - angle_type=ff.angle_types["opls_112~opls_111~opls_112"], - ) - top.add_connection(angle) - - top.update_topology() + top = apply(top, ff) return top @pytest.fixture @@ -682,3 +670,31 @@ def parmed_benzene(self): untyped_benzene, assert_dihedral_params=False ) return benzene + + # TODO: now + # add in some fixtures for (connects), amber + + @pytest.fixture + def harmonic_parmed_types_charmm(self): + from mbuild.formats.lammpsdata import write_lammpsdata + + system = mb.Compound() + first = mb.Particle(name="_CTL2", pos=[-1, 0, 0]) + second = mb.Particle(name="_CL", pos=[0, 0, 0]) + third = mb.Particle(name="_OBL", pos=[1, 0, 0]) + fourth = mb.Particle(name="_OHL", pos=[0, 1, 0]) + + system.add([first, second, third, fourth]) + + system.add_bond((first, second)) + system.add_bond((second, third)) + system.add_bond((second, fourth)) + + ff = foyer.Forcefield(forcefield_files=[get_path("charmm36_cooh.xml")]) + struc = ff.apply( + system, + assert_angle_params=False, + assert_dihedral_params=False, + assert_improper_params=False, + ) + return struc diff --git a/gmso/tests/files/charmm36_cooh.xml b/gmso/tests/files/charmm36_cooh.xml new file mode 100644 index 000000000..322f9bc42 --- /dev/null +++ b/gmso/tests/files/charmm36_cooh.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/tip3p.mol2 b/gmso/tests/files/tip3p.mol2 index 5d14e3fa4..4ad2a98b0 100644 --- a/gmso/tests/files/tip3p.mol2 +++ b/gmso/tests/files/tip3p.mol2 @@ -11,6 +11,6 @@ NO_CHARGES 3 H 0.1100 1.5400 0.4000 opls_112 1 tip3p @BOND 1 1 2 1 - 2 3 1 1 + 2 1 3 1 @SUBSTRUCTURE 1 RES 1 RESIDUE 0 **** ROOT 0 diff --git a/gmso/tests/files/tip3p.xml b/gmso/tests/files/tip3p.xml index 2ebe891b7..4c4d8e8e1 100644 --- a/gmso/tests/files/tip3p.xml +++ b/gmso/tests/files/tip3p.xml @@ -1,18 +1,18 @@ - - + + - + - + diff --git a/gmso/tests/files/typed_water_system_ref.top b/gmso/tests/files/typed_water_system_ref.top index 7c6600372..f08e3d6c0 100644 --- a/gmso/tests/files/typed_water_system_ref.top +++ b/gmso/tests/files/typed_water_system_ref.top @@ -11,13 +11,13 @@ opls_112 1 1.01100 0.41700 A 1.00000 0.00000 [ moleculetype ] ; name nrexcl -tip3p 3 +RES 3 [ atoms ] ; nr type resnr residue atom cgnr charge mass -1 opls_111 1 tip3p O 1 -0.83400 16.00000 -2 opls_112 1 tip3p H 1 0.41700 1.01100 -3 opls_112 1 tip3p H 1 0.41700 1.01100 +1 opls_111 1 RES O 1 -0.83400 16.00000 +2 opls_112 1 RES H 1 0.41700 1.01100 +3 opls_112 1 RES H 1 0.41700 1.01100 [ bonds ] ; ai aj funct b0 kb @@ -26,12 +26,12 @@ tip3p 3 [ angles ] ; ai aj ak funct phi_0 k0 -1 2 3 1 104.52000 682.02000 +2 1 3 1 104.52000 682.02000 [ system ] ; name -Topology +tip3p [ molecules ] ; molecule nmols -tip3p 2 +RES 1 diff --git a/gmso/tests/parameterization/test_molecule_utils.py b/gmso/tests/parameterization/test_molecule_utils.py index d87b529f8..322ac6ffa 100644 --- a/gmso/tests/parameterization/test_molecule_utils.py +++ b/gmso/tests/parameterization/test_molecule_utils.py @@ -26,11 +26,10 @@ def ethane_box_gmso(self): identify_connections(ethane_box_gmso) return ethane_box_gmso - def test_no_boundary_bonds_ethane(self, ethane): - for site in ethane.sites: - site.molecule = site.residue + def test_no_boundary_bonds(self, benzene_ua_box): + benzene_ua_box.sites[0].molecule = benzene_ua_box.sites[6].molecule with pytest.raises(AssertionError): - assert_no_boundary_bonds(ethane) + assert_no_boundary_bonds(benzene_ua_box) def test_no_boundary_bonds_ethane_box(self, ethane_box_gmso): assert_no_boundary_bonds(ethane_box_gmso) diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index bbf7a9ff3..64f2fd2a2 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -259,15 +259,15 @@ def test_hierarchical_mol_structure( ): top = deepcopy(hierarchical_top) # Load forcefield dicts - tip3p = ForceField(get_path("tip3p.xml")) + spce = ForceField(get_path("spce.xml")) if match_ff_by == "molecule": ff_dict = { "polymer": oplsaa_gmso, "cyclopentane": oplsaa_gmso, - "water": tip3p, + "water": spce, } elif match_ff_by == "group": - ff_dict = {"sol1": oplsaa_gmso, "sol2": tip3p} + ff_dict = {"sol1": oplsaa_gmso, "sol2": spce} else: raise ValueError("Unexpected value provided match_ff_by.") diff --git a/gmso/tests/parameterization/test_trappe_gmso.py b/gmso/tests/parameterization/test_trappe_gmso.py index 84e489e40..7442a5135 100644 --- a/gmso/tests/parameterization/test_trappe_gmso.py +++ b/gmso/tests/parameterization/test_trappe_gmso.py @@ -45,7 +45,12 @@ def test_foyer_trappe_files( mol2_file = system_dir / f"{system_dir.name}.mol2" gmso_top = Topology.load(mol2_file) struct_pmd = trappe_ua_foyer.apply(to_parmed(gmso_top)) - apply(gmso_top, trappe_ua_gmso, identify_connected_components=False) + apply( + gmso_top, + trappe_ua_gmso, + identify_connected_components=False, + identify_connections=True, + ) gmso_top_from_parmeterized_pmd = from_parmed(struct_pmd) assert_same_atom_params(gmso_top_from_parmeterized_pmd, gmso_top) diff --git a/gmso/tests/test_conversions.py b/gmso/tests/test_conversions.py index 782b3bb0c..cf1704949 100644 --- a/gmso/tests/test_conversions.py +++ b/gmso/tests/test_conversions.py @@ -1,15 +1,48 @@ from copy import deepcopy +import numpy as np import pytest import sympy import unyt as u -from mbuild.tests.base_test import BaseTest from unyt.testing import assert_allclose_units -from gmso.utils.conversions import convert_kelvin_to_energy_units +from gmso.tests.base_test import BaseTest +from gmso.utils.conversions import ( + convert_kelvin_to_energy_units, + convert_params_units, +) + + +def _convert_potential_types(top, connStr, expected_units_dim, base_units): + potentials = getattr(top, connStr + "_types") + ref_values = {"energy": "kJ/mol", "length": "nm", "angle": "radians"} + convert_params_units(potentials, expected_units_dim, base_units, ref_values) + return potentials class TestKelvinToEnergy(BaseTest): + def test_convert_potential_styles(self, typed_ethane): + from sympy import sympify + + rb_expr = sympify( + "c0 * cos(phi)**0 + c1 * cos(phi)**1 + c2 * cos(phi)**2 + c3 * cos(phi)**3 + c4 * cos(phi)**4 + c5 * cos(phi)**5" + ) + assert typed_ethane.dihedrals[0].dihedral_type.expression == rb_expr + for dihedral in typed_ethane.dihedrals: + dihedral.dihedral_type.name = "RyckaertBellemansTorsionPotential" + typed_ethane.convert_potential_styles( + {"dihedrals": "OPLSTorsionPotential"} + ) + opls_expr = sympify( + "0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + \ + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))" + ) + assert typed_ethane.dihedrals[0].dihedral_type.expression == opls_expr + assert ( + typed_ethane.dihedrals[0].dihedral_type.name + == "OPLSTorsionPotential" + ) + def test_K_to_kcal(self): input_value = 1 * u.Kelvin / u.nm**2 new_value = convert_kelvin_to_energy_units( @@ -76,3 +109,162 @@ def test_kcal_per_mol_to_string_m(self): input_value, "m", ) + + def test_conversion_for_topology_dihedrals(self, typed_ethane): + expected_units_dim = dict( + zip(["c0", "c1", "c2", "c3", "c4", "c5"], ["energy"] * 6) + ) + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "dihedral", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .dihedral_type.parameters["c0"] + .units.dimensions + ) + == "(length)**2*(mass)/(time)**2" + ) + assert potentials.iterator[0].dihedral_type.parameters[ + "c0" + ].units == u.Unit("kcal/mol") + + def test_conversion_for_topology_angles(self, typed_ethane): + expected_units_dim = dict(k="energy/angle**2", theta_eq="angle") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "angle", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .angle_type.parameters["k"] + .units.dimensions + ) + == "(length)**2*(mass)/((angle)**2*(time)**2)" + ) + assert potentials.iterator[0].angle_type.parameters[ + "k" + ].units == u.Unit("kcal/mol/rad**2") + + def test_conversion_for_topology_bonds(self, typed_ethane): + expected_units_dim = dict(k="energy/length**2", r_eq="length") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "bond", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .bond_type.parameters["k"] + .units.dimensions + ) + == "(mass)/(time)**2" + ) + assert potentials.iterator[0].bond_type.parameters["k"].units == u.Unit( + "kcal/mol/angstrom**2" + ) + + def test_conversion_for_topology_sites(self, typed_ethane): + expected_units_dim = dict(sigma="length", epsilon="energy") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "atom", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .atom_type.parameters["epsilon"] + .units.dimensions + ) + == "(length)**2*(mass)/(time)**2" + ) + assert potentials.iterator[0].atom_type.parameters[ + "epsilon" + ].units == u.Unit("kcal/mol") + + def test_lammps_dimensions_to_energy(self): + from gmso.formats.lammpsdata import _dimensions_to_energy + + units = u.Unit("kg") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == units.dimensions == u.dimensions.mass + units = u.Unit("J") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == sympy.Symbol("(energy)") + assert ( + units.dimensions + == u.dimensions.length**2 + * u.dimensions.mass + / u.dimensions.time**2 + ) + units = u.Unit("kcal/nm") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == sympy.Symbol("(energy)") / u.dimensions.length + assert ( + units.dimensions + == u.dimensions.length * u.dimensions.mass / u.dimensions.time**2 + ) + + def test_lammps_conversion_parameters_base_units(self): + from gmso.formats.lammpsdata import ( + _parameter_converted_to_float, + _unit_style_factory, + ) + + parameter = 100 * u.Unit("kcal/mol*fs/Å") + base_unyts = _unit_style_factory( + "real" + ) # "lammps_real", "Å", "amu", "fs", "K", "rad" + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None + ) + assert float_param == 100 + parameter = 100 * u.Unit("K*fs/amu/nm") + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None + ) + assert float_param == 10 + parameter = 100 * u.Unit("km*g*ms*kJ*degree") + base_unyts = _unit_style_factory( + "si" + ) # "lammps_si", "m", "kg", "s", "K", "rad", + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + ) + assert float_param == round(100 * np.pi / 180, 6) + parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") + base_unyts = _unit_style_factory( + "si" + ) # "lammps_si", "m", "kg", "s", "K", "rad" + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + ) + assert np.isclose(float_param, np.pi / 180, 1e-3) + + def test_lammps_conversion_parameters_lj(self): + from gmso.formats.lammpsdata import ( + _parameter_converted_to_float, + _unit_style_factory, + ) + + parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") + conversion_factorDict = { + "mass": 3 * u.Unit("g"), + "energy": 3 * u.Unit("kJ"), + "charge": 3 * u.Unit("Coulomb"), + "length": 3 * u.Unit("m"), + } + base_unyts = _unit_style_factory("lj") + float_param = _parameter_converted_to_float( + parameter, + base_unyts, + conversion_factorDict=conversion_factorDict, + n_decimals=6, + ) + assert np.isclose(float_param, 1 / 3**4, atol=1e-6) diff --git a/gmso/tests/test_convert_mbuild.py b/gmso/tests/test_convert_mbuild.py index 11e3ab08c..cadd5a6d6 100644 --- a/gmso/tests/test_convert_mbuild.py +++ b/gmso/tests/test_convert_mbuild.py @@ -97,8 +97,8 @@ def test_3_layer_compound(self): top = from_mbuild(top_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.sites[0].molecule == ("bot", 0) - assert top.sites[0].residue == ("bot", 0) + assert top.sites[0].molecule == ("bot", 1) + assert top.sites[0].residue == ("bot", 1) def test_4_layer_compound(self): l0_cmpnd = mb.Compound(name="l0") @@ -115,7 +115,7 @@ def test_4_layer_compound(self): top = from_mbuild(l0_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.sites[0].molecule == ("particle", 0) + assert top.sites[0].molecule == ("particle", 1) def test_uneven_hierarchy(self): top_cmpnd = mb.Compound(name="top") @@ -135,9 +135,9 @@ def test_uneven_hierarchy(self): for site in top.sites: if site.name == "particle2": assert site.group == "mid" - assert site.molecule == ("particle2", 0) + assert site.molecule == ("particle2", 1) elif site.name == "particle1": - assert site.molecule == ("particle1", 0) + assert site.molecule == ("particle1", 1) def test_pass_box(self, mb_ethane): mb_box = Box(lengths=[3, 3, 3]) diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index 2bc5eb6c3..47d10a7db 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -295,7 +295,7 @@ def test_residues_info(self, parmed_hexane_box): for site in top_from_struc.sites: assert site.residue[0] == "HEX" - assert site.residue[1] in list(range(6)) + assert site.residue[1] in list(range(1, 7)) struc_from_top = to_parmed(top_from_struc) assert len(struc_from_top.residues) == len(struc.residues) @@ -538,7 +538,7 @@ def test_pmd_complex_typed(self, parmed_methylnitroaniline): assert top.n_dihedrals == len(struc.rb_torsions) # check typing - assert len(top.atom_types) == len( + assert len(top.atom_types(filter_by=pfilter)) == len( Counter(map(attrgetter("atom_type.name"), struc.atoms)) ) bonds_list = list( diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index 90ca92071..75534fc13 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -108,7 +108,7 @@ def test_resid_for_mol(self): reread = Topology.load("ethane_methane.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2, 3} + assert nums == {1, 2, 3, 4} def test_no_mol_name(self): # here we will just add sites with no molecule information to @@ -123,7 +123,7 @@ def test_no_mol_name(self): top.save("temp_system.gro") reread = Topology.load("temp_system.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0} + assert nums == {1} def test_res_naming(self): top = Topology() @@ -142,7 +142,7 @@ def test_res_naming(self): reread = Topology.load("temp1.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1} + assert nums == {1, 2} top = Topology() ref = Atom( @@ -172,7 +172,7 @@ def test_res_naming(self): reread = Topology.load("temp2.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2} + assert nums == {1, 2, 3} top = Topology() ref = Atom( @@ -198,7 +198,7 @@ def test_res_naming(self): reread = Topology.load("temp3.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2, 3} + assert nums == {1, 2, 3, 4} @pytest.mark.parametrize("fixture", ["benzene_ua_box", "benzene_aa_box"]) def test_full_loop_gro_molecule(self, fixture, request): diff --git a/gmso/tests/test_internal_conversions.py b/gmso/tests/test_internal_conversions.py index 2ef15a27a..3f40d8a62 100644 --- a/gmso/tests/test_internal_conversions.py +++ b/gmso/tests/test_internal_conversions.py @@ -9,7 +9,7 @@ from gmso.tests.base_test import BaseTest from gmso.utils.conversions import ( convert_opls_to_ryckaert, - convert_ryckaert_to_opls, + convert_ryckaert_to_fourier, ) @@ -45,9 +45,7 @@ def test_invalid_connection_type(self, templates): ) with pytest.raises(GMSOError, match="Cannot use"): - opls_connection_type = convert_ryckaert_to_opls( - ryckaert_connection_type - ) + convert_ryckaert_to_fourier(ryckaert_connection_type) expression = "c0+c1+c2+c3+c4+c5+phi" variables = ryckaert_bellemans_torsion_potential.independent_variables @@ -59,9 +57,7 @@ def test_invalid_connection_type(self, templates): ) with pytest.raises(GMSOError, match="Cannot use"): - opls_connection_type = convert_ryckaert_to_opls( - ryckaert_connection_type - ) + convert_ryckaert_to_fourier(ryckaert_connection_type) # Pick some OPLS parameters at random params = { @@ -106,7 +102,7 @@ def test_invalid_connection_type(self, templates): opls_connection_type ) - def test_ryckaert_to_opls(self, templates): + def test_ryckaert_to_fourier(self, templates): # Pick some RB parameters at random params = { "c0": 1.53 * u.Unit("kJ/mol"), @@ -132,8 +128,8 @@ def test_ryckaert_to_opls(self, templates): parameters=params, ) - # Convert connection to OPLS - opls_connection_type = convert_ryckaert_to_opls( + # Convert connection to Fourier + opls_connection_type = convert_ryckaert_to_fourier( ryckaert_connection_type ) @@ -176,7 +172,7 @@ def test_opls_to_ryckaert(self, templates): "k4": 1.44 * u.Unit("kJ/mol"), } - opls_torsion_potential = templates["OPLSTorsionPotential"] + opls_torsion_potential = templates["FourierTorsionPotential"] name = opls_torsion_potential.name expression = opls_torsion_potential.expression variables = opls_torsion_potential.independent_variables @@ -232,7 +228,7 @@ def test_double_conversion(self, templates): "k4": 1.44 * u.Unit("kJ/mol"), } - opls_torsion_potential = templates["OPLSTorsionPotential"] + opls_torsion_potential = templates["FourierTorsionPotential"] name = opls_torsion_potential.name expression = opls_torsion_potential.expression @@ -251,7 +247,7 @@ def test_double_conversion(self, templates): ) # Convert connection back to OPLS - final_connection_type = convert_ryckaert_to_opls( + final_connection_type = convert_ryckaert_to_fourier( ryckaert_connection_type ) diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index 1816aaae9..625465e9b 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -1,45 +1,107 @@ +import copy + +import numpy as np import pytest import unyt as u from unyt.testing import assert_allclose_units import gmso +from gmso import Topology from gmso.core.box import Box from gmso.core.views import PotentialFilters + +pfilter = PotentialFilters.UNIQUE_SORTED_NAMES +from gmso.exceptions import EngineIncompatibilityError +from gmso.external import from_parmed, to_parmed +from gmso.formats.formats_registry import UnsupportedFileFormatError from gmso.formats.lammpsdata import read_lammpsdata, write_lammpsdata from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path +def compare_lammps_files(fn1, fn2, skip_linesList=[], offsets=None): + """Check for line by line equality between lammps files, by any values. + + offsets = [file1: [(start, step)], file2: [(start, step)] + """ + with open(fn1, "r") as f: + line1 = f.readlines() + with open(fn2, "r") as f: + line2 = f.readlines() + length1 = len(line1) + length2 = len(line2) + line_counter1 = 0 + line_counter2 = 0 + while True: + # check for offsets + if offsets: + if offsets[0][0][0] == line_counter1: + line_counter1 += offsets[0][0][1] + offsets[0].pop(0) + elif offsets[1][0][0] == line_counter2: + line_counter2 += offsets[1][0][1] + offsets[1].pop(0) + if line_counter1 in skip_linesList and line_counter1 == line_counter2: + line_counter1 += 1 + line_counter2 += 1 + continue + l1 = line1[line_counter1] + l2 = line2[line_counter2] + print(f"############### ({line_counter1}) ({line_counter2})") + print(l1, l2) + + for arg1, arg2 in zip(l1.split(), l2.split()): + try: + comp1 = float(arg1) + comp2 = float(arg2) + except ValueError: + comp1 = str(arg1) + comp2 = str(arg2) + if isinstance(comp1, float): + assert np.isclose( + comp1, comp2, 1e-3 + ), f"The following two lines have not been found to have equality {l1} and {l2}" + line_counter1 += 1 + line_counter2 += 1 + if line_counter1 >= length1 or line_counter2 >= length2: + break + return True + + class TestLammpsWriter(BaseTest): @pytest.mark.parametrize( "fname", ["data.lammps", "data.data", "data.lammpsdata"] ) def test_write_lammps(self, fname, typed_ar_system): - print(fname) typed_ar_system.save(fname) - def test_write_lammps_triclinic(self, typed_ar_system): - typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) - typed_ar_system.save("triclinic.lammps") - - def test_ethane_lammps(self, typed_ethane): + def test_ethane_lammps_conversion( + self, typed_ethane, are_equivalent_topologies + ): typed_ethane.save("ethane.lammps") + read_top = Topology.load("ethane.lammps") + assert are_equivalent_topologies(read_top, typed_ethane) - def test_water_lammps(self, typed_water_system): - typed_water_system.save("data.lammps") + def test_opls_lammps(self, typed_ethane_opls, are_equivalent_topologies): + typed_ethane_opls.save("ethane.lammps") + read_top = Topology.load("ethane.lammps") + assert are_equivalent_topologies(read_top, typed_ethane_opls) + + def test_water_lammps(self, typed_water_system, are_equivalent_topologies): + typed_water_system.save("water.lammps") + read_top = Topology.load("water.lammps") + assert are_equivalent_topologies(read_top, typed_water_system) def test_read_lammps(self, filename=get_path("data.lammps")): - top = gmso.Topology.load(filename) + gmso.Topology.load(filename) def test_read_box(self, filename=get_path("data.lammps")): read = gmso.Topology.load(filename) - assert read.box == Box(lengths=[1, 1, 1]) def test_read_n_sites(self, typed_ar_system): typed_ar_system.save("ar.lammps") read = gmso.Topology.load("ar.lammps") - assert read.n_sites == 100 def test_read_mass(self, filename=get_path("data.lammps")): @@ -87,8 +149,8 @@ def test_read_water(self, typed_water_system): rtol=1e-5, atol=1e-8, ) - assert water.n_sites == 6 - assert water.n_connections == 6 + assert water.n_sites == 3 + assert water.n_connections == 3 def test_read_lammps_triclinic(self, typed_ar_system): typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) @@ -108,27 +170,22 @@ def test_read_lammps_triclinic(self, typed_ar_system): atol=1e-8, ) - def test_read_n_bonds(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_bonds(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_bonds == 7 - def test_read_n_angles(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_angles(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_angles == 12 - def test_read_bond_params(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_bond_params(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") - bond_params = [ - i.parameters - for i in read.bond_types( - filter_by=PotentialFilters.UNIQUE_PARAMETERS - ) - ] + bond_params = [i.parameters for i in read.bond_types(filter_by=pfilter)] assert_allclose_units( bond_params[0]["k"], @@ -155,10 +212,12 @@ def test_read_bond_params(self, typed_ethane): atol=1e-8, ) - def test_read_angle_params(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_angle_params(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") - angle_params = [i.parameters for i in read.angle_types] + angle_params = [ + i.parameters for i in read.angle_types(filter_by=pfilter) + ] assert_allclose_units( angle_params[0]["k"], @@ -173,23 +232,386 @@ def test_read_angle_params(self, typed_ethane): atol=1e-8, ) assert_allclose_units( - angle_params[-1]["k"], + angle_params[1]["k"], u.unyt_array(66, (u.kcal / u.mol / u.radian / u.radian)), rtol=1e-5, atol=1e-8, ) assert_allclose_units( - angle_params[-1]["theta_eq"], + angle_params[1]["theta_eq"], u.unyt_array(107.8, (u.degree)), rtol=1e-5, atol=1e-8, ) - -""" - def test_read_n_diherals(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_diherals(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_dihedrals == 9 -""" + assert len(read.dihedral_types(filter_by=pfilter)) == 1 + + # TODO: would be good to create a library of molecules and styles to test + # Test potential styles that are directly comparable to ParmEd writers. + @pytest.mark.parametrize( + "top", + [ + "typed_ethane", + "typed_methylnitroaniline", + "typed_methaneUA", + "typed_water_system", + ], + ) + def test_lammps_vs_parmed_by_mol(self, top, request): + """Parmed LAMMPSDATA Compare outputs. + + atom_style = 'full', 'atomic', 'charge', 'molecular' + unit_style = 'real', 'lj' + improper_style = "cvff" + dihedral_style = 'CHARMM', 'OPLS', + angle_style = 'harmonic', 'urey_bradleys' + bond_style = 'harmonic + pair_style = 'lj + """ + top = request.getfixturevalue(top) + pmd_top = to_parmed(top) + top.save("gmso.lammps") + pmd_top.impropers = [] + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps( + structure=pmd_top, + filename="pmd.lammps", + detect_forcefield_style=True, + use_dihedrals=False, + use_rb_torsions=True, + mins=[0, 0, 0], + maxs=top.box.lengths.convert_to_units(u.nm), + ) + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + ) + + @pytest.mark.parametrize( + "atom_style", ["atomic", "charge", "molecular", "full"] + ) + def test_lammps_vs_parmed_by_styles( + self, atom_style, typed_ethane, parmed_ethane + ): + """Test all support styles in lammps writer. + _______References_______ + See https://docs.lammps.org/atom_style.html for more info. + """ + typed_ethane.save("gmso.lammps", atom_style=atom_style) + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps( + structure=parmed_ethane, + filename="pmd.lammps", + atom_style=atom_style, + detect_forcefield_style=True, + use_dihedrals=False, + use_rb_torsions=True, + mins=[0, 0, 0], + maxs=typed_ethane.box.lengths.convert_to_units(u.nm), + ) + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + ) + + def test_lammps_default_conversions( + self, typed_ethane, harmonic_parmed_types_charmm + ): + """Test for parameter intraconversions with potential styles. + + These include: + bonds: factor of 2 harmonic k + angles: factor of 2 harmonic k + dihedrals: RB torsions to OPLS + impropers: factor of 2 harmonic k + pairs: + additional: All styles to zero and none + """ + typed_ethane.save("opls.lammps") + with open("opls.lammps", "r") as f: + lines = f.readlines() + assert lines[38:41] == [ + "Dihedral Coeffs #OPLSTorsionPotential\n", + "#\tk1 (kcal/mol)\tk2 (kcal/mol)\tk3 (kcal/mol)\tk4 (kcal/mol)\n", + "1\t 0.00000\t-0.00000\t 0.30000\t-0.00000\t# opls_140\topls_135\topls_135\topls_140\n", + ] + + struc = harmonic_parmed_types_charmm + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps(struc, "pmd.lammps") + top = from_parmed(struc) + top.save("gmso.lammps") + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + offsets=[[[16, 1], ["none", "none"]], [["none", "none"]]], + ) + out_lammps = open("gmso.lammps", "r").readlines() + found_impropers = False + for i, line in enumerate(out_lammps): + if "Improper Coeffs" in line: + assert "HarmonicImproperPotential" in line + assert "k" in out_lammps[i + 1] + assert "phi_eq" in out_lammps[i + 1] + assert len(out_lammps[i + 2].split("#")[0].split()) == 3 + assert out_lammps[i + 2].split("#")[0].split()[0] == "1" + found_impropers = True + assert found_impropers + + def test_lammps_strict_true(self, typed_ethane): + with pytest.raises(EngineIncompatibilityError): + typed_ethane.save("error.lammps", strict_potentials=True) + typed_ethane = typed_ethane.convert_potential_styles( + { + "dihedrals": "OPLSTorsionPotential", + "bonds": "LAMMPSHarmonicBondPotential", + "angles": "LAMMPSHarmonicAnglePotential", + } + ) + typed_ethane.save("test2.lammps", strict_potentials=True) + + # TODO: Need to add a list of forcefield templates to check with + def test_lammps_potential_styles(self, typed_ethane): + """Test for parameter handling of potential styles. + + ______Styles______ + The GMSO topology potentials must be written in one of these formats in order to be compatable in lammps + bond_styles: ["none", "zero", "fene", "gromos", "morse", "harmonic"] + angle_styles: ["none", "zero", "amoeba", "charmm", "cosine", "fourier", "harmonic"] + dihedral_styles: ["none", "zero", "charmm", "fourier", "harmonic", "opls", "quadratic"] + improper_styles: ["none", "zero", "amoeba", "fourier", "harmonic", "umbrella"] + pair_styles: ["none", "zero", "amoeba", "buck", "coul", "dpd", "gauss", "harmonic", "lj", "mie", "morse", "yukawa"] + Additional Styles: ['special_bonds'] + + _______References_______ + See https://docs.lammps.org/Commands_category.html#force-fields for more info. + """ + # TODO: Create a library of molecules that use the above styles + typed_ethane.save("test.lammps") + # TODO: Read and check test.lammps for correct writing + + @pytest.mark.parametrize( + "unit_style", + ["real", "metal", "si", "cgs", "electron", "micro", "nano", "lj"], + ) + def test_lammps_units(self, typed_ethane, unit_style): + """Generate topoogy with different units and check the output. + Supporte styles are: ["real", "lj", "metal", "si", "cgs", "electron", "micro", "nano"] + _______References_______ + https://docs.lammps.org/units.html + """ + # check the initial set of units + from gmso.formats.lammpsdata import get_units + + # real units should be in: [g/mol, angstroms, fs, kcal/mol, kelvin, electon charge, ...] + mass_multiplierDict = { + "si": 1, + "cgs": 1 / 1000, + "micro": 1e-15, + "nano": 1e-21, + } + length_multiplierDict = {"si": 1e10, "cgs": 1e8} + if unit_style in ["si", "cgs", "micro", "nano"]: + typed_ethane.box.lengths *= length_multiplierDict.get(unit_style, 1) + for atype in typed_ethane.atom_types: + atype.mass = 12 * mass_multiplierDict[unit_style] * u.kg + typed_ethane.save("ethane.lammps", unit_style=unit_style) + real_top = Topology().load("ethane.lammps", unit_style=unit_style) + energy_unit = get_units(unit_style, "energy") + angle_unit = get_units(unit_style, "angle_eq") + length_unit = get_units(unit_style, "length") + charge_unit = get_units(unit_style, "charge") + assert ( + real_top.dihedrals[0].dihedral_type.parameters["k1"].units + == energy_unit + ) + assert ( + real_top.angles[0].angle_type.parameters["theta_eq"].units + == angle_unit + ) + assert ( + real_top.bonds[0].bond_type.parameters["r_eq"].units == length_unit + ) + assert real_top.sites[0].charge.units == charge_unit + if unit_style == "lj": + largest_eps = max( + list( + map( + lambda x: x.parameters["epsilon"], + typed_ethane.atom_types, + ) + ) + ) + largest_sig = max( + list( + map( + lambda x: x.parameters["sigma"], typed_ethane.atom_types + ) + ) + ) + assert_allclose_units( + real_top.dihedrals[0].dihedral_type.parameters["k1"], + ( + typed_ethane.dihedrals[0].dihedral_type.parameters["k1"] + / largest_eps + ), + rtol=1e-5, + atol=1e-8, + ) + assert ( + real_top.dihedrals[0].dihedral_type.parameters["k1"] + == typed_ethane.dihedrals[0].dihedral_type.parameters["k1"] + / largest_eps + ) + assert_allclose_units( + real_top.bonds[0].bond_type.parameters["r_eq"], + ( + typed_ethane.bonds[0].bond_type.parameters["r_eq"] + / largest_sig + ), + rtol=1e-5, + atol=1e-8, + ) + + from gmso.exceptions import EngineIncompatibilityError + + def test_lammps_errors(self, typed_ethane): + with pytest.raises(UnsupportedFileFormatError): + typed_ethane.save("e.lammmps") + missing_bonds_top = copy.deepcopy(typed_ethane) + for bond in missing_bonds_top.bonds: + bond.bond_type = None + with pytest.raises(AttributeError): + missing_bonds_top.save("e.lammps") + with pytest.raises(ValueError): + typed_ethane.save( + "e.lammps", unit_style="lj", lj_cfactorsDict={"bonds": 1} + ) + with pytest.raises(ValueError): + typed_ethane.save("e.lammps", lj_cfactorsDict={"energy": "kJ/mol"}) + + with pytest.raises(ValueError): + typed_ethane.save("error.lammps", atom_style="None") + + with pytest.raises(ValueError): + typed_ethane.save("error.lammps", unit_style="None") + + def test_lammps_units(self, typed_methylnitroaniline): + from gmso.formats.lammpsdata import _validate_unit_compatibility + + usys = u.unit_systems.mks_unit_system + with pytest.raises(AssertionError): + _validate_unit_compatibility(typed_methylnitroaniline, usys) + from gmso.formats.lammpsdata import _unit_style_factory + + usys = _unit_style_factory("real") + _validate_unit_compatibility(typed_methylnitroaniline, usys) + + def test_units_in_headers(self, typed_ethane): + """Make sure units are written out properly.""" + typed_ethane.save("ethane.lammps") + with open("ethane.lammps", "r") as f: + lines = f.readlines() + + unitsDict = { + "Pair": {"epsilon": "kcal/mol", "sigma": "Å"}, + "Bond": {"k": "kcal/(mol*Å**2)", "r_eq": "Å"}, + "Angle": {"k": "kcal/(mol*rad**2)", "theta_eq": "degrees"}, + "Dihedral": {"k1": "kcal/mol"}, + "Improper": {"k": "kcal/(mol*rad**2)", "phi_eq": "degrees"}, + } + for i, line in enumerate(lines): + if "Coeffs" in line: + units = lines[i + 1].split(" \n") + for j in range(len(units[1:-1:2])): + assert units[j * 2 + 1] == unitsDict[units[j * 2 + 2]] + + def test_atom_style_printing(self, typed_ethane): + """Check writers for correctly printing potential eqn.""" + typed_ethane.save("ethane.lammps") + with open("ethane.lammps", "r") as f: + lines = f.readlines() + + stylesDict = { + "Pair": "4*epsilon*(-sigma**6/r**6+sigma**12/r**12)", + "Bond": "#LAMMPSHarmonicBondPotential", + "Angle": "#LAMMPSHarmonicAnglePotential", + "Dihedral": "#OPLSTorsionPotential", + "Improper": "#HarmonicImproperPotential", + } + for i, line in enumerate(lines): + if "Coeffs" in line: + styleLine = lines[i].split() + if styleLine[0] == "Pair": + assert "".join(styleLine[-3:]) == stylesDict[styleLine[0]] + else: + assert styleLine[-1] == stylesDict[styleLine[0]] + + def test_lj_passed_units(self, typed_ethane): + largest_eps = max( + list( + map( + lambda x: x.parameters["epsilon"], + typed_ethane.atom_types, + ) + ) + ) + typed_ethane.save( + "ethane.lammps", + unit_style="lj", + lj_cfactorsDict={"energy": largest_eps * 2}, + ) + with open("ethane.lammps", "r") as f: + lines = f.readlines() + start = 0 + end = 1 + for i in range(len(lines)): + if "Pair Coeffs" in lines[i]: + start = i + if start > 0 and lines[i] == "\n": + end = i + break + largest_eps_written = max( + [ + obj + for obj in map( + lambda x: float(x.split()[1]), lines[start + 2 : end] + ) + ] + ) + assert largest_eps_written == 0.5 + + def test_unit_style_factor(self): + from gmso.formats.lammpsdata import _unit_style_factory + + for styleStr in [ + "real", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: + assert _unit_style_factory(styleStr).name == "lammps_" + styleStr + from gmso.exceptions import NotYetImplementedWarning + + with pytest.raises(NotYetImplementedWarning): + _unit_style_factory("None") diff --git a/gmso/tests/test_potential_templates.py b/gmso/tests/test_potential_templates.py index dd87a24ba..e8ac358ab 100644 --- a/gmso/tests/test_potential_templates.py +++ b/gmso/tests/test_potential_templates.py @@ -48,11 +48,32 @@ def test_mie_potential(self, templates): "sigma": ud.length, } + def test_fourier_torsion_potential(self, templates): + fourier_torsion_potential = templates["FourierTorsionPotential"] + assert fourier_torsion_potential.name == "FourierTorsionPotential" + assert fourier_torsion_potential.expression == sympy.sympify( + "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) +" + "0.5 * k2 * (1 - cos(2*phi)) +" + "0.5 * k3 * (1 + cos(3*phi)) +" + "0.5 * k4 * (1 - cos(4*phi))" + ) + assert fourier_torsion_potential.independent_variables == { + sympy.sympify("phi") + } + + assert fourier_torsion_potential.expected_parameters_dimensions == { + "k0": ud.energy, + "k1": ud.energy, + "k2": ud.energy, + "k3": ud.energy, + "k4": ud.energy, + } + def test_opls_torsion_potential(self, templates): opls_torsion_potential = templates["OPLSTorsionPotential"] assert opls_torsion_potential.name == "OPLSTorsionPotential" assert opls_torsion_potential.expression == sympy.sympify( - "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) +" + "0.5 * k1 * (1 + cos(phi)) +" "0.5 * k2 * (1 - cos(2*phi)) +" "0.5 * k3 * (1 + cos(3*phi)) +" "0.5 * k4 * (1 - cos(4*phi))" @@ -62,12 +83,10 @@ def test_opls_torsion_potential(self, templates): } assert opls_torsion_potential.expected_parameters_dimensions == { - "k0": ud.energy, "k1": ud.energy, "k2": ud.energy, "k3": ud.energy, "k4": ud.energy, - "k5": ud.energy, } def test_periodic_torsion_potential(self, templates): diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index f8e155922..433b758a1 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -82,19 +82,16 @@ def test_water_top(self, water_system): top = water_system ff = gmso.ForceField(get_path("tip3p.xml")) + top = apply(top, ff) for site in top.sites: - site.atom_type = ff.atom_types[site.name] - - top.update_atom_types() + site.atom_type = ff.atom_types[site.atom_type.name] for bond in top.bonds: bond.bond_type = bond.connection_type = ff.bond_types[ "opls_111~opls_112" ] - top.update_bond_types() - for molecule in top.unique_site_labels("molecule"): angle = gmso.core.angle.Angle( connection_members=[ diff --git a/gmso/tests/test_views.py b/gmso/tests/test_views.py index 4dfb2f1c6..5ccd783ea 100644 --- a/gmso/tests/test_views.py +++ b/gmso/tests/test_views.py @@ -74,7 +74,7 @@ def test_ethane_views(self, typed_ethane): unique_atomtypes = atom_types( filter_by=PotentialFilters.UNIQUE_NAME_CLASS ) - assert len(atom_types) == 2 + assert len(atom_types) == 8 assert len(unique_atomtypes) == 2 bond_types = typed_ethane.bond_types diff --git a/gmso/utils/conversions.py b/gmso/utils/conversions.py index 269e8923d..42fed96fb 100644 --- a/gmso/utils/conversions.py +++ b/gmso/utils/conversions.py @@ -1,4 +1,8 @@ """Module for standard conversions needed in molecular simulations.""" +import re +from functools import lru_cache + +import numpy as np import sympy import unyt as u from unyt.dimensions import length, mass, time @@ -7,7 +11,116 @@ from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary +templates = PotentialTemplateLibrary() + + +@lru_cache(maxsize=128) +def _constant_multiplier(pot1, pot2): + # TODO: Doc string + # TODO: Test outputs + # TODO: Check speed + try: + constant = sympy.simplify(pot1.expression / pot2.expression) + if constant.is_number: + for eq_term in pot1.expression.args: + if eq_term.is_symbol: + key = str(eq_term) + return {key: pot1.parameters[key] * float(constant)} + except Exception: + # return nothing if the sympy conversion errors out + pass + return None + + +sympy_conversionsList = [_constant_multiplier] + + +def _try_sympy_conversions(pot1, pot2): + # TODO: Doc string + # TODO: Test outputs + # TODO: Check speed + convertersList = [] + for conversion in sympy_conversionsList: + convertersList.append(conversion(pot1, pot2)) + completed_conversions = np.where(convertersList)[0] + if len(completed_conversions) > 0: # check to see if any conversions worked + return convertersList[ + completed_conversions[0] + ] # return first completed value + return None + + +def convert_topology_expressions(top, expressionMap={}): + """Convert from one parameter form to another. + + Parameters + ---------- + expressionMap : dict, default={} + map with keys of the potential type and the potential to change to + + Examples + -------- + Convert from RB torsions to OPLS torsions + top.convert_expressions({"dihedrals": "OPLSTorsionPotential"}) + """ + # TODO: Raise errors + + # Apply from predefined conversions or easy sympy conversions + conversions_map = { + ( + "OPLSTorsionPotential", + "RyckaertBellemansTorsionPotential", + ): convert_opls_to_ryckaert, + ( + "RyckaertBellemansTorsionPotential", + "OPLSTorsionPotential", + ): convert_ryckaert_to_opls, + ( + "RyckaertBellemansTorsionPotential", + "FourierTorsionPotential", + ): convert_ryckaert_to_opls, + } # map of all accessible conversions currently supported + for conv in expressionMap: + # check all connections with these types for compatibility + for conn in getattr(top, conv): + current_expression = getattr(conn, conv[:-1] + "_type") + if ( + current_expression.name == expressionMap[conv] + ): # check to see if we can skip this one + # TODO: Do something instead of just comparing the names + continue + + # convert it using pre-defined conversion functions + conversion_from_conversion_toTuple = ( + current_expression.name, + expressionMap[conv], + ) + if ( + conversion_from_conversion_toTuple in conversions_map + ): # Try mapped conversions + new_conn_type = conversions_map.get( + conversion_from_conversion_toTuple + )(current_expression) + setattr(conn, conv[:-1] + "_type", new_conn_type) + continue + + # convert it using sympy expression conversion + new_potential = templates[expressionMap[conv]] + modified_connection_parametersDict = _try_sympy_conversions( + current_expression, new_potential + ) + if modified_connection_parametersDict: # try sympy conversions + current_expression.name = new_potential.name + current_expression.expression = new_potential.expression + current_expression.parameters.update( + modified_connection_parametersDict + ) + + return top + + +@lru_cache(maxsize=128) def convert_opls_to_ryckaert(opls_connection_type): """Convert an OPLS dihedral to Ryckaert-Bellemans dihedral. @@ -19,8 +132,8 @@ def convert_opls_to_ryckaert(opls_connection_type): for OPLS and RB torsions. OPLS torsions are defined with phi_cis = 0 while RB torsions are defined as phi_trans = 0. """ - templates = PotentialTemplateLibrary() - opls_torsion_potential = templates["OPLSTorsionPotential"] + # TODO: this function really converts the fourier torsion to rb, not opls + opls_torsion_potential = templates["FourierTorsionPotential"] valid_connection_type = False if ( opls_connection_type.independent_variables @@ -67,14 +180,48 @@ def convert_opls_to_ryckaert(opls_connection_type): expression=expression, independent_variables=variables, parameters=converted_params, + member_types=opls_connection_type.member_types, ) return ryckaert_connection_type +@lru_cache(maxsize=128) def convert_ryckaert_to_opls(ryckaert_connection_type): """Convert Ryckaert-Bellemans dihedral to OPLS. + NOTE: the conventions defining the dihedral angle are different + for OPLS and RB torsions. OPLS torsions are defined with + phi_cis = 0 while RB torsions are defined as phi_trans = 0. + """ + fourier_connection_type = convert_ryckaert_to_fourier( + ryckaert_connection_type + ) + opls_torsion_potential = templates["OPLSTorsionPotential"] + converted_params = { + k: fourier_connection_type.parameters.get(k, None) + for k in ["k1", "k2", "k3", "k4"] + } + + name = opls_torsion_potential.name + expression = opls_torsion_potential.expression + variables = opls_torsion_potential.independent_variables + + opls_connection_type = gmso.DihedralType( + name=name, + expression=expression, + independent_variables=variables, + parameters=converted_params, + member_types=ryckaert_connection_type.member_types, + ) + + return opls_connection_type + + +@lru_cache(maxsize=128) +def convert_ryckaert_to_fourier(ryckaert_connection_type): + """Convert Ryckaert-Bellemans dihedral to Fourier. + NOTE: the conventions defining the dihedral angle are different for OPLS and RB torsions. OPLS torsions are defined with phi_cis = 0 while RB torsions are defined as phi_trans = 0. @@ -83,7 +230,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): ryckaert_bellemans_torsion_potential = templates[ "RyckaertBellemansTorsionPotential" ] - opls_torsion_potential = templates["OPLSTorsionPotential"] + fourier_torsion_potential = templates["FourierTorsionPotential"] valid_connection_type = False if ( @@ -100,7 +247,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): valid_connection_type = True if not valid_connection_type: raise GMSOError( - "Cannot use convert_ryckaert_to_opls " + "Cannot use convert_ryckaert_to_fourier " "function to convert a ConnectionType that is not an " "RyckaertBellemansTorsionPotential" ) @@ -115,7 +262,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): if c5 != 0.0: raise GMSOError( "Cannot convert Ryckaert-Bellemans dihedral " - "to OPLS dihedral if c5 is not equal to zero." + "to Fourier dihedral if c5 is not equal to zero." ) converted_params = { @@ -126,18 +273,19 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): "k4": ((-1.0 / 4.0) * c4), } - name = opls_torsion_potential.name - expression = opls_torsion_potential.expression - variables = opls_torsion_potential.independent_variables + name = fourier_torsion_potential.name + expression = fourier_torsion_potential.expression + variables = fourier_torsion_potential.independent_variables - opls_connection_type = gmso.DihedralType( + fourier_connection_type = gmso.DihedralType( name=name, expression=expression, independent_variables=variables, parameters=converted_params, + member_types=ryckaert_connection_type.member_types, ) - return opls_connection_type + return fourier_connection_type def convert_kelvin_to_energy_units( @@ -219,3 +367,28 @@ def convert_kelvin_to_energy_units( energy_output_unyt = energy_input_unyt return energy_output_unyt + + +def convert_params_units( + potentials, expected_units_dim, base_units, ref_values +): + """Convert parameters' units in the potential to that specified in the base_units.""" + converted_potentials = list() + for potential in potentials: + converted_params = dict() + for parameter in potential.parameters: + unit_dim = expected_units_dim[parameter] + ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() + for unit in ind_units: + if unit != "angle": + unit_dim = unit_dim.replace(unit, f"{base_units[unit]}") + else: + # angle doesn't show up, degree or radian does + unit_dim = unit_dim.replace(unit, str(base_units[unit])) + + converted_params[parameter] = potential.parameters[parameter].to( + u.Unit(unit_dim, registry=base_units.registry) + ) + potential.parameters = converted_params + converted_potentials.append(potential) + return converted_potentials diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 77f6e3756..56e89e0c9 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -62,3 +62,23 @@ def _deprecate_kwargs(kwargs, deprecated_kwargs): DeprecationWarning, 3, ) + + +def mark_WIP(message=""): + """Decorate functions with WIP marking.""" + + def _function_wrapper(function): + @functools.wraps(function) + def _inner(*args, **kwargs): + warnings.simplefilter("always", UserWarning) # turn off filter + warnings.warn( + "Call to function {} is WIP.".format(function.__name__), + category=UserWarning, + stacklevel=2, + ) + warnings.simplefilter("default", UserWarning) # reset filter + return function(*args, **kwargs) + + return _inner + + return _function_wrapper diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml index 706a8ad9c..41b2555c4 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/misc.py b/gmso/utils/misc.py index 6c92eb631..eea849cfb 100644 --- a/gmso/utils/misc.py +++ b/gmso/utils/misc.py @@ -60,7 +60,9 @@ def ensure_valid_dimensions( quantity_1: u.unyt_quantity quantity_2: u.unyt_quantity """ - if quantity_1.units.dimensions != quantity_2.units.dimensions: + if quantity_1.units == u.dimensionless: + return + elif quantity_1.units.dimensions != quantity_2.units.dimensions: raise UnitConversionError( quantity_1.units, quantity_1.units.dimensions,