Dihedral angle analysis

chic can be used to find the organic linkers to assist structural analysis. Here, I show how we can calculate dihedral angles for a MOF with 1,3-BDC linkers.

Here, I’ve included a quick analysis script that can take a AtomicCluster object as an argument. Note in particular how I make use of the AtomicCluster.graph molecular graph object in order to traverse the shortest paths between oxygens in the molecule.

dihedral.py
from typing import Tuple, Dict
from dataclasses import dataclass
from itertools import combinations

import numpy as np
from networkx import all_shortest_paths
from chic.atomic_cluster import AtomicCluster

class DihedralAnalysis:
    """
    Identifies the carbonate groups on a 1,3-BDC linker and computes the
    dihedral angles between the carbonate and the ring. Assumes the molecular
    graph for the building unit is reasonable (CrystalNN neighbour list is 
    likely more reliable for this task).

    Attributes:
        None

    Methods:
        get_coord_by_ix(unit: AtomicCluster, ix: int) -> np.ndarray: Retrieves
            coordinate by index.
        compute_dihedral(A, B, C, D) -> float: Computes the dihedral angle 
            between points A, B, C, and D.
        analyse_ligand(unit: AtomicCluster) -> Dict[str, np.ndarray]: Analyses
            ligands in the building unit, computing dihedrals for carbonates.

    Inner Classes:
        Carbonate: Represents a carbonate group with methods to check carbon
            presence and compute dihedrals.
    """

    @staticmethod
    def get_coord_by_ix(unit: AtomicCluster, ix: int) -> np.ndarray:
        """
        Retrieves coordinate by index.

        Arguments:
            unit (AtomicCluster): The building unit.
            ix (int): The index to retrieve.
        
        Returns:
            np.ndarray: The coordinate.
        """
        ix_ = np.argwhere(np.array(unit.site_indices, dtype=int) == ix)[0][0]
        return unit.cart_coords[ix_]

    @staticmethod
    def compute_dihedral(
        A: np.ndarray, B: np.ndarray, C: np.ndarray, D: np.ndarray
    ) -> float:
        """
        Computes the dihedral angle between points A, B, C, and D.

        Arguments:
            A (np.ndarray): The first point.
            B (np.ndarray): The second point.
            C (np.ndarray): The third point.
            D (np.ndarray): The fourth point.

        Returns:
            float: The dihedral angle in degrees.
        """
        BA, BC = A - B, C - B
        CB, CD = -BC, D - C
        n1, n2 = np.cross(BA, BC), np.cross(CB, CD)
        n1 /= np.linalg.norm(n1)
        n2 /= np.linalg.norm(n2)
        d = np.dot(n1, n2)
        sign = 1 if np.dot(np.cross(BA, CD), BC) >= 0 else -1
        return sign * np.arccos(d) * 180 / np.pi

    @dataclass
    class Carbonate:
        """
        Represents a carbonate group with methods to check carbon presence and 
        compute dihedrals.

                            C(3)           O(1)
                                \           /
                                C(2) --- C(1)
                                            \
                                            O(2)

        Attributes:
            o1 (int): The index of the first oxygen atom.
            c1 (int): The index of the first carbon atom.
            o2 (int): The index of the second oxygen atom.
            c2 (int): The index of the second carbon atom.
            c3 (int): The index of the third carbon atom.

        Methods:
            has_c(ix: int) -> bool: Checks if given index ix is a carbon in the 
                carbonate.
            dihedrals(bu: AtomicCluster) -> Tuple[float, float]: Computes and 
                returns dihedral angles for the carbonate based on building unit 
                provided.
        """
        o1: int
        c1: int
        o2: int
        c2: int = None
        c3: int = None

        def has_c(self, ix: int) -> bool:
            """ 
            Checks if given index ix is a carbon in the carbonate.

            Arguments:
                ix (int): The index to check.

            Returns:
                bool: True if the index is a carbon in the carbonate.
            """
            return ix==self.c1

        def dihedrals(self, unit: AtomicCluster) -> Tuple[float, float]:
            """
            Computes and returns dihedral angles for the carbonate based on 
            building unit provided.

            Arguments:
                unit (AtomicCluster): The building unit.

            Returns:
                Tuple[float, float]: The dihedral angles in degrees.
            """
            coords = lambda idx: DihedralAnalysis.get_coord_by_ix(unit, idx)
            dh1 = DihedralAnalysis.compute_dihedral(
                coords(self.o1), coords(self.c1), 
                coords(self.c2), coords(self.c3)
            )
            dh2 = DihedralAnalysis.compute_dihedral(
                coords(self.o2), coords(self.c1), 
                coords(self.c2), coords(self.c3)
            )
            return dh1, dh2

    def analyse_ligand(self, unit: AtomicCluster) -> Dict[str, np.ndarray]:
        """
        Analyzes ligands in a given building unit, computing dihedrals for 
        carbonates.

        Arguments:
            unit (AtomicCluster): The building unit.

        Returns:
            Dict[str, np.ndarray]: A dictionary with dihedral angles for each 
                carbonate.
        """

        # get unique combinations of oxygen atoms to search for paths between.
        oxygen_combinations = combinations([
            x for s,x in zip(unit._symbols,unit.site_indices) if s=='O'], r=2
        )

        backbone = []
        carbonates = []
        for n1, n2 in oxygen_combinations:

            # access the molecular graph automatically found by chic.
            p = list(all_shortest_paths(unit.graph, n1, n2))

            if len(p) == 1:
                p = p[0]

                # this is the path between oxygens on the same carbonate group.
                if len(p) == 3:
                    carbonates.append(self.Carbonate(*p))

                # this is a linking path between the two carbonte groups.
                else:
                    backbone.append(tuple(p[1:-1]))
        
        # each 1,3-BDC ligand should have two carbnate groups, and one obvious
        # shortest path passing through C(2) of the ring.
        assert len(carbonates)==2 and len(set(backbone))==1, "Analysis failed."
        backbone = list(backbone)[0]

        # Assign additional carbon indices based on backbone analysis
        for carbonate in carbonates:
            if carbonate.has_c(backbone[0]):
                carbonate.c2, carbonate.c3 = backbone[1], backbone[2]
            else:
                carbonate.c2, carbonate.c3 = backbone[-2], backbone[-3]

        # Compute dihedrals for each carbonate
        dihedrals = [carbonate.dihedrals(unit) for carbonate in carbonates]
        dihedrals = [sorted(d, key=abs, reverse=True) for d in dihedrals]

        return {
            'carboxylate_a': np.round(dihedrals[0],3), 
            'carboxylate_b': np.round(dihedrals[1],3)
        }

We can load structure in the usual way and search for nodes and linkers. This particular MOF can be downloaded from the Cambridge Structural Database (CDC), with RefCode JOLFEZ

>>> from chic import Structure
>>> mof = Structure.from_cif("JOLFEZ.cif")
>>> mof.get_neighbours_crystalnn()
>>> mof.find_atomic_clusters()
>>> mof.get_coarse_grained_net()

First we can select a linker molecule from the atomic_clusters dictionary, where the linkers will default to “B sites” (and zinc nodes will be “A sites”).

>>> linker = mof.atomic_clusters[("b", 2)]
>>> print(linker)
AtomicCluster("C8 O4 H4", site_indices=[16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])

We can instantiate our analysis class and analyse our chosen linker molecule:

>>> from dihedral import DihedralAnalysis
>>> analysis = DihedralAnalysis()
>>> results = analysis.analyse_ligand(linker)
>>> print(results)
{'carboxylate_a': array([176.342,  -5.238]),
'carboxylate_b': array([149.77 , -32.647])}