🆕 Print Protein-Ligand Interactions to Console
Problem
You want to perceive protein-ligand interaction and print them to console.
Ingredients
|
Difficulty Level
🌶️ 🌶️
Download
Source Code
interactions2console
#!/usr/bin/env python3
# (C) 2026 Cadence Design Systems, Inc. (Cadence)
# All rights reserved.
# TERMS FOR USE OF SAMPLE CODE The software below ("Sample Code") is
# provided to current licensees or subscribers of Cadence products or
# SaaS offerings (each a "Customer").
# Customer is hereby permitted to use, copy, and modify the Sample Code,
# subject to these terms. Cadence claims no rights to Customer's
# modifications. Modification of Sample Code is at Customer's sole and
# exclusive risk. Sample Code may require Customer to have a then
# current license or subscription to the applicable Cadence offering.
# THE SAMPLE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED. OPENEYE DISCLAIMS ALL WARRANTIES, INCLUDING, BUT
# NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
# PARTICULAR PURPOSE AND NONINFRINGEMENT. In no event shall Cadence be
# liable for any damages or liability in connection with the Sample Code
# or its use.
"""Print protein-ligand interactions to console."""
import argparse
import os
import pathlib
import sys
import typing
from collections import Counter
import rich.console
import rich.table
from openeye import oechem
from rich_argparse import HelpPreviewAction, RichHelpFormatter
__SCRIPT_NAME__ = pathlib.Path(__file__).absolute().stem
__SCRIPT_DESC__ = "Print protein-ligand interactions to console."
__SCRIPT_TOOLKITS__ = ["oechem"]
__SCRIPT_KEYWORDS__ = [
"perception",
"active-site",
"protein-ligand",
"interactions",
"display",
]
__SCRIPT_CATEGORIES__ = ["protein-ligand interactions"]
def parse_options() -> argparse.Namespace:
"""Set up command line options."""
parser = argparse.ArgumentParser(
add_help=True,
formatter_class=RichHelpFormatter,
description="[yellow]" + __SCRIPT_DESC__ + "[/yellow]",
)
# input options
input_group = parser.add_argument_group("Input ligand-protein complex")
exclusive_input_group = input_group.add_mutually_exclusive_group(required=True)
exclusive_input_group.add_argument(
"--complex",
type=str,
required=False,
metavar="PDB/CIF-FILE",
help="input file of the ligand-protein complex (.pdb, .cif)",
)
exclusive_input_group.add_argument(
"--design-unit",
"--du",
type=str,
metavar="DU-FILE",
help="input design unit file (.oedu)",
)
exclusive_input_group.add_argument(
"--serialized",
type=str,
metavar="OEB/JSON-FILE",
help="input file with serialized interactions (.oeb or .json)",
)
exclusive_input_group.add_argument(
"--protein",
type=str,
metavar="PDB-FILE",
help="input protein file",
)
input_group.add_argument(
"--ligand",
type=str,
metavar="MOL-FILE",
help="input ligand file (required if --protein is provided)",
)
display_group = parser.add_argument_group("Display options")
display_group.add_argument(
"--disable-summery-table",
action="store_true",
help="skip summery interaction table",
)
ligand_group = parser.add_argument_group("Interaction selection")
ligand_group.add_argument(
"--smarts",
type=str,
metavar="SMARTS",
help="show only interactions of ligand atoms matched to given pattern",
)
parser.add_argument("--help-image", action=HelpPreviewAction)
parser.add_argument(
"--save-console-svg",
default=False,
action="store_true",
help=f"run command and capture console output in {__SCRIPT_NAME__}.svg file",
)
return parser.parse_args()
def main() -> int: # noqa: C901, PLR0912
"""Print interactions to console."""
args = parse_options()
if args.complex:
active_site = get_active_site_from_pdb(args.complex)
elif args.design_unit:
active_site = get_active_site_from_design_unit(args.design_unit)
elif args.serialized:
active_site = get_active_site_from_serialized(args.serialized)
elif args.protein:
if not args.ligand:
oechem.OEThrow.Fatal(
"Please provide a ligand file (--ligand) when a protein file is provided!"
)
active_site = get_active_site_from_separate_files(args.protein, args.ligand)
else:
oechem.OEThrow.Fatal("Invalid input option!")
console = rich.console.Console(record=args.save_console_svg)
# Perceive interactions
if active_site.NumInteractions() == 0 and not args.serialized:
oechem.OEPerceiveInteractionHints(active_site)
ligand: oechem.OEMolBase = active_site.GetMolecule(
oechem.OELigandInteractionHintComponent()
)
console.print(f"Ligand detected = '{oechem.OEMolToSmiles(ligand)}'")
only_ligand_atoms = oechem.OEAtomBondSet(ligand.GetAtoms())
if args.smarts:
sub_search = oechem.OESubSearch(args.smarts)
if not sub_search.IsValid():
oechem.OEThrow.Fatal(f"Unable to parse {args.smarts}'")
console.print(f"Using '{args.smarts}' to select part of the ligand.")
oechem.OEPrepareSearch(ligand, sub_search)
unique = True
only_ligand_atoms.Clear()
for match in sub_search.Match(ligand, unique):
only_ligand_atoms = oechem.OEAtomBondSet(match.GetTargetAtoms())
break
if only_ligand_atoms.NumAtoms() == 0:
oechem.OEThrow.Fatal(
"Substructure search was unsuccessful using '{smarts}'"
)
if not args.disable_summery_table:
_print_summery_interaction_table(console, active_site)
_print_ligand_interaction_table(console, active_site, only_ligand_atoms)
if args.save_console_svg:
console.save_svg(f"{__SCRIPT_NAME__}.svg", title="output")
return os.EX_OK
class InteractionHasLigandAtomPred(oechem.OEUnaryInteractionHintPred):
"""
Predicate to identify interactions that involve only the specified ligand atoms.
This class is used to filter interaction hints to those that contain at least one atom
from a provided set of ligand atoms. It is useful for focusing on interactions relevant
to a particular subset of ligand atoms, such as those matched by a SMARTS pattern.
"""
def __init__(self, atom_set: oechem.OEAtomBondSet) -> None:
"""Initialize predicate."""
oechem.OEUnaryInteractionHintPred.__init__(self)
self._atoms_set = atom_set
self._atom_pred = oechem.OEIsAtomMember(atom_set.GetAtoms())
def __call__(self, inter: oechem.OEInteractionHint) -> bool:
"""Evaluate interaction."""
for frag in [inter.GetBgnFragment(), inter.GetEndFragment()]:
if frag.GetComponentType() == oechem.OELigandInteractionHintComponent():
for a in inter.GetBgnFragment().GetAtoms():
if self._atom_pred(a):
return True
return False
def CreateCopy(self): # noqa: ANN201, N802
"""Copy constructor."""
return InteractionHasLigandAtomPred(self._atoms).__disown__()
def get_active_site_from_pdb(filename: str) -> oechem.OEInteractionHintContainer:
"""
Initialize an active site interaction container from a PDB or CIF file.
This function reads a ligand-protein complex from the specified file, separates the ligand and protein,
and constructs an OEInteractionHintContainer for interaction analysis.
"""
ifs = oechem.oemolistream()
if not ifs.open(filename):
oechem.OEThrow.Fatal("Unable to open {filename} for reading")
if ifs.GetFormat() not in [oechem.OEFormat_PDB, oechem.OEFormat_CIF]:
oechem.OEThrow.Fatal("Input file must have .pdb or .cif extension")
complex_mol = oechem.OEGraphMol()
if not oechem.OEReadMolecule(ifs, complex_mol):
oechem.OEThrow.Fatal("Unable to read complex from {filename}")
if not oechem.OEHasResidues(complex_mol):
oechem.OEPerceiveResidues(complex_mol, oechem.OEPreserveResInfo_All)
# separate ligand and protein
split_opts = oechem.OESplitMolComplexOptions()
ligand = oechem.OEGraphMol()
protein = oechem.OEGraphMol()
water = oechem.OEGraphMol()
other = oechem.OEGraphMol()
split_opts.SetProteinFilter(
oechem.OEOrRoleSet(split_opts.GetProteinFilter(), split_opts.GetWaterFilter())
)
split_opts.SetWaterFilter(
oechem.OEMolComplexFilterFactory(oechem.OEMolComplexFilterCategory_Nothing)
)
oechem.OESplitMolComplex(ligand, protein, water, other, complex_mol, split_opts)
if ligand.NumAtoms() == 0:
oechem.OEThrow.Fatal("Cannot separate complex!")
active_site = oechem.OEInteractionHintContainer(protein, ligand)
if not oechem.OEIsValidActiveSite(active_site):
oechem.OEThrow.Fatal("Cannot initialize active site!")
return active_site
def get_active_site_from_design_unit(
filename: str,
) -> oechem.OEInteractionHintContainer:
"""
Initialize an active site interaction container from a design unit file.
This function reads a design unit file, extracts the protein and ligand components,
and constructs an OEInteractionHintContainer for interaction analysis.
"""
design_unit = oechem.OEDesignUnit()
if not oechem.OEIsReadableDesignUnit(filename) or not oechem.OEReadDesignUnit(
filename, design_unit
):
oechem.OEThrow.Fatal("Cannot read design unit.")
active_site = oechem.OEInteractionHintContainer(design_unit)
if not oechem.OEIsValidActiveSite(active_site):
oechem.OEThrow.Fatal("Cannot initialize active site!")
return active_site
def get_active_site_from_serialized(
filename: str,
) -> oechem.OEInteractionHintContainer:
"""Initialize an active site interaction container from a serialized OEB/JSON file."""
ifs = oechem.oemolistream()
if not ifs.open(filename):
oechem.OEThrow.Fatal(f"Unable to open {filename} for reading")
if ifs.GetFormat() not in [oechem.OEFormat_OEB, oechem.OEFormat_JSON]:
oechem.OEThrow.Fatal("Input file must have .oeb or .json extension")
protein = oechem.OEGraphMol()
ligand = oechem.OEGraphMol()
# protein is expected to be first
if not oechem.OEReadMolecule(ifs, protein) or not oechem.OEReadMolecule(
ifs, ligand
):
oechem.OEThrow.Fatal(f"Unable to read serialized interactions from {filename}")
for mol in [protein, ligand]:
if not oechem.OEHasInteractionsHintSerializationData(mol):
oechem.OEThrow.Fatal("Interaction serialized data is missing!")
active_site = oechem.OEInteractionHintContainer()
if not oechem.OEConstructInteractionHintContainer(active_site, protein, ligand):
oechem.OEThrow.Fatal("Failed to construct active site from serialized data!")
if active_site.NumInteractions() == 0:
oechem.OEThrow.Fatal("No interactions found in the serialized data!")
return active_site
def get_active_site_from_separate_files(
pro_filename: str, lig_filename: str
) -> oechem.OEInteractionHintContainer:
"""Read protein and ligand from separate PDB and MOL files."""
protein = oechem.OEGraphMol()
ligand = oechem.OEGraphMol()
for filename, mol in [(pro_filename, protein), (lig_filename, ligand)]:
ifs = oechem.oemolistream()
if not ifs.open(filename):
oechem.OEThrow.Fatal(f"Unable to open {filename} for reading")
if not oechem.OEReadMolecule(ifs, mol):
oechem.OEThrow.Fatal(f"Unable to read molecule from {filename}")
oechem.OESetDimensionFromCoords(mol)
active_site = oechem.OEInteractionHintContainer(protein, ligand)
if not oechem.OEIsValidActiveSite(active_site):
oechem.OEThrow.Fatal("Cannot initialize active site!")
return active_site
def _print_summery_interaction_table(
console: rich.console.Console, active_site: oechem.OEInteractionHintContainer
) -> None:
columns = ["interaction name", "count"]
columns += [
"inter",
"intra (pro)",
"intra (lig)",
"unpaired",
]
table = rich.table.Table(
*columns,
title=f"[bold]Number of all interactions: {active_site.NumInteractions()} [/bold]",
)
for interaction_type in oechem.OEGetActiveSiteInteractionHintTypes():
num_interactions, num_inter = 0, 0
num_intra_pro, num_intra_lig, num_unpaired = 0, 0, 0
for i in active_site.GetInteractions(
oechem.OEHasInteractionHintType(interaction_type)
):
num_interactions = num_interactions + 1
component_type = i.GetBgnFragment().GetComponentType()
if i.IsIntra():
if i.GetBgnFragment().GetAtom(
oechem.OEIsTrueAtom()
) == i.GetEndFragment().GetAtom(oechem.OEIsTrueAtom()):
num_unpaired = num_unpaired + 1
elif component_type == oechem.OELigandInteractionHintComponent():
num_intra_lig = num_intra_lig + 1
elif component_type == oechem.OEProteinInteractionHintComponent():
num_intra_pro = num_intra_pro + 1
if i.IsInter():
num_inter = num_inter + 1
if num_interactions != 0:
inter_name = interaction_type.GetName().removeprefix("bio:active-site:")
row_data: list[str] = [inter_name, str(num_interactions)]
for inter_count in [num_inter, num_intra_pro, num_intra_lig, num_unpaired]:
row_data.append("✅" if inter_count != 0 else "") # noqa: PERF401
table.add_row(*row_data)
console.print(table)
class _InteractionSymbol(typing.NamedTuple):
"""Data structure representing a mapping between an interaction predicate, its name, and a display symbol."""
pred: oechem.OEUnaryInteractionHintPred
name: str
symbol: str
class IsLigandAcceptsHBondInterInteraction(oechem.OEUnaryInteractionHintPred):
"""
Predicate to identify hydrogen bond inter-molecular interaction where ligand accepts proton from protein atom.
LigandAccepts - same as ProteinDonates
NonIdealLigandAccepts - same as NonIdealProteinDonates
"""
def __call__(self, inter: oechem.OEInteractionHint) -> bool:
"""Evaluate interaction."""
if inter.IsIntra():
return False
accepted_types = [
oechem.OEHBondInteractionHintType_LigandAccepts,
oechem.OEHBondInteractionHintType_NonIdealLigandAccepts,
]
return any(
inter.GetInteractionType() == oechem.OEHBondInteractionHint(a)
for a in accepted_types
)
class IsLigandDonatesHBondInterInteraction(oechem.OEUnaryInteractionHintPred):
"""
Predicate to identify hydrogen bond inter-molecular interaction where ligand donates proton from protein atom.
LigandDonates - same as ProteinAccepts
NonIdealLigandDonates - same as NonIdealProteinAccepts
"""
def __call__(self, inter: oechem.OEInteractionHint) -> bool:
"""Evaluate interaction."""
if inter.IsIntra():
return False
accepted_types = [
oechem.OEHBondInteractionHintType_LigandDonates,
oechem.OEHBondInteractionHintType_NonIdealLigandDonates,
]
return any(
inter.GetInteractionType() == oechem.OEHBondInteractionHint(a)
for a in accepted_types
)
class IsStackingInteraction(oechem.OEUnaryInteractionHintPred):
"""Predicate to identify Pi/T stacking inter molecular interaction."""
def __init__(self, stacking_type: int) -> None:
"""Initialize predicate."""
self._stacking_type = stacking_type
def __call__(self, inter: oechem.OEInteractionHint) -> bool:
"""Evaluate interaction."""
if inter.IsIntra():
return False
return inter.GetInteractionType() == oechem.OEStackingInteractionHint(
oechem.OEStackingInteractionHintType_Pi
)
def CreateCopy(self): # noqa: ANN201, N802
"""Copy constructor."""
return IsStackingInteraction(self._stacking_type).__disown__()
INTERACTION_SYMBOLS = [
_InteractionSymbol(oechem.OEIsClashInteractionHint(), "clash", "❌"),
_InteractionSymbol(oechem.OEIsContactInteractionHint(), "contact", "🤝"),
_InteractionSymbol(
IsLigandAcceptsHBondInterInteraction(), "lig-acc", "[blue]⬅H[/blue]"
),
_InteractionSymbol(
IsLigandDonatesHBondInterInteraction(), "lig-don", "[red]H⮕[/red]"
),
_InteractionSymbol(oechem.OEIsSaltBridgeInteractionHint(), "salt-bridge", "🧂"),
_InteractionSymbol(
IsStackingInteraction(oechem.OEStackingInteractionHintType_T),
"t-stack",
"[green]T⏣[/green]",
),
_InteractionSymbol(
IsStackingInteraction(oechem.OEStackingInteractionHintType_Pi),
"pi-stack",
"[green]π⏣[/green]",
),
_InteractionSymbol(oechem.OEIsHalogenBondInteractionHint(), "halogen", "💡"),
_InteractionSymbol(
oechem.OEIsCationPiInteractionHint(), "cation-pi", "[blue]+π[/blue]"
),
_InteractionSymbol(oechem.OEIsChelatorInteractionHint(), "chelator", "🟨"),
]
def _print_ligand_interaction_table(
console: rich.console.Console,
active_site: oechem.OEInteractionHintContainer,
only_ligand_atoms: oechem.OEAtomBondSet,
) -> None:
columns = [
"ligand atom",
"interactions",
"contacts/clashes",
"interacting with residue(s)",
]
caption = "; ".join(f"{s} {n}" for _, n, s in INTERACTION_SYMBOLS)
table = rich.table.Table(
*columns,
title="Ligand Protein interactions",
caption=caption,
caption_style="bold",
)
for atom in active_site.GetMolecule(
oechem.OELigandInteractionHintComponent()
).GetAtoms():
if not only_ligand_atoms.HasAtom(atom):
continue
residue_names: set[str] = set()
symbols: list[str] = []
for inter in active_site.GetInteractions(
oechem.OEAndInteractionHint(
oechem.OEIsInterInteractionHint(), oechem.OEHasInteractionHint(atom)
)
):
if prot_frag := inter.GetFragment(
oechem.OEProteinInteractionHintComponent()
):
for prot_atom in prot_frag.GetAtoms():
residue = oechem.OEAtomGetResidue(prot_atom)
residue_names.add(_get_residue_str(residue))
symbol = next(
(symbol for pred, _, symbol in INTERACTION_SYMBOLS if pred(inter)), "❓"
)
symbols.append(symbol)
if len(residue_names) != 0:
symbol_counts: dict[str, int] = dict(Counter(symbols))
num_clashes, num_contacts = symbol_counts.pop("❌", 0), symbol_counts.pop(
"🤝", 0
)
clash_contact_symbols = "" if num_clashes == 0 else num_clashes * "❌"
clash_contact_symbols += "" if num_contacts == 0 else num_contacts * "🤝"
other_symbols = " ".join(
c * s if c == 1 else f"{c}x{s}" for (s, c) in symbol_counts.items()
)
row_data = [
str(atom),
other_symbols,
clash_contact_symbols,
" ".join(sorted(residue_names)),
]
table.add_row(*row_data)
console.print(table)
def _get_residue_str(residue: oechem.OEResidue) -> str:
return (
f"{residue.GetName():3s} {residue.GetResidueNumber():4d} {residue.GetChainID()}"
)
setattr(main, "__SCRIPT_NAME__", __SCRIPT_NAME__)
setattr(main, "__SCRIPT_DESC__", __SCRIPT_DESC__)
setattr(main, "__SCRIPT_TOOLKITS__", __SCRIPT_TOOLKITS__)
setattr(main, "__SCRIPT_KEYWORDS__", __SCRIPT_KEYWORDS__)
setattr(main, "__SCRIPT_CATEGORIES__", __SCRIPT_CATEGORIES__)
if __name__ == "__main__":
sys.exit(main())
Discussion
The protein-ligand complexes can be initialized from different input formats:
1. From .pdb or .cif file format, where the protein and ligand are expected to be in the
same file and the OESplitMolComplex function is used to split the complex into protein and ligand components.
def get_active_site_from_pdb(filename: str) -> oechem.OEInteractionHintContainer:
"""
Initialize an active site interaction container from a PDB or CIF file.
This function reads a ligand-protein complex from the specified file, separates the ligand and protein,
and constructs an OEInteractionHintContainer for interaction analysis.
"""
ifs = oechem.oemolistream()
if not ifs.open(filename):
oechem.OEThrow.Fatal("Unable to open {filename} for reading")
if ifs.GetFormat() not in [oechem.OEFormat_PDB, oechem.OEFormat_CIF]:
oechem.OEThrow.Fatal("Input file must have .pdb or .cif extension")
complex_mol = oechem.OEGraphMol()
if not oechem.OEReadMolecule(ifs, complex_mol):
oechem.OEThrow.Fatal("Unable to read complex from {filename}")
if not oechem.OEHasResidues(complex_mol):
oechem.OEPerceiveResidues(complex_mol, oechem.OEPreserveResInfo_All)
# separate ligand and protein
split_opts = oechem.OESplitMolComplexOptions()
ligand = oechem.OEGraphMol()
protein = oechem.OEGraphMol()
water = oechem.OEGraphMol()
other = oechem.OEGraphMol()
split_opts.SetProteinFilter(
oechem.OEOrRoleSet(split_opts.GetProteinFilter(), split_opts.GetWaterFilter())
)
split_opts.SetWaterFilter(
oechem.OEMolComplexFilterFactory(oechem.OEMolComplexFilterCategory_Nothing)
)
oechem.OESplitMolComplex(ligand, protein, water, other, complex_mol, split_opts)
if ligand.NumAtoms() == 0:
oechem.OEThrow.Fatal("Cannot separate complex!")
active_site = oechem.OEInteractionHintContainer(protein, ligand)
if not oechem.OEIsValidActiveSite(active_site):
oechem.OEThrow.Fatal("Cannot initialize active site!")
return active_site
From
.oedufile format, where the protein and ligand are already identified and can be retrieved using the OEDesignUnit class.
def get_active_site_from_design_unit(
filename: str,
) -> oechem.OEInteractionHintContainer:
"""
Initialize an active site interaction container from a design unit file.
This function reads a design unit file, extracts the protein and ligand components,
and constructs an OEInteractionHintContainer for interaction analysis.
"""
design_unit = oechem.OEDesignUnit()
if not oechem.OEIsReadableDesignUnit(filename) or not oechem.OEReadDesignUnit(
filename, design_unit
):
oechem.OEThrow.Fatal("Cannot read design unit.")
active_site = oechem.OEInteractionHintContainer(design_unit)
if not oechem.OEIsValidActiveSite(active_site):
oechem.OEThrow.Fatal("Cannot initialize active site!")
return active_site
From serialized interaction file format (
.oebor.json), where the protein and ligand interactions serialized in the input file are reconstructed using the OEConstructInteractionHintContainer function.
def get_active_site_from_serialized(
filename: str,
) -> oechem.OEInteractionHintContainer:
"""Initialize an active site interaction container from a serialized OEB/JSON file."""
ifs = oechem.oemolistream()
if not ifs.open(filename):
oechem.OEThrow.Fatal(f"Unable to open {filename} for reading")
if ifs.GetFormat() not in [oechem.OEFormat_OEB, oechem.OEFormat_JSON]:
oechem.OEThrow.Fatal("Input file must have .oeb or .json extension")
protein = oechem.OEGraphMol()
ligand = oechem.OEGraphMol()
# protein is expected to be first
if not oechem.OEReadMolecule(ifs, protein) or not oechem.OEReadMolecule(
ifs, ligand
):
oechem.OEThrow.Fatal(f"Unable to read serialized interactions from {filename}")
for mol in [protein, ligand]:
if not oechem.OEHasInteractionsHintSerializationData(mol):
oechem.OEThrow.Fatal("Interaction serialized data is missing!")
active_site = oechem.OEInteractionHintContainer()
if not oechem.OEConstructInteractionHintContainer(active_site, protein, ligand):
oechem.OEThrow.Fatal("Failed to construct active site from serialized data!")
if active_site.NumInteractions() == 0:
oechem.OEThrow.Fatal("No interactions found in the serialized data!")
return active_site
Usage
See Download section to download the script.
> interactions2console --help
Printing interactions of 1GKC.pdb
complex (depicted above).
> interactions2console --complex 1GKC.pdb
> interactions2console --complex 1GKC.pdb --smarts 'C(=O)N[C@H](C(=O)N)' --disable-summery-table
Input Ligand-Protein Complex
- --complex
From input 1GKC.cif
> interactions2console --complex 1GKC.cif
- --design-unit
From input 1GKC_DU_1.oedu
> interactions2console --design-unit 1GKC_DU_1.oedu
- --serialized
From input 1GKC.json file
that can be generated using 🆕 Serialize Protein-Ligand Interactions script.
> interactions2console --serialized 1GKC.json
See also in OEChem TK manual
API
OEConstructInteractionHintContainer function
OEDesignUnit class
OEHasInteractionHintType predicate
OEInteractionHint class
OEPerceiveInteractionHints function
OEResidue class