Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add missing package error and unit tests for molecule interfaces #150

Merged
merged 13 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ResultsError,
TrajectoryError,
UnitsError,
MissingOptionalPackageError,
)
from scm.plams.core.functions import (
add_to_class,
Expand Down Expand Up @@ -238,6 +239,7 @@
"AMSPipeUnknownMethodError",
"AMSPipeUnknownArgumentError",
"AMSPipeInvalidArgumentError",
"MissingOptionalPackageError",
"ForceFieldPatch",
"forcefield_params_from_kf",
"AMSWorker",
Expand Down
10 changes: 10 additions & 0 deletions core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"UnitsError",
"MoleculeError",
"TrajectoryError",
"MissingOptionalPackageError",
]


Expand Down Expand Up @@ -40,3 +41,12 @@ class MoleculeError(PlamsError):

class TrajectoryError(PlamsError):
""":class:`Trajectory<scm.plams.trajectories.TrajectoryFile>` error."""


class MissingOptionalPackageError(PlamsError):
"""Missing optional package related error."""

def __init__(self, package_name: str):
super().__init__(
f"The optional package '{package_name}' is required for this PLAMS functionality, but is not available. Please install and try again."
)
27 changes: 26 additions & 1 deletion core/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from os.path import join as opj
from typing import Dict, Iterable, Optional, TYPE_CHECKING
import atexit
from importlib.util import find_spec
import functools

from scm.plams.core.errors import FileError
from scm.plams.core.errors import FileError, MissingOptionalPackageError
from scm.plams.core.private import retry
from scm.plams.core.settings import Settings, ConfigSettings
from scm.plams.core.enums import JobStatus
Expand All @@ -29,6 +31,7 @@
"delete_job",
"add_to_class",
"add_to_instance",
"requires_optional_package",
"config",
"read_molecules",
"read_all_molecules_in_xyz_file",
Expand Down Expand Up @@ -472,6 +475,28 @@ def decorator(func):
# ===========================================================================


def requires_optional_package(package_name: str):
"""
Ensures a given package is available before running a function, otherwise raises an ImportError.
This can be used to check for optional dependencies which are required for specific functionality.
:param package_name: name of the required package
"""

def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if find_spec(package_name) is None:
raise MissingOptionalPackageError(package_name)
return func(*args, **kwargs)

return wrapper

return decorator


# ===========================================================================


def parse_heredoc(bash_input: str, heredoc_delimit: str = "eor") -> str:
"""Take a string and isolate the content of a bash-style `Here Document`_.

Expand Down
2 changes: 2 additions & 0 deletions doc/source/general.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ PLAMS requires the following Python packages as dependencies:

* `numpy <http://www.numpy.org>`_
* `dill <https://pypi.python.org/pypi/dill>`_ (enhanced pickling)
* `natsort <https://natsort.readthedocs.io>`_
* `ase <https://wiki.fysik.dtu.dk/ase>`_ (optional dependency)
* `rdkit <https://pypi.org/project/rdkit>`_ (optional dependency)
* `networkx <https://networkx.org>`_ (optional dependency)

If you are using Amsterdam Modeling Suite, all the above packages are already included in our Python stack.
When you install PLAMS using ``pip``, the required packages (numpy and dill) will be installed automatically.
Expand Down
5 changes: 2 additions & 3 deletions interfaces/adfsuite/ams.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import numpy as np
from scm.plams.core.basejob import SingleJob
from scm.plams.core.errors import FileError, JobError, PlamsError, PTError, ResultsError
from scm.plams.core.functions import config, log, parse_heredoc
from scm.plams.core.functions import config, log, parse_heredoc, requires_optional_package
from scm.plams.core.private import sha256
from scm.plams.core.results import Results
from scm.plams.core.settings import Settings
Expand Down Expand Up @@ -209,6 +209,7 @@ def get_molecule(self, section: str, file: str = "ams") -> Molecule:
if sectiondict:
return Molecule._mol_from_rkf_section(sectiondict)

@requires_optional_package("scm.libbase")
def get_system(self, section: str, file: str = "ams") -> "ChemicalSystem":
"""Return a ``ChemicalSystem`` instance stored in a given *section* of a chosen ``.rkf`` file.

Expand All @@ -218,8 +219,6 @@ def get_system(self, section: str, file: str = "ams") -> "ChemicalSystem":

Note that ``ChemicalSystem`` is only available within AMS python. If unavailable, the call will raise an error.
"""
if not _has_scm_chemsys:
raise PlamsError("'ChemicalSystem' not available outside of AMS python.")
return ChemicalSystem.from_kf(self.rkfpath(file), section)

def get_ase_atoms(self, section: str, file: str = "ams") -> "AseAtoms":
Expand Down
71 changes: 38 additions & 33 deletions interfaces/molecule/ase.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import numpy as np
from typing import Optional, TYPE_CHECKING

from scm.plams.core.functions import add_to_class
from scm.plams.mol.molecule import Atom, Molecule, MoleculeError

__all__ = ["toASE", "fromASE"]
ase_present = False
from scm.plams.core.functions import add_to_class, requires_optional_package
from scm.plams.core.settings import Settings
from scm.plams.mol.molecule import Atom, Molecule

try:
import ase

ase_present = True
_has_ase = True
except ImportError:
__all__ = []

_has_ase = False

if TYPE_CHECKING:
from ase import atoms as ASEAtoms


__all__ = ["toASE", "fromASE"]


@add_to_class(Molecule)
@requires_optional_package("ase")
def readase(self, f, **other):
"""Read Molecule using ASE engine

Expand All @@ -31,13 +38,10 @@ def readase(self, f, **other):
The nomenclature of PLAMS and ASE is incompatible for reading multiple geometries, make sure that you only read single geometries with ASE! Reading multiple geometries is not supported, each geometry needs to be read individually.

"""
try:
from ase import io as aseIO
except ImportError:
raise MoleculeError("Asked for ASE IO engine but could not load ASE.io module")
from ase import io

aseMol = aseIO.read(f, **other)
mol = fromASE(aseMol)
ase_mol = io.read(f, **other)
mol = fromASE(ase_mol)
# update self with the molecule read without overwriting e.g. settings
self += mol
# lattice does not survive soft update
Expand All @@ -59,17 +63,18 @@ def writease(self, f, **other):
molecule.writease('filename.anyextension', format='gen')

"""
aseMol = toASE(self)
aseMol.write(f, **other)
ase_mol = toASE(self)
ase_mol.write(f, **other)
return


if ase_present:
if _has_ase:
Molecule._readformat["ase"] = Molecule.readase
Molecule._writeformat["ase"] = Molecule.writease


def toASE(molecule, set_atomic_charges=False):
@requires_optional_package("ase")
def toASE(molecule: Molecule, set_atomic_charges: bool = False) -> "ASEAtoms":
"""Convert a PLAMS |Molecule| to an ASE molecule (``ase.Atoms`` instance). Translate coordinates, atomic numbers, and lattice vectors (if present). The order of atoms is preserved.


Expand All @@ -84,7 +89,7 @@ def toASE(molecule, set_atomic_charges=False):
if not all(isinstance(x, (int, float)) for x in atom.coords):
raise ValueError("Non-Number in Atomic Coordinates, not compatible with ASE")

aseMol = ase.Atoms(numbers=molecule.numbers, positions=molecule.as_array())
ase_mol = ase.Atoms(numbers=molecule.numbers, positions=molecule.as_array())

# get lattice info if any
lattice = np.zeros((3, 3))
Expand All @@ -98,10 +103,10 @@ def toASE(molecule, set_atomic_charges=False):
pbc[i] = True
lattice[i] = np.array(vec)

# save lattice info to aseMol
# save lattice info to ase_mol
if any(pbc):
aseMol.set_pbc(pbc)
aseMol.set_cell(lattice)
ase_mol.set_pbc(pbc)
ase_mol.set_cell(lattice)

if set_atomic_charges:
charge = molecule.properties.get("charge", 0)
Expand All @@ -110,22 +115,22 @@ def toASE(molecule, set_atomic_charges=False):
else:
atomic_charges = [float(charge)] + [0.0] * (len(molecule) - 1)

aseMol.set_initial_charges(atomic_charges)
ase_mol.set_initial_charges(atomic_charges)

return aseMol
return ase_mol


def fromASE(molecule, properties=None, set_charge=False):
def fromASE(molecule: "ASEAtoms", properties: Optional[Settings] = None, set_charge: bool = False) -> Molecule:
"""Convert an ASE molecule to a PLAMS |Molecule|. Translate coordinates, atomic numbers, and lattice vectors (if present). The order of atoms is preserved.

Pass a |Settings| instance through the ``properties`` option to inherit them to the returned molecule.
"""
plamsMol = Molecule()
plams_mol = Molecule()

# iterate over ASE atoms
for atom in molecule:
# add atom to plamsMol
plamsMol.add_atom(Atom(atnum=atom.number, coords=tuple(atom.position)))
# add atom to plams_mol
plams_mol.add_atom(Atom(atnum=atom.number, coords=tuple(atom.position)))

# add Lattice if any
if any(molecule.get_pbc()):
Expand All @@ -135,13 +140,13 @@ def fromASE(molecule, properties=None, set_charge=False):
if boolean:
lattice.append(tuple(molecule.get_cell()[i]))

# write lattice to plamsMol
plamsMol.lattice = lattice.copy()
# write lattice to plams_mol
plams_mol.lattice = lattice.copy()

if properties:
plamsMol.properties.update(properties)
plams_mol.properties.update(properties)
if (properties and "charge" not in properties or not properties) and set_charge:
plamsMol.properties.charge = sum(molecule.get_initial_charges())
plams_mol.properties.charge = sum(molecule.get_initial_charges())
if "charge" in molecule.info:
plamsMol.properties.charge += molecule.info["charge"]
return plamsMol
plams_mol.properties.charge += molecule.info["charge"]
return plams_mol
11 changes: 3 additions & 8 deletions interfaces/molecule/packmol.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@
from scm.plams.mol.molecule import Molecule
from scm.plams.tools.periodic_table import PeriodicTable
from scm.plams.tools.units import Units

try:
from scm.plams.interfaces.molecule.rdkit import readpdb, writepdb
except ImportError:
pass
from scm.plams.interfaces.molecule.rdkit import readpdb, writepdb

__all__ = [
"packmol",
Expand Down Expand Up @@ -286,9 +282,8 @@ def run(self):
structure.molecule.write(structure_fname)
input_file.write(structure.get_input_block(structure_fname, tolerance=self.tolerance))

my_input = open(input_fname, "r")
saferun(self.executable, stdin=my_input, stdout=subprocess.DEVNULL)
my_input.close()
with open(input_fname) as my_input:
saferun(self.executable, stdin=my_input, stdout=subprocess.DEVNULL)

if not os.path.exists(output_fname):
raise PackMolError("Packmol failed. It may work if you try a lower density.")
Expand Down
Loading
Loading