from bokeh.io import output_notebook
from bokeh.models import HoverTool
from bokeh.plotting import figure, show, ColumnDataSource
import pandas as pd
from rdkit import Chem
from rdkit.Chem import AllChem
# Read the csv file
= pd.read_csv("delaney-processed.csv")
df
# Get data to plot
= df["smiles"]
all_smiles = df["measured log solubility in mols per litre"].values
x = df["Molecular Weight"].values
y
# Create SVGs for each smiles with the "new" RDKit drawing code
= []
imgs for smiles in all_smiles:
= Chem.MolFromSmiles(smiles)
mol = Chem.Draw.MolDraw2DSVG(150, 150)
d2d
d2d.DrawMolecule(mol)
d2d.FinishDrawing()= d2d.GetDrawingText()
svg
imgs.append(svg)
# Configure for output in the notebook
output_notebook()
# Load the data into a source and plot
= ColumnDataSource(
source ={
data"x": x,
"y": y,
"imgs": imgs,
"smiles": all_smiles,
}
)= figure()
p "x", "y", source=source)
p.scatter(= 300
p.plot_height = 400
p.plot_width = "scale_width"
p.sizing_mode = "Molecular weight"
p.xaxis.axis_label = "log S"
p.yaxis.axis_label
# Create tooltips referencing stored images
= """\
TOOLTIPS <div>
<div>
@imgs{safe}
</div>
<div>
<span>[$index]</span>
</div>
<div>
<span>@smiles{safe}</span>
</div>
<div>
<span>($x, $y)</span>
</div>
</div>
"""
# Connect tooltips to plot
=TOOLTIPS))
p.add_tools(HoverTool(tooltips
# Show figure
show(p)
Interactive molecular content
How to embed interactive content in webpages with Quarto using Bokeh, 3DMol.js and NGL
Introduction
I recently rebased the blog on the Quarto publishing system. Quarto is an evolution of R Markdown and allows publishing a notebook (.qmd or .ipynb) in various formats, inlcuding as blog posts.
In the previous post on visualizing atomic type orbitals, we had some code for interactive visualization with ipywidgets. Unfortunately, it didn’t work in the browser as it needs a Python backend running. With Quarto as publishing system, we can work around that problem.
Thanks to the support for the Observable dialect of JavaScript (OJS) in Quarto, we can create interactive elements which will work on the final static webpage. This will require some level of proficiency with JavaScript, but rest assured that I knew zero JavaScript when I started writing this blog post. The code cells featuring OJS are hidden in this post, but can be shown by clicking on the arrow next to “Code”. But first we will start with a visualization that doesn’t need any JavaScript skills.
Visualing molecules with molplotly
molplotly is a great add-on to plotly to display data together with 2D images of the associated molecules. It is really easy to use and works nicely in a Jupyter Notebook, but requires a Dash app to run in the background. Here we will instead use Bokeh to create similar plots which can be displayed on a static webpage, although with a bit more effort. See the Bokeh documentation as well as the blog post by iwatobipen and the notebook from OpenEye Software for more ideas.
We visualize the ESOL dataset,1 downloaded from MoleculeNet.
Visualizing different conformers
As a second exercise, we are going to create a number of conformers for the propanal molecule using morfeus. We save one file with lowest energy conformer and one file with all the conformers. The function ojs_define
is special to Quarto and allows passing data from Python or R to OJS. We use it to send over a list of the conformer energies for later display.
from morfeus.conformer import ConformerEnsemble, _extract_from_mol
from morfeus.io import write_xyz
# Optimize propanal conformers
= "CCC=O"
smiles = ConformerEnsemble.from_rdkit(smiles, optimize="MMFF94", random_seed=42)
ce
ce.prune_rmsd()
ce.sort()
# Write out conformers
"lowest.xyz", ids=[0])
ce.write_xyz(
ce.align_conformers()= _extract_from_mol(ce.mol)
elements, conformer_coordinates, _, _ "conformers.xyz", elements, conformer_coordinates)
write_xyz(
# Send variables to OJS
=list(ce.get_relative_energies())) ojs_define(confEnergies
Now we will create the interactive visualization with 3Dmol.js.2 We will first create an input slider to allow the reader to select which conformer to show. This is done with Inputs.range
in OJS (click “Code” to reveal the OJS code). We then use 3DMol.js to load the conformers.xyz
file and define the function updateViewer
which we couple to the input slider. There is a Python interface to 3Dmol called py3Dmol, but it currently cannot generate the level of interactivity that we need.
We create a slider to choose the conformer, and a reactive label that prints the energy of the currently selected conformer.
Code
= Inputs.range([1, confEnergies.length], {value: 1, step: 1, label: "Conformer ID"});
viewof confIDInput md`Energy (kcal/mol): ${confEnergies[confIDInput - 1].toFixed(3)}`
Code
// Create container
= html`<div style="width:${layoutWidth["layout-conf"]}px;height:${layoutWidth["layout-conf"] * 2 / 3}px;position:relative"></div>`; divConf
Code
= require("jquery");
jquery = require("3dmol");
$3Dmol = require("ngl@next");
NGL
// Create viewer
= {
viewerConf let xyzString = await FileAttachment("conformers.xyz").text();
let viewer = $3Dmol.createViewer(divConf, {});
.addModelsAsFrames(xyzString, "xyz");
viewer.setStyle({stick:{}});
viewer.zoomTo();
viewer.render();
viewerreturn viewer;
;
}
= function(viewer, ID){
updateViewer .setFrame(ID);
viewer.render();
viewerreturn viewer;
;
}
updateViewer(viewerConf, confIDInput - 1);
Optimization trajectory
Next we will display the results of a geometry optimization. To do the optimization we are going to use the PyBerny package, which is a partial Python re-implementation of the algorithm described by Bernhard Schlegel and co-workers.3.
We then use PyBerny together with the MOPAC backend to optimize the molecule. MOPAC is nowadays available for free and can be installed with conda
.
$ conda install -c conda-forge mopac
We store all the energies and coordinates and write an xyz file with the whole optimization trajectory. wurlitzer is needed to suppress some output from MOPAC.
from berny import Berny, geomlib
from berny.solvers import MopacSolver
from wurlitzer import pipes
from morfeus.io import write_xyz
from morfeus.data import HARTREE_TO_KCAL, HARTREE_TO_EV
= Berny(geomlib.readfile("lowest.xyz"))
optimizer = MopacSolver()
solver next(solver)
= []
traj = []
energies with pipes() as (stdout, stderr):
for geom in optimizer:
= solver.send((list(geom), geom.lattice))
energy, gradients
optimizer.send((energy, gradients))
traj.append(geom)
energies.append(energy)= [
energies - energies[-1]) * HARTREE_TO_KCAL + 1e-8 for energy in energies
(energy # add small energy to avoid bug in observable plot
] "traj.xyz", traj[0].species, [geom.coords for geom in traj]) write_xyz(
Now we want to calculate some additional information to use in the visualization. That includes the bond lenghts of the C1–C2 bond for each step of the trajectory, which we store in labels
. We also pass over the data on the energies for each step as a Pandas DataFrame.
from morfeus.io import read_xyz
import scipy.spatial
import pandas as pd
# Calculate the bond distance labels
= read_xyz("traj.xyz")
_, coordinates = [scipy.spatial.distance_matrix(coord, coord)[0, 1] for coord in coordinates]
labels
# Pass the variables onto Observable
= pd.DataFrame({"step": range(1, len(energies) + 1), "energy": energies})
opt_data =labels, optData=opt_data) ojs_define(labels
To be able to animate the trajectory we need to use a special type of input object that is not part of the standard Observable inputs. Luckily, the Observable creator Mike Bostock has created the Scrubber
for us to do this work and we can easily import it.
Code
import {Scrubber} from "@mbostock/scrubber"
= Array.from({length: labels.length}, (_, i) => i + 1);
numbers = Scrubber(numbers, {delay: 500, autoplay: false}) viewof frameIDInput
Code
// Create drawing area
= html`<div style="width:${layoutWidth["layout-berny"] / 2}px;height:${layoutWidth["layout-berny"] / 3}px;position="relative"></div>`; divBerny
Code
= Plot.plot({
plot x: {label: "→ Step"},
y: {label: "↑ Energy (kcal/mol)"},
style: {fontSize: 20},
margin: 50,
marks: [
.line(transpose(optData),
Plotx: "step", y: "energy"},
{ stroke: "black" }
{ ,
).dot(transpose(optData), Plot.select(I => [frameIDInput - 1], {x: "step", y: "energy"})),
Plot.text(transpose(optData), Plot.select(I => [frameIDInput - 1], {x: "step", y: "energy", text: "energy", dx: 10, dy: -10}))
Plot
]}; )
Code
= {
viewerBerny let xyzString = await FileAttachment("traj.xyz").text();
let viewer = $3Dmol.createViewer(divBerny);
.addModelsAsFrames(xyzString, "xyz");
viewer.setStyle({stick: {}});
viewerfor (let i = 0; i < viewer.getNumFrames(); i++) {
.addLabel(labels[i].toFixed(3), {alignment: "center", frame: i}, {serial: [1, 2]});
viewer;
}.zoomTo();
viewer.render();
viewerreturn viewer;
;
}
// Update the view
updateViewer(viewerBerny, frameIDInput - 1)
Molecular orbitals
We’re now ready to tackle the interactive visualization of molecular orbitals. We again use PySCF to generate them. We send over the orbital occupations numbers and energies for display with OJS.
import pyscf
from pyscf import gto, lo, tools, dft
= read_xyz("lowest.xyz")
elements, coordinates = [(element, coordinate) for element, coordinate in zip(elements, coordinates)]
atoms = gto.Mole(basis="sto-3g")
pyscf_mole = atoms
pyscf_mole.atom
pyscf_mole.build()
= dft.RKS(pyscf_mole)
mf = "b3lyp"
mf.xc
mf.run()
= mf.mo_coeff.shape[1]
n_orbs for i in range(n_orbs):
tools.cubegen.orbital(f"mo_{i+1:02d}.cube", mf.mo_coeff[:, i], nx=40, ny=40, nz=40
pyscf_mole,
)
= pyscf_mole.nelectron // 2
homo_ID = list(mf.mo_energy * HARTREE_TO_EV)
mo_energies = pd.DataFrame({"energy": mo_energies})
mo_data
ojs_define(=list(mf.mo_occ), MOEnergies=mo_energies, homoID=homo_ID, MOData=mo_data
MOOccs )
We now create a slider to select the MO number, as well as an input box to select the isodensity surface value. We show both the orbitals, and a “tick” plot made with Observable Plot to show were the selected orbital lies in the manifold.
Code
= Inputs.range([1, MOOccs.length], {value: homoID, step: 1, label: "MO ID"});
viewof MOIDInput = Inputs.number([0.0, Infinity], {value: 0.04, step: 0.0001, label: "Isodensity value"});
viewof MOIsoInput md`Occ: ${MOOccs[MOIDInput - 1]}
Energy (eV): ${MOEnergies[MOIDInput - 1].toFixed(3)}`;
Code
// Create drawing area
= html`<div style="width:${layoutWidth["layout-mo"] / 2}px;height:${layoutWidth["layout-mo"] / 3}px;position="relative"></div>`; divMO
Code
= Plot.plot({
plot_mo y: {
domain: [-30, d3.max(MOData["energy"])],
label: "↑ Energy (eV)"
,
}style: {fontSize: 20},
margin: 40,
marks: [
.tickY(transpose(MOData), {y: "energy"}),
Plot.tickY(transpose(MOData), Plot.select(I => [MOIDInput - 1], {y: "energy", strokeWidth: 3}))
Plot
]}; )
Code
= [
MOStrings await FileAttachment("mo_01.cube").text(),
await FileAttachment("mo_02.cube").text(),
await FileAttachment("mo_03.cube").text(),
await FileAttachment("mo_04.cube").text(),
await FileAttachment("mo_05.cube").text(),
await FileAttachment("mo_06.cube").text(),
await FileAttachment("mo_07.cube").text(),
await FileAttachment("mo_08.cube").text(),
await FileAttachment("mo_09.cube").text(),
await FileAttachment("mo_10.cube").text(),
await FileAttachment("mo_11.cube").text(),
await FileAttachment("mo_12.cube").text(),
await FileAttachment("mo_13.cube").text(),
await FileAttachment("mo_14.cube").text(),
await FileAttachment("mo_15.cube").text(),
await FileAttachment("mo_16.cube").text(),
await FileAttachment("mo_17.cube").text(),
await FileAttachment("mo_18.cube").text(),
await FileAttachment("mo_19.cube").text(),
await FileAttachment("mo_20.cube").text(),
await FileAttachment("mo_21.cube").text(),
await FileAttachment("mo_22.cube").text(),
await FileAttachment("mo_23.cube").text(),
await FileAttachment("mo_24.cube").text(),
await FileAttachment("mo_25.cube").text(),
await FileAttachment("mo_26.cube").text()
]
// Create viewer
= {
viewerMO let xyzString = await FileAttachment("lowest.xyz").text();
let viewer = $3Dmol.createViewer(divMO, {});
.addModelsAsFrames(xyzString.repeat(MOOccs.length), "xyz");
viewer.setStyle({stick: {}});
viewerfor (let i = 0; i < MOOccs.length; i++) {
.addVolumetricData(MOStrings[i], "cube", {"isoval": -MOIsoInput, "color": "red", "opacity": 0.8, frame: i});
viewer.addVolumetricData(MOStrings[i], "cube", {"isoval": MOIsoInput, "color": "blue", "opacity": 0.8, frame: i});
viewer.render();
viewer;
}.zoomTo();
viewer.render();
viewerreturn viewer;
;
}
updateViewer(viewerMO, MOIDInput - 1);
Surface properties
3Dmol can also be used to display surfaces. Here we generate the total electron density and the electrostatic potential as cube files.
= mf.make_rdm1()
dm "density.cube", dm, nx=40, ny=40, nz=40)
tools.cubegen.density(pyscf_mole, "esp.cube", dm, nx=40, ny=40, nz=40) tools.cubegen.mep(pyscf_mole,
We then create a visualization of the surface and an input box to select the isodensity value.
Code
// Create input slider
= Inputs.number([0.0, Infinity], {value: 0.001, step: 0.0001, label: "Isodensity value"}); viewof isoInput
Code
// Create drawing area
= html`<div style="width:${layoutWidth["layout-density"]}px;height:${layoutWidth["layout-density"] * 2 / 3}px;position:relative"></div>`; divDensity
Code
= {
viewerDensity let xyz_string = await FileAttachment("lowest.xyz").text();
let viewer = $3Dmol.createViewer(divDensity, {});
.addModel(xyz_string, "xyz");
viewer.setStyle({stick: {}});
viewer.zoomTo();
viewer.render();
viewerreturn viewer;
;
}
= function(viewer, voldata, isoValue) {
add_iso .removeAllShapes();
viewer.addIsosurface(voldata, {isoval: isoValue, color: "lightgray", opacity:0.85});
viewer.render();
viewer;
}
{let densityString = await FileAttachment("density.cube").text();
let voldata = new $3Dmol.VolumeData(densityString, "cube");
add_iso(viewerDensity, voldata, isoInput);
; }
We can do the same thing with an electrostatic potential (ESP) surface, where we map the ESP onto an isodensity surface.
Code
// Create input object
= Inputs.number([0.0, Infinity], {value: 0.001, step: 0.0001, label: "Isodensity value"}); viewof ESPInput
Code
= html`<div style="width:${layoutWidth["layout-esp"]}px;height:${layoutWidth["layout-esp"] * 2 / 3}px;position:relative"></div>`;
div_ESP
// Create a color legend
.legend({label: "esp", color: {scheme: "rdbu", domain: [-1, 1]}, width: layoutWidth["layout-esp"] / 3}) Plot
Code
= {
viewerESP let xyzString = await FileAttachment("lowest.xyz").text();
let viewer = $3Dmol.createViewer(div_ESP, {});
.addModelsAsFrames(xyzString, "xyz");
viewer.setStyle({stick: {}});
viewer.zoomTo();
viewer.render();
viewerreturn viewer;
;
}
// Create function to add ESP to viewer
= function(viewer, densityString, espString, isoValue) {
add_esp .removeAllShapes();
viewer.addVolumetricData(densityString, "cube", {"isoval": isoValue, "smoothness": 2, "opacity": 0.95, "voldata": espString, "volformat": "cube", "volscheme": {"gradient":"rwb", "min":-.1, "max":.1}});
viewer.render();
viewer;
}
// Draw the ESP
{let densityString = await FileAttachment("density.cube").text();
let espString = await FileAttachment("esp.cube").text();
add_esp(viewerESP, densityString, espString, ESPInput);
}
NGL
An alternative to 3Dmol is NGL.4 There is a very nice IPython/Jupyter widget called nglview that is based on NGL. Unfortunately, I also couldn’t get functionalities like the Trajectory to work interactively. Therefore we work around this and use the NGL library directly with JavaScript and make our own interactive controls as we did above for 3Dmol.
First we need to create a structure file that can be read by NGL. The PDB format is most convenient for this purpose and we use Atomic Simulation Environment to help us rewrite the multi-structure xyz file to a multi-structure pdb file.
import ase.io
= [atoms for atoms in ase.io.iread("conformers.xyz")]
traj "confomers.pdb", traj) ase.io.write(
We are now ready to visualize the PDB file with NGL.
Code
= Inputs.range([1, confEnergies.length], {value: 1, step: 1, label: "Conformer ID"});
viewof trajInput md`Energy (kcal/mol): ${confEnergies[trajInput - 1].toFixed(3)}`
Code
// Create drawing area
= html`<div style="width:${layoutWidth["layout-ngl"]}px;height:${layoutWidth["layout-ngl"] * 2 / 3}px;position:relative"></div>`; divNGL
Code
= {
trajPDB let stage = new NGL.Stage(divNGL, {clipDist: 0.0, backgroundColor: "white"});
let pdbString = await FileAttachment("confomers.pdb").blob();
let structure = await stage.loadFile(pdbString, {ext: "pdb", asTrajectory: true})
let traj = structure.addTrajectory().trajectory
.addRepresentation("licorice");
structure.autoView();
structurereturn traj;
;
}
// Create function to update trajectory
= function(traj, id){
update_traj .setFrame(id)
traj;
}
// Update trajectory based on slider
update_traj(trajPDB, trajInput - 1);