Пример #1
0
import numpy as np
from scipy.spatial.distance import cdist

from morfeus.d3_data import c6_reference_data, r2_r4
from morfeus.data import ANGSTROM_TO_BOHR
from morfeus.geometry import Atom
from morfeus.typing import Array1DFloat, Array1DInt, Array2DFloat, ArrayLike2D
from morfeus.utils import convert_elements, get_radii, Import, requires_dependency

if typing.TYPE_CHECKING:
    from dftd4.interface import DispersionModel


@requires_dependency(
    [
        Import(module="dftd4.interface", item="DispersionModel"),
    ],
    globals(),
)
class D4Grimme:
    """Calculates D4 Cᴬᴬ coefficients with dftd4.

    Args:
        elements : Elements as atomic symbols or numbers
        coordinates : Coordinates (Å)
        order: Maximum order for the CN coefficients.
        charge: Molecular charge

    Attributes:
        charges: Partial charges
        c_n_coefficients: Cᴬᴬ coefficients (a.u.)
Пример #2
0
class Dispersion:
    """Calculates and stores the results for the 🍺P_int dispersion descriptor.

    The descriptor is defined in 10.1002/anie.201905439. Morfeus can compute it based on
    a surface either from vdW radii, surface vertices or the electron density.
    Dispersion can be obtained with the D3 or D4 model.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        radii: VdW radii (Å)
        radii_type: Choice of vdW radii: 'alvarez', 'bondi', 'crc', 'rahm' and 'truhlar'
        point_surface: Use point surface from vdW radii
        compute_coefficients: Whether to compute D3 coefficients with internal code
        density: Area per point (Ų) on the vdW surface
        excluded_atoms: Atoms to exclude (1-indexed). Used for substituent P_ints
        included_atoms: Atoms to include. Used for functional group P_ints

    Attributes:
        area: Area of surface (Ų)
        atom_areas: Atom indices as keys and atom areas as values (Ų)
        atom_p_int: Atom indices as keys and P_int as values (kcal¹ᐟ² mol⁻¹⸍²))
        atom_p_max: Atom indices as keys and P_max as values (kcal¹ᐟ² mol⁻¹ᐟ²)
        atom_p_min: Atom indices as keys and P_min as values( kcal¹ᐟ² mol⁻¹ᐟ²)
        p_int: P_int value for molecule (kcal¹ᐟ² mol⁻¹ᐟ²)
        p_max: Highest P value (kcal¹ᐟ² mol⁻¹ᐟ²)
        p_min: Lowest P value (kcal¹ᐟ² mol⁻¹ᐟ²)
        p_values: All P values (kcal¹ᐟ² mol⁻¹ᐟ²)
        volume: Volume of surface (ų)

    Raises:
        Exception: When both exluded_atoms and included_atom are given
    """

    area: float
    atom_areas: Dict[int, float]
    atom_p_int: Dict[int, float]
    atom_p_max: Dict[int, float]
    atom_p_min: Dict[int, float]
    p_int: float
    p_max: float
    p_min: float
    p_values: Array1D
    volume: float
    _atoms: List[Atom]
    _c_n_coefficients: Dict[int, Array1D]
    _density: float
    _excluded_atoms: List[int]
    _point_areas: Array1D
    _point_map: Array1D
    _points: Array2D
    _radii: Array1D
    _surface: "pv.PolyData"

    def __init__(
        self,
        elements: Union[Iterable[int], Iterable[str]],
        coordinates: ArrayLike2D,
        radii: Optional[ArrayLike1D] = None,
        radii_type: str = "rahm",
        point_surface: bool = True,
        compute_coefficients: bool = True,
        density: float = 0.1,
        excluded_atoms: Optional[Sequence[int]] = None,
        included_atoms: Optional[Sequence[int]] = None,
    ) -> None:
        # Check that only excluded or included atoms are given
        if excluded_atoms is not None and included_atoms is not None:
            raise Exception(
                "Give either excluded or included atoms but not both.")

        # Converting elements to atomic numbers if the are symbols
        elements = convert_elements(elements, output="numbers")
        coordinates = np.array(coordinates)

        # Set excluded atoms
        all_atoms = set(range(1, len(elements) + 1))
        if included_atoms is not None:
            included_atoms_ = set(included_atoms)
            excluded_atoms = list(all_atoms - included_atoms_)
        elif excluded_atoms is None:
            excluded_atoms = []
        else:
            excluded_atoms = list(excluded_atoms)

        self._excluded_atoms = excluded_atoms

        # Set up
        self._surface = None
        self._density = density

        # Getting radii if they are not supplied
        if radii is None:
            radii = get_radii(elements, radii_type=radii_type)
        radii = np.array(radii)

        self._radii = radii

        # Get vdW surface if requested
        if point_surface:
            self._surface_from_sasa(elements, coordinates)
        else:
            # Get list of atoms as Atom objects
            atoms: List[Atom] = []
            for i, (element, coord,
                    radius) in enumerate(zip(elements, coordinates, radii),
                                         start=1):
                atom = Atom(element, coord, radius, i)
                atoms.append(atom)
            self._atoms = atoms

        # Calculate coefficients
        if compute_coefficients:
            self.compute_coefficients(model="id3")

        # Calculatte P_int values
        if point_surface and compute_coefficients:
            self.compute_p_int()

    def _surface_from_sasa(
        self,
        elements: Union[Iterable[int], Iterable[str]],
        coordinates: ArrayLike2D,
    ) -> None:
        """Get surface from SASA."""
        sasa = SASA(
            elements,
            coordinates,
            radii=self._radii,
            density=self._density,
            probe_radius=0,
        )
        self._atoms = sasa._atoms
        self.area = sum([
            atom.area for atom in self._atoms
            if atom.index not in self._excluded_atoms
        ])
        self.atom_areas = sasa.atom_areas
        self.volume = sum([
            atom.volume for atom in self._atoms
            if atom.index not in self._excluded_atoms
        ])

        # Get point areas and map from point to atom
        point_areas: List[np.ndarray] = []
        point_map = []
        for atom in self._atoms:
            n_points = len(atom.accessible_points)
            if n_points > 0:
                point_area = atom.area / n_points
            else:
                point_area = 0.0
            atom.point_areas = np.repeat(point_area, n_points)
            point_areas.extend(atom.point_areas)
            point_map.extend([atom.index] * n_points)
        self._point_areas = np.array(point_areas)
        self._point_map = np.array(point_map)

    @requires_dependency([Import(module="pyvista", alias="pv")], globals())
    def surface_from_cube(
        self,
        file: Union[str, PathLike],
        isodensity: float = 0.001,
        method: str = "flying_edges",
    ) -> "Dispersion":
        """Adds an isodensity surface from a Gaussian cube file.

        Args:
            file: Gaussian cube file
            isodensity: Isodensity value (electrons/bohr³)
            method: Method for contouring: 'contour' or 'flying_edges

        Returns:
            self: Self
        """
        # Parse the cubefile
        parser = CubeParser(file)

        # Generate grid and fill with values
        grid = pv.UniformGrid()
        grid.dimensions = np.array(parser.X.shape)
        grid.origin = (parser.min_x, parser.min_y, parser.min_z)
        grid.spacing = (parser.step_x, parser.step_y, parser.step_z)
        grid.point_arrays["values"] = parser.S.flatten(order="F")
        self.grid = grid

        # Contour and process the surface
        surface = self._contour_surface(grid,
                                        method=method,
                                        isodensity=isodensity)
        self._surface = surface
        self._process_surface()

        return self

    @requires_dependency(
        [Import("pymeshfix"),
         Import(module="pyvista", alias="pv")], globals())
    def surface_from_multiwfn(self,
                              file: Union[str, PathLike],
                              fix_mesh: bool = True) -> "Dispersion":
        """Adds surface from Multiwfn vertex file with connectivity information.

        Args:
            file: Vertex.pdb file
            fix_mesh: Whether to fix holes in the mesh with pymeshfix (recommended)

        Returns:
            self: Self
        """
        # Read the vertices and faces from the Multiwfn output file
        parser = VertexParser(file)
        vertices = np.array(parser.vertices)
        faces = np.array(parser.faces)
        faces = np.insert(faces, 0, values=3, axis=1)

        # Construct surface and fix it with pymeshfix
        surface = pv.PolyData(vertices, faces, show_edges=True)
        if fix_mesh:
            meshfix = pymeshfix.MeshFix(surface)
            meshfix.repair()
            surface = meshfix.mesh

        # Process surface
        self._surface = surface
        self._process_surface()

        return self

    def _process_surface(self) -> None:
        """Extracts face center points and assigns these to atoms based on proximity."""
        # Get the area and volume
        self.area = self._surface.area
        self.volume = self._surface.volume

        # Assign face centers to atoms according to Voronoi partitioning
        coordinates = np.array([atom.coordinates for atom in self._atoms])
        points = np.array(self._surface.cell_centers().points)
        kd_tree = scipy.spatial.cKDTree(coordinates)
        _, point_regions = kd_tree.query(points, k=1)
        point_regions = point_regions + 1

        # Compute faces areas
        area_data = self._surface.compute_cell_sizes()
        areas = np.array(area_data.cell_arrays["Area"])

        # Assign face centers and areas to atoms
        atom_areas = {}
        for atom in self._atoms:
            atom.accessible_points = points[point_regions == atom.index]
            point_areas = areas[point_regions == atom.index]
            atom.area = np.sum(point_areas)
            atom.point_areas = point_areas
            atom_areas[atom.index] = atom.area

        # Set up attributes
        self.atom_areas = atom_areas
        self._point_areas = areas
        self._point_map = point_regions

    @requires_dependency([Import(module="pyvista", alias="pv"),
                          Import("vtk")], globals())
    @staticmethod
    def _contour_surface(grid: "pv.Grid",
                         method: str = "flying_edges",
                         isodensity: float = 0.001) -> "pv.PolyData":
        """Counter surface from grid.

        Args:
            grid: Electron density as PyVista Grid object
            isodensity: Isodensity value (electrons/bohr³)
            method: Method for contouring: 'contour' or 'flying_edges

        Returns:
            surface: Surface as Pyvista PolyData object
        """
        # Select method for contouring
        if method == "flying_edges":
            contour_filter = vtk.vtkFlyingEdges3D()
        elif method == "contour":
            contour_filter = vtk.vtkContourFilter()

        # Run the contour filter
        isodensity = isodensity
        contour_filter.SetInputData(grid)
        contour_filter.SetValue(0, isodensity)
        contour_filter.Update()
        surface = contour_filter.GetOutput()
        surface = pv.wrap(surface)

        return surface

    def compute_p_int(self,
                      points: Optional[ArrayLike2D] = None) -> "Dispersion":
        """Compute P_int values for surface or points.

        Args:
            points: Points to compute P values for

        Returns:
            self: Self
        """
        # Set up atoms and coefficients that are part of the calculation
        atom_indices = np.array([
            atom.index - 1 for atom in self._atoms
            if atom.index not in self._excluded_atoms
        ])
        coordinates = np.array([atom.coordinates for atom in self._atoms])
        coordinates = coordinates[atom_indices]
        c_n_coefficients = dict(self._c_n_coefficients)
        for key, value in c_n_coefficients.items():
            c_n_coefficients[key] = np.array(
                value)[atom_indices] * HARTREE_TO_KCAL

        # Take surface points if none are given
        if points is None:
            points = np.vstack([
                atom.accessible_points for atom in self._atoms
                if atom.index not in self._excluded_atoms
                and atom.accessible_points.size > 0
            ])
            atomic = True
        else:
            points = np.array(points)

        # Calculate p_int for each point
        dist = scipy.spatial.distance.cdist(points,
                                            coordinates) * ANGSTROM_TO_BOHR
        p = np.sum(
            [
                np.sum(np.sqrt(coefficients / (dist**order)), axis=1)
                for order, coefficients in c_n_coefficients.items()
            ],
            axis=0,
        )
        p = cast(np.ndarray, p)

        self.p_values = p

        # Take out atomic p_ints if no points are given
        if atomic:
            atom_p_max = {}
            atom_p_min = {}
            atom_p_int = {}
            i_start = 0
            for atom in self._atoms:
                if atom.index not in self._excluded_atoms:
                    n_points = len(atom.accessible_points)
                    if n_points > 0:
                        i_stop = i_start + n_points
                        atom_ps = p[i_start:i_stop]
                        atom.p_values = atom_ps
                        atom_p_max[atom.index] = np.max(atom_ps)
                        atom_p_min[atom.index] = np.min(atom_ps)
                        atom_p_int[atom.index] = np.sum(
                            atom_ps * atom.point_areas / atom.area)
                        i_start = i_stop
                    else:
                        atom_p_max[atom.index] = 0
                        atom_p_min[atom.index] = 0
                        atom_p_int[atom.index] = 0
                        atom.p_values = np.array([])
            self.atom_p_max = atom_p_max
            self.atom_p_min = atom_p_min
            self.atom_p_int = atom_p_int

        point_areas = self._point_areas[np.isin(self._point_map,
                                                atom_indices + 1)]
        self.p_int = np.sum(p * point_areas / self.area)

        # Calculate p_min and p_max with slight modification to Robert's
        # definitions
        self.p_min = np.min(p)
        self.p_max = np.max(p)

        # Map p_values onto surface
        if self._surface:
            mapped_p = np.zeros(len(p))
            for atom in self._atoms:
                if atom.index not in self._excluded_atoms:
                    mapped_p[self._point_map == atom.index] = atom.p_values
            self._surface.cell_arrays["values"] = mapped_p
            self._surface = self._surface.cell_data_to_point_data()

        # Store points for later use
        self._points = points

        return self

    def compute_coefficients(self,
                             model: str = "id3",
                             order: int = 8,
                             charge: int = 0) -> "Dispersion":
        """Compute dispersion coefficients.

        Can either use internal D3 model or D4 or D3-like model available through
        Grimme's dftd4 program.

        Args:
            model: Calculation model: 'id3'. 'gd3' or 'gd4'
            order: Order of the Cᴬᴬ coefficients
            charge: Molecular charge for D4 model

        Returns:
            self: Self

        Raises:
            ValueError: When model not supported
        """
        # Set up atoms and coordinates
        elements = [atom.element for atom in self._atoms]
        coordinates = np.array([atom.coordinates for atom in self._atoms])

        calculators = {
            "id3": D3Calculator,
            "gd3": D3Grimme,
            "gd4": D4Grimme,
        }
        calc: Union[D3Calculator, D3Grimme, D4Grimme]
        # Calculate  D3 values with internal model
        if model in ["id3", "gd3"]:
            calc = calculators[model](elements, coordinates, order=order)
        elif model in ["gd4"]:
            calc = calculators[model](elements,
                                      coordinates,
                                      order=order,
                                      charge=charge)
        else:
            raise ValueError(f"model={model} not supported.")
        self._c_n_coefficients = calc.c_n_coefficients

        return self

    def load_coefficients(self, file: Union[str, PathLike],
                          model: str) -> "Dispersion":
        """Load the C₆ and C₈ coefficients.

        Output can be read from the dftd3 and dftd4 programs by giving a file in
        combination with the corresponding model.

        Args:
            file: Output file from the dftd3 or dftd4 programs
            model: Calculation model: 'd3' or 'd4'

        Returns:
            self: Self

        Raises:
            ValueError: When model not supported
        """
        parser: Union[D3Parser, D4Parser]
        if model == "d3":
            parser = D3Parser(file)
        elif model == "d4":
            parser = D4Parser(file)
        else:
            raise ValueError(f"model={model} not supported.")
        self._c_n_coefficients = {}
        self._c_n_coefficients[6] = parser.c6_coefficients
        self._c_n_coefficients[8] = parser.c8_coefficients

        return self

    def print_report(self, verbose: bool = False) -> None:
        """Print report of results.

        Args:
            verbose: Whether to print atom P_ints
        """
        print(f"Surface area (Ų): {self.area:.1f}")
        print(f"Surface volume (ų): {self.volume:.1f}")
        print(f"P_int (kcal¹ᐟ² mol⁻¹ᐟ²): {self.p_int:.1f}")
        if verbose:
            print(
                f"{'Symbol':<10s}{'Index':<10s}{'P_int (kcal^(1/2) mol^(-1/2))':<30s}"
            )
            for atom, (i, p_int) in zip(self._atoms, self.atom_p_int.items()):
                symbol = atomic_symbols[atom.element]
                print(f"{symbol:<10s}{i:<10d}{p_int:<10.1f}")

    def save_vtk(self, filename: str) -> "Dispersion":
        """Save surface as .vtk file.

        Args:
            filename: Name of file. Use .vtk suffix.

        Returns:
            self: Self
        """
        self._surface.save(filename)

        return self

    @requires_dependency(
        [
            Import(module="matplotlib.colors", item="hex2color"),
            Import(module="pyvista", alias="pv"),
            Import(module="pyvistaqt", item="BackgroundPlotter"),
        ],
        globals(),
    )
    def draw_3D(
        self,
        opacity: float = 1,
        display_p_int: bool = True,
        molecule_opacity: float = 1,
        atom_scale: float = 1,
    ) -> None:
        """Draw surface with mapped P_int values.

        Args:
            opacity: Surface opacity
            display_p_int: Whether to display P_int mapped onto the surface
            molecule_opacity: Molecule opacity
            atom_scale: Scale factor for atom size
        """
        # Set up plotter
        p = BackgroundPlotter()

        # Draw molecule
        for atom in self._atoms:
            color = hex2color(jmol_colors[atom.element])
            radius = atom.radius * atom_scale
            sphere = pv.Sphere(center=list(atom.coordinates), radius=radius)
            p.add_mesh(sphere,
                       color=color,
                       opacity=molecule_opacity,
                       name=str(atom.index))

        cmap: Optional[str]
        # Set up plotting of mapped surface
        if display_p_int is True:
            color = None
            cmap = "coolwarm"
        else:
            color = "tan"
            cmap = None

        # Draw surface
        if self._surface:
            p.add_mesh(self._surface, opacity=opacity, color=color, cmap=cmap)
        else:
            point_cloud = pv.PolyData(self._points)
            point_cloud["values"] = self.p_values
            p.add_mesh(
                point_cloud,
                opacity=opacity,
                color=color,
                cmap=cmap,
                render_points_as_spheres=True,
            )

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self._atoms)!r} atoms)"
Пример #3
0
from __future__ import annotations

from collections.abc import Sequence
import typing

import numpy as np

from morfeus.typing import Array1DFloat
from morfeus.utils import Import, requires_dependency

if typing.TYPE_CHECKING:
    import pyvista as pv
    import vtk


@requires_dependency([Import(module="pyvista", alias="pv")], globals())
def get_drawing_arrow(
    start: Sequence[float] | None = None,
    direction: Sequence[float] | None = None,
    length: float = 1,
    shaft_radius: float = 0.05,
    shaft_resolution: int = 20,
    tip_length: float = 0.25,
    tip_radius: float = 0.1,
    tip_resolution: int = 20,
) -> "pv.MultiBlock":
    """Creates PyVista 3D arrow from cone and cylider.

    Args:
        start: Starting point (Å)
        direction: Direction vector (Å)
Пример #4
0
class ConeAngle:
    """Calculates and stores the results of exact cone angle calculation.

    As described in J. Comput. Chem. 2013, 34, 1189.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        atom_1: Index of central atom (1-inexed)
        radii: vdW radii (Å)
        radii_type: Type of vdW radii: 'alvarez', 'bondi', 'crc' or 'truhlar'
        method: Method of calculation: 'internal' or 'libconeangle' (default)

    Attributes:
        cone_angle: Exact cone angle (degrees)
        tangent_atoms: Atoms tangent to cone (1-indexed)

    Raises:
        RunTimeError: If cone angle could not be found by internal algorithm
        ValueError: If atoms within vdW radius of central atom or if exception happened
            with libconeangle or if wrong method chosen
    """

    cone_angle: float
    tangent_atoms: list[int]
    _atoms: list[Atom]
    _max_2_cone: Cone

    def __init__(  # noqa: C901
        self,
        elements: Iterable[int] | Iterable[str],
        coordinates: ArrayLike2D,
        atom_1: int,
        radii: ArrayLike1D | None = None,
        radii_type: str = "crc",
        method: str = "libconeangle",
    ) -> None:
        # Convert elements to atomic numbers if the are symbols
        elements = convert_elements(elements, output="numbers")
        coordinates: Array2DFloat = np.array(coordinates)

        # Get radii if they are not supplied
        if radii is None:
            radii = get_radii(elements, radii_type=radii_type)
        radii: Array1DFloat = np.array(radii)

        # Check so that no atom is within vdW distance of atom 1
        within = check_distances(elements, coordinates, atom_1, radii=radii)
        if len(within) > 0:
            atom_string = " ".join([str(i) for i in within])
            raise ValueError("Atoms within vdW radius of central atom:", atom_string)

        # Set up coordinate array and translate coordinates
        coordinates -= coordinates[atom_1 - 1]

        # Get list of atoms as Atom objects
        atoms: list[Atom] = []
        for i, (element, coord, radius) in enumerate(
            zip(elements, coordinates, radii), start=1
        ):
            if i != atom_1:
                atom = Atom(element, coord, radius, i)
                atom.get_cone()
                atoms.append(atom)
        self._atoms = atoms

        # Calculate cone angle
        if method == "libconeangle":
            try:
                from libconeangle import cone_angle

                angle, axis, tangent_atoms = cone_angle(coordinates, radii, atom_1 - 1)
                self.cone_angle = angle
                self.tangent_atoms = [i + 1 for i in tangent_atoms]
                atoms = [
                    atom for atom in self._atoms if atom.index in self.tangent_atoms
                ]
                self._cone = Cone(self.cone_angle, atoms, axis)
            except ImportError:
                warnings.warn(
                    "Failed to import libconeangle. Defaulting to method='internal'"
                )
                self._cone_angle_internal()
        elif method == "internal":
            self._cone_angle_internal()
        else:
            raise ValueError(
                "Method not implemented. Choose between 'libconeangle' and 'internal'"
            )

    def print_report(self) -> None:
        """Prints report of results."""
        tangent_atoms = [
            atom for atom in self._atoms if atom.index in self.tangent_atoms
        ]
        tangent_labels = [
            f"{atomic_symbols[atom.element]}{atom.index}" for atom in tangent_atoms
        ]
        tangent_string = " ".join(tangent_labels)
        print(f"Cone angle: {self.cone_angle:.1f}")
        print(f"No. tangent atoms: {len(tangent_atoms)}")
        print(f"Tangent to: {tangent_string}")

    def _cone_angle_internal(self) -> None:
        """Calculates cone angle with internal algorithm.

        Raises:
            RuntimeError: If cone cannot be found.
        """
        # Search for cone over single atoms
        cone = self._search_one_cones()

        # Prune out atoms that lie in the shadow of another atom's cone
        if cone is None:
            loop_atoms = list(self._atoms)
            remove_atoms: set[Atom] = set()
            for cone_atom in loop_atoms:
                for test_atom in loop_atoms:
                    if cone_atom is not test_atom:
                        if cone_atom.cone.is_inside(test_atom):
                            remove_atoms.add(test_atom)
            for atom in remove_atoms:
                loop_atoms.remove(atom)
            self._loop_atoms = loop_atoms

        # Search for cone over pairs of atoms
        if cone is None:
            cone = self._search_two_cones()

        # Search for cones over triples of atoms
        if cone is None:
            cone = self._search_three_cones()

        # Check if no cone was found
        if cone is None:
            raise RuntimeError("Cone not found")

        # Set attributes
        self._cone = cone
        self.cone_angle = math.degrees(cone.angle * 2)
        self.tangent_atoms = [atom.index for atom in cone.atoms]

    def _get_upper_bound(self) -> float:
        """Calculates upper bound for apex angle.

        Returns:
            upper_bound: Upper bound to apex angle (radians)
        """
        # Calculate unit vector to centroid
        coordinates: Array2DFloat = np.array([atom.coordinates for atom in self._atoms])
        centroid_vector = np.mean(coordinates, axis=0)
        centroid_unit_vector = centroid_vector / np.linalg.norm(centroid_vector)

        # Getting sums of angle to centroid and vertex angle.
        angle_sums = []
        for atom in self._atoms:
            cone = atom.cone
            cos_angle = np.dot(centroid_unit_vector, cone.normal)
            vertex_angle = math.acos(cos_angle)
            angle_sum = cone.angle + vertex_angle
            angle_sums.append(angle_sum)

        # Select upper bound as the maximum angle
        upper_bound = max(angle_sums)

        return upper_bound

    def _search_one_cones(self) -> Cone | None:
        """Searches over cones tangent to one atom.

        Returns:
            max_1_cone: Largest cone tangent to one atom
        """
        # Get the largest cone
        atoms = self._atoms
        alphas: list[float] = []
        for atom in atoms:
            alphas.append(atom.cone.angle)
        idx = int(np.argmax(alphas))
        max_1_cone = atoms[idx].cone

        # Check if all atoms are contained in cone. If yes, return cone,
        # otherwise, return None.
        in_atoms = []
        test_atoms = [atom for atom in atoms if atom not in max_1_cone.atoms]
        for atom in test_atoms:
            in_atoms.append(max_1_cone.is_inside(atom))
        if all(in_atoms):
            return max_1_cone
        else:
            return None

    def _search_two_cones(self) -> Cone | None:
        """Search over cones tangent to two atoms.

        Returns:
            max_2_cone: Largest cone tangent to two atoms
        """
        # Create two-atom cones
        loop_atoms = self._loop_atoms
        cones = []
        for atom_i, atom_j in itertools.combinations(loop_atoms, r=2):
            cone = _get_two_atom_cone(atom_i, atom_j)
            cones.append(cone)

        # Select largest two-atom cone
        angles = [cone.angle for cone in cones]
        idx = int(np.argmax(angles))
        max_2_cone = cones[idx]
        self._max_2_cone = max_2_cone

        # Check if all atoms are contained in cone. If yes, return cone,
        # otherwise, return None
        in_atoms = []
        for atom in loop_atoms:
            in_atoms.append(max_2_cone.is_inside(atom))

        if all(in_atoms):
            return max_2_cone
        else:
            return None

    def _search_three_cones(self) -> Cone:
        """Search over cones tangent to three atoms.

        Returns:
            min_3_cone: Smallest cone tangent to three atoms
        """
        # Create three-atom cones
        loop_atoms = self._loop_atoms
        cones = []
        for atom_i, atom_j, atom_k in itertools.combinations(loop_atoms, r=3):
            three_cones = _get_three_atom_cones(atom_i, atom_j, atom_k)
            cones.extend(three_cones)

        # Get upper and lower bound to apex angle
        upper_bound = self._get_upper_bound()
        lower_bound = self._max_2_cone.angle

        # Remove cones from consideration which are outside the bounds
        remove_cones = []
        for cone in cones:
            if cone.angle - lower_bound < -1e-5 or upper_bound - cone.angle < -1e-5:
                remove_cones.append(cone)

        for cone in reversed(remove_cones):
            cones.remove(cone)

        # Keep only cones that encompass all atoms
        keep_cones: list[Cone] = []
        for cone in cones:
            in_atoms = []
            for atom in loop_atoms:
                in_atoms.append(cone.is_inside(atom))
            if all(in_atoms):
                keep_cones.append(cone)

        # Take the smallest cone that encompasses all atoms
        angles = [cone.angle for cone in keep_cones]
        idx = int(np.argmin(angles))
        min_3_cone = keep_cones[idx]

        return min_3_cone

    @requires_dependency(
        [
            Import(module="matplotlib.colors", item="hex2color"),
            Import(module="pyvista", alias="pv"),
            Import(module="pyvistaqt", item="BackgroundPlotter"),
        ],
        globals(),
    )
    def draw_3D(
        self,
        atom_scale: float = 1,
        background_color: str = "white",
        cone_color: str = "steelblue",
        cone_opacity: float = 0.75,
    ) -> None:
        """Draw a 3D representation of the molecule with the cone.

        Args:
            atom_scale: Scaling factor for atom size
            background_color: Background color for plot
            cone_color: Cone color
            cone_opacity: Cone opacity
        """
        # Set up plotter
        p = BackgroundPlotter()
        p.set_background(background_color)

        # Draw molecule
        for atom in self._atoms:
            color = hex2color(jmol_colors[atom.element])
            radius = atom.radius * atom_scale
            sphere = pv.Sphere(center=list(atom.coordinates), radius=radius)
            p.add_mesh(sphere, color=color, opacity=1, name=str(atom.index))

        # Determine direction and extension of cone
        angle = math.degrees(self._cone.angle)
        coordinates: Array2DFloat = np.array([atom.coordinates for atom in self._atoms])
        radii: Array1DFloat = np.array([atom.radius for atom in self._atoms])
        if angle > 180:
            normal = -self._cone.normal
        else:
            normal = self._cone.normal
        projected = np.dot(normal, coordinates.T) + np.array(radii)

        max_extension = np.max(projected)
        if angle > 180:
            max_extension += 1

        # Make the cone
        cone = get_drawing_cone(
            center=[0, 0, 0] + (max_extension * normal) / 2,
            direction=-normal,
            angle=angle,
            height=max_extension,
            capping=False,
            resolution=100,
        )
        p.add_mesh(cone, opacity=cone_opacity, color=cone_color)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self._atoms)!r} atoms)"
Пример #5
0
from morfeus.data import ANGSTROM_TO_BOHR, BOHR_TO_ANGSTROM
from morfeus.typing import (
    Array1DFloat,
    Array1DStr,
    Array2DFloat,
    Array3DFloat,
    ArrayLike2D,
)
from morfeus.utils import convert_elements, Import, requires_dependency

if typing.TYPE_CHECKING:
    import qcelemental as qcel
    import qcengine as qcng


@requires_dependency([Import(module="qcengine", alias="qcng")], globals())
def optimize_qc_engine(
    elements: Iterable[int] | Iterable[str],
    coordinates: ArrayLike2D,
    charge: int | None = None,
    multiplicity: int | None = None,
    connectivity_matrix: ArrayLike2D | None = None,
    program: str = "xtb",
    model: dict[str, Any] | None = None,
    keywords: dict[str, Any] | None = None,
    local_options: dict[str, Any] | None = None,
    procedure: str = "berny",
    return_trajectory: bool = False,
) -> tuple[Array2DFloat | Array3DFloat, Array1DFloat]:
    """Optimize molecule with QCEngine.
Пример #6
0
class BuriedVolume:
    """Performs and stores the results of a buried volume calculation.

    Algorithm similar as to described in Organometallics 2016, 35, 2286.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        metal_index: Index of metal atom (1-indexed)
        excluded_atoms: Indices of atoms to exclude (1-indexed). Metal atom is always
            excluded and does not have to be given here.
        radii: vdW radii (Å)
        include_hs: Whether to include H atoms in the calculation
        radius: Radius of sphere (Å)
        radii_type: Type of radii to use: 'alvarez', 'bondi', 'crc' or 'truhlar'
        radii_scale: Scaling factor for radii
        density: Volume per point in the sphere (ų)
        z_axis_atoms: Atom indices for deciding orientation of z axis (1-indexed)
        xz_plane_atoms: Atom indices for deciding orientation of xz plane (1-indexed)

    Attributes:
        buried_volume: Buried volume of sphere (ų)
        distal_volume: Distal volume of ligand (ų)
        fraction_buried_volume: Fraction buried volume of sphere
        free_volume: Free volume of sphere (ų)
        octants: Results for octant analysis
        quadrants: Results for quadrant analysis
    """

    buried_volume: float
    distal_volume: float
    fraction_buried_volume: float
    free_volume: float
    molecular_volume: float
    octants: dict[str, dict[int, float]]
    quadrants: dict[str, dict[int, float]]
    _all_coordinates: Array2DFloat
    _atoms: list[Atom]
    _buried_points: Array2DFloat
    _density: float
    _excluded_atoms: set[int]
    _free_points: Array2DFloat
    _octant_limits: dict[int, tuple[tuple[float, float], tuple[float, float],
                                    tuple[float, float]]]
    _sphere: Sphere

    def __init__(
        self,
        elements: Iterable[int] | Iterable[str],
        coordinates: ArrayLike2D,
        metal_index: int,
        excluded_atoms: Sequence[int] | None = None,
        radii: ArrayLike1D | None = None,
        include_hs: bool = False,
        radius: float = 3.5,
        radii_type: str = "bondi",
        radii_scale: float = 1.17,
        density: float = 0.001,
        z_axis_atoms: Sequence[int] | None = None,
        xz_plane_atoms: Sequence[int] | None = None,
    ) -> None:
        # Get center and and reortient coordinate system
        coordinates: Array2DFloat = np.array(coordinates)
        center = coordinates[metal_index - 1]
        coordinates -= center

        if excluded_atoms is None:
            excluded_atoms = []
        excluded_atoms = set(excluded_atoms)

        if metal_index not in excluded_atoms:
            excluded_atoms.add(metal_index)

        if z_axis_atoms is not None and xz_plane_atoms is not None:
            z_axis_coordinates = coordinates[np.array(z_axis_atoms) - 1]
            z_point = np.mean(z_axis_coordinates, axis=0)

            xz_plane_coordinates = coordinates[np.array(xz_plane_atoms) - 1]
            xz_point = np.mean(xz_plane_coordinates, axis=0)

            v_1 = z_point - center
            v_2 = xz_point - center
            # TODO: Remove type ignores when https://github.com/numpy/numpy/pull/21216 is released
            v_3: Array1DFloat = np.cross(v_2, v_1)
            real: Array2DFloat = np.vstack([v_1, v_3])  # type: ignore
            real /= np.linalg.norm(real, axis=1).reshape(-1, 1)
            ref_1 = np.array([0.0, 0.0, -1.0])
            ref_2 = np.array([0.0, 1.0, 0.0])
            ref = np.vstack([ref_1, ref_2])
            R = kabsch_rotation_matrix(real, ref, center=False)
            coordinates = (R @ coordinates.T).T
        elif z_axis_atoms is not None:
            z_axis_coordinates = coordinates[np.array(z_axis_atoms) - 1]
            z_point = np.mean(z_axis_coordinates, axis=0)
            v_1 = z_point - center
            v_1 = v_1 / np.linalg.norm(v_1)
            coordinates = rotate_coordinates(coordinates, v_1,
                                             np.array([0, 0, -1]))

        self._z_axis_atoms = z_axis_atoms
        self._xz_plane_atoms = xz_plane_atoms

        # Save density and coordinates for steric map plotting.
        self._density = density
        self._all_coordinates = coordinates

        # Converting element ids to atomic numbers if the are symbols
        elements = convert_elements(elements, output="numbers")

        # Getting radii if they are not supplied
        if radii is None:
            radii = get_radii(elements,
                              radii_type=radii_type,
                              scale=radii_scale)
        radii: Array1DFloat = np.array(radii)

        # Get list of atoms as Atom objects
        atoms = []
        for i, (element,
                radius_, coord) in enumerate(zip(elements, radii, coordinates),
                                             start=1):
            if i in excluded_atoms:
                continue
            elif (not include_hs) and element == 1:
                continue
            else:
                atom = Atom(element, coord, radius_, i)
                atoms.append(atom)

        # Set variables for outside access and function access.
        self._atoms = atoms
        self._excluded_atoms = set(excluded_atoms)

        # Compute buried volume
        self._compute_buried_volume(center=center,
                                    radius=radius,
                                    density=density)

    def octant_analysis(self) -> "BuriedVolume":
        """Perform octant analysis of the buried volume."""
        # Set up limits depending on the sphere radius
        lim = self._sphere.radius
        octant_limits = {
            0: ((0, lim), (0, lim), (0, lim)),
            1: ((-lim, 0), (0, lim), (0, lim)),
            3: ((0, lim), (-lim, 0), (0, lim)),
            2: ((-lim, 0), (-lim, 0), (0, lim)),
            7: ((0, lim), (0, lim), (-lim, 0)),
            6: ((-lim, 0), (0, lim), (-lim, 0)),
            4: ((0, lim), (-lim, 0), (-lim, 0)),
            5: ((-lim, 0), (-lim, 0), (-lim, 0)),
        }

        # Calculated volume for each octant.
        octant_volume = self._sphere.volume / 8

        # Do octant analysis
        percent_buried_volume = {}
        buried_volume = {}
        free_volume = {}
        for name, limits in octant_limits.items():
            buried_points = self._buried_points[np.logical_and.reduce([
                self._buried_points[:, 0] > limits[0][0],
                self._buried_points[:, 0] < limits[0][1],
                self._buried_points[:, 1] > limits[1][0],
                self._buried_points[:, 1] < limits[1][1],
                self._buried_points[:, 2] > limits[2][0],
                self._buried_points[:, 2] < limits[2][1],
            ])]
            free_points = self._free_points[np.logical_and.reduce([
                self._free_points[:, 0] > limits[0][0],
                self._free_points[:, 0] < limits[0][1],
                self._free_points[:, 1] > limits[1][0],
                self._free_points[:, 1] < limits[1][1],
                self._free_points[:, 2] > limits[2][0],
                self._free_points[:, 2] < limits[2][1],
            ])]
            fraction_buried = len(buried_points) / (len(buried_points) +
                                                    len(free_points))
            percent_buried_volume[name] = fraction_buried * 100
            buried_volume[name] = fraction_buried * octant_volume
            free_volume[name] = (1 - fraction_buried) * octant_volume
        self.octants = {
            "percent_buried_volume": percent_buried_volume,
            "buried_volume": buried_volume,
            "free_volume": free_volume,
        }

        # Do quadrant analysis
        percent_buried_volume = {}
        buried_volume = {}
        free_volume = {}
        for name, octants in QUADRANT_OCTANT_MAP.items():
            percent_buried_volume[name] = (sum([
                self.octants["percent_buried_volume"][octant]
                for octant in octants
            ]) / 2)
            buried_volume[name] = sum(
                [self.octants["buried_volume"][octant] for octant in octants])
            free_volume[name] = sum(
                [self.octants["free_volume"][octant] for octant in octants])
        self.quadrants = {
            "percent_buried_volume": percent_buried_volume,
            "buried_volume": buried_volume,
            "free_volume": free_volume,
        }

        self._octant_limits = octant_limits

        return self

    def _compute_buried_volume(self, center: ArrayLike1D, radius: float,
                               density: float) -> None:
        """Compute buried volume."""
        center: Array1DFloat = np.array(center)
        # Construct sphere at metal center
        sphere = Sphere(center,
                        radius,
                        method="projection",
                        density=density,
                        filled=True)

        # Prune sphere points which are within vdW radius of other atoms.
        tree = scipy.spatial.cKDTree(sphere.points,
                                     compact_nodes=False,
                                     balanced_tree=False)
        mask: Array1DBool = np.zeros(len(sphere.points), dtype=bool)
        for atom in self._atoms:
            if atom.radius + sphere.radius > np.linalg.norm(atom.coordinates):
                to_prune = tree.query_ball_point(atom.coordinates, atom.radius)
                mask[to_prune] = True
        buried_points = sphere.points[mask, :]
        free_points = sphere.points[np.invert(mask), :]

        # Calculate buried_volume
        self.fraction_buried_volume = len(buried_points) / len(sphere.points)
        self.buried_volume = sphere.volume * self.fraction_buried_volume
        self.free_volume = sphere.volume - self.buried_volume
        self._sphere = sphere
        self._buried_points = buried_points
        self._free_points = free_points

    def compute_distal_volume(self,
                              method: str = "sasa",
                              octants: bool = False,
                              sasa_density: float = 0.01) -> "BuriedVolume":
        """Computes the distal volume.

        Uses either SASA or Buried volume with large radius to calculate the molecular
        volume.

        Args:
            method: Method to get total volume: 'buried_volume' or 'sasa'
            octants: Whether to compute distal volume for quadrants and octants.
                Requires method='buried_volume'
            sasa_density: Density of points on SASA surface. Ignored unless
                method='sasa'

        Returns:
            self: Self

        Raises:
            ValueError: When method is not specified correctly.
        """
        loop_coordinates: list[Array1DFloat]
        # Use SASA to calculate total volume of the molecule
        if method == "sasa":
            # Calculate total volume
            elements: list[int] = []
            loop_coordinates = []
            radii: list[float] = []
            for atom in self._atoms:
                elements.append(atom.element)
                loop_coordinates.append(atom.coordinates)
                radii.append(atom.radius)
            coordinates: Array2DFloat = np.vstack(loop_coordinates)
            sasa = SASA(
                elements,
                coordinates,
                radii=radii,
                probe_radius=0.0,
                density=sasa_density,
            )
            self.molecular_volume = sasa.volume

            # Calculate distal volume
            self.distal_volume = self.molecular_volume - self.buried_volume
        elif method == "buried_volume":
            if octants is True and self.octants is None:
                raise ValueError("Needs octant analysis.")

            # Save the values for the old buried volume calculation
            temp_bv = copy.deepcopy(self)

            # Determine sphere radius to cover the whole molecule
            loop_coordinates = []
            radii = []
            for atom in self._atoms:
                loop_coordinates.append(atom.coordinates)
                radii.append(atom.radius)
            coordinates = np.vstack(loop_coordinates)
            distances = scipy.spatial.distance.cdist(
                self._sphere.center.reshape(1, -1), coordinates)
            new_radius = np.max(distances + radii) + 0.5

            # Compute the distal volume
            temp_bv._compute_buried_volume(
                center=self._sphere.center,
                radius=new_radius,
                density=self._sphere.density,
            )
            self.molecular_volume = temp_bv.buried_volume
            self.distal_volume = self.molecular_volume - self.buried_volume
            if octants is True:
                temp_bv.octant_analysis()

                # Octant analysis
                distal_volume = {}
                molecular_volume = {}
                for name in self.octants["buried_volume"].keys():
                    molecular_volume[name] = temp_bv.octants["buried_volume"][
                        name]
                    distal_volume[name] = (
                        temp_bv.octants["buried_volume"][name] -
                        self.octants["buried_volume"][name])
                self.octants["distal_volume"] = distal_volume
                self.octants["molecular_volume"] = molecular_volume

                # Quadrant analyis
                distal_volume = {}
                molecular_volume = {}
                for name, octants_ in QUADRANT_OCTANT_MAP.items():
                    distal_volume[name] = sum([
                        self.octants["distal_volume"][octant]
                        for octant in octants_
                    ])
                    molecular_volume[name] = sum([
                        self.octants["molecular_volume"][octant]
                        for octant in octants_
                    ])
                self.quadrants["distal_volume"] = distal_volume
                self.quadrants["molecular_volume"] = molecular_volume
        else:
            raise ValueError(f"Method {method} is not valid.")

        return self

    @requires_dependency([Import(module="matplotlib.pyplot", alias="plt")],
                         globals())
    def plot_steric_map(  # noqa: C901
        self,
        filename: str | None = None,
        levels: float = 150,
        grid: int = 100,
        all_positive: bool = True,
        cmap: str = "viridis",
    ) -> None:
        """Plots a steric map as in the original article.

        Args:
            filename: Name of file for saving the plot.
            levels: Number of levels in the contour plot
            grid: Number of points along each axis of plotting grid
            all_positive: Whether to plot only positive values
            cmap: Matplotlib colormap for contour plot

        Raises:
            ValueError: When z-axis atoms not present
        """
        if self._z_axis_atoms is None:
            raise ValueError(
                "Must give z-axis atoms when instantiating BuriedVolume.")
        # Set up coordinates
        atoms = self._atoms
        center: Array1DFloat = np.array(self._sphere.center)
        all_coordinates: Array2DFloat = self._all_coordinates
        coordinates: Array2DFloat = np.array(
            [atom.coordinates for atom in atoms])

        # Translate coordinates
        all_coordinates -= center
        coordinates -= center
        center -= center

        # Get vector to midpoint of z-axis atoms
        z_axis_coordinates = all_coordinates[np.array(self._z_axis_atoms) - 1]
        point = np.mean(z_axis_coordinates, axis=0)
        vector = point - center
        vector = vector / np.linalg.norm(vector)

        # Rotate coordinate system
        coordinates = rotate_coordinates(coordinates, vector,
                                         np.array([0, 0, -1]))

        # Make grid
        r = self._sphere.radius
        x_ = np.linspace(-r, r, grid)
        y_ = np.linspace(-r, r, grid)

        # Calculate z values
        z = []
        for line in np.dstack(np.meshgrid(x_, y_)).reshape(-1, 2):
            if np.linalg.norm(line) > r:
                z.append(np.nan)
                continue
            x = line[0]
            y = line[1]
            z_list = []
            for i, atom in enumerate(atoms):
                # Check if point is within reach of atom.
                x_s = coordinates[i, 0]
                y_s = coordinates[i, 1]
                z_s = coordinates[i, 2]
                test = atom.radius**2 - (x - x_s)**2 - (y - y_s)**2
                if test >= 0:
                    z_atom = math.sqrt(test) + z_s
                    z_list.append(z_atom)
            # Take point which is furthest along z axis
            if z_list:
                z_max = max(z_list)
                # Test if point is inside the sphere. Points with positive z
                # values are included by default anyway in accordance to
                # article
                if all_positive:
                    if z_max < 0:
                        if np.linalg.norm(np.array([x, y, z_max])) >= r:
                            z_max = np.nan
                else:
                    if np.linalg.norm(np.array([x, y, z_max])) >= r:
                        z_max = np.nan
            else:
                z_max = np.nan
            z.append(z_max)

        # Create interaction surface
        z: Array2DFloat = np.array(z).reshape(len(x_), len(y_))

        # Plot surface
        fig, ax = plt.subplots()
        cf = ax.contourf(x_, y_, z, levels, cmap=cmap)
        circle = plt.Circle((0, 0), r, fill=False)
        ax.add_patch(circle)
        plt.xlabel("x (Å)")
        plt.ylabel("y (Å)")
        cf.set_clim(-r, r)
        c_bar = fig.colorbar(cf)
        c_bar.set_label("z(Å)")
        ax.set_aspect("equal", "box")

        if filename:
            plt.savefig(filename)
        else:
            plt.show()

    def print_report(self) -> None:
        """Prints a report of the buried volume."""
        print("V_bur (%):", round(self.fraction_buried_volume * 100, 1))

    @requires_dependency(
        [
            Import(module="matplotlib.colors", item="hex2color"),
            Import(module="pyvista", alias="pv"),
            Import(module="pyvistaqt", item="BackgroundPlotter"),
        ],
        globals(),
    )
    def draw_3D(
        self,
        atom_scale: float = 1,
        background_color: str = "white",
        buried_color: str = "tomato",
        free_color: str = "steelblue",
        opacity: float = 0.5,
        size: float = 5,
    ) -> None:
        """Draw a the molecule with the buried and free points.

        Args:
            atom_scale: Scaling factor for atom size
            background_color: Background color for plot
            buried_color: Color of buried points
            free_color: Color of free points
            opacity: Point opacity
            size: Point size
        """
        # Set up plotter
        p = BackgroundPlotter()
        p.set_background(background_color)

        # Draw molecule
        for atom in self._atoms:
            color = hex2color(jmol_colors[atom.element])
            radius = atom.radius * atom_scale
            sphere = pv.Sphere(center=list(atom.coordinates), radius=radius)
            p.add_mesh(sphere, color=color, opacity=1, name=str(atom.index))

        # Add buried points
        p.add_points(self._buried_points,
                     color=buried_color,
                     opacity=opacity,
                     point_size=size)

        # Add free points
        p.add_points(self._free_points,
                     color=free_color,
                     opacity=opacity,
                     point_size=size)

        if hasattr(self, "_octant_limits"):
            for name, limits_ in self._octant_limits.items():
                limits = tuple(itertools.chain(*limits_))
                box = pv.Box(limits)
                p.add_mesh(box, style="wireframe")
                x = np.array(limits)[:2][np.argmax(np.abs(limits[:2]))]
                y = np.array(limits)[2:4][np.argmax(np.abs(limits[2:4]))]
                z = np.array(limits)[4:][np.argmax(np.abs(limits[4:]))]
                p.add_point_labels(np.array([x, y, z]), [OCTANT_SIGNS[name]],
                                   text_color="black")

        self._plotter = p

    @property
    def percent_buried_volume(self) -> float:
        """Deprecated attribute. Use 'fraction_buried_volume' instead."""
        warnings.warn(
            "'percent_buried_volume' is deprecated. Use 'fraction_buried_volume'.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.fraction_buried_volume

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self._atoms)!r} atoms)"
Пример #7
0
class SASA:
    """Performs and stores results of solvent accessible surface area calculations.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        radii: VdW radii (Å)
        radii_type: Choice of vdW radii: 'bondi' or 'crc' (default)
        probe_radius: Radius of probe atom (Å)
        density: Area per point (Ų) on the vdW surface

    Attributes:
        area: Area of the solvent accessible surface.
        atom_areas: Atom areas (starting from 1)
        atom_volumes: Atom volumes (starting from 1)
        volume: Volume of the solvent accessible surface
    """

    area: float
    atom_areas: dict[int, float]
    atom_volumes: dict[int, float]
    volume: float
    _atoms: list[Atom]
    _density: float
    _probe_radius: float

    def __init__(
        self,
        elements: Iterable[int] | Iterable[str],
        coordinates: ArrayLike2D,
        radii: ArrayLike1D | None = None,
        radii_type: str = "crc",
        probe_radius: float = 1.4,
        density: float = 0.01,
    ) -> None:
        # Converting elements to atomic numbers if the are symbols
        elements = convert_elements(elements, output="numbers")
        coordinates: Array2DFloat = np.array(coordinates)

        # Getting radii if they are not supplied
        if radii is None:
            radii = get_radii(elements, radii_type=radii_type)

        # Increment the radii with the probe radius
        radii: Array1DFloat = np.array(radii)
        radii = radii + probe_radius

        # Construct list of atoms
        atoms = []
        for i, (coordinate, radius,
                element) in enumerate(zip(coordinates, radii, elements),
                                      start=1):
            atom = Atom(element, coordinate, radius, i)
            atoms.append(atom)

        # Set up attributes
        self._atoms = atoms
        self._density = density
        self._probe_radius = probe_radius

        # Determine accessible and occluded points for each atom
        self._determine_accessible_points()

        # Calculate atom areas and volumes
        self._calculate()

    def _calculate(self) -> None:
        """Calculate solvent accessible surface area and volume."""
        for atom in self._atoms:
            # Get number of points of eache type
            n_accessible = len(atom.accessible_points)
            n_occluded = len(atom.occluded_points)
            n_points = len(atom.accessible_points) + len(atom.occluded_points)

            # Calculate part occluded and accessible
            ratio_occluded = n_occluded / n_points
            ratio_accessible = 1 - ratio_occluded

            # Calculate area
            area = 4 * np.pi * atom.radius**2 * ratio_accessible
            atom.area = area
            atom.point_areas = np.zeros(n_points)
            if n_accessible > 0:
                atom.point_areas[
                    atom.accessible_mask] = atom.area / n_accessible

            # Center accessible points and normalize
            centered_points = np.array(
                atom.accessible_points) - atom.coordinates
            centered_points /= np.linalg.norm(centered_points,
                                              axis=1).reshape(-1, 1)

            # Add accessible points
            accessible_summed = np.sum(centered_points, axis=0)

            # Calculate volume
            volume = (4 * np.pi / 3 / n_points) * (
                atom.radius**2 * np.dot(atom.coordinates, accessible_summed) +
                atom.radius**3 * n_accessible)
            atom.volume = volume
            atom.point_volumes = np.zeros(n_points)
            if n_accessible > 0:
                atom.point_volumes[
                    atom.accessible_mask] = atom.volume / n_accessible

        # Set up attributes
        self.atom_areas = {atom.index: atom.area for atom in self._atoms}
        self.atom_volumes = {atom.index: atom.volume for atom in self._atoms}
        self.area = sum([atom.area for atom in self._atoms])
        self.volume = sum([atom.volume for atom in self._atoms])

    def _determine_accessible_points(self) -> None:
        """Determine occluded and accessible points of each atom."""
        # Based on distances to all other atoms (brute force).
        for atom in self._atoms:
            # Construct sphere for atom
            sphere = Sphere(atom.coordinates,
                            atom.radius,
                            density=self._density)
            atom.points = sphere.points

            # Select atoms that are at a distance less than the sum of radii
            # !TODO can be vectorized
            test_atoms = []
            for test_atom in self._atoms:
                if test_atom is not atom:
                    distance = scipy.spatial.distance.euclidean(
                        atom.coordinates, test_atom.coordinates)
                    radii_sum = atom.radius + test_atom.radius
                    if distance < radii_sum:
                        test_atoms.append(test_atom)

            # Select coordinates and radii for other atoms
            test_coordinates = [
                test_atom.coordinates for test_atom in test_atoms
            ]
            test_radii = [test_atom.radius for test_atom in test_atoms]
            test_radii: Array1DFloat = np.array(test_radii).reshape(-1, 1)

            # Get distances to other atoms and subtract radii
            if test_coordinates:
                distances = scipy.spatial.distance.cdist(
                    test_coordinates, sphere.points)
                distances -= test_radii
                # Take smallest distance and perform check
                min_distances = np.min(distances, axis=0)
                atom.occluded_mask = min_distances < 0
                atom.accessible_mask = ~atom.occluded_mask
            else:
                atom.occluded_mask = np.zeros(len(atom.points), dtype=bool)
                atom.accessible_mask = np.ones(len(atom.points), dtype=bool)
            atom.occluded_points = sphere.points[atom.occluded_mask]
            atom.accessible_points = sphere.points[atom.accessible_mask]

    def print_report(self, verbose: bool = False) -> None:
        """Print report of results.

        Args:
            verbose: Whether to print atom areas
        """
        print(f"Probe radius (Å): {self._probe_radius}")
        print(f"Solvent accessible surface area (Ų): {self.area:.1f}")
        print("Volume inside solvent accessible surface (ų): "
              f"{self.volume:.1f}")
        if verbose:
            print(f"{'Symbol':<10s}{'Index':<10s}{'Area (Ų)':<10s}")
            for atom, (i, area) in zip(self._atoms, self.atom_areas.items()):
                symbol = atomic_symbols[atom.element]
                print(f"{symbol:<10s}{i:<10d}{area:<10.1f}")

    @requires_dependency(
        [
            Import(module="matplotlib.colors", item="hex2color"),
            Import(module="pyvista", alias="pv"),
            Import(module="pyvistaqt", item="BackgroundPlotter"),
        ],
        globals(),
    )
    def draw_3D(
        self,
        atom_scale: float = 1,
        background_color: str = "white",
        point_color: str = "steelblue",
        opacity: float = 0.25,
        size: float = 1,
    ) -> None:
        """Draw a 3D representation.

        Draws the molecule with the solvent accessible surface area.

        Args:
            atom_scale: Scaling factor for atom size
            background_color: Background color for plot
            point_color: Color of surface points
            opacity: Point opacity
            size: Point size
        """
        # Set up plotter
        p = BackgroundPlotter()
        p.set_background(background_color)

        # Draw molecule
        for atom in self._atoms:
            color = hex2color(jmol_colors[atom.element])
            radius = atom.radius * atom_scale - self._probe_radius
            sphere = pv.Sphere(center=list(atom.coordinates), radius=radius)
            p.add_mesh(sphere, color=color, opacity=1, name=str(atom.index))

        # Draw surface points
        surface_points: Array2DFloat = np.vstack(
            [atom.accessible_points for atom in self._atoms])
        p.add_points(surface_points,
                     color=point_color,
                     opacity=opacity,
                     point_size=size)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self._atoms)!r} atoms)"
Пример #8
0
            atom = strip_line[0]
            if atom.isdigit():
                atom = int(atom)
            elements.append(atom)
            coordinates.append(
                [float(strip_line[1]), float(strip_line[2]), float(strip_line[3])]
            )
    elements = np.array(elements)[:n_atoms]
    coordinates = np.array(coordinates).reshape(-1, n_atoms, 3)
    if coordinates.shape[0] == 1:
        coordinates = coordinates[0]

    return elements, coordinates


@requires_dependency([Import("cclib")], globals())
def read_cclib(file: Any) -> Tuple[Array1D, Array1D]:
    """Reads geometry file with cclib.

    Returns elements as atomic numbers and coordinates.

    Args:
        file: Input file to cclib

    Returns:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
    """
    data = cclib.io.ccread(file)
    elements = data.atomnos
    coordinates = data.atomcoords
Пример #9
0
import numpy as np

from morfeus.data import ANGSTROM_TO_BOHR, HARTREE_TO_EV
from morfeus.io import read_geometry
from morfeus.typing import Array1DFloat, Array2DFloat, ArrayLike2D
from morfeus.utils import convert_elements, Import, requires_dependency

if typing.TYPE_CHECKING:
    import xtb
    import xtb.interface

IPEA_CORRECTIONS = {"1": 5.700, "2": 4.846}


@requires_dependency([Import("xtb"), Import("xtb.interface")], globals())
class XTB:
    """Calculates electronic properties with the xtb-python package.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        version: Version of xtb to use. Currently works with '1' or '2'.
        charge: Molecular charge
        n_unpaired: Number of unpaired electrons
        solvent: Solvent. See xtb-python documentation
        electronic_temperature: Electronic temperature (K)
    """

    _charge: int
    _coordinates: Array2DFloat
Пример #10
0
class Sterimol:
    """Performs and stores results of Sterimol calculation.

    Args:
        elements: Elements as atomic symbols or numbers
        coordinates: Coordinates (Å)
        dummy_index: Index of dummy atom (1-indexed)
        attached_index: Index of attached atom of substituent (1-indexed). For a list of
            indices, a dummy atom is created at their geometric center
        radii: List of radii (Å)
        radii_type: vdW radii type: 'alvarez', 'bondi', 'crc' or 'truhlar'
        n_rot_vectors: Number of rotational vectors for determining B₁ and B₅
        excluded_atoms: Atom indices to exclude from the calculation
        calculate: Whether to calculate the Sterimol parameters directly

    Attributes:
        B_1_value: Sterimol B₁ value (Å)
        B_1: Sterimol B₁ vector (Å)
        B_5_value: Sterimol B_5 value (Å)
        B_5: Sterimol B₅ vector (Å)
        bond_length: Bond length between atom 1 and atom 2 (Å)
        L_value_uncorrected: Sterimol L value minus 0.40 (Å)
        L_value: Sterimol L value (Å)
        L: Sterimol L vector (Å)
    """

    B_1_value: float
    B_1: Array1D
    B_5_value: float
    B_5: Array1D
    bond_length: float
    L_value_uncorrected: float
    L_value: float
    L: Array1D
    _atoms: List[Atom]
    _attached_atom: Atom
    _dummy_atom: Atom
    _excluded_atoms: Set[int]
    _n_rot_vectors: int
    _origin: Array1D
    _plotter: "BackgroundPlotter"
    _points: Array1D
    _sphere_radius: float

    def __init__(
        self,
        elements: Union[Iterable[int], Iterable[str]],
        coordinates: ArrayLike2D,
        dummy_index: int,
        attached_index: Union[int, Iterable[int]],
        radii: Optional[ArrayLike1D] = None,
        radii_type: str = "crc",
        n_rot_vectors: int = 3600,
        excluded_atoms: Optional[Sequence[int]] = None,
        calculate: bool = True,
    ) -> None:
        # Convert elements to atomic numbers if the are symbols
        elements = convert_elements(elements, output="numbers")
        coordinates = np.array(coordinates)

        if excluded_atoms is None:
            excluded_atoms = []
        excluded_atoms = np.array(excluded_atoms)

        # Get radii if they are not supplied
        if radii is None:
            radii = get_radii(elements, radii_type=radii_type)
        radii = np.array(radii)

        # Add dummy atom if multiple attached indices are given
        if isinstance(attached_index, Iterable):
            attached_dummy_coordinates = np.mean(
                [coordinates[i - 1] for i in attached_index], axis=0)
            attached_dummy_coordinates = cast(np.ndarray,
                                              attached_dummy_coordinates)
            coordinates = np.vstack([coordinates, attached_dummy_coordinates])
            elements.append(0)
            radii = np.concatenate([radii, [0.0]])
            attached_index = len(elements)

        # Set up coordinate array
        all_coordinates = coordinates
        all_radii = radii

        # Translate coordinates so origin is at atom 2
        origin = all_coordinates[attached_index - 1]
        all_coordinates -= origin

        # Get vector pointing from atom 2 to atom 1
        vector_2_to_1 = (all_coordinates[attached_index - 1] -
                         all_coordinates[dummy_index - 1])
        bond_length = np.linalg.norm(vector_2_to_1)
        vector_2_to_1 = vector_2_to_1 / np.linalg.norm(vector_2_to_1)

        # Get rotation quaternion that overlays vector with x-axis
        x_axis = np.array([[1.0, 0.0, 0.0]])
        R = kabsch_rotation_matrix(vector_2_to_1.reshape(1, -1),
                                   x_axis,
                                   center=False)
        all_coordinates = (R @ all_coordinates.T).T
        self._rotation_matrix = R

        # Get list of atoms as Atom objects
        atoms = []
        for i, (element, radius,
                coord) in enumerate(zip(elements, all_radii, all_coordinates),
                                    start=1):
            atom = Atom(element, coord, radius, i)
            atoms.append(atom)
            if i == dummy_index:
                dummy_atom = atom
            if i == attached_index:
                attached_atom = atom

        # Set up attributes
        self._atoms = atoms
        self._excluded_atoms = set(excluded_atoms)
        self._origin = origin

        self._dummy_atom = dummy_atom
        self._attached_atom = attached_atom

        self.bond_length = bond_length

        self._n_rot_vectors = n_rot_vectors

        if calculate:
            self.calculate()

    def set_points(self,
                   points: Sequence[Sequence[float]],
                   shift: bool = True) -> "Sterimol":
        """Set points for calculation of Sterimol.

        Args:
            points: Points (Å)
            shift: Whether to shift the points to have origin at dummy atom.

        Returns:
            self: Self
        """
        points = np.array(points)
        if shift is True:
            points -= self._origin

        return self

    def bury(
        self,
        sphere_radius: float = 5.5,
        method: str = "delete",
        radii_scale: float = 0.5,
        density: float = 0.01,
    ) -> "Sterimol":
        """Do a Buried Sterimol calculation.

        There are three available schemes based on deletion, truncation or slicing.

        Args:
            sphere_radius: Radius of sphere (Å)
            method: Method for burying: 'delete', 'slice' or 'truncate'
            radii_scale: Scale radii for metohd='delete' calculation
            density: Area per point on surface (Ų)

        Returns:
            self: Self

        Raises:
            ValueError: When method is not specified correctly
            Exception: When using method='truncate' and sphere is too small
        """
        if method == "delete":
            # Remove all atoms outside sphere (taking vdW radii into account)
            coordinates = np.vstack([atom.coordinates for atom in self._atoms])
            radii = np.array([atom.radius for atom in self._atoms])
            distances = scipy.spatial.distance.cdist(
                self._dummy_atom.coordinates.reshape(1, -1),
                coordinates).reshape(-1)
            distances = distances - radii * radii_scale
            excluded_atoms = set(
                np.array(self._atoms)[distances >= sphere_radius])
            self._excluded_atoms.update(
                [atom.index for atom in excluded_atoms])

            # Calculate Sterimol parameters
            self.calculate()
        elif method == "truncate":
            # Calculate Sterimol parameters
            self.calculate()

            # Calculate intersection between vectors and sphere.
            atom_1_coordinates = self._dummy_atom.coordinates
            atom_2_coordinates = self._attached_atom.coordinates

            new_vectors = []
            for vector, ref_coordinates in [
                (self.L, atom_1_coordinates),
                (self.B_1, atom_2_coordinates),
                (self.B_5, atom_2_coordinates),
            ]:
                # Get intersection point
                intersection_points = sphere_line_intersection(
                    vector, atom_1_coordinates, sphere_radius)
                if len(intersection_points) < 1:
                    raise Exception(
                        "Sphere so small that vectors don't intersect")

                # Get vector pointing in the right direction
                trial_vectors = [
                    point - ref_coordinates for point in intersection_points
                ]
                norm_vector = vector / np.linalg.norm(vector)
                dot_products = [
                    np.dot(norm_vector,
                           trial_vector / np.linalg.norm(trial_vector))
                    for trial_vector in trial_vectors
                ]
                new_vector = trial_vectors[int(np.argmax(dot_products))]
                new_vectors.append(new_vector)

            # Replace vectors if new ones are shorter than old ones
            if np.linalg.norm(self.L) > np.linalg.norm(new_vectors[0]):
                self.L = new_vectors[0]
                L_value = np.linalg.norm(self.L)
                self.L_value = L_value + 0.40
                self.L_value_uncorrected = L_value
            if np.linalg.norm(self.B_1) > np.linalg.norm(new_vectors[1]):
                self.B_1 = new_vectors[1]
                self.B_1_value = np.linalg.norm(self.B_1)
            if np.linalg.norm(self.B_5) > np.linalg.norm(new_vectors[2]):
                self.B_5 = new_vectors[2]
                self.B_5_value = np.linalg.norm(self.B_5)

        elif method == "slice":
            if not hasattr(self, "_points"):
                self.surface_from_radii(density=density)
            # Remove points outside of sphere
            distances = scipy.spatial.distance.cdist(
                self._dummy_atom.coordinates.reshape(1, -1),
                self._points).reshape(-1)
            self._points = self._points[distances <= sphere_radius]

            # Calculate Sterimol parameters
            self.calculate()
        else:
            raise ValueError(f"Method: {method} is not supported.")

        # Set attributes
        self._sphere_radius = sphere_radius

        return self

    def surface_from_radii(self, density: float = 0.01) -> "Sterimol":
        """Create surface points from vdW surface.

        Args:
            density: Area per point on surface (Ų)

        Returns:
            self: Self
        """
        # Calculate vdW surface for all active atoms
        elements = []
        coordinates = []
        radii = []
        for atom in self._atoms:
            if atom.index not in self._excluded_atoms and atom is not self._dummy_atom:
                elements.append(atom.element)
                coordinates.append(atom.coordinates)
                radii.append(atom.radius)
        elements = np.array(elements)
        coordinates = np.vstack(coordinates)
        radii = radii
        sasa = SASA(elements,
                    coordinates,
                    radii=radii,
                    density=density,
                    probe_radius=0)

        # Take out points of vdW surface
        points = np.vstack([
            atom.accessible_points for atom in sasa._atoms
            if atom.index not in self._excluded_atoms
            and atom.accessible_points.size > 0
        ])
        self._points = points

        return self

    def calculate(self) -> "Sterimol":
        """Calculate Sterimol parameters."""
        # Use coordinates and radii if points are not given
        if not hasattr(self, "_points"):
            coordinates = []
            radii = []
            for atom in self._atoms:
                if (atom is not self._dummy_atom
                        and atom.index not in self._excluded_atoms):
                    coordinates.append(atom.coordinates)
                    radii.append(atom.radius)
            coordinates = np.vstack(coordinates)
            radii = np.vstack(radii).reshape(-1)

        # Project coordinates onto vector between atoms 1 and 2
        vector = self._attached_atom.coordinates - self._dummy_atom.coordinates
        bond_length = np.linalg.norm(vector)
        unit_vector = vector / np.linalg.norm(vector)

        if not hasattr(self, "_points"):
            c_values = np.dot(unit_vector.reshape(1, -1), coordinates.T)
            projected = c_values + radii
        else:
            projected = np.dot(unit_vector.reshape(1, -1), self._points.T)

        # Get L as largest projection along the vector
        L_value = np.max(projected) + bond_length
        L = unit_vector * L_value
        L = L.reshape(-1)

        # Get rotation vectors in yz plane
        r = 1
        theta = np.linspace(0, 2 * math.pi, self._n_rot_vectors)
        x = np.zeros(len(theta))
        y = r * np.cos(theta)
        z = r * np.sin(theta)
        rot_vectors = np.column_stack((x, y, z))

        # Project coordinates onto rotation vectors
        if not hasattr(self, "_points"):
            c_values = np.dot(rot_vectors, coordinates.T)
            projected = c_values + radii
        else:
            projected = np.dot(rot_vectors, self._points.T)
        max_c_values = np.max(projected, axis=1)

        # Determine B1 and B5 from the smallest and largest scalar projections
        B_1_value = np.min(max_c_values)
        B_1 = rot_vectors[np.argmin(max_c_values)] * B_1_value

        B_5_value = np.max(max_c_values)
        B_5 = rot_vectors[np.argmax(max_c_values)] * B_5_value

        # Set up attributes
        self.L = L
        self.L_value = L_value + 0.40
        self.L_value_uncorrected = L_value

        self.B_1 = B_1
        self.B_1_value = B_1_value

        self.B_5 = B_5
        self.B_5_value = B_5_value

        return self

    def print_report(self, verbose: bool = False) -> None:
        """Prints the values of the Sterimol parameters.

        Args:
            verbose: Whether to print uncorrected L_value and bond length
        """
        if verbose:
            print(f"{'L':10s}{'B_1':10s}{'B_5':10s}"
                  f"{'L_uncorr':10s}{'d(a1-a2)':10s}")
            print(f"{self.L_value:<10.2f}{self.B_1_value:<10.2f}"
                  f"{self.B_5_value:<10.2f}{self.L_value_uncorrected:<10.2f}"
                  f"{self.bond_length:<10.2f}")
        else:
            print(f"{'L':10s}{'B_1':10s}{'B_5':10s}")
            print(f"{self.L_value:<10.2f}{self.B_1_value:<10.2f}"
                  f"{self.B_5_value:<10.2f}")

    @requires_dependency(
        [
            Import(module="matplotlib.colors", item="hex2color"),
            Import(module="pyvista", alias="pv"),
            Import(module="pyvistaqt", item="BackgroundPlotter"),
        ],
        globals(),
    )
    def draw_3D(
        self,
        atom_scale: float = 0.5,
        background_color: str = "white",
        arrow_color: str = "steelblue",
    ) -> None:
        """Draw a 3D representation of the molecule with the Sterimol vectors.

        Args:
            atom_scale: Scaling factor for atom size
            background_color: Background color for plot
            arrow_color: Arrow color
        """
        # Set up plotter
        p = BackgroundPlotter()
        p.set_background(background_color)

        # Draw molecule
        for atom in self._atoms:
            color = hex2color(jmol_colors[atom.element])
            if atom.element == 0:
                radius = 0.5 * atom_scale
            else:
                radius = atom.radius * atom_scale
            sphere = pv.Sphere(center=list(atom.coordinates), radius=radius)
            if atom.index in self._excluded_atoms:
                opacity = 0.25
            else:
                opacity = 1
            p.add_mesh(sphere,
                       color=color,
                       opacity=opacity,
                       name=str(atom.index))

        # Draw sphere for Buried Sterimol
        if hasattr(self, "_sphere_radius"):
            sphere = pv.Sphere(center=self._dummy_atom.coordinates,
                               radius=self._sphere_radius)
            p.add_mesh(sphere, opacity=0.25)

        if hasattr(self, "_points"):
            p.add_points(self._points, color="gray")

        # Get arrow starting points
        start_L = self._dummy_atom.coordinates
        start_B = self._attached_atom.coordinates

        # Add L arrow with label
        length = np.linalg.norm(self.L)
        direction = self.L / length
        stop_L = start_L + length * direction
        L_arrow = get_drawing_arrow(start=start_L,
                                    direction=direction,
                                    length=length)
        p.add_mesh(L_arrow, color=arrow_color)

        # Add B_1 arrow
        length = np.linalg.norm(self.B_1)
        direction = self.B_1 / length
        stop_B_1 = start_B + length * direction
        B_1_arrow = get_drawing_arrow(start=start_B,
                                      direction=direction,
                                      length=length)
        p.add_mesh(B_1_arrow, color=arrow_color)

        # Add B_5 arrow
        length = np.linalg.norm(self.B_5)
        direction = self.B_5 / length
        stop_B_5 = start_B + length * direction
        B_5_arrow = get_drawing_arrow(start=start_B,
                                      direction=direction,
                                      length=length)
        p.add_mesh(B_5_arrow, color=arrow_color)

        # Add labels
        points = np.vstack([stop_L, stop_B_1, stop_B_5])
        labels = ["L", "B1", "B5"]
        p.add_point_labels(
            points,
            labels,
            text_color="black",
            font_size=30,
            bold=False,
            show_points=False,
            point_size=1,
        )

        self._plotter = p

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self._atoms)!r} atoms)"
Пример #11
0
from morfeus.d3_data import c6_reference_data, r2_r4
from morfeus.data import ANGSTROM, BOHR, EV, HARTREE
from morfeus.geometry import Atom
from morfeus.typing import Array1D, ArrayLike2D
from morfeus.utils import convert_elements, get_radii, Import, requires_dependency

if typing.TYPE_CHECKING:
    import ase
    from dftd4.calculators import D3_model, D4_model
    from dftd4.utils import extrapolate_c_n_coeff


@requires_dependency(
    [
        Import("ase"),
        Import(module="dftd4.calculators", item="D3_model"),
        Import(module="dftd4.utils", item="extrapolate_c_n_coeff"),
    ],
    globals(),
)
class D3Grimme:
    """Calculates D3-like Cᴬᴬ coefficients with dftd4.

    Args:
        elements : Elements as atomic symbols or numbers
        coordinates : Coordinates (Å)
        order: Maximum order for the CN coefficients.

    Attributes:
        c_n_coefficients: Cᴬᴬ coefficients (a.u.)