Source code for shepherd_score.visualize

"""
Visualize pharmacophores and exit vectors with py3dmol.
"""
from __future__ import annotations
from typing import Union, List, Literal, Optional, Sequence, TYPE_CHECKING
from pathlib import Path
from copy import deepcopy
import time

import numpy as np
from matplotlib.colors import to_hex

from rdkit import Chem
from rdkit.Chem import AllChem

# drawing
import py3Dmol
from IPython.display import SVG
import matplotlib.colors as mcolors
from rdkit.Chem.Draw import rdMolDraw2D


from shepherd_score.pharm_utils.pharmacophore import (
    Pharmacophore,
    feature_colors,
    get_pharmacophores_dict,
    get_pharmacophores,
)
from shepherd_score.container.profiles import Surface
from shepherd_score.evaluations.utils.convert_data import get_xyz_content_with_dummy
from shepherd_score.score.constants import P_TYPES

if TYPE_CHECKING:
    from shepherd_score.container import Molecule

P_TYPES_LWRCASE = tuple(map(str.lower, P_TYPES))
P_IND2TYPES = {i : p for i, p in enumerate(P_TYPES)}


def __draw_arrow(view, color, anchor_pos, rel_unit_vec, flip: bool = False, opacity: float = 1.0):
    """
    Add arrow
    """
    keys = ['x', 'y', 'z']
    if flip:
        flip = -1.
    else:
        flip = 1.

    view.addArrow({
        'start' : {k: float(anchor_pos[i]) for i, k in enumerate(keys)},
        'end' : {k: float(flip*2*rel_unit_vec[i] + anchor_pos[i]) for i, k in enumerate(keys)},
        'radius': .1,
        'radiusRatio':2.5,
        'mid':0.7,
        'color':to_hex(color),
        'opacity': opacity
    })


_COORD_KEYS = ('x', 'y', 'z')


def _ensure_view(view=None, width: int = 800, height: int = 400):
    if view is None:
        view = py3Dmol.view(width=width, height=height)
        view.removeAllModels()
    return view


def _compute_esp_colors(esp: np.ndarray) -> np.ndarray:
    esp_colors = np.zeros((len(esp), 3))
    norm = np.max((np.max(-esp), np.max(esp)))
    esp_colors[:, 2] = np.where(esp < 0, 0, esp / norm).squeeze()
    esp_colors[:, 0] = np.where(esp >= 0, 0, -esp / norm).squeeze()
    return esp_colors


def _resolve_custom_carbon_color(custom_carbon_color: str) -> str:
    if custom_carbon_color == 'dark slate grey':
        return '#2F4F4F'
    if custom_carbon_color == 'light steel blue':
        return '#B0C4DE'
    if custom_carbon_color.startswith('#'):
        return custom_carbon_color
    raise ValueError(f'Expects hex code for custom_carbon_color, got "{custom_carbon_color}"')


[docs] def draw_mol(mol: Union[Chem.Mol, str], removeHs: bool = False, opacity: float = 1.0, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, highlight_atoms: Optional[List[int]] = None, dummy_atom_pos=None, add_SAS: bool = False, width: int = 800, height: int = 400, view=None): """ Draw a molecule (RDKit mol or XYZ string) into a py3Dmol view. Parameters ---------- mol : Chem.Mol | str RDKit molecule or XYZ-format string (need not be a valid structure). removeHs : bool Whether to strip hydrogens before drawing. opacity : float Stick opacity for the molecule. color_scheme : str, optional py3Dmol color scheme (e.g. ``'whiteCarbon'``). custom_carbon_color : str, optional Hex color or preset name for carbon atoms. highlight_atoms : list of int, optional Atom serial indices to highlight in purple. dummy_atom_pos : array-like (N, 3), optional Positions of dummy atoms to render as lavender spheres. add_SAS : bool Whether to add a solvent-accessible surface. width, height : int View dimensions when creating a new view. view : py3Dmol.view, optional Existing view; if ``None``, a new view is created and zoomed. """ created = view is None view = _ensure_view(view, width=width, height=height) if removeHs: mol = Chem.RemoveHs(mol) if isinstance(mol, Chem.Mol): view.addModel(Chem.MolToMolBlock(mol), 'sdf') else: view.addModel(mol, 'xyz') if highlight_atoms is not None: view.setStyle({'serial': highlight_atoms}, {'stick': {'color': 'purple'}}) if color_scheme is not None: view.setStyle({'model': -1}, {'stick': {'colorscheme': color_scheme, 'opacity': opacity}}) elif custom_carbon_color is not None: carbon_color = _resolve_custom_carbon_color(custom_carbon_color) view.setStyle({'model': -1, 'elem': 'C'}, {'stick': {'color': carbon_color, 'opacity': opacity}}) view.setStyle({'model': -1, 'not': {'elem': 'C'}}, {'stick': {'opacity': opacity}}) else: view.setStyle({'model': -1}, {'stick': {'opacity': opacity}}) if dummy_atom_pos is not None: clr = (.8, .6, 1.) for pos in dummy_atom_pos: view.addSphere({ 'center': {_COORD_KEYS[k]: float(pos[k]) for k in range(3)}, 'radius': .45, 'color': to_hex(clr), 'opacity': 0.9, }) if add_SAS: view.addSurface(py3Dmol.SAS, {'opacity': 0.5}) if created: view.zoomTo() return view
[docs] def draw_pharm(pharmacophore: Union[Pharmacophore, None] = None, feats: Optional[dict] = None, pharm_types: Union[np.ndarray, None] = None, pharm_ancs: Union[np.ndarray, None] = None, pharm_vecs: Union[np.ndarray, None] = None, ev_pos=None, ev_vecs=None, opacity_features: float = 0.9, width: int = 800, height: int = 400, view=None): """ Draw pharmacophore features into a py3Dmol view. Accepts a :class:`~shepherd_score.pharm_utils.pharmacophore.Pharmacophore` container, a legacy ``feats`` dict from :func:`get_pharmacophores_dict`, or separate ``pharm_types`` / ``pharm_ancs`` / ``pharm_vecs`` arrays. Parameters ---------- pharmacophore : Pharmacophore, optional Pharmacophore profile container. feats : dict, optional Feature-family dict with ``'P'`` positions and ``'V'`` vectors per type. pharm_types, pharm_ancs, pharm_vecs : np.ndarray, optional Flat pharmacophore arrays (used when ``feats`` is empty and no ``Pharmacophore`` is given). ev_pos, ev_vecs : array-like, optional Exit-vector anchor positions and unit vectors. opacity_features : float Opacity of pharmacophore spheres and arrows. width, height : int View dimensions when creating a new view. view : py3Dmol.view, optional Existing view; if ``None``, a new view is created and zoomed. """ created = view is None view = _ensure_view(view, width=width, height=height) if feats is None: feats = {} if isinstance(pharmacophore, Pharmacophore): pharm_types = pharmacophore.types pharm_ancs = pharmacophore.positions pharm_vecs = pharmacophore.vectors if feats: for fam in feats: clr = feature_colors.get(fam, (.5, .5, .5)) num_points = len(feats[fam]['P']) for i in range(num_points): pos = feats[fam]['P'][i] view.addSphere({ 'center': {_COORD_KEYS[k]: float(pos[k]) for k in range(3)}, 'radius': .5, 'color': to_hex(clr), 'opacity': opacity_features, }) if fam not in ('Aromatic', 'Donor', 'Acceptor', 'Halogen'): continue vec = feats[fam]['V'][i] __draw_arrow(view, clr, pos, vec, flip=False, opacity=opacity_features) if fam == 'Aromatic': __draw_arrow(view, clr, pos, vec, flip=True, opacity=opacity_features) elif pharm_types is not None and pharm_ancs is not None and pharm_vecs is not None: for i, ptype in enumerate(pharm_types): if ptype < 0 or ptype >= len(P_TYPES): continue fam = P_IND2TYPES[ptype] clr = feature_colors.get(fam, (.5, .5, .5)) view.addSphere({ 'center': {_COORD_KEYS[k]: float(pharm_ancs[i][k]) for k in range(3)}, 'radius': .5, 'color': to_hex(clr), 'opacity': opacity_features, }) if fam not in ('Aromatic', 'Donor', 'Acceptor', 'Halogen', 'Dummy'): continue vec = pharm_vecs[i] __draw_arrow(view, clr, pharm_ancs[i], vec, flip=False, opacity=opacity_features) if fam == 'Aromatic': __draw_arrow(view, clr, pharm_ancs[i], vec, flip=True, opacity=opacity_features) if ev_pos is not None: for i, pos in enumerate(ev_pos): clr = (0., 0., 0.) if ev_vecs is not None: __draw_arrow(view, clr, pos, ev_vecs[i], flip=False, opacity=0.9) if created: view.zoomTo() return view
[docs] def draw_surface(surface: Union[Surface, None] = None, point_cloud=None, esp: Union[np.ndarray, None] = None, opacity: float = 0.5, radius: float = 0.1, width: int = 800, height: int = 400, view=None): """ Draw a molecular surface point cloud into a py3Dmol view. Accepts a :class:`~shepherd_score.container.profiles.Surface` container or separate ``point_cloud`` / ``esp`` arrays. Parameters ---------- surface : Surface, optional Surface profile with ``positions`` and optional ``esp``. point_cloud : array-like (N, 3), optional Surface point positions (overrides ``surface.positions`` when given). esp : np.ndarray (N,), optional Electrostatic potential per point (overrides ``surface.esp`` when given). opacity : float Sphere opacity for surface points. radius : float Sphere radius for surface points. width, height : int View dimensions when creating a new view. view : py3Dmol.view, optional Existing view; if ``None``, a new view is created and zoomed. """ created = view is None view = _ensure_view(view, width=width, height=height) if isinstance(surface, Surface): if point_cloud is None: point_cloud = surface.positions if esp is None: esp = surface.esp if point_cloud is None: if created: view.zoomTo() return view esp_colors = _compute_esp_colors(esp) if esp is not None else None if isinstance(point_cloud, np.ndarray): point_cloud = point_cloud.tolist() clr = np.zeros(3) for i, pc in enumerate(point_cloud): if esp_colors is not None: if np.sqrt(np.sum(np.square(esp_colors[i]))) < 0.3: clr = np.ones(3) else: clr = esp_colors[i] else: clr = np.ones(3) view.addSphere({ 'center': {'x': float(pc[0]), 'y': float(pc[1]), 'z': float(pc[2])}, 'radius': radius, 'color': to_hex(clr), 'opacity': opacity, }) if created: view.zoomTo() return view
[docs] def draw(mol: Union[Chem.Mol, str], pharmacophore: Union[Pharmacophore, None] = None, surface: Union[Surface, None] = None, feats: dict = {}, pharm_types: Union[np.ndarray, None] = None, pharm_ancs: Union[np.ndarray, None] = None, pharm_vecs: Union[np.ndarray, None] = None, point_cloud=None, esp=None, dummy_atom_pos=None, ev_pos=None, ev_vecs=None, add_SAS=False, view=None, removeHs=False, opacity=1.0, opacity_features=0.9, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, highlight_atoms: Optional[List[int]] = None, width=800, height=400): """ Draw molecule with pharmacophore features and surface point cloud. Convenience wrapper around :func:`draw_mol`, :func:`draw_pharm`, and :func:`draw_surface`. Parameters ---------- mol : Chem.Mol | str RDKit molecule or XYZ-format string. pharmacophore : Pharmacophore, optional Pharmacophore profile container. Takes precedence over ``feats`` and flat ``pharm_*`` arrays when given. surface : Surface, optional Surface profile container. ``point_cloud`` / ``esp`` override container fields when explicitly provided. feats : dict, optional Legacy pharmacophore dict from :func:`get_pharmacophores_dict`. pharm_types, pharm_ancs, pharm_vecs : np.ndarray, optional Flat pharmacophore arrays. point_cloud : array-like (N, 3), optional Surface point positions. esp : np.ndarray (N,), optional Electrostatic potential per surface point. view : py3Dmol.view, optional Existing view; if ``None``, a new view is created. """ view = draw_mol( mol, removeHs=removeHs, opacity=opacity, color_scheme=color_scheme, custom_carbon_color=custom_carbon_color, highlight_atoms=highlight_atoms, dummy_atom_pos=dummy_atom_pos, add_SAS=add_SAS, width=width, height=height, view=view, ) view = draw_pharm( pharmacophore=pharmacophore, feats=feats, pharm_types=pharm_types, pharm_ancs=pharm_ancs, pharm_vecs=pharm_vecs, ev_pos=ev_pos, ev_vecs=ev_vecs, opacity_features=opacity_features, view=view, ) view = draw_surface( surface=surface, point_cloud=point_cloud, esp=esp, view=view, ) view.zoomTo() return view
def _process_generated_sample( generated_sample: dict, model_type: Literal['all', 'x2', 'x3', 'x4'] = 'all' ) -> tuple[str, np.ndarray | None, np.ndarray | None, np.ndarray | None, np.ndarray | None, np.ndarray | None, np.ndarray | None]: if 'x1' not in generated_sample or 'atoms' not in generated_sample['x1'] or 'positions' not in generated_sample['x1']: raise ValueError('Generated sample does not contain atoms and positions in expected dict.') if model_type not in ['all', 'x2', 'x3', 'x4']: raise ValueError(f'Invalid model type: {model_type}') xyz_block, dummy_atom_pos = get_xyz_content_with_dummy(generated_sample['x1']['atoms'], generated_sample['x1']['positions']) surf_pos = generated_sample['x3']['positions'] if model_type in ['all', 'x3'] else None if model_type == 'x2': surf_pos = generated_sample['x2']['positions'] surf_esp = generated_sample['x3']['charges'] if model_type in ['all', 'x3'] else None pharm_types = generated_sample['x4']['types'] if model_type in ['all', 'x4'] else None pharm_ancs = generated_sample['x4']['positions'] if model_type in ['all', 'x4'] else None pharm_vecs = generated_sample['x4']['directions'] if model_type in ['all', 'x4'] else None return xyz_block, dummy_atom_pos, surf_pos, surf_esp, pharm_types, pharm_ancs, pharm_vecs
[docs] def draw_sample( generated_sample: dict, ref_mol = None, only_atoms = False, model_type: Literal['all', 'x2', 'x3', 'x4'] = 'all', opacity = 0.6, view = None, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, width = 800, height = 400, ): """ Draw generated ShEPhERD sample with pharmacophore features and point cloud. Draws on surface accessible surface and electrostatics, optionally overlaid on the reference molecule. Parameters ---------- generated_sample : dict The generated sample dictionary. Note that it does NOT use x2 and assumes shape positions are in x3. Expected format:: {'x1': {'atoms': np.ndarray, 'positions': np.ndarray}, 'x2': {'positions': np.ndarray}, 'x3': {'charges': np.ndarray, 'positions': np.ndarray}, 'x4': {'types': np.ndarray, 'positions': np.ndarray, 'directions': np.ndarray}} ref_mol : Chem.Mol, optional The reference molecule with a conformer. Default is ``None``. only_atoms : bool, optional Whether to only draw the atoms and ignore the interaction profiles. Default is ``False``. model_type : str, optional One of 'all', 'x2', 'x3', 'x4'. Default is 'all'. opacity : float, optional The opacity of the reference molecule. Default is 0.6. view : py3Dmol.view, optional The view to draw the molecule to. If ``None``, a new view will be created. color_scheme : str, optional Provide a py3Dmol color scheme string (e.g., 'whiteCarbon'). custom_carbon_color : str, optional Provide hex color of the carbon atoms. Programmed are 'dark slate grey' and 'light steel blue'. width : int, optional The width of the view. Default is 800. height : int, optional The height of the view. Default is 400. """ xyz_block, dummy_atom_pos, surf_pos, surf_esp, pharm_types, pharm_ancs, pharm_vecs = _process_generated_sample(generated_sample, model_type) if view is None: view = py3Dmol.view(width=width, height=height) view.removeAllModels() if ref_mol is not None: mb = Chem.MolToMolBlock(ref_mol) view.addModel(mb, 'sdf') view.setStyle({'model': -1}, {'stick': {'opacity': opacity}}) view = draw(xyz_block, feats={}, pharm_types=pharm_types if not only_atoms else None, pharm_ancs=pharm_ancs if not only_atoms else None, pharm_vecs=pharm_vecs if not only_atoms else None, point_cloud=surf_pos if not only_atoms else None, esp=surf_esp if not only_atoms else None, dummy_atom_pos=dummy_atom_pos, view=view, color_scheme=color_scheme, custom_carbon_color=custom_carbon_color if color_scheme is None else None) # return view.show() # view.show() to save memory return view
[docs] def draw_molecule(molecule: Molecule, dummy_atom_pos = None, add_SAS = False, view = None, removeHs = False, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, opacity: float = 1.0, opacity_features: float = 1.0, no_surface_points: bool = False, highlight_atoms: Optional[List[int]] = None, width = 800, height = 400): view = draw_mol( molecule.mol, removeHs=removeHs, opacity=opacity, color_scheme=color_scheme, custom_carbon_color=custom_carbon_color if color_scheme is None else None, highlight_atoms=highlight_atoms, dummy_atom_pos=dummy_atom_pos, add_SAS=add_SAS, width=width, height=height, view=view, ) if molecule.pharmacophore is not None: view = draw_pharm( pharmacophore=molecule.pharmacophore, opacity_features=opacity_features, view=view, ) if not no_surface_points and molecule.surface.positions is not None: view = draw_surface(surface=molecule.surface, view=view) view.zoomTo() return view
[docs] def draw_pharmacophores(mol, view=None, width=800, height=400, opacity=1.0, opacity_features=1.0): """ Generate the pharmacophores and visualize them. """ view = draw_mol( mol, width=width, height=height, opacity=opacity, view=view, ) view = draw_pharm(feats=get_pharmacophores_dict(mol), opacity_features=opacity_features, view=view) view.zoomTo() return view
[docs] def draw_atom_sample( generated_sample: List, ref_mol = None, feats: dict = {}, pharm_types: Union[np.ndarray, None] = None, pharm_ancs: Union[np.ndarray, None] = None, pharm_vecs: Union[np.ndarray, None] = None, point_cloud = None, esp = None, opacity = 0.6, view = None, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, width = 800, height = 400, ): """ Draw generated ShEPhERD sample with pharmacophore features and point cloud. Draws on surface accessible surface and electrostatics, optionally overlaid on the reference molecule. Parameters ---------- generated_sample : list Expects [atoms, positions] where atoms is a list of atomic numbers and positions is a list of 3D coordinates. ref_mol : Chem.Mol, optional The reference molecule with a conformer. Default is ``None``. opacity : float, optional The opacity of the reference molecule. Default is 0.6. view : py3Dmol.view, optional The view to draw the molecule to. If ``None``, a new view will be created. color_scheme : str, optional Provide a py3Dmol color scheme string (e.g., 'whiteCarbon'). custom_carbon_color : str, optional Provide hex color of the carbon atoms. Programmed are 'dark slate grey' and 'light steel blue'. width : int, optional The width of the view. Default is 800. height : int, optional The height of the view. Default is 400. """ xyz_block, dummy_atom_pos = get_xyz_content_with_dummy(generated_sample[0], generated_sample[1]) if view is None: view = py3Dmol.view(width=width, height=height) view.removeAllModels() if ref_mol is not None: mb = Chem.MolToMolBlock(ref_mol) view.addModel(mb, 'sdf') view.setStyle({'model': -1}, {'stick': {'opacity': opacity}}) view = draw(xyz_block, feats=feats, pharm_types=pharm_types, pharm_ancs=pharm_ancs, pharm_vecs=pharm_vecs, point_cloud=point_cloud, esp=esp, dummy_atom_pos=dummy_atom_pos, view=view, color_scheme=color_scheme, custom_carbon_color=custom_carbon_color if color_scheme is None else None) # return view.show() # view.show() to save memory return view
def _normalize_chimera_outputs( outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None, ) -> set[str]: valid = {'x1', 'x2', 'x3', 'x4'} if outputs is None: return valid selected = set(outputs) invalid = selected - valid if invalid: raise ValueError(f'Invalid chimera outputs: {sorted(invalid)}. Expected subset of {sorted(valid)}.') return selected def _merge_dummy_into_pharm( dummy_atom_pos: np.ndarray | None, pharm_types: np.ndarray | None, pharm_ancs: np.ndarray | None, pharm_vecs: np.ndarray | None, dummy_type: int = 9, ) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None]: """Prepend dummy-atom markers into pharmacophore arrays (type index before +1 shift).""" if dummy_atom_pos is None: return pharm_types, pharm_ancs, pharm_vecs n_dummy = len(dummy_atom_pos) dummy_types = np.zeros(n_dummy) + dummy_type dummy_vecs = np.zeros((n_dummy, 3)) if pharm_ancs is not None: pharm_ancs = np.concatenate([dummy_atom_pos, pharm_ancs], axis=0) else: pharm_ancs = dummy_atom_pos if pharm_vecs is not None: pharm_vecs = np.concatenate([dummy_vecs, pharm_vecs], axis=0) else: pharm_vecs = dummy_vecs if pharm_types is not None: pharm_types = np.concatenate([dummy_types, pharm_types], axis=0) else: pharm_types = dummy_types return pharm_types, pharm_ancs, pharm_vecs def _chimera_pharmacophore_file(pharm_types: np.ndarray, pharm_pos: np.ndarray, pharm_direction: np.ndarray, pharm_transparency: float = 0.5) -> str: pharmacophore_colors = { 0: (None, (0,0,0), 0.0, 0.0), # virtual node type 1: ('Acceptor', (0.62,0.03,0.35), 0.3, pharm_transparency), 2: ('Donor', (0,0.55,0.55), 0.3, pharm_transparency), 3: ('Aromatic', (.85,.5,.0), 0.5, pharm_transparency), 4: ('Hydrophobe', (0.2,0.2,0.2), 0.5, pharm_transparency), 5: ('Halogen', (0.,1.,0), 0.5, pharm_transparency), 6: ('Cation', (0,0,1.), 0.5, pharm_transparency), 7: ('Anion', (1.,0,0), 0.5, pharm_transparency), 8: ('ZnBinder', (1.,.5,.5), 0.5, pharm_transparency), 9: ('Dummy', feature_colors['Dummy'], 0.5, pharm_transparency), 10: ('Dummy atom', (0.8, 0.6, 1.), 0.5, pharm_transparency), 11: ('Exit vector', (0., 0., 0.), 0.5, pharm_transparency), } bild = '' for i in range(len(pharm_types)): pharm_type = int(pharm_types[i]) pharm_name = pharmacophore_colors[pharm_type][0] p = pharm_pos[i] v = pharm_direction[i] * 2.0 # scaling size of vector bild += f'.color {pharmacophore_colors[pharm_type][1][0]} {pharmacophore_colors[pharm_type][1][1]} {pharmacophore_colors[pharm_type][1][2]}\n' bild += f'.transparency {pharmacophore_colors[pharm_type][3]}\n' if pharm_name not in ['Aromatic', 'Acceptor', 'Donor', 'Halogen', 'Exit vector']: bild += f'.sphere {p[0]} {p[1]} {p[2]} {pharmacophore_colors[pharm_type][2]}\n' if np.linalg.norm(v) > 0.0: bild += f'.arrow {p[0]} {p[1]} {p[2]} {p[0] + v[0]} {p[1] + v[1]} {p[2] + v[2]} 0.1 0.2\n' if pharm_name == 'Aromatic': bild += f'.arrow {p[0]} {p[1]} {p[2]} {p[0] - v[0]} {p[1] - v[1]} {p[2] - v[2]} 0.1 0.2\n' return bild def _chimera_shape_esp_file(surf_pos: np.ndarray, surf_esp: np.ndarray | None = None, norm_factor: float = 2.0, surf_point_size: float = 0.05, transparency_charged: float = 0.0, neutral_color_threshold: float = 0.3, transparency_neutral: float = 0.9, ) -> str: if surf_esp is None: bild = '' for i in range(len(surf_pos)): p = surf_pos[i] bild += f'.color {0.0} {0.0} {0.0}\n' bild += f'.transparency {transparency_neutral}\n' bild += f'.sphere {p[0]} {p[1]} {p[2]} {surf_point_size}\n' return bild esp = surf_esp * 4.0 esp_pos = surf_pos esp_colors = np.zeros((len(esp), 3)) esp_colors[:,2] = np.where(esp < 0, 0, esp/norm_factor).squeeze() esp_colors[:,0] = np.where(esp >= 0, 0, -esp/norm_factor).squeeze() # Low ESP defaults to black (0,0,0); transparency alone still looks dark in ChimeraX. # Use white for neutral points, matching draw()'s py3Dmol ESP cloud behavior. color_magnitude = np.sqrt(np.sum(np.square(esp_colors), axis=1)) neutral_mask = color_magnitude < neutral_color_threshold esp_colors[neutral_mask] = 1.0 bild = '' for i in range(len(esp_pos)): esp_color = esp_colors[i] p = esp_pos[i] bild += f'.color {esp_color[0]} {esp_color[1]} {esp_color[2]}\n' if neutral_mask[i]: bild += f'.transparency {transparency_neutral}\n' else: bild += f'.transparency {transparency_charged}\n' bild += f'.sphere {p[0]} {p[1]} {p[2]} {surf_point_size}\n' return bild def _write_chimera_files( mol_id: str | int, save_dir: str | Path, *, outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None = None, mol: Chem.Mol | None = None, xyz_block: str | None = None, output_mol_file: Literal['sdf', 'xyz'] = 'sdf', surf_pos: np.ndarray | None = None, surf_esp: np.ndarray | None = None, pharm_types: np.ndarray | None = None, pharm_ancs: np.ndarray | None = None, pharm_vecs: np.ndarray | None = None, dummy_atom_pos: np.ndarray | None = None, pharm_transparency: float = 0.5, esp_norm_factor: float = 2.0, esp_transparency_charged: float = 0.9, esp_neutral_color_threshold: float = 0.3, esp_transparency_neutral: float = 0.9, surf_point_size: float = 0.05, verbose: bool = True, ) -> None: """ Write ChimeraX-compatible files for available representations. Only representations listed in ``outputs`` that are also available are written: - ``x1``: atoms (``.sdf`` or ``.xyz``) - ``x2``: shape surface points (``.bild``) - ``x3``: ESP surface points (``.bild``) - ``x4``: pharmacophores (``.bild``) """ selected = _normalize_chimera_outputs(outputs) path_ = Path(save_dir) if not path_.is_dir(): path_.mkdir(parents=True, exist_ok=True) if 'x1' in selected: if output_mol_file == 'sdf' and mol is not None: out_path = path_ / f'{mol_id}_x1.sdf' with Chem.SDWriter(out_path) as w: w.write(mol) if verbose: print(f'Wrote mol file to {out_path}') elif xyz_block is not None: out_path = path_ / f'{mol_id}_x1.xyz' with open(out_path, 'w') as f: f.write(xyz_block) if verbose: print(f'Wrote xyz file to {out_path}') if 'x2' in selected and surf_pos is not None: shape_bild = _chimera_shape_esp_file( surf_pos, None, norm_factor=esp_norm_factor, surf_point_size=surf_point_size, transparency_charged=esp_transparency_charged, neutral_color_threshold=esp_neutral_color_threshold, transparency_neutral=esp_transparency_neutral, ) out_path = path_ / f'{mol_id}_x2.bild' with open(out_path, 'w') as f: f.write(shape_bild) if verbose: print(f'Wrote shape file to {out_path}') if 'x3' in selected and surf_pos is not None and surf_esp is not None: esp_bild = _chimera_shape_esp_file( surf_pos, surf_esp, norm_factor=esp_norm_factor, surf_point_size=surf_point_size, transparency_charged=esp_transparency_charged, neutral_color_threshold=esp_neutral_color_threshold, transparency_neutral=esp_transparency_neutral, ) out_path = path_ / f'{mol_id}_x3.bild' with open(out_path, 'w') as f: f.write(esp_bild) if verbose: print(f'Wrote ESP file to {out_path}') if 'x4' in selected: pharm_types, pharm_ancs, pharm_vecs = _merge_dummy_into_pharm( dummy_atom_pos, pharm_types, pharm_ancs, pharm_vecs ) if pharm_types is not None and pharm_ancs is not None and pharm_vecs is not None: # Accommodate virtual node at idx=0 pharm_bild = _chimera_pharmacophore_file( pharm_types + 1, pharm_ancs, pharm_vecs, pharm_transparency=pharm_transparency ) out_path = path_ / f'{mol_id}_x4.bild' with open(out_path, 'w') as f: f.write(pharm_bild) if verbose: print(f'Wrote pharmacophore file to {out_path}')
[docs] def chimera_from_mol(mol: Chem.Mol, mol_id: Union[str, int], surf_pos = None, surf_esp = None, ev_pos = None, ev_vecs = None, save_dir: str = './', outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None = None, pharm_transparency: float = 0.5, esp_norm_factor: float = 2.0, esp_transparency_charged: float = 0.9, esp_neutral_color_threshold: float = 0.3, esp_transparency_neutral: float = 0.9, surf_point_size: float = 0.05, verbose: bool = True, ) -> None: """ Write ChimeraX files from an RDKit mol (and optional surface / exit-vector data). Parameters ---------- outputs : sequence of {'x1','x2','x3','x4'}, optional Representations to write. Default writes all that are available. """ selected = _normalize_chimera_outputs(outputs) pharm_types = pharm_ancs = pharm_vecs = None if 'x4' in selected: pharm_types, pharm_ancs, pharm_vecs = get_pharmacophores( mol, multi_vector=False, check_access=False, ) if ev_pos is not None and ev_vecs is not None: # Exit vectors use type index 10 before the +1 virtual-node shift pharm_ancs = np.concatenate([ev_pos, pharm_ancs], axis=0) pharm_vecs = np.concatenate([ev_vecs, pharm_vecs], axis=0) pharm_types = np.concatenate( [np.zeros(len(ev_pos), dtype=int) + 10, pharm_types], axis=0 ) _write_chimera_files( mol_id, save_dir, outputs=selected, mol=mol, output_mol_file='sdf', surf_pos=surf_pos, surf_esp=surf_esp, pharm_types=pharm_types, pharm_ancs=pharm_ancs, pharm_vecs=pharm_vecs, pharm_transparency=pharm_transparency, esp_norm_factor=esp_norm_factor, esp_transparency_charged=esp_transparency_charged, esp_neutral_color_threshold=esp_neutral_color_threshold, esp_transparency_neutral=esp_transparency_neutral, surf_point_size=surf_point_size, verbose=verbose, )
[docs] def chimera_from_sample(generated_sample: dict, mol_id: str | int, save_dir: str, model_type: Literal['all', 'x2', 'x3', 'x4'] = 'all', outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None = None, pharm_transparency: float = 0.5, esp_norm_factor: float = 2.0, esp_transparency_charged: float = 0.9, esp_neutral_color_threshold: float = 0.3, esp_transparency_neutral: float = 0.9, surf_point_size: float = 0.05, verbose: bool = True, ) -> None: """ Write ChimeraX files from a generated ShEPhERD sample dict. Parameters ---------- outputs : sequence of {'x1','x2','x3','x4'}, optional Representations to write. Default writes all that are available. """ xyz_block, dummy_atom_pos, surf_pos, surf_esp, pharm_types, pharm_ancs, pharm_vecs = ( _process_generated_sample(generated_sample, model_type) ) _write_chimera_files( mol_id, save_dir, outputs=outputs, xyz_block=xyz_block, output_mol_file='xyz', surf_pos=surf_pos, surf_esp=surf_esp, pharm_types=pharm_types, pharm_ancs=pharm_ancs, pharm_vecs=pharm_vecs, dummy_atom_pos=dummy_atom_pos, pharm_transparency=pharm_transparency, esp_norm_factor=esp_norm_factor, esp_transparency_charged=esp_transparency_charged, esp_neutral_color_threshold=esp_neutral_color_threshold, esp_transparency_neutral=esp_transparency_neutral, surf_point_size=surf_point_size, verbose=verbose, )
[docs] def chimera_from_atom_sample( generated_sample: List, mol_id: str | int, save_dir: str, only_dummy_atoms: bool = False, outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None = None, pharm_transparency: float = 0.5, verbose: bool = True, ) -> None: """ Write ChimeraX files from an atom-only sample ``[atomic_numbers, positions]``. Parameters ---------- outputs : sequence of {'x1','x2','x3','x4'}, optional Representations to write. Default is ``('x1', 'x4')`` (what this input can provide). If ``only_dummy_atoms=True``, ``x1`` is omitted. """ if outputs is None: outputs = ('x1', 'x4') selected = _normalize_chimera_outputs(outputs) if only_dummy_atoms: selected.discard('x1') xyz_block, dummy_atom_pos = get_xyz_content_with_dummy(generated_sample[0], generated_sample[1]) _write_chimera_files( mol_id, save_dir, outputs=selected, xyz_block=xyz_block, output_mol_file='xyz', dummy_atom_pos=dummy_atom_pos, pharm_transparency=pharm_transparency, verbose=verbose, )
[docs] def chimera_from_molecule(molec: Molecule, mol_id: str | int, save_dir: str, outputs: Sequence[Literal['x1', 'x2', 'x3', 'x4']] | None = None, pharm_transparency: float = 0.5, esp_norm_factor: float = 2.0, esp_transparency_charged: float = 0.9, esp_neutral_color_threshold: float = 0.3, esp_transparency_neutral: float = 0.9, surf_point_size: float = 0.05, dummy_atom_pos: Optional[np.ndarray] = None, output_mol_file: Literal['sdf', 'xyz'] = 'sdf', verbose: bool = True, ) -> None: """ Write ChimeraX files from a ``Molecule`` object. Parameters ---------- outputs : sequence of {'x1','x2','x3','x4'}, optional Representations to write. Default writes all that are available. """ selected = _normalize_chimera_outputs(outputs) xyz_block = None if dummy_atom_pos is None or ('x1' in selected and output_mol_file == 'xyz'): xyz_block, extracted_dummy = get_xyz_content_with_dummy( atomic_numbers=np.array([a.GetAtomicNum() for a in molec.mol.GetAtoms()]), positions=molec.mol.GetConformer().GetPositions(), ) if dummy_atom_pos is None: dummy_atom_pos = extracted_dummy _write_chimera_files( mol_id, save_dir, outputs=selected, mol=molec.mol, xyz_block=xyz_block, output_mol_file=output_mol_file, surf_pos=molec.surf_pos, surf_esp=molec.surf_esp, pharm_types=molec.pharm_types, pharm_ancs=molec.pharm_ancs, pharm_vecs=molec.pharm_vecs, dummy_atom_pos=dummy_atom_pos, pharm_transparency=pharm_transparency, esp_norm_factor=esp_norm_factor, esp_transparency_charged=esp_transparency_charged, esp_neutral_color_threshold=esp_neutral_color_threshold, esp_transparency_neutral=esp_transparency_neutral, surf_point_size=surf_point_size, verbose=verbose, )
[docs] def draw_2d_valid(ref_mol: Chem.Mol, mols: List[Chem.Mol | None], mols_per_row: int = 5, use_svg: bool = True, find_atomic_overlap: bool = True, ): """ Draw 2D grid image of the reference molecule and a list of corresponding molecules. It will align the molecules to the reference molecule using the MCS and highlight the maximum common substructure between the reference molecule and the other molecules. Parameters ---------- ref_mol : Chem.Mol The reference molecule to align the other molecules to. mols : List[Chem.Mol | None] The list of molecules to draw. mols_per_row : int The number of molecules to draw per row. use_svg : bool Whether to use SVG for the image. Returns ------- MolsToGridImage The image of the molecules. Credit ------ https://github.com/PatWalters/practical_cheminformatics_tutorials/ """ from rdkit.Chem import rdFMCS, AllChem temp_mol = Chem.MolFromSmiles(Chem.MolToSmiles(ref_mol)) valid_mols = [Chem.MolFromSmiles(Chem.MolToSmiles(m)) for m in mols if m is not None] if (len(valid_mols) == 1 and valid_mols[0] is None) or len(valid_mols) == 0: return Chem.Draw.MolToImage(temp_mol, useSVG=True, legend='Target | Found no valid molecules') valid_inds = [i for i in range(len(mols)) if mols[i] is not None] if find_atomic_overlap: params = rdFMCS.MCSParameters() params.BondCompareParameters.CompleteRingsOnly = True params.AtomCompareParameters.CompleteRingsOnly = True # find the MCS mcs = rdFMCS.FindMCS([temp_mol] + valid_mols, params) # get query molecule from the MCS, we will use this as a template for alignment qmol = mcs.queryMol # generate coordinates for the template AllChem.Compute2DCoords(qmol) # generate coordinates for the molecules using the template [AllChem.GenerateDepictionMatching2DStructure(m, qmol) for m in valid_mols] return Chem.Draw.MolsToGridImage( [temp_mol]+ valid_mols, highlightAtomLists=[temp_mol.GetSubstructMatch(mcs.queryMol)]+[m.GetSubstructMatch(mcs.queryMol) for m in valid_mols] if find_atomic_overlap else None, molsPerRow=mols_per_row, legends=['Target'] + [f'Sample {i}' for i in valid_inds], useSVG=use_svg)
[docs] def draw_2d_highlight(mol: Chem.Mol, atom_sets: List[List[int]], colors: Optional[List[str]] = None, label: Optional[Literal['atomLabel', 'molAtomMapNumber', 'atomNote']] = None, compute_2d_coords: bool = True, add_stereo_annotation: bool = True, width: int = 800, height: int = 600, embed_display: bool = True ) -> SVG: """ Create an SVG representation of the molecule with highlighted atom sets. Parameters ---------- mol : Chem.Mol The molecule to draw. atom_sets : List[List[int]] The list of atom sets to highlight. colors : List[str] The list of colors to use for the atom sets. label : Literal['atomLabel', 'molAtomMapNumber', 'atomNote'] The label to use for the atom indices. width : int The width of the SVG image. height : int The height of the SVG image. Returns ------- SVG: The SVG representation of the molecule with highlighted atom sets. """ if colors is None: colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'] non_empty_sets = [s for s in atom_sets if s] highlight_atoms = {} highlight_colors = {} for set_idx, atom_set in enumerate(non_empty_sets): color_rgb = mcolors.to_rgb(colors[set_idx % len(colors)]) for atom_id in atom_set: highlight_atoms[atom_id] = color_rgb highlight_colors[atom_id] = color_rgb drawer = rdMolDraw2D.MolDraw2DSVG(width, height) opts = drawer.drawOptions() opts.addStereoAnnotation = add_stereo_annotation if label is not None: mol_copy = mol_with_atom_index(mol, label=label) else: mol_copy = deepcopy(mol) if compute_2d_coords: AllChem.Compute2DCoords(mol_copy) drawer.DrawMolecule(mol_copy, highlightAtoms=list(highlight_atoms.keys()), highlightAtomColors=highlight_colors) drawer.FinishDrawing() svg = drawer.GetDrawingText() if embed_display: return SVG(svg) else: return svg
[docs] def mol_with_atom_index(mol: Chem.Mol, label: Literal['atomLabel', 'molAtomMapNumber', 'atomNote']='atomLabel'): mol_label = deepcopy(mol) for atom in mol_label.GetAtoms(): atom.SetProp(label, str(atom.GetIdx())) return mol_label
[docs] def view_sample_trajectory(generated_sample, trajectory: Literal['x', 'x0']='x', frame_sleep: float=0.05, ref_mol = None, only_atoms = True, opacity = 0.6, color_scheme: Optional[str] = None, custom_carbon_color: Optional[str] = None, width = 800, height = 400, ): """ View the trajectory of the generated sample. Must set store_trajectory=True or store_trajectory_x0=True in the `generate` function. """ view = py3Dmol.view(width=width, height=height) suffix = f'_{trajectory}' if trajectory == 'x0' else '' for i in range(len(generated_sample['trajectories' + suffix])): view.clear() view = draw_sample(generated_sample['trajectories' + suffix][i], only_atoms=only_atoms, view = view, ref_mol=ref_mol, opacity=opacity, color_scheme=color_scheme, custom_carbon_color=custom_carbon_color) view.update() time.sleep(frame_sleep) return view