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.)
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)"
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 (Å)
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)"
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.
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)"
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)"
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
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
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)"
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.)