proteusPy.Disulfide

This module, Disulfide, is part of the proteusPy package, a Python package for the analysis and modeling of protein structures, with an emphasis on disulfide bonds. It represents the core of the current implementation of proteusPy.

This work is based on the original C/C++ implementation by Eric G. Suchanek.

Author: Eric G. Suchanek, PhD Last Modification: 2025-02-09 23:32:12

   1"""
   2This module, *Disulfide*, is part of the proteusPy package, a Python package for 
   3the analysis and modeling of protein structures, with an emphasis on disulfide bonds.
   4It represents the core of the current implementation of *proteusPy*.
   5
   6This work is based on the original C/C++ implementation by Eric G. Suchanek. \n
   7Author: Eric G. Suchanek, PhD
   8Last Modification: 2025-02-09 23:32:12
   9"""
  10
  11# pylint: disable=W1203 # use of print
  12# pylint: disable=C0103 # invalid name
  13# pylint: disable=C0301 # line too long
  14# pylint: disable=C0302 # too many lines in module
  15# pylint: disable=W0212 # access to protected member
  16# pylint: disable=W0613 # unused argument
  17# pylint: disable=C2801 # no dunder method
  18
  19# Cα N, Cα, Cβ, C', Sγ Å ° ρ
  20
  21import copy
  22import logging
  23import math
  24import warnings
  25from math import cos
  26
  27import numpy as np
  28import pyvista as pv
  29from scipy.optimize import minimize
  30
  31# import proteusPy
  32from proteusPy.atoms import (
  33    ATOM_COLORS,
  34    ATOM_RADII_COVALENT,
  35    ATOM_RADII_CPK,
  36    BOND_COLOR,
  37    BOND_RADIUS,
  38    BS_SCALE,
  39    SPEC_POWER,
  40    SPECULARITY,
  41)
  42from proteusPy.DisulfideClassManager import DisulfideClassManager
  43from proteusPy.DisulfideExceptions import (
  44    DisulfideConstructionException,
  45    DisulfideConstructionWarning,
  46    ProteusPyWarning,
  47)
  48from proteusPy.DisulfideList import DisulfideList
  49from proteusPy.logger_config import create_logger
  50from proteusPy.ProteusGlobals import _ANG_INIT, _FLOAT_INIT, FONTSIZE, WINSIZE
  51from proteusPy.Residue import build_residue
  52from proteusPy.ssparser import (
  53    get_phipsi_atoms_coordinates,
  54    get_residue_atoms_coordinates,
  55)
  56from proteusPy.turtle3D import ORIENT_SIDECHAIN, Turtle3D
  57from proteusPy.utility import set_pyvista_theme
  58from proteusPy.vector3D import (
  59    Vector3D,
  60    calc_dihedral,
  61    calculate_bond_angle,
  62    distance3d,
  63    rms_difference,
  64)
  65
  66np.set_printoptions(suppress=True)
  67pv.global_theme.color = "white"
  68
  69# Configure logging
  70logging.basicConfig(
  71    level=logging.WARNING,
  72    format="%(asctime)s - %(levelname)s - %(message)s",
  73)
  74
  75
  76# Suppress findfont debug messages
  77logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
  78
  79ORIGIN = Vector3D(0.0, 0.0, 0.0)
  80
  81_logger = create_logger(__name__)
  82_logger.setLevel(logging.ERROR)
  83
  84
  85# class for the Disulfide bond
  86class Disulfide:
  87    r"""
  88    This class provides a Python object and methods representing a physical disulfide bond
  89    either extracted from the RCSB protein databank or built using the
  90    [proteusPy.Turtle3D](turtle3D.html) class. The disulfide bond is an important
  91    intramolecular stabilizing structural element and is characterized by:
  92
  93    * Atomic coordinates for the atoms N, Cα, Cβ, C', Sγ for both residues.
  94    These are stored as both raw atomic coordinates as read from the RCSB file
  95    and internal local coordinates.
  96    * The dihedral angles Χ1 - Χ5 for the disulfide bond
  97    * A name, by default {pdb_id}{prox_resnumb}{prox_chain}_{distal_resnum}{distal_chain}
  98    * Proximal residue number
  99    * Distal residue number
 100    * Approximate bond torsional energy (kcal/mol):
 101
 102    $$
 103    E_{kcal/mol} \\approx 2.0 * cos(3.0 * \\chi_{1}) + cos(3.0 * \\chi_{5}) + cos(3.0 * \\chi_{2}) +
 104    $$
 105    $$
 106    cos(3.0 * \chi_{4}) + 3.5 * cos(2.0 * \chi_{3}) + 0.6 * cos(3.0 * \chi_{3}) + 10.1
 107    $$
 108
 109    The equation embodies the typical 3-fold rotation barriers associated with single bonds,
 110    (Χ1, Χ5, Χ2, Χ4) and a high 2-fold barrier for Χ3, resulting from the partial double bond
 111    character of the S-S bond. This property leads to two major disulfide families, characterized
 112    by the sign of Χ3. *Left-handed* disulfides have Χ3 < 0° and *right-handed* disulfides have
 113    Χ3 > 0°. Within this breakdown there are numerous subfamilies, broadly known as the *hook*,
 114    *spiral* and *staple*. These are under characgterization.
 115
 116    * Euclidean length of the dihedral angles (degrees) defined as:
 117    $$\sqrt(\chi_{1}^{2} + \chi_{2}^{2} + \chi_{3}^{2} + \chi_{4}^{2} + \chi_{5}^{2})$$
 118    * Cα - Cα distance (Å)
 119    * Cβ - Cβ distance (Å)
 120    * Sγ - Sγ distance (Å)
 121    * The previous C' and next N for both the proximal and distal residues. These are needed
 122    to calculate the backbone dihedral angles Φ and Ψ.
 123    * Backbone dihedral angles Φ and Ψ, when possible. Not all structures are complete and
 124    in those cases the atoms needed may be undefined. In this case the Φ and Ψ angles are set
 125    to -180°.
 126
 127    The class also provides a rendering capabilities using the excellent [PyVista](https://pyvista.org)
 128    library, and can display disulfides interactively in a variety of display styles:
 129    * 'sb' - Split Bonds style - bonds colored by their atom type
 130    * 'bs' - Ball and Stick style - split bond coloring with small atoms
 131    * 'pd' - Proximal/Distal style - bonds colored *Red* for proximal residue and *Green* for
 132    the distal residue.
 133    * 'cpk' - CPK style rendering, colored by atom type:
 134        * Carbon   - Grey
 135        * Nitrogen - Blue
 136        * Sulfur   - Yellow
 137        * Oxygen   - Red
 138        * Hydrogen - White
 139
 140    Individual renderings can be saved to a file, and animations created.
 141    """
 142
 143    def __init__(
 144        self,
 145        name: str = "SSBOND",
 146        proximal: int = -1,
 147        distal: int = -1,
 148        proximal_chain: str = "A",
 149        distal_chain: str = "A",
 150        pdb_id: str = "1egs",
 151        quiet: bool = True,
 152        torsions: list = None,
 153    ) -> None:
 154        """
 155        Initialize the class to defined internal values. If torsions are provided, the
 156        Disulfide object is built using the torsions and initialized.
 157
 158        :param name: Disulfide name, by default "SSBOND"
 159        :param proximal: Proximal residue number, by default -1
 160        :param distal: Distal residue number, by default -1
 161        :param proximal_chain: Chain identifier for the proximal residue, by default "A"
 162        :param distal_chain: Chain identifier for the distal residue, by default "A"
 163        :param pdb_id: PDB identifier, by default "1egs"
 164        :param quiet: If True, suppress output, by default True
 165        :param torsions: List of torsion angles, by default None
 166        """
 167        self.name = name
 168        self.proximal = proximal
 169        self.distal = distal
 170        self.energy = _FLOAT_INIT
 171        self.proximal_chain = proximal_chain
 172        self.distal_chain = distal_chain
 173        self.pdb_id = pdb_id
 174        self.quiet = quiet
 175        self.proximal_secondary = "Nosecondary"
 176        self.distal_secondary = "Nosecondary"
 177        self.ca_distance = _FLOAT_INIT
 178        self.cb_distance = _FLOAT_INIT
 179        self.sg_distance = _FLOAT_INIT
 180        self.torsion_array = np.array(
 181            (_ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT)
 182        )
 183        self.phiprox = _ANG_INIT
 184        self.psiprox = _ANG_INIT
 185        self.phidist = _ANG_INIT
 186        self.psidist = _ANG_INIT
 187
 188        # global coordinates for the Disulfide, typically as
 189        # returned from the PDB file
 190
 191        self.n_prox = ORIGIN
 192        self.ca_prox = ORIGIN
 193        self.c_prox = ORIGIN
 194        self.o_prox = ORIGIN
 195        self.cb_prox = ORIGIN
 196        self.sg_prox = ORIGIN
 197        self.sg_dist = ORIGIN
 198        self.cb_dist = ORIGIN
 199        self.ca_dist = ORIGIN
 200        self.n_dist = ORIGIN
 201        self.c_dist = ORIGIN
 202        self.o_dist = ORIGIN
 203
 204        # set when we can't find previous or next prox or distal
 205        # C' or N atoms.
 206        self.missing_atoms = False
 207        self.modelled = False
 208        self.resolution = -1.0
 209
 210        # need these to calculate backbone dihedral angles
 211        self.c_prev_prox = ORIGIN
 212        self.n_next_prox = ORIGIN
 213        self.c_prev_dist = ORIGIN
 214        self.n_next_dist = ORIGIN
 215
 216        # local coordinates for the Disulfide, computed using the Turtle3D in
 217        # Orientation #1. these are generally private.
 218
 219        self._n_prox = ORIGIN
 220        self._ca_prox = ORIGIN
 221        self._c_prox = ORIGIN
 222        self._o_prox = ORIGIN
 223        self._cb_prox = ORIGIN
 224        self._sg_prox = ORIGIN
 225        self._sg_dist = ORIGIN
 226        self._cb_dist = ORIGIN
 227        self._ca_dist = ORIGIN
 228        self._n_dist = ORIGIN
 229        self._c_dist = ORIGIN
 230        self._o_dist = ORIGIN
 231
 232        # need these to calculate backbone dihedral angles
 233        self._c_prev_prox = ORIGIN
 234        self._n_next_prox = ORIGIN
 235        self._c_prev_dist = ORIGIN
 236        self._n_next_dist = ORIGIN
 237
 238        # Dihedral angles for the disulfide bond itself, set to _ANG_INIT
 239        self.chi1 = _ANG_INIT
 240        self.chi2 = _ANG_INIT
 241        self.chi3 = _ANG_INIT
 242        self.chi4 = _ANG_INIT
 243        self.chi5 = _ANG_INIT
 244        self._rho = _ANG_INIT  # new dihedral angle: Nprox - Ca_prox - Ca_dist - N_dist
 245
 246        self.torsion_length = _FLOAT_INIT
 247
 248        if torsions is not None and len(torsions) == 5:
 249            # computes energy, torsion length and rho
 250            self.dihedrals = torsions
 251            self.build_yourself()
 252
 253    # comparison operators, used for sorting. keyed to SS bond energy
 254    def __lt__(self, other):
 255        if isinstance(other, Disulfide):
 256            return self.energy < other.energy
 257        return NotImplemented
 258
 259    def __le__(self, other):
 260        if isinstance(other, Disulfide):
 261            return self.energy <= other.energy
 262        return NotImplemented
 263
 264    def __gt__(self, other):
 265        if isinstance(other, Disulfide):
 266            return self.energy > other.energy
 267        return NotImplemented
 268
 269    def __ge__(self, other):
 270        if isinstance(other, Disulfide):
 271            return self.energy >= other.energy
 272        return NotImplemented
 273
 274    def __eq__(self, other):
 275        if isinstance(other, Disulfide):
 276            return (
 277                math.isclose(self.torsion_length, other.torsion_length, rel_tol=1e-1)
 278                and self.proximal == other.proximal
 279                and self.distal == other.distal
 280            )
 281        return False
 282
 283    def __ne__(self, other):
 284        if isinstance(other, Disulfide):
 285            return self.proximal != other.proximal or self.distal != other.distal
 286        return NotImplemented
 287
 288    def __repr__(self):
 289        """
 290        Representation for the Disulfide class
 291        """
 292        s1 = self.repr_ss_info()
 293        res = f"{s1}>"
 294        return res
 295
 296    def _draw_bonds(
 297        self,
 298        pvp,
 299        coords,
 300        bond_radius=BOND_RADIUS,
 301        style="sb",
 302        bcolor=BOND_COLOR,
 303        all_atoms=True,
 304        res=100,
 305    ):
 306        """
 307        Generate the appropriate pyVista cylinder objects to represent
 308        a particular disulfide bond. This utilizes a connection table
 309        for the starting and ending atoms and a color table for the
 310        bond colors. Used internally.
 311
 312        :param pvp: input plotter object to be updated
 313        :param bradius: bond radius
 314        :param style: bond style. One of sb, plain, pd
 315        :param bcolor: pyvista color
 316        :param missing: True if atoms are missing, False othersie
 317        :param all_atoms: True if rendering O, False if only backbone rendered
 318
 319        :return pvp: Updated Plotter object.
 320
 321        """
 322        _bond_conn = np.array(
 323            [
 324                [0, 1],  # n-ca
 325                [1, 2],  # ca-c
 326                [2, 3],  # c-o
 327                [1, 4],  # ca-cb
 328                [4, 5],  # cb-sg
 329                [6, 7],  # n-ca
 330                [7, 8],  # ca-c
 331                [8, 9],  # c-o
 332                [7, 10],  # ca-cb
 333                [10, 11],  # cb-sg
 334                [5, 11],  # sg -sg
 335                [12, 0],  # cprev_prox-n
 336                [2, 13],  # c-nnext_prox
 337                [14, 6],  # cprev_dist-n_dist
 338                [8, 15],  # c-nnext_dist
 339            ]
 340        )
 341
 342        # modeled disulfides only have backbone atoms since
 343        # phi and psi are undefined, which makes the carbonyl
 344        # oxygen (O) undefined as well. Their previous and next N
 345        # are also undefined.
 346
 347        missing = self.missing_atoms
 348        bradius = bond_radius
 349
 350        _bond_conn_backbone = np.array(
 351            [
 352                [0, 1],  # n-ca
 353                [1, 2],  # ca-c
 354                [1, 4],  # ca-cb
 355                [4, 5],  # cb-sg
 356                [6, 7],  # n-ca
 357                [7, 8],  # ca-c
 358                [7, 10],  # ca-cb
 359                [10, 11],  # cb-sg
 360                [5, 11],  # sg -sg
 361            ]
 362        )
 363
 364        # colors for the bonds. Index into ATOM_COLORS array
 365        _bond_split_colors = np.array(
 366            [
 367                ("N", "C"),
 368                ("C", "C"),
 369                ("C", "O"),
 370                ("C", "C"),
 371                ("C", "SG"),
 372                ("N", "C"),
 373                ("C", "C"),
 374                ("C", "O"),
 375                ("C", "C"),
 376                ("C", "SG"),
 377                ("SG", "SG"),
 378                # prev and next C-N bonds - color by atom Z
 379                ("C", "N"),
 380                ("C", "N"),
 381                ("C", "N"),
 382                ("C", "N"),
 383            ]
 384        )
 385
 386        _bond_split_colors_backbone = np.array(
 387            [
 388                ("N", "C"),
 389                ("C", "C"),
 390                ("C", "C"),
 391                ("C", "SG"),
 392                ("N", "C"),
 393                ("C", "C"),
 394                ("C", "C"),
 395                ("C", "SG"),
 396                ("SG", "SG"),
 397            ]
 398        )
 399        # work through connectivity and colors
 400        orig_col = dest_col = bcolor
 401
 402        if all_atoms:
 403            bond_conn = _bond_conn
 404            bond_split_colors = _bond_split_colors
 405        else:
 406            bond_conn = _bond_conn_backbone
 407            bond_split_colors = _bond_split_colors_backbone
 408
 409        for i, bond in enumerate(bond_conn):
 410            if all_atoms:
 411                if i > 10 and missing:  # skip missing atoms
 412                    continue
 413
 414            orig, dest = bond
 415            col = bond_split_colors[i]
 416
 417            # get the coords
 418            prox_pos = coords[orig]
 419            distal_pos = coords[dest]
 420
 421            # compute a direction vector
 422            direction = distal_pos - prox_pos
 423
 424            # compute vector length. divide by 2 since split bond
 425            height = math.dist(prox_pos, distal_pos) / 2.0
 426
 427            # the cylinder origins are actually in the
 428            # middle so we translate
 429
 430            origin = prox_pos + 0.5 * direction  # for a single plain bond
 431            origin1 = prox_pos + 0.25 * direction
 432            origin2 = prox_pos + 0.75 * direction
 433
 434            if style == "plain":
 435                orig_col = dest_col = bcolor
 436
 437            # proximal-distal red/green coloring
 438            elif style == "pd":
 439                if i <= 4 or i == 11 or i == 12:
 440                    orig_col = dest_col = "red"
 441                else:
 442                    orig_col = dest_col = "green"
 443                if i == 10:
 444                    orig_col = dest_col = "yellow"
 445            else:
 446                orig_col = ATOM_COLORS[col[0]]
 447                dest_col = ATOM_COLORS[col[1]]
 448
 449            if i >= 11:  # prev and next residue atoms for phi/psi calcs
 450                bradius = bradius * 0.5  # make smaller to distinguish
 451
 452            cap1 = pv.Sphere(center=prox_pos, radius=bradius)
 453            cap2 = pv.Sphere(center=distal_pos, radius=bradius)
 454
 455            if style == "plain":
 456                cyl = pv.Cylinder(
 457                    origin, direction, radius=bradius, height=height * 2.0
 458                )
 459                pvp.add_mesh(cyl, color=orig_col)
 460            else:
 461                cyl1 = pv.Cylinder(
 462                    origin1,
 463                    direction,
 464                    radius=bradius,
 465                    height=height,
 466                    capping=False,
 467                    resolution=res,
 468                )
 469                cyl2 = pv.Cylinder(
 470                    origin2,
 471                    direction,
 472                    radius=bradius,
 473                    height=height,
 474                    capping=False,
 475                    resolution=res,
 476                )
 477                pvp.add_mesh(cyl1, color=orig_col)
 478                pvp.add_mesh(cyl2, color=dest_col)
 479
 480            pvp.add_mesh(cap1, color=orig_col)
 481            pvp.add_mesh(cap2, color=dest_col)
 482
 483        return pvp  # end draw_bonds
 484
 485    def _render(
 486        self,
 487        pvplot: pv.Plotter,
 488        style="bs",
 489        plain=False,
 490        bondcolor=BOND_COLOR,
 491        bs_scale=BS_SCALE,
 492        spec=SPECULARITY,
 493        specpow=SPEC_POWER,
 494        translate=True,
 495        bond_radius=BOND_RADIUS,
 496        res=100,
 497    ):
 498        """
 499        Update the passed pyVista plotter() object with the mesh data for the
 500        input Disulfide Bond. Used internally.
 501
 502        :param pvplot: pyvista.Plotter object
 503        :type pvplot: pv.Plotter
 504
 505        :param style: Rendering style, by default 'bs'. One of 'bs', 'st', 'cpk'. Render as \
 506            CPK, ball-and-stick or stick. Bonds are colored by atom color, unless \
 507            'plain' is specified.
 508        :type style: str, optional
 509
 510        :param plain: Used internally, by default False
 511        :type plain: bool, optional
 512
 513        :param bondcolor: pyVista color name, optional bond color for simple bonds, by default BOND_COLOR
 514        :type bondcolor: str, optional
 515
 516        :param bs_scale: Scale factor (0-1) to reduce the atom sizes for ball and stick, by default BS_SCALE
 517        :type bs_scale: float, optional
 518
 519        :param spec: Specularity (0-1), where 1 is totally smooth and 0 is rough, by default SPECULARITY
 520        :type spec: float, optional
 521
 522        :param specpow: Exponent used for specularity calculations, by default SPEC_POWER
 523        :type specpow: int, optional
 524
 525        :param translate: Flag used internally to indicate if we should translate \
 526            the disulfide to its geometric center of mass, by default True.
 527        :type translate: bool, optional
 528
 529        :returns: Updated pv.Plotter object with atoms and bonds.
 530        :rtype: pv.Plotter
 531        """
 532
 533        def add_atoms(pvp, coords, atoms, radii, colors, spec, specpow):
 534            for i, atom in enumerate(atoms):
 535                rad = radii[atom]
 536                if style == "bs" and i > 11:
 537                    rad *= 0.75
 538                pvp.add_mesh(
 539                    pv.Sphere(center=coords[i], radius=rad),
 540                    color=colors[atom],
 541                    smooth_shading=True,
 542                    specular=spec,
 543                    specular_power=specpow,
 544                )
 545
 546        def draw_bonds(pvp, coords, style, all_atoms, bond_radius, bondcolor=None):
 547            return self._draw_bonds(
 548                pvp,
 549                coords,
 550                style=style,
 551                all_atoms=all_atoms,
 552                bond_radius=bond_radius,
 553                bcolor=bondcolor,
 554            )
 555
 556        model = self.modelled
 557        coords = self.internal_coords
 558        if translate:
 559            coords -= self.cofmass
 560
 561        atoms = (
 562            "N",
 563            "C",
 564            "C",
 565            "O",
 566            "C",
 567            "SG",
 568            "N",
 569            "C",
 570            "C",
 571            "O",
 572            "C",
 573            "SG",
 574            "C",
 575            "N",
 576            "C",
 577            "N",
 578        )
 579        pvp = pvplot
 580        all_atoms = not model
 581
 582        if style == "cpk":
 583            add_atoms(pvp, coords, atoms, ATOM_RADII_CPK, ATOM_COLORS, spec, specpow)
 584        elif style == "cov":
 585            add_atoms(
 586                pvp, coords, atoms, ATOM_RADII_COVALENT, ATOM_COLORS, spec, specpow
 587            )
 588        elif style == "bs":
 589            add_atoms(
 590                pvp,
 591                coords,
 592                atoms,
 593                {atom: ATOM_RADII_CPK[atom] * bs_scale for atom in atoms},
 594                ATOM_COLORS,
 595                spec,
 596                specpow,
 597            )
 598            pvp = draw_bonds(pvp, coords, "bs", all_atoms, bond_radius)
 599        elif style in ["sb", "pd", "plain"]:
 600            pvp = draw_bonds(
 601                pvp,
 602                coords,
 603                style,
 604                all_atoms,
 605                bond_radius,
 606                bondcolor if style == "plain" else None,
 607            )
 608
 609        return pvp
 610
 611    def _plot(
 612        self,
 613        pvplot,
 614        style="bs",
 615        plain=False,
 616        bondcolor=BOND_COLOR,
 617        bs_scale=BS_SCALE,
 618        spec=SPECULARITY,
 619        specpow=SPEC_POWER,
 620        translate=True,
 621        bond_radius=BOND_RADIUS,
 622        res=100,
 623    ):
 624        """
 625            Update the passed pyVista plotter() object with the mesh data for the
 626            input Disulfide Bond. Used internally
 627
 628            Parameters
 629            ----------
 630            pvplot : pv.Plotter
 631                pyvista.Plotter object
 632
 633            style : str, optional
 634                Rendering style, by default 'bs'. One of 'bs', 'st', 'cpk', Render as \
 635                CPK, ball-and-stick or stick. Bonds are colored by atom color, unless \
 636                'plain' is specified.
 637
 638            plain : bool, optional
 639                Used internally, by default False
 640
 641            bondcolor : pyVista color name, optional bond color for simple bonds, by default BOND_COLOR
 642
 643            bs_scale : float, optional
 644                scale factor (0-1) to reduce the atom sizes for ball and stick, by default BS_SCALE
 645            
 646            spec : float, optional
 647                specularity (0-1), where 1 is totally smooth and 0 is rough, by default SPECULARITY
 648
 649            specpow : int, optional
 650                exponent used for specularity calculations, by default SPEC_POWER
 651
 652            translate : bool, optional
 653                Flag used internally to indicate if we should translate \
 654                the disulfide to its geometric center of mass, by default True.
 655
 656            Returns
 657            -------
 658            pv.Plotter
 659                Updated pv.Plotter object with atoms and bonds.
 660            """
 661
 662        _bradius = bond_radius
 663        coords = self.internal_coords
 664
 665        model = self.modelled
 666        if model:
 667            all_atoms = False
 668        else:
 669            all_atoms = True
 670
 671        if translate:
 672            coords -= self.cofmass
 673
 674        atoms = (
 675            "N",
 676            "C",
 677            "C",
 678            "O",
 679            "C",
 680            "SG",
 681            "N",
 682            "C",
 683            "C",
 684            "O",
 685            "C",
 686            "SG",
 687            "C",
 688            "N",
 689            "C",
 690            "N",
 691        )
 692        pvp = pvplot.copy()
 693
 694        # bond connection table with atoms in the specific order shown above:
 695        # returned by ss.get_internal_coords()
 696
 697        if style == "cpk":
 698            for i, atom in enumerate(atoms):
 699                rad = ATOM_RADII_CPK[atom]
 700                pvp.append(pv.Sphere(center=coords[i], radius=rad))
 701
 702        elif style == "cov":
 703            for i, atom in enumerate(atoms):
 704                rad = ATOM_RADII_COVALENT[atom]
 705                pvp.append(pv.Sphere(center=coords[i], radius=rad))
 706
 707        elif style == "bs":  # ball and stick
 708            for i, atom in enumerate(atoms):
 709                rad = ATOM_RADII_CPK[atom] * bs_scale
 710                if i > 11:
 711                    rad = rad * 0.75
 712
 713                pvp.append(pv.Sphere(center=coords[i]))
 714            pvp = self._draw_bonds(pvp, coords, style="bs", all_atoms=all_atoms)
 715
 716        else:
 717            pvp = self._draw_bonds(pvp, coords, style=style, all_atoms=all_atoms)
 718
 719        return
 720
 721    def _handle_SS_exception(self, message: str):
 722        """
 723        This method catches an exception that occurs in the Disulfide
 724        object (if quiet), or raises it again, this time adding the
 725        PDB line number to the error message. (private).
 726
 727        :param message: Error message
 728        :raises DisulfideConstructionException: Fatal construction exception.
 729
 730        """
 731        # message = "%s at line %i." % (message)
 732        message = f"{message}"
 733
 734        if self.quiet:
 735            # just print a warning - some residues/atoms may be missing
 736            warnings.warn(
 737                f"DisulfideConstructionException: {message}\n",
 738                "Exception ignored.\n",
 739                "Some atoms may be missing in the data structure.",
 740                DisulfideConstructionWarning,
 741            )
 742        else:
 743            # exceptions are fatal - raise again with new message (including line nr)
 744            raise DisulfideConstructionException(message) from None
 745
 746    @property
 747    def binary_class_string(self):
 748        """
 749        Return a binary string representation of the disulfide bond class.
 750        """
 751        return DisulfideClassManager.class_string_from_dihedral(
 752            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=2
 753        )
 754
 755    @property
 756    def octant_class_string(self):
 757        """
 758        Return the octant string representation of the disulfide bond class.
 759        """
 760        return DisulfideClassManager.class_string_from_dihedral(
 761            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=8
 762        )
 763
 764    @property
 765    def bond_angle_ideality(self):
 766        """
 767        Calculate all bond angles for a disulfide bond and compare them to idealized angles.
 768
 769        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
 770            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
 771        :return: RMS difference between calculated bond angles and idealized bond angles.
 772        :rtype: float
 773        """
 774
 775        atom_coordinates = self.coords_array
 776        verbose = not self.quiet
 777        if verbose:
 778            _logger.setLevel(logging.INFO)
 779
 780        idealized_angles = {
 781            ("N1", "CA1", "C1"): 111.0,
 782            ("N1", "CA1", "CB1"): 108.5,
 783            ("CA1", "CB1", "SG1"): 112.8,
 784            ("CB1", "SG1", "SG2"): 103.8,  # This angle is for the disulfide bond itself
 785            ("SG1", "SG2", "CB2"): 103.8,  # This angle is for the disulfide bond itself
 786            ("SG2", "CB2", "CA2"): 112.8,
 787            ("CB2", "CA2", "N2"): 108.5,
 788            ("N2", "CA2", "C2"): 111.0,
 789        }
 790
 791        # List of triplets for which we need to calculate bond angles
 792        # I am omitting the proximal and distal backbone angle N, Ca, C
 793        # to focus on the disulfide bond angles themselves.
 794        angle_triplets = [
 795            ("N1", "CA1", "C1"),
 796            ("N1", "CA1", "CB1"),
 797            ("CA1", "CB1", "SG1"),
 798            ("CB1", "SG1", "SG2"),
 799            ("SG1", "SG2", "CB2"),
 800            ("SG2", "CB2", "CA2"),
 801            ("CB2", "CA2", "N2"),
 802            ("N2", "CA2", "C2"),
 803        ]
 804
 805        atom_indices = {
 806            "N1": 0,
 807            "CA1": 1,
 808            "C1": 2,
 809            "CB1": 4,
 810            "SG1": 5,
 811            "SG2": 11,
 812            "CB2": 10,
 813            "CA2": 7,
 814            "N2": 6,
 815            "C2": 8,
 816        }
 817
 818        calculated_angles = []
 819        for triplet in angle_triplets:
 820            a = atom_coordinates[atom_indices[triplet[0]]]
 821            b = atom_coordinates[atom_indices[triplet[1]]]
 822            c = atom_coordinates[atom_indices[triplet[2]]]
 823            ideal = idealized_angles[triplet]
 824            try:
 825                angle = calculate_bond_angle(a, b, c)
 826            except ValueError as e:
 827                print(f"Error calculating angle for atoms {triplet}: {e}")
 828                return None
 829            calculated_angles.append(angle)
 830            if verbose:
 831                _logger.info(
 832                    f"Calculated angle for atoms {triplet}: {angle:.2f}, Ideal angle: {ideal:.2f}"
 833                )
 834
 835        # Convert idealized angles to a list
 836        idealized_angles_list = [
 837            idealized_angles[triplet] for triplet in angle_triplets
 838        ]
 839
 840        # Calculate RMS difference
 841        rms_diff = rms_difference(
 842            np.array(calculated_angles), np.array(idealized_angles_list)
 843        )
 844
 845        if verbose:
 846            _logger.info(f"RMS bond angle deviation:, {rms_diff:.2f}")
 847
 848        return rms_diff
 849
 850    @property
 851    def bond_length_ideality(self):
 852        """
 853        Calculate bond lengths for a disulfide bond and compare them to idealized lengths.
 854
 855        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
 856            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
 857        :return: RMS difference between calculated bond lengths and idealized bond lengths.
 858        :rtype: float
 859        """
 860
 861        atom_coordinates = self.coords_array
 862        verbose = not self.quiet
 863        if verbose:
 864            _logger.setLevel(logging.INFO)
 865
 866        idealized_bonds = {
 867            ("N1", "CA1"): 1.46,
 868            ("CA1", "C1"): 1.52,
 869            ("CA1", "CB1"): 1.52,
 870            ("CB1", "SG1"): 1.86,
 871            ("SG1", "SG2"): 2.044,  # This angle is for the disulfide bond itself
 872            ("SG2", "CB2"): 1.86,
 873            ("CB2", "CA2"): 1.52,
 874            ("CA2", "C2"): 1.52,
 875            ("N2", "CA2"): 1.46,
 876        }
 877
 878        # List of triplets for which we need to calculate bond angles
 879        # I am omitting the proximal and distal backbone angle N, Ca, C
 880        # to focus on the disulfide bond angles themselves.
 881        distance_pairs = [
 882            ("N1", "CA1"),
 883            ("CA1", "C1"),
 884            ("CA1", "CB1"),
 885            ("CB1", "SG1"),
 886            ("SG1", "SG2"),  # This angle is for the disulfide bond itself
 887            ("SG2", "CB2"),
 888            ("CB2", "CA2"),
 889            ("CA2", "C2"),
 890            ("N2", "CA2"),
 891        ]
 892
 893        atom_indices = {
 894            "N1": 0,
 895            "CA1": 1,
 896            "C1": 2,
 897            "CB1": 4,
 898            "SG1": 5,
 899            "SG2": 11,
 900            "CB2": 10,
 901            "CA2": 7,
 902            "N2": 6,
 903            "C2": 8,
 904        }
 905
 906        calculated_distances = []
 907        for pair in distance_pairs:
 908            a = atom_coordinates[atom_indices[pair[0]]]
 909            b = atom_coordinates[atom_indices[pair[1]]]
 910            ideal = idealized_bonds[pair]
 911            try:
 912                distance = math.dist(a, b)
 913            except ValueError as e:
 914                _logger.error(f"Error calculating bond length for atoms {pair}: {e}")
 915                return None
 916            calculated_distances.append(distance)
 917            if verbose:
 918                _logger.info(
 919                    f"Calculated distance for atoms {pair}: {distance:.2f}A, Ideal distance: {ideal:.2f}A"
 920                )
 921
 922        # Convert idealized distances to a list
 923        idealized_distance_list = [idealized_bonds[pair] for pair in distance_pairs]
 924
 925        # Calculate RMS difference
 926        rms_diff = rms_difference(
 927            np.array(calculated_distances), np.array(idealized_distance_list)
 928        )
 929
 930        if verbose:
 931            _logger.info(
 932                f"RMS distance deviation from ideality for SS atoms: {rms_diff:.2f}"
 933            )
 934
 935            # Reset logger level
 936            _logger.setLevel(logging.WARNING)
 937
 938        return rms_diff
 939
 940    @property
 941    def internal_coords_array(self):
 942        """
 943        Return an array of internal coordinates for the disulfide bond.
 944
 945        This function collects the coordinates of the backbone atoms involved in the
 946        disulfide bond and returns them as a numpy array.
 947
 948        :param self: The instance of the Disulfide class.
 949        :type self: Disulfide
 950        :return: A numpy array containing the coordinates of the atoms.
 951        :rtype: np.ndarray
 952        """
 953        coords = []
 954        coords.append(self._n_prox.get_array())
 955        coords.append(self._ca_prox.get_array())
 956        coords.append(self._c_prox.get_array())
 957        coords.append(self._o_prox.get_array())
 958        coords.append(self._cb_prox.get_array())
 959        coords.append(self._sg_prox.get_array())
 960        coords.append(self._n_dist.get_array())
 961        coords.append(self._ca_dist.get_array())
 962        coords.append(self._c_dist.get_array())
 963        coords.append(self._o_dist.get_array())
 964        coords.append(self._cb_dist.get_array())
 965        coords.append(self._sg_dist.get_array())
 966
 967        return np.array(coords)
 968
 969    @property
 970    def coords_array(self):
 971        """
 972        Return an array of coordinates for the disulfide bond.
 973
 974        This function collects the coordinates of backbone atoms involved in the
 975        disulfide bond and returns them as a numpy array.
 976
 977        :param self: The instance of the Disulfide class.
 978        :type self: Disulfide
 979        :return: A numpy array containing the coordinates of the atoms.
 980        :rtype: np.ndarray
 981        """
 982        coords = []
 983        coords.append(self.n_prox.get_array())
 984        coords.append(self.ca_prox.get_array())
 985        coords.append(self.c_prox.get_array())
 986        coords.append(self.o_prox.get_array())
 987        coords.append(self.cb_prox.get_array())
 988        coords.append(self.sg_prox.get_array())
 989        coords.append(self.n_dist.get_array())
 990        coords.append(self.ca_dist.get_array())
 991        coords.append(self.c_dist.get_array())
 992        coords.append(self.o_dist.get_array())
 993        coords.append(self.cb_dist.get_array())
 994        coords.append(self.sg_dist.get_array())
 995
 996        return np.array(coords)
 997
 998    @property
 999    def dihedrals(self) -> list:
1000        """
1001        Return a list containing the dihedral angles for the disulfide.
1002
1003        """
1004        return [self.chi1, self.chi2, self.chi3, self.chi4, self.chi5]
1005
1006    @dihedrals.setter
1007    def dihedrals(self, dihedrals: list) -> None:
1008        """
1009        Sets the disulfide dihedral angles to the inputs specified in the list and
1010        computes the torsional energy and length of the disulfide bond.
1011
1012        :param dihedrals: list of dihedral angles.
1013        """
1014        self.chi1 = dihedrals[0]
1015        self.chi2 = dihedrals[1]
1016        self.chi3 = dihedrals[2]
1017        self.chi4 = dihedrals[3]
1018        self.chi5 = dihedrals[4]
1019        self.torsion_array = np.array(dihedrals)
1020        self._compute_torsional_energy()
1021        self._compute_torsion_length()
1022        self._compute_rho()
1023
1024    def bounding_box(self) -> np.array:
1025        """
1026        Return the bounding box array for the given disulfide.
1027
1028        :return: np.array
1029            Array containing the min, max for X, Y, and Z respectively.
1030            Does not currently take the atom's radius into account.
1031        """
1032        coords = self.internal_coords
1033
1034        xmin, ymin, zmin = coords.min(axis=0)
1035        xmax, ymax, zmax = coords.max(axis=0)
1036
1037        res = np.array([[xmin, xmax], [ymin, ymax], [zmin, zmax]])
1038
1039        return res
1040
1041    def build_yourself(self) -> None:
1042        """
1043        Build a model Disulfide based its internal dihedral state
1044        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1045        Cb, with N on left), builds disulfide, and updates the object's internal
1046        coordinates. It also adds the distal protein backbone,
1047        and computes the disulfide conformational energy.
1048        """
1049        self.build_model(self.chi1, self.chi2, self.chi3, self.chi4, self.chi5)
1050
1051    def build_model(
1052        self, chi1: float, chi2: float, chi3: float, chi4: float, chi5: float
1053    ) -> None:
1054        """
1055        Build a model Disulfide based on the input dihedral angles.
1056        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1057        Cb, with N on left), builds disulfide, and updates the object's internal
1058        coordinates. It also adds the distal protein backbone,
1059        and computes the disulfide conformational energy.
1060
1061        :param chi1: Chi1 (degrees)
1062        :param chi2: Chi2 (degrees)
1063        :param chi3: Chi3 (degrees)
1064        :param chi4: Chi4 (degrees)
1065        :param chi5: Chi5 (degrees)
1066
1067        Example:
1068        >>> import proteusPy as pp
1069        >>> modss = pp.Disulfide('model')
1070        >>> modss.build_model(-60, -60, -90, -60, -60)
1071        >>> modss.display(style='sb', light="auto")
1072        """
1073
1074        self.dihedrals = [chi1, chi2, chi3, chi4, chi5]
1075        self.proximal = 1
1076        self.distal = 2
1077
1078        tmp = Turtle3D("tmp")
1079        tmp.Orientation = 1
1080
1081        n = ORIGIN
1082        ca = ORIGIN
1083        cb = ORIGIN
1084        c = ORIGIN
1085
1086        self.ca_prox = tmp._position
1087        tmp.schain_to_bbone()
1088        n, ca, cb, c = build_residue(tmp)
1089
1090        self.n_prox = n
1091        self.ca_prox = ca
1092        self.c_prox = c
1093        self.cb_prox = cb
1094
1095        tmp.bbone_to_schain()
1096        tmp.move(1.53)
1097        tmp.roll(self.chi1)
1098        tmp.yaw(112.8)
1099        self.cb_prox = Vector3D(tmp._position)
1100
1101        tmp.move(1.86)
1102        tmp.roll(self.chi2)
1103        tmp.yaw(103.8)
1104        self.sg_prox = Vector3D(tmp._position)
1105
1106        tmp.move(2.044)
1107        tmp.roll(self.chi3)
1108        tmp.yaw(103.8)
1109        self.sg_dist = Vector3D(tmp._position)
1110
1111        tmp.move(1.86)
1112        tmp.roll(self.chi4)
1113        tmp.yaw(112.8)
1114        self.cb_dist = Vector3D(tmp._position)
1115
1116        tmp.move(1.53)
1117        tmp.roll(self.chi5)
1118        tmp.pitch(180.0)
1119
1120        tmp.schain_to_bbone()
1121
1122        n, ca, cb, c = build_residue(tmp)
1123
1124        self.n_dist = n
1125        self.ca_dist = ca
1126        self.c_dist = c
1127        self._compute_torsional_energy()
1128        self._compute_local_coords()
1129        self._compute_torsion_length()
1130        self._compute_rho()
1131        self.ca_distance = distance3d(self.ca_prox, self.ca_dist)
1132        self.cb_distance = distance3d(self.cb_prox, self.cb_dist)
1133        self.sg_distance = distance3d(self.sg_prox, self.sg_dist)
1134        self.torsion_array = np.array([chi1, chi2, chi3, chi4, chi5])
1135        self.missing_atoms = True
1136        self.modelled = True
1137
1138    @property
1139    def cofmass(self) -> np.array:
1140        """
1141        Return the geometric center of mass for the internal coordinates of
1142        the given Disulfide. Missing atoms are not included.
1143
1144        :return: 3D array for the geometric center of mass
1145        """
1146
1147        res = self.internal_coords.mean(axis=0)
1148        return res
1149
1150    @property
1151    def coord_cofmass(self) -> np.array:
1152        """
1153        Return the geometric center of mass for the global coordinates of
1154        the given Disulfide. Missing atoms are not included.
1155
1156        :return: 3D array for the geometric center of mass
1157        """
1158
1159        res = self.coords.mean(axis=0)
1160        return res
1161
1162    def copy(self):
1163        """
1164        Copy the Disulfide.
1165
1166        :return: A copy of self.
1167        """
1168        return copy.deepcopy(self)
1169
1170    def _compute_local_coords(self) -> None:
1171        """
1172        Compute the internal coordinates for a properly initialized Disulfide Object.
1173
1174        :param self: SS initialized Disulfide object
1175        :returns: None, modifies internal state of the input
1176        """
1177
1178        turt = Turtle3D("tmp")
1179        # get the coordinates as np.array for Turtle3D use.
1180        cpp = self.c_prev_prox.get_array()
1181        nnp = self.n_next_prox.get_array()
1182
1183        n = self.n_prox.get_array()
1184        ca = self.ca_prox.get_array()
1185        c = self.c_prox.get_array()
1186        cb = self.cb_prox.get_array()
1187        o = self.o_prox.get_array()
1188        sg = self.sg_prox.get_array()
1189
1190        sg2 = self.sg_dist.get_array()
1191        cb2 = self.cb_dist.get_array()
1192        ca2 = self.ca_dist.get_array()
1193        c2 = self.c_dist.get_array()
1194        n2 = self.n_dist.get_array()
1195        o2 = self.o_dist.get_array()
1196
1197        cpd = self.c_prev_dist.get_array()
1198        nnd = self.n_next_dist.get_array()
1199
1200        turt.orient_from_backbone(n, ca, c, cb, ORIENT_SIDECHAIN)
1201
1202        # internal (local) coordinates, stored as Vector objects
1203        # to_local returns np.array objects
1204
1205        self._n_prox = Vector3D(turt.to_local(n))
1206        self._ca_prox = Vector3D(turt.to_local(ca))
1207        self._c_prox = Vector3D(turt.to_local(c))
1208        self._o_prox = Vector3D(turt.to_local(o))
1209        self._cb_prox = Vector3D(turt.to_local(cb))
1210        self._sg_prox = Vector3D(turt.to_local(sg))
1211
1212        self._c_prev_prox = Vector3D(turt.to_local(cpp))
1213        self._n_next_prox = Vector3D(turt.to_local(nnp))
1214        self._c_prev_dist = Vector3D(turt.to_local(cpd))
1215        self._n_next_dist = Vector3D(turt.to_local(nnd))
1216
1217        self._n_dist = Vector3D(turt.to_local(n2))
1218        self._ca_dist = Vector3D(turt.to_local(ca2))
1219        self._c_dist = Vector3D(turt.to_local(c2))
1220        self._o_dist = Vector3D(turt.to_local(o2))
1221        self._cb_dist = Vector3D(turt.to_local(cb2))
1222        self._sg_dist = Vector3D(turt.to_local(sg2))
1223
1224    def _compute_torsional_energy(self) -> float:
1225        """
1226        Compute the approximate torsional energy for the Disulfide's
1227        conformation and sets its internal state.
1228
1229        :return: Energy (kcal/mol)
1230        """
1231        # @TODO find citation for the ss bond energy calculation
1232
1233        def torad(deg):
1234            return np.radians(deg)
1235
1236        chi1 = self.chi1
1237        chi2 = self.chi2
1238        chi3 = self.chi3
1239        chi4 = self.chi4
1240        chi5 = self.chi5
1241
1242        energy = 2.0 * (cos(torad(3.0 * chi1)) + cos(torad(3.0 * chi5)))
1243        energy += cos(torad(3.0 * chi2)) + cos(torad(3.0 * chi4))
1244        energy += 3.5 * cos(torad(2.0 * chi3)) + 0.6 * cos(torad(3.0 * chi3)) + 10.1
1245
1246        self.energy = energy
1247        return energy
1248
1249    def display(
1250        self, single=True, style="sb", light="auto", shadows=False, winsize=WINSIZE
1251    ) -> None:
1252        """
1253        Display the Disulfide bond in the specific rendering style.
1254
1255        :param single: Display the bond in a single panel in the specific style.
1256        :param style:  Rendering style: One of:
1257            * 'sb' - split bonds
1258            * 'bs' - ball and stick
1259            * 'cpk' - CPK style
1260            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1261            * 'plain' - boring single color
1262        :param light: If True, light background, if False, dark
1263
1264        Example:
1265        >>> import proteusPy as pp
1266
1267        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
1268        >>> ss = PDB_SS[0]
1269        >>> ss.display(style='cpk', light="auto")
1270        >>> ss.screenshot(style='bs', fname='proteus_logo_sb.png')
1271        """
1272        src = self.pdb_id
1273        enrg = self.energy
1274
1275        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å, Sg: {self.sg_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1276
1277        set_pyvista_theme(light)
1278        fontsize = 8
1279
1280        if single:
1281            _pl = pv.Plotter(window_size=winsize)
1282            _pl.add_title(title=title, font_size=fontsize)
1283            _pl.enable_anti_aliasing("msaa")
1284
1285            self._render(
1286                _pl,
1287                style=style,
1288            )
1289            _pl.reset_camera()
1290            if shadows:
1291                _pl.enable_shadows()
1292            _pl.show()
1293
1294        else:
1295            pl = pv.Plotter(window_size=winsize, shape=(2, 2))
1296            pl.subplot(0, 0)
1297
1298            pl.add_title(title=title, font_size=fontsize)
1299            pl.enable_anti_aliasing("msaa")
1300
1301            # pl.add_camera_orientation_widget()
1302
1303            self._render(
1304                pl,
1305                style="cpk",
1306            )
1307
1308            pl.subplot(0, 1)
1309            pl.add_title(title=title, font_size=fontsize)
1310
1311            self._render(
1312                pl,
1313                style="bs",
1314            )
1315
1316            pl.subplot(1, 0)
1317            pl.add_title(title=title, font_size=fontsize)
1318
1319            self._render(
1320                pl,
1321                style="sb",
1322            )
1323
1324            pl.subplot(1, 1)
1325            pl.add_title(title=title, font_size=fontsize)
1326
1327            self._render(
1328                pl,
1329                style="pd",
1330            )
1331
1332            pl.link_views()
1333            pl.reset_camera()
1334            if shadows:
1335                pl.enable_shadows()
1336            pl.show()
1337        return
1338
1339    @property
1340    def TorsionEnergy(self) -> float:
1341        """
1342        Return the energy of the Disulfide bond.
1343        """
1344        return self._compute_torsional_energy()
1345
1346    @property
1347    def TorsionLength(self) -> float:
1348        """
1349        Return the energy of the Disulfide bond.
1350        """
1351        return self._compute_torsion_length()
1352
1353    def Distance_neighbors(self, others: DisulfideList, cutoff: float) -> DisulfideList:
1354        """
1355        Return list of Disulfides whose RMS atomic distance is within
1356        the cutoff (Å) in the others list.
1357
1358        :param others: DisulfideList to search
1359        :param cutoff: Distance cutoff (Å)
1360        :return: DisulfideList within the cutoff
1361        """
1362
1363        res = [ss.copy() for ss in others if self.Distance_RMS(ss) < cutoff]
1364        return DisulfideList(res, "neighbors")
1365
1366    def Distance_RMS(self, other) -> float:
1367        """
1368        Calculate the RMS distance between the internal coordinates of self and another Disulfide.
1369        :param other: Comparison Disulfide
1370        :return: RMS distance (Å)
1371        """
1372
1373        # Get internal coordinates of both objects
1374        ic1 = self.internal_coords
1375        ic2 = other.internal_coords
1376
1377        # Compute the sum of squared differences between corresponding internal coordinates
1378        totsq = sum(math.dist(p1, p2) ** 2 for p1, p2 in zip(ic1, ic2))
1379
1380        # Compute the mean of the squared distances
1381        totsq /= len(ic1)
1382
1383        # Take the square root of the mean to get the RMS distance
1384        return math.sqrt(totsq)
1385
1386    def Torsion_RMS(self, other) -> float:
1387        """
1388        Calculate the RMS distance between the dihedral angles of self and another Disulfide.
1389
1390        :param other: Disulfide object to compare against.
1391        :type other: Disulfide
1392        :return: RMS distance in degrees.
1393        :rtype: float
1394        :raises ValueError: If the torsion arrays of self and other are not of equal length.
1395        """
1396        # Get internal coordinates of both objects
1397        ic1 = self.torsion_array
1398        ic2 = other.torsion_array
1399
1400        # Ensure both torsion arrays have the same length
1401        if len(ic1) != len(ic2):
1402            raise ValueError("Torsion arrays must be of the same length.")
1403
1404        # Compute the total squared difference between corresponding internal coordinates
1405        total_squared_diff = sum(
1406            (angle1 - angle2) ** 2 for angle1, angle2 in zip(ic1, ic2)
1407        )
1408        mean_squared_diff = total_squared_diff / len(ic1)
1409
1410        # Return the square root of the mean squared difference as the RMS distance
1411        return math.sqrt(mean_squared_diff)
1412
1413    def get_chains(self) -> tuple:
1414        """
1415        Return the proximal and distal chain IDs for the Disulfide.
1416
1417        :return: tuple (proximal, distal) chain IDs
1418        """
1419        prox = self.proximal_chain
1420        dist = self.distal_chain
1421        return tuple(prox, dist)
1422
1423    def get_full_id(self) -> tuple:
1424        """
1425        Return the Disulfide full IDs (Used with BIO.PDB)
1426
1427        :return: Disulfide full IDs
1428        """
1429        return (self.proximal, self.distal)
1430
1431    @property
1432    def internal_coords(self) -> np.array:
1433        """
1434        Return the internal coordinates for the Disulfide.
1435
1436        :return: Array containing the coordinates, [16][3].
1437        """
1438        return self._internal_coords()
1439
1440    def _internal_coords(self) -> np.array:
1441        """
1442        Return the internal coordinates for the Disulfide.
1443        If there are missing atoms the extra atoms for the proximal
1444        and distal N and C are set to [0,0,0]. This is needed for the center of
1445        mass calculations, used when rendering.
1446
1447        :return: Array containing the coordinates, [16][3].
1448        """
1449
1450        # if we don't have the prior and next atoms we initialize those
1451        # atoms to the origin so as to not effect the center of mass calculations
1452        if self.missing_atoms:
1453            res_array = np.array(
1454                (
1455                    self._n_prox.get_array(),
1456                    self._ca_prox.get_array(),
1457                    self._c_prox.get_array(),
1458                    self._o_prox.get_array(),
1459                    self._cb_prox.get_array(),
1460                    self._sg_prox.get_array(),
1461                    self._n_dist.get_array(),
1462                    self._ca_dist.get_array(),
1463                    self._c_dist.get_array(),
1464                    self._o_dist.get_array(),
1465                    self._cb_dist.get_array(),
1466                    self._sg_dist.get_array(),
1467                    [0, 0, 0],
1468                    [0, 0, 0],
1469                    [0, 0, 0],
1470                    [0, 0, 0],
1471                )
1472            )
1473        else:
1474            res_array = np.array(
1475                (
1476                    self._n_prox.get_array(),
1477                    self._ca_prox.get_array(),
1478                    self._c_prox.get_array(),
1479                    self._o_prox.get_array(),
1480                    self._cb_prox.get_array(),
1481                    self._sg_prox.get_array(),
1482                    self._n_dist.get_array(),
1483                    self._ca_dist.get_array(),
1484                    self._c_dist.get_array(),
1485                    self._o_dist.get_array(),
1486                    self._cb_dist.get_array(),
1487                    self._sg_dist.get_array(),
1488                    self._c_prev_prox.get_array(),
1489                    self._n_next_prox.get_array(),
1490                    self._c_prev_dist.get_array(),
1491                    self._n_next_dist.get_array(),
1492                )
1493            )
1494        return res_array
1495
1496    @property
1497    def coords(self) -> np.array:
1498        """
1499        Return the coordinates for the Disulfide as an array.
1500
1501        :return: Array containing the coordinates, [16][3].
1502        """
1503        return self._coords()
1504
1505    def _coords(self) -> np.array:
1506        """
1507        Return the coordinates for the Disulfide as an array.
1508        If there are missing atoms the extra atoms for the proximal
1509        and distal N and C are set to [0,0,0]. This is needed for the center of
1510        mass calculations, used when rendering.
1511
1512        :return: Array containing the coordinates, [16][3].
1513        """
1514
1515        # if we don't have the prior and next atoms we initialize those
1516        # atoms to the origin so as to not effect the center of mass calculations
1517        if self.missing_atoms:
1518            res_array = np.array(
1519                (
1520                    self.n_prox.get_array(),
1521                    self.ca_prox.get_array(),
1522                    self.c_prox.get_array(),
1523                    self.o_prox.get_array(),
1524                    self.cb_prox.get_array(),
1525                    self.sg_prox.get_array(),
1526                    self.n_dist.get_array(),
1527                    self.ca_dist.get_array(),
1528                    self.c_dist.get_array(),
1529                    self.o_dist.get_array(),
1530                    self.cb_dist.get_array(),
1531                    self.sg_dist.get_array(),
1532                    [0, 0, 0],
1533                    [0, 0, 0],
1534                    [0, 0, 0],
1535                    [0, 0, 0],
1536                )
1537            )
1538        else:
1539            res_array = np.array(
1540                (
1541                    self.n_prox.get_array(),
1542                    self.ca_prox.get_array(),
1543                    self.c_prox.get_array(),
1544                    self.o_prox.get_array(),
1545                    self.cb_prox.get_array(),
1546                    self.sg_prox.get_array(),
1547                    self.n_dist.get_array(),
1548                    self.ca_dist.get_array(),
1549                    self.c_dist.get_array(),
1550                    self.o_dist.get_array(),
1551                    self.cb_dist.get_array(),
1552                    self.sg_dist.get_array(),
1553                    self.c_prev_prox.get_array(),
1554                    self.n_next_prox.get_array(),
1555                    self.c_prev_dist.get_array(),
1556                    self.n_next_dist.get_array(),
1557                )
1558            )
1559        return res_array
1560
1561    def internal_coords_res(self, resnumb) -> np.array:
1562        """
1563        Return the internal coordinates for the Disulfide. Missing atoms are not included.
1564
1565        :return: Array containing the coordinates, [12][3].
1566        """
1567        return self._internal_coords_res(resnumb)
1568
1569    def _internal_coords_res(self, resnumb) -> np.array:
1570        """
1571        Return the internal coordinates for the internal coordinates of
1572        the given Disulfide. Missing atoms are not included.
1573
1574        :param resnumb: Residue number for disulfide
1575        :raises DisulfideConstructionWarning: Warning raised if the residue number is invalid
1576        :return: Array containing the internal coordinates for the disulfide
1577        """
1578        res_array = np.zeros(shape=(6, 3))
1579
1580        if resnumb == self.proximal:
1581            res_array = np.array(
1582                (
1583                    self._n_prox.get_array(),
1584                    self._ca_prox.get_array(),
1585                    self._c_prox.get_array(),
1586                    self._o_prox.get_array(),
1587                    self._cb_prox.get_array(),
1588                    self._sg_prox.get_array(),
1589                )
1590            )
1591            return res_array
1592
1593        elif resnumb == self.distal:
1594            res_array = np.array(
1595                (
1596                    self._n_dist.get_array(),
1597                    self._ca_dist.get_array(),
1598                    self._c_dist.get_array(),
1599                    self._o_dist.get_array(),
1600                    self._cb_dist.get_array(),
1601                    self._sg_dist.get_array(),
1602                )
1603            )
1604            return res_array
1605        else:
1606            mess = f"-> Disulfide._internal_coords(): Invalid argument. \
1607             Unable to find residue: {resnumb} "
1608            raise DisulfideConstructionWarning(mess)
1609
1610    def make_movie(
1611        self, style="sb", fname="ssbond.mp4", verbose=False, steps=360
1612    ) -> None:
1613        """
1614        Create an animation for ```self``` rotating one revolution about the Y axis,
1615        in the given ```style```, saving to ```filename```.
1616
1617        :param style: Rendering style, defaults to 'sb', one of:
1618        * 'sb' - split bonds
1619        * 'bs' - ball and stick
1620        * 'cpk' - CPK style
1621        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1622        * 'plain' - boring single color
1623
1624        :param fname: Output filename, defaults to ```ssbond.mp4```
1625        :param verbose: Verbosity, defaults to False
1626        :param steps: Number of steps for one complete rotation, defaults to 360.
1627        """
1628
1629        # src = self.pdb_id
1630        # name = self.name
1631        # enrg = self.energy
1632        # title = f"{src} {name}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1633
1634        if verbose:
1635            print(f"Rendering animation to {fname}...")
1636
1637        pl = pv.Plotter(window_size=WINSIZE, off_screen=True, theme="document")
1638        pl.open_movie(fname)
1639        path = pl.generate_orbital_path(n_points=steps)
1640
1641        #
1642        # pl.add_title(title=title, font_size=FONTSIZE)
1643        pl.enable_anti_aliasing("msaa")
1644        pl = self._render(
1645            pl,
1646            style=style,
1647        )
1648        pl.reset_camera()
1649        pl.orbit_on_path(path, write_frames=True)
1650        pl.close()
1651
1652        if verbose:
1653            print(f"Saved mp4 animation to: {fname}")
1654
1655    def spin(self, style="sb", verbose=False, steps=360, theme="auto") -> None:
1656        """
1657        Spin the object by rotating it one revolution about the Y axis in the given style.
1658
1659        :param style: Rendering style, defaults to 'sb', one of:
1660            * 'sb' - split bonds
1661            * 'bs' - ball and stick
1662            * 'cpk' - CPK style
1663            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1664            * 'plain' - boring single color
1665
1666        :param verbose: Verbosity, defaults to False
1667        :param steps: Number of steps for one complete rotation, defaults to 360.
1668        """
1669
1670        src = self.pdb_id
1671        enrg = self.energy
1672
1673        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1674
1675        set_pyvista_theme(theme)
1676
1677        if verbose:
1678            _logger.info("Spinning object: %d steps...", steps)
1679
1680        # Create a Plotter instance
1681        pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
1682        pl.add_title(title=title, font_size=FONTSIZE)
1683
1684        # Enable anti-aliasing for smoother rendering
1685        pl.enable_anti_aliasing("msaa")
1686
1687        # Generate an orbital path for spinning
1688        path = pl.generate_orbital_path(n_points=steps)
1689
1690        # Render the object in the specified style
1691        pl = self._render(pl, style=style)
1692
1693        pl.reset_camera()
1694        pl.show(auto_close=False)
1695
1696        # Orbit the camera along the generated path
1697        pl.orbit_on_path(path, write_frames=False, step=1 / steps)
1698
1699        if verbose:
1700            print("Spinning completed.")
1701
1702    def plot(
1703        self, pl, single=True, style="sb", light="True", shadows=False
1704    ) -> pv.Plotter:
1705        """
1706        Return the pyVista Plotter object for the Disulfide bond in the specific rendering style.
1707
1708        :param single: Display the bond in a single panel in the specific style.
1709        :param style:  Rendering style: One of:
1710            * 'sb' - split bonds
1711            * 'bs' - ball and stick
1712            * 'cpk' - CPK style
1713            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1714            * 'plain' - boring single color
1715        :param light: If True, light background, if False, dark
1716        """
1717        src = self.pdb_id
1718        enrg = self.energy
1719        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1720
1721        if light:
1722            pv.set_plot_theme("document")
1723        else:
1724            pv.set_plot_theme("dark")
1725
1726        pl.clear()
1727
1728        if single:
1729            pl = pv.Plotter(window_size=WINSIZE)
1730            pl.add_title(title=title, font_size=FONTSIZE)
1731            pl.enable_anti_aliasing("msaa")
1732            # pl.add_camera_orientation_widget()
1733
1734            self._render(
1735                pl,
1736                style=style,
1737                bs_scale=BS_SCALE,
1738                spec=SPECULARITY,
1739                specpow=SPEC_POWER,
1740            )
1741            pl.reset_camera()
1742            if shadows:
1743                pl.enable_shadows()
1744        else:
1745            pl = pv.Plotter(shape=(2, 2))
1746            pl.subplot(0, 0)
1747
1748            # pl.add_title(title=title, font_size=FONTSIZE)
1749            pl.enable_anti_aliasing("msaa")
1750
1751            # pl.add_camera_orientation_widget()
1752
1753            self._render(
1754                pl,
1755                style="cpk",
1756            )
1757
1758            pl.subplot(0, 1)
1759
1760            self._render(
1761                pl,
1762                style="bs",
1763            )
1764
1765            pl.subplot(1, 0)
1766
1767            self._render(
1768                pl,
1769                style="sb",
1770            )
1771
1772            pl.subplot(1, 1)
1773            self._render(
1774                pl,
1775                style="pd",
1776            )
1777
1778            pl.link_views()
1779            pl.reset_camera()
1780            if shadows:
1781                pl.enable_shadows()
1782        return pl
1783
1784    def pprint(self) -> None:
1785        """
1786        Pretty print general info for the Disulfide
1787        """
1788        s1 = self.repr_ss_info()
1789        s2 = self.repr_ss_ca_dist()
1790        s2b = self.repr_ss_sg_dist()
1791        s3 = self.repr_ss_conformation()
1792        s4 = self.repr_ss_torsion_length()
1793        res = f"{s1} \n{s3} \n{s2} \n{s2b} \n{s4}>"
1794        print(res)
1795
1796    def pprint_all(self) -> None:
1797        """
1798        Pretty print all info for the Disulfide
1799        """
1800        s1 = self.repr_ss_info() + "\n"
1801        s2 = self.repr_ss_coords()
1802        s3 = self.repr_ss_local_coords()
1803        s4 = self.repr_ss_conformation()
1804        s4b = self.repr_phipsi()
1805        s6 = self.repr_ss_ca_dist()
1806        s6b = self.repr_ss_cb_dist()
1807        s6c = self.repr_ss_sg_dist()
1808        s7 = self.repr_ss_torsion_length()
1809        s8 = self.repr_ss_secondary_structure()
1810
1811        res = f"{s1} {s2} {s3} {s4}\n {s4b}\n {s6}\n {s6b}\n {s6c}\n {s7}\n {s8}>"
1812
1813        print(res)
1814
1815    # repr functions. The class is large, so I split it up into sections
1816    def repr_ss_info(self) -> str:
1817        """
1818        Representation for the Disulfide class
1819        """
1820        s1 = f"<Disulfide {self.name}, Source: {self.pdb_id}, Resolution: {self.resolution} Å"
1821        return s1
1822
1823    def repr_ss_coords(self) -> str:
1824        """
1825        Representation for Disulfide coordinates
1826        """
1827        s2 = f"\nProximal Coordinates:\n   N: {self.n_prox}\n   Cα: {self.ca_prox}\n   C: {self.c_prox}\n   O: {self.o_prox}\n   Cβ: {self.cb_prox}\n   Sγ: {self.sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1828        s3 = f"Distal Coordinates:\n   N: {self.n_dist}\n   Cα: {self.ca_dist}\n   C: {self.c_dist}\n   O: {self.o_dist}\n   Cβ: {self.cb_dist}\n   Sγ: {self.sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n\n"
1829        stot = f"{s2} {s3}"
1830        return stot
1831
1832    def repr_ss_conformation(self) -> str:
1833        """
1834        Representation for Disulfide conformation
1835        """
1836        s4 = f"Χ1-Χ5: {self.chi1:.2f}°, {self.chi2:.2f}°, {self.chi3:.2f}°, {self.chi4:.2f}° {self.chi5:.2f}°, {self.rho:.2f}°, {self.energy:.2f} kcal/mol"
1837        stot = f"{s4}"
1838        return stot
1839
1840    def repr_ss_local_coords(self) -> str:
1841        """
1842        Representation for the Disulfide internal coordinates.
1843        """
1844        s2i = f"Proximal Internal Coords:\n   N: {self._n_prox}\n   Cα: {self._ca_prox}\n   C: {self._c_prox}\n   O: {self._o_prox}\n   Cβ: {self._cb_prox}\n   Sγ: {self._sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1845        s3i = f"Distal Internal Coords:\n   N: {self._n_dist}\n   Cα: {self._ca_dist}\n   C: {self._c_dist}\n   O: {self._o_dist}\n   Cβ: {self._cb_dist}\n   Sγ: {self._sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n"
1846        stot = f"{s2i}{s3i}"
1847        return stot
1848
1849    def repr_ss_residue_ids(self) -> str:
1850        """
1851        Representation for Disulfide chain IDs
1852        """
1853        return f"Proximal Residue fullID: <{self.proximal}> Distal Residue fullID: <{self.distal}>"
1854
1855    def repr_ss_ca_dist(self) -> str:
1856        """
1857        Representation for Disulfide Ca distance
1858        """
1859        s1 = f"Cα Distance: {self.ca_distance:.2f} Å"
1860        return s1
1861
1862    def repr_ss_cb_dist(self) -> str:
1863        """
1864        Representation for Disulfide Ca distance
1865        """
1866        s1 = f"Cβ Distance: {self.cb_distance:.2f} Å"
1867        return s1
1868
1869    def repr_ss_sg_dist(self) -> str:
1870        """
1871        Representation for Disulfide Ca distance
1872        """
1873        s1 = f"Sγ Distance: {self.sg_distance:.2f} Å"
1874        return s1
1875
1876    def repr_ss_torsion_length(self) -> str:
1877        """
1878        Representation for Disulfide torsion length
1879        """
1880        s1 = f"Torsion length: {self.torsion_length:.2f} deg"
1881        return s1
1882
1883    def repr_ss_secondary_structure(self) -> str:
1884        """
1885        Representation for Disulfide secondary structure
1886        """
1887        s1 = f"Proximal secondary: {self.proximal_secondary} Distal secondary: {self.distal_secondary}"
1888        return s1
1889
1890    def repr_phipsi(self) -> str:
1891        """
1892        Representation for Disulfide phi psi angles
1893        """
1894        s1 = f"PhiProx: {self.phiprox:.2f}° PsiProx: {self.psiprox:.2f}°, PhiDist: {self.phidist:.2f}° PsiDist: {self.psidist:.2f}°"
1895        return s1
1896
1897    def repr_all(self) -> str:
1898        """
1899        Return a string representation for all Disulfide information
1900        contained in self.
1901        """
1902
1903        s1 = self.repr_ss_info() + "\n"
1904        s2 = self.repr_ss_coords()
1905        s3 = self.repr_ss_local_coords()
1906        s4 = self.repr_ss_conformation()
1907        s4b = self.repr_phipsi()
1908        s6 = self.repr_ss_ca_dist()
1909        s8 = self.repr_ss_cb_dist()
1910        s7 = self.repr_ss_torsion_length()
1911        s9 = self.repr_ss_secondary_structure()
1912
1913        res = f"{s1} {s2} {s3} {s4} {s4b} {s6} {s7} {s8} {s9}>"
1914        return res
1915
1916    def repr_compact(self) -> str:
1917        """
1918        Return a compact representation of the Disulfide object
1919        :return: string
1920        """
1921        return f"{self.repr_ss_info()} {self.repr_ss_conformation()}"
1922
1923    def repr_conformation(self) -> str:
1924        """
1925        Return a string representation of the Disulfide object's conformation.
1926        :return: string
1927        """
1928        return f"{self.repr_ss_conformation()}"
1929
1930    def repr_coords(self) -> str:
1931        """
1932        Return a string representation of the Disulfide object's coordinates.
1933        :return: string
1934        """
1935        return f"{self.repr_ss_coords()}"
1936
1937    def repr_internal_coords(self) -> str:
1938        """
1939        Return a string representation of the Disulfide object's internal coordinaes.
1940        :return: string
1941        """
1942        return f"{self.repr_ss_local_coords()}"
1943
1944    def repr_chain_ids(self) -> str:
1945        """
1946        Return a string representation of the Disulfide object's chain ids.
1947        :return: string
1948        """
1949        return f"{self.repr_ss_residue_ids()}"
1950
1951    @property
1952    def rho(self) -> float:
1953        """
1954        Return the dihedral angle rho for the Disulfide.
1955        """
1956        return self._compute_rho()
1957
1958    @rho.setter
1959    def rho(self, value: float):
1960        """
1961        Set the dihedral angle rho for the Disulfide.
1962        """
1963        self._rho = value
1964
1965    def _compute_rho(self) -> float:
1966        """
1967        Compute the dihedral angle rho for a Disulfide object and
1968        sets the internal state of the object.
1969        """
1970
1971        v1 = self.n_prox - self.ca_prox
1972        v2 = self.c_prox - self.ca_prox
1973        n1 = np.cross(v2.get_array(), v1.get_array())
1974
1975        v4 = self.n_dist - self.ca_dist
1976        v3 = self.c_dist - self.ca_dist
1977        n2 = np.cross(v4.get_array(), v3.get_array())
1978        self._rho = calc_dihedral(
1979            Vector3D(n1), self.ca_prox, self.ca_dist, Vector3D(n2)
1980        )
1981        return self._rho
1982
1983    def reset(self) -> None:
1984        """
1985        Resets the disulfide object to its initial state. All distances,
1986        angles and positions are reset. The name is unchanged.
1987        """
1988        self.__init__(self)
1989
1990    def same_chains(self) -> bool:
1991        """
1992        Function checks if the Disulfide is cross-chain or not.
1993
1994        Returns
1995        -------
1996        bool \n
1997            True if the proximal and distal residues are on the same chains,
1998            False otherwise.
1999        """
2000
2001        (prox, dist) = self.get_chains()
2002        return prox == dist
2003
2004    def screenshot(
2005        self,
2006        single=True,
2007        style="sb",
2008        fname="ssbond.png",
2009        verbose=False,
2010        shadows=False,
2011        light="Auto",
2012    ) -> None:
2013        """
2014        Create and save a screenshot of the Disulfide in the given style
2015        and filename
2016
2017        :param single: Display a single vs panel view, defaults to True
2018        :param style: Rendering style, one of:
2019        * 'sb' - split bonds
2020        * 'bs' - ball and stick
2021        * 'cpk' - CPK style
2022        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2023        * 'plain' - boring single color,
2024        :param fname: output filename,, defaults to 'ssbond.png'
2025        :param verbose: Verbosit, defaults to False
2026        :param shadows: Enable shadows, defaults to False
2027        """
2028
2029        src = self.pdb_id
2030        enrg = self.energy
2031        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Cβ: {self.cb_distance:.2f} Å, Sγ: {self.sg_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
2032
2033        set_pyvista_theme(light)
2034
2035        if verbose:
2036            _logger.info("Rendering screenshot to file {fname}")
2037
2038        if single:
2039            pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
2040            pl.add_title(title=title, font_size=FONTSIZE)
2041            pl.enable_anti_aliasing("msaa")
2042            self._render(
2043                pl,
2044                style=style,
2045            )
2046            pl.reset_camera()
2047            if shadows:
2048                pl.enable_shadows()
2049
2050            pl.show(auto_close=False)  # allows for manipulation
2051            # Take the screenshot after ensuring the plotter is still active
2052            try:
2053                pl.screenshot(fname)
2054            except RuntimeError as e:
2055                _logger.error(f"Error saving screenshot: {e}")
2056
2057        else:
2058            pl = pv.Plotter(window_size=WINSIZE, shape=(2, 2), off_screen=False)
2059            pl.subplot(0, 0)
2060
2061            pl.add_title(title=title, font_size=FONTSIZE)
2062            pl.enable_anti_aliasing("msaa")
2063
2064            # pl.add_camera_orientation_widget()
2065            self._render(
2066                pl,
2067                style="cpk",
2068            )
2069
2070            pl.subplot(0, 1)
2071            pl.add_title(title=title, font_size=FONTSIZE)
2072            self._render(
2073                pl,
2074                style="pd",
2075            )
2076
2077            pl.subplot(1, 0)
2078            pl.add_title(title=title, font_size=FONTSIZE)
2079            self._render(
2080                pl,
2081                style="bs",
2082            )
2083
2084            pl.subplot(1, 1)
2085            pl.add_title(title=title, font_size=FONTSIZE)
2086            self._render(
2087                pl,
2088                style="sb",
2089            )
2090
2091            pl.link_views()
2092            pl.reset_camera()
2093            if shadows:
2094                pl.enable_shadows()
2095
2096            # Take the screenshot after ensuring the plotter is still active
2097            pl.show(auto_close=False)  # allows for manipulation
2098
2099            try:
2100                pl.screenshot(fname)
2101            except RuntimeError as e:
2102                _logger.error(f"Error saving screenshot: {e}")
2103
2104        if verbose:
2105            print(f"Screenshot saved as: {fname}")
2106
2107    def save_meshes_as_stl(self, meshes, filename) -> None:
2108        """Save a list of meshes as a single STL file.
2109
2110        Args:
2111            meshes (list): List of pyvista mesh objects to save.
2112            filename (str): Path to save the STL file to.
2113        """
2114        merged_mesh = pv.UnstructuredGrid()
2115        for mesh in meshes:
2116            merged_mesh += mesh
2117        merged_mesh.save(filename)
2118
2119    def export(self, style="sb", verbose=True, fname="ssbond_plt") -> None:
2120        """
2121        Create and save a screenshot of the Disulfide in the given style and filename.
2122
2123        :param single: Display a single vs panel view, defaults to True
2124        :param style: Rendering style, one of:
2125        * 'sb' - split bonds
2126        * 'bs' - ball and stick
2127        * 'cpk' - CPK style
2128        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2129        * 'plain' - boring single color,
2130
2131        :param fname: output filename,, defaults to 'ssbond.stl'
2132        :param verbose: Verbosit, defaults to False
2133        """
2134
2135        if verbose:
2136            print(f"-> screenshot(): Rendering screenshot to file {fname}")
2137
2138        pl = pv.PolyData()
2139
2140        self._plot(
2141            pl,
2142            style=style,
2143        )
2144
2145        self.save_meshes_as_stl(pl, fname)
2146
2147        return
2148
2149    def set_positions(
2150        self,
2151        n_prox: Vector3D,
2152        ca_prox: Vector3D,
2153        c_prox: Vector3D,
2154        o_prox: Vector3D,
2155        cb_prox: Vector3D,
2156        sg_prox: Vector3D,
2157        n_dist: Vector3D,
2158        ca_dist: Vector3D,
2159        c_dist: Vector3D,
2160        o_dist: Vector3D,
2161        cb_dist: Vector3D,
2162        sg_dist: Vector3D,
2163        c_prev_prox: Vector3D,
2164        n_next_prox: Vector3D,
2165        c_prev_dist: Vector3D,
2166        n_next_dist: Vector3D,
2167    ) -> None:
2168        """
2169        Set the atomic coordinates for all atoms in the Disulfide object.
2170
2171        :param n_prox: Proximal N position
2172        :param ca_prox: Proximal Cα position
2173        :param c_prox: Proximal C' position
2174        :param o_prox: Proximal O position
2175        :param cb_prox: Proximal Cβ position
2176        :param sg_prox: Proximal Sγ position
2177        :param n_dist: Distal N position
2178        :param ca_dist: Distal Cα position
2179        :param c_dist: Distal C' position
2180        :param o_dist: Distal O position
2181        :param cb_dist: Distal Cβ position
2182        :param sg_dist: Distal Sγ position
2183        :param c_prev_prox: Proximal previous C'
2184        :param n_next_prox: Proximal next N
2185        :param c_prev_dist: Distal previous C'
2186        :param n_next_dist: Distal next N
2187        """
2188
2189        # deep copy
2190        self.n_prox = n_prox.copy()
2191        self.ca_prox = ca_prox.copy()
2192        self.c_prox = c_prox.copy()
2193        self.o_prox = o_prox.copy()
2194        self.cb_prox = cb_prox.copy()
2195        self.sg_prox = sg_prox.copy()
2196        self.sg_dist = sg_dist.copy()
2197        self.cb_dist = cb_dist.copy()
2198        self.ca_dist = ca_dist.copy()
2199        self.n_dist = n_dist.copy()
2200        self.c_dist = c_dist.copy()
2201        self.o_dist = o_dist.copy()
2202
2203        self.c_prev_prox = c_prev_prox.copy()
2204        self.n_next_prox = n_next_prox.copy()
2205        self.c_prev_dist = c_prev_dist.copy()
2206        self.n_next_dist = n_next_dist.copy()
2207        self._compute_local_coords()
2208
2209    def set_name(self, namestr="Disulfide") -> None:
2210        """
2211        Set the Disulfide's name.
2212
2213        :param namestr: Name, by default "Disulfide"
2214        """
2215        self.name = namestr
2216
2217    def set_resnum(self, proximal: int, distal: int) -> None:
2218        """
2219        Set the proximal and residue numbers for the Disulfide.
2220
2221        :param proximal: Proximal residue number
2222        :param distal: Distal residue number
2223        """
2224        self.proximal = proximal
2225        self.distal = distal
2226
2227    def _compute_torsion_length(self) -> float:
2228        """
2229        Compute the 5D Euclidean length of the Disulfide object. Update the disulfide internal state.
2230
2231        :return: Torsion length (Degrees)
2232        """
2233        # Use numpy array to compute element-wise square
2234        tors2 = np.square(self.torsion_array)
2235
2236        # Compute the sum of squares using numpy's sum function
2237        dist = math.sqrt(np.sum(tors2))
2238
2239        # Update the internal state
2240        self.torsion_length = dist
2241
2242        return dist
2243
2244    def torsion_distance(self, other) -> float:
2245        """
2246        Calculate the 5D Euclidean distance between `self` and another Disulfide
2247        object. This is used to compare Disulfide Bond torsion angles to
2248        determine their torsional similarity via a 5-Dimensional Euclidean distance metric.
2249
2250        :param other: Comparison Disulfide
2251        :raises ProteusPyWarning: Warning if `other` is not a Disulfide object
2252        :return: Euclidean distance (Degrees) between `self` and `other`.
2253        """
2254
2255        # Check length of torsion arrays
2256        if len(self.torsion_array) != 5 or len(other.torsion_array) != 5:
2257            raise ProteusPyWarning(
2258                "--> Torsion_Distance() requires vectors of length 5!"
2259            )
2260
2261        # Convert to numpy arrays
2262        p1 = np.array(self.torsion_array)
2263        p2 = np.array(other.torsion_array)
2264
2265        # Compute the difference and handle angle wrapping
2266        diff = np.abs(p1 - p2)
2267        diff = np.where(diff > 180, 360 - diff, diff)
2268
2269        # Compute the 5D Euclidean distance using numpy's linalg.norm function
2270        dist = np.linalg.norm(diff)
2271
2272        return dist
2273
2274    def torsion_neighbors(self, others, cutoff) -> DisulfideList:
2275        """
2276        Return a list of Disulfides within the angular cutoff in the others list.
2277        This routine is used to find Disulfides having the same torsion length
2278        within the others list. This is used to find families of Disulfides with
2279        similar conformations. Assumes self is properly initialized.
2280
2281        *NB* The routine will not distinguish between +/-
2282        dihedral angles. *i.e.* [-60, -60, -90, -60, -60] would have the same
2283        torsion length as [60, 60, 90, 60, 60], two clearly different structures.
2284
2285        :param others: ```DisulfideList``` to search
2286        :param cutoff: Dihedral angle degree cutoff
2287        :return: DisulfideList within the cutoff
2288
2289        Example:
2290        In this example we load the disulfide database subset, find the disulfides with
2291        the lowest and highest energies, and then find the nearest conformational neighbors.
2292        Finally, we display the neighbors overlaid against a common reference frame.
2293
2294        >>> import proteusPy as pp
2295        >>> _theme = pp.set_pyvista_theme("auto")
2296        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
2297        >>> ss_list = pp.DisulfideList([], 'tmp')
2298
2299        We point to the complete list to search for lowest and highest energies.
2300        >>> sslist = PDB_SS.SSList
2301        >>> ssmin_enrg, ssmax_enrg = PDB_SS.SSList.minmax_energy
2302
2303        Make an empty list and find the nearest neighbors within 10 degrees avg RMS in
2304        sidechain dihedral angle space.
2305
2306        >>> low_energy_neighbors = DisulfideList([],'Neighbors')
2307        >>> low_energy_neighbors = ssmin_enrg.torsion_neighbors(sslist, 8)
2308
2309        Display the number found, and then display them overlaid onto their common reference frame.
2310
2311        >>> tot = low_energy_neighbors.length
2312        >>> print(f'Neighbors: {tot}')
2313        Neighbors: 8
2314        >>> low_energy_neighbors.display_overlay(light="auto")
2315
2316        """
2317
2318        res = [ss for ss in others if self.torsion_distance(ss) <= cutoff]
2319        return DisulfideList(res, "neighbors")
2320
2321    def translate(self, translation_vector: Vector3D):
2322        """Translate the Disulfide object by the given vector."""
2323
2324        self.n_prox += translation_vector
2325        self.ca_prox += translation_vector
2326        self.c_prox += translation_vector
2327        self.o_prox += translation_vector
2328        self.cb_prox += translation_vector
2329        self.sg_prox += translation_vector
2330        self.sg_dist += translation_vector
2331        self.cb_dist += translation_vector
2332        self.ca_dist += translation_vector
2333        self.n_dist += translation_vector
2334        self.c_dist += translation_vector
2335        self.o_dist += translation_vector
2336
2337        self.c_prev_prox += translation_vector
2338        self.n_next_prox += translation_vector
2339        self.c_prev_dist += translation_vector
2340        self.n_next_dist += translation_vector
2341        self._compute_local_coords()
2342
2343
2344# Class defination ends
2345
2346
2347def disulfide_energy_function(x: list) -> float:
2348    """
2349    Compute the approximate torsional energy (kcal/mpl) for the input dihedral angles.
2350
2351    :param x: A list of dihedral angles: [chi1, chi2, chi3, chi4, chi5]
2352    :return: Energy in kcal/mol
2353
2354    Example:
2355    >>> from proteusPy import disulfide_energy_function
2356    >>> dihed = [-60.0, -60.0, -90.0, -60.0, -90.0]
2357    >>> res = disulfide_energy_function(dihed)
2358    >>> float(res)
2359    2.5999999999999996
2360    """
2361
2362    chi1, chi2, chi3, chi4, chi5 = x
2363    energy = 2.0 * (np.cos(np.deg2rad(3.0 * chi1)) + np.cos(np.deg2rad(3.0 * chi5)))
2364    energy += np.cos(np.deg2rad(3.0 * chi2)) + np.cos(np.deg2rad(3.0 * chi4))
2365    energy += (
2366        3.5 * np.cos(np.deg2rad(2.0 * chi3))
2367        + 0.6 * np.cos(np.deg2rad(3.0 * chi3))
2368        + 10.1
2369    )
2370    return energy
2371
2372
2373def minimize_ss_energy(inputSS: Disulfide) -> Disulfide:
2374    """
2375    Minimizes the energy of a Disulfide object using the Nelder-Mead optimization method.
2376
2377    Parameters:
2378        inputSS (Disulfide): The Disulfide object to be minimized.
2379
2380    Returns:
2381        Disulfide: The minimized Disulfide object.
2382
2383    """
2384
2385    initial_guess = inputSS.torsion_array
2386    result = minimize(disulfide_energy_function, initial_guess, method="Nelder-Mead")
2387    minimum_conformation = result.x
2388    modelled_min = Disulfide("minimized", minimum_conformation)
2389    # modelled_min.dihedrals = minimum_conformation
2390    # modelled_min.build_yourself()
2391    return modelled_min
2392
2393
2394def Initialize_Disulfide_From_Coords(
2395    ssbond_atom_data,
2396    pdb_id,
2397    proximal_chain_id,
2398    distal_chain_id,
2399    proximal,
2400    distal,
2401    resolution,
2402    proximal_secondary,
2403    distal_secondary,
2404    verbose=False,
2405    quiet=True,
2406    dbg=False,
2407) -> Disulfide:
2408    """
2409    Initialize a new Disulfide object with atomic coordinates from
2410    the proximal and distal coordinates, typically taken from a PDB file.
2411    This routine is primarily used internally when building the compressed
2412    database.
2413
2414    :param ssbond_atom_data: Dictionary containing atomic data for the disulfide bond.
2415    :type ssbond_atom_data: dict
2416    :param pdb_id: PDB identifier for the structure.
2417    :type pdb_id: str
2418    :param proximal_chain_id: Chain identifier for the proximal residue.
2419    :type proximal_chain_id: str
2420    :param distal_chain_id: Chain identifier for the distal residue.
2421    :type distal_chain_id: str
2422    :param proximal: Residue number for the proximal residue.
2423    :type proximal: int
2424    :param distal: Residue number for the distal residue.
2425    :type distal: int
2426    :param resolution: Structure resolution.
2427    :type resolution: float
2428    :param verbose: If True, enables verbose logging. Defaults to False.
2429    :type verbose: bool, optional
2430    :param quiet: If True, suppresses logging output. Defaults to True.
2431    :type quiet: bool, optional
2432    :param dbg: If True, enables debug mode. Defaults to False.
2433    :type dbg: bool, optional
2434    :return: An instance of the Disulfide class initialized with the provided coordinates.
2435    :rtype: Disulfide
2436    :raises DisulfideConstructionWarning: Raised when the disulfide bond is not parsed correctly.
2437
2438    """
2439
2440    ssbond_name = f"{pdb_id}_{proximal}{proximal_chain_id}_{distal}{distal_chain_id}"
2441    new_ss = Disulfide(ssbond_name)
2442
2443    new_ss.pdb_id = pdb_id
2444    new_ss.resolution = resolution
2445    new_ss.proximal_secondary = proximal_secondary
2446    new_ss.distal_secondary = distal_secondary
2447    prox_atom_list = []
2448    dist_atom_list = []
2449
2450    if quiet:
2451        _logger.setLevel(logging.ERROR)
2452        logging.getLogger().setLevel(logging.CRITICAL)
2453
2454    # set the objects proximal and distal values
2455    new_ss.set_resnum(proximal, distal)
2456
2457    if resolution is not None:
2458        new_ss.resolution = resolution
2459    else:
2460        new_ss.resolution = -1.0
2461
2462    new_ss.proximal_chain = proximal_chain_id
2463    new_ss.distal_chain = distal_chain_id
2464
2465    # restore loggins
2466    if quiet:
2467        _logger.setLevel(logging.ERROR)
2468        logging.getLogger().setLevel(logging.CRITICAL)  ## may want to be CRITICAL
2469
2470    # Get the coordinates for the proximal and distal residues as vectors
2471    # so we can do math on them later. Trap errors here to avoid problems
2472    # with missing residues or atoms.
2473
2474    # proximal residue
2475
2476    try:
2477        prox_atom_list = get_residue_atoms_coordinates(
2478            ssbond_atom_data, proximal_chain_id, proximal
2479        )
2480
2481        n1 = prox_atom_list[0]
2482        ca1 = prox_atom_list[1]
2483        c1 = prox_atom_list[2]
2484        o1 = prox_atom_list[3]
2485        cb1 = prox_atom_list[4]
2486        sg1 = prox_atom_list[5]
2487
2488    except KeyError:
2489        # i'm torn on this. there are a lot of missing coordinates, so is
2490        # it worth the trouble to note them? I think so.
2491        _logger.error(f"Invalid/missing coordinates for: {id}, proximal: {proximal}")
2492        return None
2493
2494    # distal residue
2495    try:
2496        dist_atom_list = get_residue_atoms_coordinates(
2497            ssbond_atom_data, distal_chain_id, distal
2498        )
2499        n2 = dist_atom_list[0]
2500        ca2 = dist_atom_list[1]
2501        c2 = dist_atom_list[2]
2502        o2 = dist_atom_list[3]
2503        cb2 = dist_atom_list[4]
2504        sg2 = dist_atom_list[5]
2505
2506    except KeyError:
2507        _logger.error(f"Invalid/missing coordinates for: {id}, distal: {distal}")
2508        return False
2509
2510    # previous residue and next residue - optional, used for phi, psi calculations
2511    prevprox_atom_list = get_phipsi_atoms_coordinates(
2512        ssbond_atom_data, proximal_chain_id, "proximal-1"
2513    )
2514
2515    nextprox_atom_list = get_phipsi_atoms_coordinates(
2516        ssbond_atom_data, proximal_chain_id, "proximal+1"
2517    )
2518
2519    prevdist_atom_list = get_phipsi_atoms_coordinates(
2520        ssbond_atom_data, distal_chain_id, "distal-1"
2521    )
2522
2523    nextdist_atom_list = get_phipsi_atoms_coordinates(
2524        ssbond_atom_data, distal_chain_id, "distal+1"
2525    )
2526
2527    if len(prevprox_atom_list) != 0:
2528        cprev_prox = prevprox_atom_list[1]
2529        new_ss.phiprox = calc_dihedral(cprev_prox, n1, ca1, c1)
2530
2531    else:
2532        cprev_prox = Vector3D(-1.0, -1.0, -1.0)
2533        new_ss.missing_atoms = True
2534        if verbose:
2535            _logger.warning(
2536                f"Missing Proximal coords for: {id} {proximal}-1. SS: {proximal}-{distal}, phi/psi not computed."
2537            )
2538
2539    if len(prevdist_atom_list) != 0:
2540        # list is N, C
2541        cprev_dist = prevdist_atom_list[1]
2542        new_ss.phidist = calc_dihedral(cprev_dist, n2, ca2, c2)
2543    else:
2544        cprev_dist = nnext_dist = Vector3D(-1.0, -1.0, -1.0)
2545        new_ss.missing_atoms = True
2546        if verbose:
2547            _logger.warning(
2548                f"Missing Distal coords for: {id} {distal}-1). S:S {proximal}-{distal}, phi/psi not computed."
2549            )
2550
2551    if len(nextprox_atom_list) != 0:
2552        nnext_prox = nextprox_atom_list[0]
2553        new_ss.psiprox = calc_dihedral(n1, ca1, c1, nnext_prox)
2554    else:
2555        nnext_prox = Vector3D(-1.0, -1.0, -1.0)
2556        new_ss.missing_atoms = True
2557        _logger.warning(
2558            f"Missing Proximal coords for: {id} {proximal}+1). SS: {proximal}-{distal}, phi/psi not computed."
2559        )
2560
2561    if len(nextdist_atom_list) != 0:
2562        nnext_dist = nextdist_atom_list[0]
2563        new_ss.psidist = calc_dihedral(n2, ca2, c2, nnext_dist)
2564    else:
2565        nnext_dist = Vector3D(-1.0, -1.0, -1.0)
2566        new_ss.missing_atoms = True
2567        _logger.warning(
2568            f"Missing Distal coords for: {id} {distal}+1). SS: {proximal}-{distal}, phi/psi not computed."
2569        )
2570
2571    # update the positions and conformation
2572    new_ss.set_positions(
2573        n1,
2574        ca1,
2575        c1,
2576        o1,
2577        cb1,
2578        sg1,
2579        n2,
2580        ca2,
2581        c2,
2582        o2,
2583        cb2,
2584        sg2,
2585        cprev_prox,
2586        nnext_prox,
2587        cprev_dist,
2588        nnext_dist,
2589    )
2590
2591    # calculate and set the disulfide dihedral angles
2592    new_ss.chi1 = calc_dihedral(n1, ca1, cb1, sg1)
2593    new_ss.chi2 = calc_dihedral(ca1, cb1, sg1, sg2)
2594    new_ss.chi3 = calc_dihedral(cb1, sg1, sg2, cb2)
2595    new_ss.chi4 = calc_dihedral(sg1, sg2, cb2, ca2)
2596    new_ss.chi5 = calc_dihedral(sg2, cb2, ca2, n2)
2597    new_ss.ca_distance = distance3d(new_ss.ca_prox, new_ss.ca_dist)
2598    new_ss.cb_distance = distance3d(new_ss.cb_prox, new_ss.cb_dist)
2599    new_ss.sg_distance = distance3d(new_ss.sg_prox, new_ss.sg_dist)
2600
2601    new_ss.torsion_array = np.array(
2602        (new_ss.chi1, new_ss.chi2, new_ss.chi3, new_ss.chi4, new_ss.chi5)
2603    )
2604    new_ss._compute_torsion_length()
2605
2606    # calculate and set the SS bond torsional energy
2607    new_ss._compute_torsional_energy()
2608
2609    # compute and set the local coordinates
2610    new_ss._compute_local_coords()
2611
2612    # compute rho
2613    new_ss._compute_rho()
2614
2615    # turn warnings back on
2616    if quiet:
2617        _logger.setLevel(logging.ERROR)
2618
2619    if verbose:
2620        _logger.info(f"Disulfide {ssbond_name} initialized.")
2621
2622    return new_ss
2623
2624
2625if __name__ == "__main__":
2626    import doctest
2627
2628    doctest.testmod()
2629
2630# End of file
ORIGIN = <Vector3D (0.00, 0.00, 0.00)>
class Disulfide:
  87class Disulfide:
  88    r"""
  89    This class provides a Python object and methods representing a physical disulfide bond
  90    either extracted from the RCSB protein databank or built using the
  91    [proteusPy.Turtle3D](turtle3D.html) class. The disulfide bond is an important
  92    intramolecular stabilizing structural element and is characterized by:
  93
  94    * Atomic coordinates for the atoms N, Cα, Cβ, C', Sγ for both residues.
  95    These are stored as both raw atomic coordinates as read from the RCSB file
  96    and internal local coordinates.
  97    * The dihedral angles Χ1 - Χ5 for the disulfide bond
  98    * A name, by default {pdb_id}{prox_resnumb}{prox_chain}_{distal_resnum}{distal_chain}
  99    * Proximal residue number
 100    * Distal residue number
 101    * Approximate bond torsional energy (kcal/mol):
 102
 103    $$
 104    E_{kcal/mol} \\approx 2.0 * cos(3.0 * \\chi_{1}) + cos(3.0 * \\chi_{5}) + cos(3.0 * \\chi_{2}) +
 105    $$
 106    $$
 107    cos(3.0 * \chi_{4}) + 3.5 * cos(2.0 * \chi_{3}) + 0.6 * cos(3.0 * \chi_{3}) + 10.1
 108    $$
 109
 110    The equation embodies the typical 3-fold rotation barriers associated with single bonds,
 111    (Χ1, Χ5, Χ2, Χ4) and a high 2-fold barrier for Χ3, resulting from the partial double bond
 112    character of the S-S bond. This property leads to two major disulfide families, characterized
 113    by the sign of Χ3. *Left-handed* disulfides have Χ3 < 0° and *right-handed* disulfides have
 114    Χ3 > 0°. Within this breakdown there are numerous subfamilies, broadly known as the *hook*,
 115    *spiral* and *staple*. These are under characgterization.
 116
 117    * Euclidean length of the dihedral angles (degrees) defined as:
 118    $$\sqrt(\chi_{1}^{2} + \chi_{2}^{2} + \chi_{3}^{2} + \chi_{4}^{2} + \chi_{5}^{2})$$
 119    * Cα - Cα distance (Å)
 120    * Cβ - Cβ distance (Å)
 121    * Sγ - Sγ distance (Å)
 122    * The previous C' and next N for both the proximal and distal residues. These are needed
 123    to calculate the backbone dihedral angles Φ and Ψ.
 124    * Backbone dihedral angles Φ and Ψ, when possible. Not all structures are complete and
 125    in those cases the atoms needed may be undefined. In this case the Φ and Ψ angles are set
 126    to -180°.
 127
 128    The class also provides a rendering capabilities using the excellent [PyVista](https://pyvista.org)
 129    library, and can display disulfides interactively in a variety of display styles:
 130    * 'sb' - Split Bonds style - bonds colored by their atom type
 131    * 'bs' - Ball and Stick style - split bond coloring with small atoms
 132    * 'pd' - Proximal/Distal style - bonds colored *Red* for proximal residue and *Green* for
 133    the distal residue.
 134    * 'cpk' - CPK style rendering, colored by atom type:
 135        * Carbon   - Grey
 136        * Nitrogen - Blue
 137        * Sulfur   - Yellow
 138        * Oxygen   - Red
 139        * Hydrogen - White
 140
 141    Individual renderings can be saved to a file, and animations created.
 142    """
 143
 144    def __init__(
 145        self,
 146        name: str = "SSBOND",
 147        proximal: int = -1,
 148        distal: int = -1,
 149        proximal_chain: str = "A",
 150        distal_chain: str = "A",
 151        pdb_id: str = "1egs",
 152        quiet: bool = True,
 153        torsions: list = None,
 154    ) -> None:
 155        """
 156        Initialize the class to defined internal values. If torsions are provided, the
 157        Disulfide object is built using the torsions and initialized.
 158
 159        :param name: Disulfide name, by default "SSBOND"
 160        :param proximal: Proximal residue number, by default -1
 161        :param distal: Distal residue number, by default -1
 162        :param proximal_chain: Chain identifier for the proximal residue, by default "A"
 163        :param distal_chain: Chain identifier for the distal residue, by default "A"
 164        :param pdb_id: PDB identifier, by default "1egs"
 165        :param quiet: If True, suppress output, by default True
 166        :param torsions: List of torsion angles, by default None
 167        """
 168        self.name = name
 169        self.proximal = proximal
 170        self.distal = distal
 171        self.energy = _FLOAT_INIT
 172        self.proximal_chain = proximal_chain
 173        self.distal_chain = distal_chain
 174        self.pdb_id = pdb_id
 175        self.quiet = quiet
 176        self.proximal_secondary = "Nosecondary"
 177        self.distal_secondary = "Nosecondary"
 178        self.ca_distance = _FLOAT_INIT
 179        self.cb_distance = _FLOAT_INIT
 180        self.sg_distance = _FLOAT_INIT
 181        self.torsion_array = np.array(
 182            (_ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT)
 183        )
 184        self.phiprox = _ANG_INIT
 185        self.psiprox = _ANG_INIT
 186        self.phidist = _ANG_INIT
 187        self.psidist = _ANG_INIT
 188
 189        # global coordinates for the Disulfide, typically as
 190        # returned from the PDB file
 191
 192        self.n_prox = ORIGIN
 193        self.ca_prox = ORIGIN
 194        self.c_prox = ORIGIN
 195        self.o_prox = ORIGIN
 196        self.cb_prox = ORIGIN
 197        self.sg_prox = ORIGIN
 198        self.sg_dist = ORIGIN
 199        self.cb_dist = ORIGIN
 200        self.ca_dist = ORIGIN
 201        self.n_dist = ORIGIN
 202        self.c_dist = ORIGIN
 203        self.o_dist = ORIGIN
 204
 205        # set when we can't find previous or next prox or distal
 206        # C' or N atoms.
 207        self.missing_atoms = False
 208        self.modelled = False
 209        self.resolution = -1.0
 210
 211        # need these to calculate backbone dihedral angles
 212        self.c_prev_prox = ORIGIN
 213        self.n_next_prox = ORIGIN
 214        self.c_prev_dist = ORIGIN
 215        self.n_next_dist = ORIGIN
 216
 217        # local coordinates for the Disulfide, computed using the Turtle3D in
 218        # Orientation #1. these are generally private.
 219
 220        self._n_prox = ORIGIN
 221        self._ca_prox = ORIGIN
 222        self._c_prox = ORIGIN
 223        self._o_prox = ORIGIN
 224        self._cb_prox = ORIGIN
 225        self._sg_prox = ORIGIN
 226        self._sg_dist = ORIGIN
 227        self._cb_dist = ORIGIN
 228        self._ca_dist = ORIGIN
 229        self._n_dist = ORIGIN
 230        self._c_dist = ORIGIN
 231        self._o_dist = ORIGIN
 232
 233        # need these to calculate backbone dihedral angles
 234        self._c_prev_prox = ORIGIN
 235        self._n_next_prox = ORIGIN
 236        self._c_prev_dist = ORIGIN
 237        self._n_next_dist = ORIGIN
 238
 239        # Dihedral angles for the disulfide bond itself, set to _ANG_INIT
 240        self.chi1 = _ANG_INIT
 241        self.chi2 = _ANG_INIT
 242        self.chi3 = _ANG_INIT
 243        self.chi4 = _ANG_INIT
 244        self.chi5 = _ANG_INIT
 245        self._rho = _ANG_INIT  # new dihedral angle: Nprox - Ca_prox - Ca_dist - N_dist
 246
 247        self.torsion_length = _FLOAT_INIT
 248
 249        if torsions is not None and len(torsions) == 5:
 250            # computes energy, torsion length and rho
 251            self.dihedrals = torsions
 252            self.build_yourself()
 253
 254    # comparison operators, used for sorting. keyed to SS bond energy
 255    def __lt__(self, other):
 256        if isinstance(other, Disulfide):
 257            return self.energy < other.energy
 258        return NotImplemented
 259
 260    def __le__(self, other):
 261        if isinstance(other, Disulfide):
 262            return self.energy <= other.energy
 263        return NotImplemented
 264
 265    def __gt__(self, other):
 266        if isinstance(other, Disulfide):
 267            return self.energy > other.energy
 268        return NotImplemented
 269
 270    def __ge__(self, other):
 271        if isinstance(other, Disulfide):
 272            return self.energy >= other.energy
 273        return NotImplemented
 274
 275    def __eq__(self, other):
 276        if isinstance(other, Disulfide):
 277            return (
 278                math.isclose(self.torsion_length, other.torsion_length, rel_tol=1e-1)
 279                and self.proximal == other.proximal
 280                and self.distal == other.distal
 281            )
 282        return False
 283
 284    def __ne__(self, other):
 285        if isinstance(other, Disulfide):
 286            return self.proximal != other.proximal or self.distal != other.distal
 287        return NotImplemented
 288
 289    def __repr__(self):
 290        """
 291        Representation for the Disulfide class
 292        """
 293        s1 = self.repr_ss_info()
 294        res = f"{s1}>"
 295        return res
 296
 297    def _draw_bonds(
 298        self,
 299        pvp,
 300        coords,
 301        bond_radius=BOND_RADIUS,
 302        style="sb",
 303        bcolor=BOND_COLOR,
 304        all_atoms=True,
 305        res=100,
 306    ):
 307        """
 308        Generate the appropriate pyVista cylinder objects to represent
 309        a particular disulfide bond. This utilizes a connection table
 310        for the starting and ending atoms and a color table for the
 311        bond colors. Used internally.
 312
 313        :param pvp: input plotter object to be updated
 314        :param bradius: bond radius
 315        :param style: bond style. One of sb, plain, pd
 316        :param bcolor: pyvista color
 317        :param missing: True if atoms are missing, False othersie
 318        :param all_atoms: True if rendering O, False if only backbone rendered
 319
 320        :return pvp: Updated Plotter object.
 321
 322        """
 323        _bond_conn = np.array(
 324            [
 325                [0, 1],  # n-ca
 326                [1, 2],  # ca-c
 327                [2, 3],  # c-o
 328                [1, 4],  # ca-cb
 329                [4, 5],  # cb-sg
 330                [6, 7],  # n-ca
 331                [7, 8],  # ca-c
 332                [8, 9],  # c-o
 333                [7, 10],  # ca-cb
 334                [10, 11],  # cb-sg
 335                [5, 11],  # sg -sg
 336                [12, 0],  # cprev_prox-n
 337                [2, 13],  # c-nnext_prox
 338                [14, 6],  # cprev_dist-n_dist
 339                [8, 15],  # c-nnext_dist
 340            ]
 341        )
 342
 343        # modeled disulfides only have backbone atoms since
 344        # phi and psi are undefined, which makes the carbonyl
 345        # oxygen (O) undefined as well. Their previous and next N
 346        # are also undefined.
 347
 348        missing = self.missing_atoms
 349        bradius = bond_radius
 350
 351        _bond_conn_backbone = np.array(
 352            [
 353                [0, 1],  # n-ca
 354                [1, 2],  # ca-c
 355                [1, 4],  # ca-cb
 356                [4, 5],  # cb-sg
 357                [6, 7],  # n-ca
 358                [7, 8],  # ca-c
 359                [7, 10],  # ca-cb
 360                [10, 11],  # cb-sg
 361                [5, 11],  # sg -sg
 362            ]
 363        )
 364
 365        # colors for the bonds. Index into ATOM_COLORS array
 366        _bond_split_colors = np.array(
 367            [
 368                ("N", "C"),
 369                ("C", "C"),
 370                ("C", "O"),
 371                ("C", "C"),
 372                ("C", "SG"),
 373                ("N", "C"),
 374                ("C", "C"),
 375                ("C", "O"),
 376                ("C", "C"),
 377                ("C", "SG"),
 378                ("SG", "SG"),
 379                # prev and next C-N bonds - color by atom Z
 380                ("C", "N"),
 381                ("C", "N"),
 382                ("C", "N"),
 383                ("C", "N"),
 384            ]
 385        )
 386
 387        _bond_split_colors_backbone = np.array(
 388            [
 389                ("N", "C"),
 390                ("C", "C"),
 391                ("C", "C"),
 392                ("C", "SG"),
 393                ("N", "C"),
 394                ("C", "C"),
 395                ("C", "C"),
 396                ("C", "SG"),
 397                ("SG", "SG"),
 398            ]
 399        )
 400        # work through connectivity and colors
 401        orig_col = dest_col = bcolor
 402
 403        if all_atoms:
 404            bond_conn = _bond_conn
 405            bond_split_colors = _bond_split_colors
 406        else:
 407            bond_conn = _bond_conn_backbone
 408            bond_split_colors = _bond_split_colors_backbone
 409
 410        for i, bond in enumerate(bond_conn):
 411            if all_atoms:
 412                if i > 10 and missing:  # skip missing atoms
 413                    continue
 414
 415            orig, dest = bond
 416            col = bond_split_colors[i]
 417
 418            # get the coords
 419            prox_pos = coords[orig]
 420            distal_pos = coords[dest]
 421
 422            # compute a direction vector
 423            direction = distal_pos - prox_pos
 424
 425            # compute vector length. divide by 2 since split bond
 426            height = math.dist(prox_pos, distal_pos) / 2.0
 427
 428            # the cylinder origins are actually in the
 429            # middle so we translate
 430
 431            origin = prox_pos + 0.5 * direction  # for a single plain bond
 432            origin1 = prox_pos + 0.25 * direction
 433            origin2 = prox_pos + 0.75 * direction
 434
 435            if style == "plain":
 436                orig_col = dest_col = bcolor
 437
 438            # proximal-distal red/green coloring
 439            elif style == "pd":
 440                if i <= 4 or i == 11 or i == 12:
 441                    orig_col = dest_col = "red"
 442                else:
 443                    orig_col = dest_col = "green"
 444                if i == 10:
 445                    orig_col = dest_col = "yellow"
 446            else:
 447                orig_col = ATOM_COLORS[col[0]]
 448                dest_col = ATOM_COLORS[col[1]]
 449
 450            if i >= 11:  # prev and next residue atoms for phi/psi calcs
 451                bradius = bradius * 0.5  # make smaller to distinguish
 452
 453            cap1 = pv.Sphere(center=prox_pos, radius=bradius)
 454            cap2 = pv.Sphere(center=distal_pos, radius=bradius)
 455
 456            if style == "plain":
 457                cyl = pv.Cylinder(
 458                    origin, direction, radius=bradius, height=height * 2.0
 459                )
 460                pvp.add_mesh(cyl, color=orig_col)
 461            else:
 462                cyl1 = pv.Cylinder(
 463                    origin1,
 464                    direction,
 465                    radius=bradius,
 466                    height=height,
 467                    capping=False,
 468                    resolution=res,
 469                )
 470                cyl2 = pv.Cylinder(
 471                    origin2,
 472                    direction,
 473                    radius=bradius,
 474                    height=height,
 475                    capping=False,
 476                    resolution=res,
 477                )
 478                pvp.add_mesh(cyl1, color=orig_col)
 479                pvp.add_mesh(cyl2, color=dest_col)
 480
 481            pvp.add_mesh(cap1, color=orig_col)
 482            pvp.add_mesh(cap2, color=dest_col)
 483
 484        return pvp  # end draw_bonds
 485
 486    def _render(
 487        self,
 488        pvplot: pv.Plotter,
 489        style="bs",
 490        plain=False,
 491        bondcolor=BOND_COLOR,
 492        bs_scale=BS_SCALE,
 493        spec=SPECULARITY,
 494        specpow=SPEC_POWER,
 495        translate=True,
 496        bond_radius=BOND_RADIUS,
 497        res=100,
 498    ):
 499        """
 500        Update the passed pyVista plotter() object with the mesh data for the
 501        input Disulfide Bond. Used internally.
 502
 503        :param pvplot: pyvista.Plotter object
 504        :type pvplot: pv.Plotter
 505
 506        :param style: Rendering style, by default 'bs'. One of 'bs', 'st', 'cpk'. Render as \
 507            CPK, ball-and-stick or stick. Bonds are colored by atom color, unless \
 508            'plain' is specified.
 509        :type style: str, optional
 510
 511        :param plain: Used internally, by default False
 512        :type plain: bool, optional
 513
 514        :param bondcolor: pyVista color name, optional bond color for simple bonds, by default BOND_COLOR
 515        :type bondcolor: str, optional
 516
 517        :param bs_scale: Scale factor (0-1) to reduce the atom sizes for ball and stick, by default BS_SCALE
 518        :type bs_scale: float, optional
 519
 520        :param spec: Specularity (0-1), where 1 is totally smooth and 0 is rough, by default SPECULARITY
 521        :type spec: float, optional
 522
 523        :param specpow: Exponent used for specularity calculations, by default SPEC_POWER
 524        :type specpow: int, optional
 525
 526        :param translate: Flag used internally to indicate if we should translate \
 527            the disulfide to its geometric center of mass, by default True.
 528        :type translate: bool, optional
 529
 530        :returns: Updated pv.Plotter object with atoms and bonds.
 531        :rtype: pv.Plotter
 532        """
 533
 534        def add_atoms(pvp, coords, atoms, radii, colors, spec, specpow):
 535            for i, atom in enumerate(atoms):
 536                rad = radii[atom]
 537                if style == "bs" and i > 11:
 538                    rad *= 0.75
 539                pvp.add_mesh(
 540                    pv.Sphere(center=coords[i], radius=rad),
 541                    color=colors[atom],
 542                    smooth_shading=True,
 543                    specular=spec,
 544                    specular_power=specpow,
 545                )
 546
 547        def draw_bonds(pvp, coords, style, all_atoms, bond_radius, bondcolor=None):
 548            return self._draw_bonds(
 549                pvp,
 550                coords,
 551                style=style,
 552                all_atoms=all_atoms,
 553                bond_radius=bond_radius,
 554                bcolor=bondcolor,
 555            )
 556
 557        model = self.modelled
 558        coords = self.internal_coords
 559        if translate:
 560            coords -= self.cofmass
 561
 562        atoms = (
 563            "N",
 564            "C",
 565            "C",
 566            "O",
 567            "C",
 568            "SG",
 569            "N",
 570            "C",
 571            "C",
 572            "O",
 573            "C",
 574            "SG",
 575            "C",
 576            "N",
 577            "C",
 578            "N",
 579        )
 580        pvp = pvplot
 581        all_atoms = not model
 582
 583        if style == "cpk":
 584            add_atoms(pvp, coords, atoms, ATOM_RADII_CPK, ATOM_COLORS, spec, specpow)
 585        elif style == "cov":
 586            add_atoms(
 587                pvp, coords, atoms, ATOM_RADII_COVALENT, ATOM_COLORS, spec, specpow
 588            )
 589        elif style == "bs":
 590            add_atoms(
 591                pvp,
 592                coords,
 593                atoms,
 594                {atom: ATOM_RADII_CPK[atom] * bs_scale for atom in atoms},
 595                ATOM_COLORS,
 596                spec,
 597                specpow,
 598            )
 599            pvp = draw_bonds(pvp, coords, "bs", all_atoms, bond_radius)
 600        elif style in ["sb", "pd", "plain"]:
 601            pvp = draw_bonds(
 602                pvp,
 603                coords,
 604                style,
 605                all_atoms,
 606                bond_radius,
 607                bondcolor if style == "plain" else None,
 608            )
 609
 610        return pvp
 611
 612    def _plot(
 613        self,
 614        pvplot,
 615        style="bs",
 616        plain=False,
 617        bondcolor=BOND_COLOR,
 618        bs_scale=BS_SCALE,
 619        spec=SPECULARITY,
 620        specpow=SPEC_POWER,
 621        translate=True,
 622        bond_radius=BOND_RADIUS,
 623        res=100,
 624    ):
 625        """
 626            Update the passed pyVista plotter() object with the mesh data for the
 627            input Disulfide Bond. Used internally
 628
 629            Parameters
 630            ----------
 631            pvplot : pv.Plotter
 632                pyvista.Plotter object
 633
 634            style : str, optional
 635                Rendering style, by default 'bs'. One of 'bs', 'st', 'cpk', Render as \
 636                CPK, ball-and-stick or stick. Bonds are colored by atom color, unless \
 637                'plain' is specified.
 638
 639            plain : bool, optional
 640                Used internally, by default False
 641
 642            bondcolor : pyVista color name, optional bond color for simple bonds, by default BOND_COLOR
 643
 644            bs_scale : float, optional
 645                scale factor (0-1) to reduce the atom sizes for ball and stick, by default BS_SCALE
 646            
 647            spec : float, optional
 648                specularity (0-1), where 1 is totally smooth and 0 is rough, by default SPECULARITY
 649
 650            specpow : int, optional
 651                exponent used for specularity calculations, by default SPEC_POWER
 652
 653            translate : bool, optional
 654                Flag used internally to indicate if we should translate \
 655                the disulfide to its geometric center of mass, by default True.
 656
 657            Returns
 658            -------
 659            pv.Plotter
 660                Updated pv.Plotter object with atoms and bonds.
 661            """
 662
 663        _bradius = bond_radius
 664        coords = self.internal_coords
 665
 666        model = self.modelled
 667        if model:
 668            all_atoms = False
 669        else:
 670            all_atoms = True
 671
 672        if translate:
 673            coords -= self.cofmass
 674
 675        atoms = (
 676            "N",
 677            "C",
 678            "C",
 679            "O",
 680            "C",
 681            "SG",
 682            "N",
 683            "C",
 684            "C",
 685            "O",
 686            "C",
 687            "SG",
 688            "C",
 689            "N",
 690            "C",
 691            "N",
 692        )
 693        pvp = pvplot.copy()
 694
 695        # bond connection table with atoms in the specific order shown above:
 696        # returned by ss.get_internal_coords()
 697
 698        if style == "cpk":
 699            for i, atom in enumerate(atoms):
 700                rad = ATOM_RADII_CPK[atom]
 701                pvp.append(pv.Sphere(center=coords[i], radius=rad))
 702
 703        elif style == "cov":
 704            for i, atom in enumerate(atoms):
 705                rad = ATOM_RADII_COVALENT[atom]
 706                pvp.append(pv.Sphere(center=coords[i], radius=rad))
 707
 708        elif style == "bs":  # ball and stick
 709            for i, atom in enumerate(atoms):
 710                rad = ATOM_RADII_CPK[atom] * bs_scale
 711                if i > 11:
 712                    rad = rad * 0.75
 713
 714                pvp.append(pv.Sphere(center=coords[i]))
 715            pvp = self._draw_bonds(pvp, coords, style="bs", all_atoms=all_atoms)
 716
 717        else:
 718            pvp = self._draw_bonds(pvp, coords, style=style, all_atoms=all_atoms)
 719
 720        return
 721
 722    def _handle_SS_exception(self, message: str):
 723        """
 724        This method catches an exception that occurs in the Disulfide
 725        object (if quiet), or raises it again, this time adding the
 726        PDB line number to the error message. (private).
 727
 728        :param message: Error message
 729        :raises DisulfideConstructionException: Fatal construction exception.
 730
 731        """
 732        # message = "%s at line %i." % (message)
 733        message = f"{message}"
 734
 735        if self.quiet:
 736            # just print a warning - some residues/atoms may be missing
 737            warnings.warn(
 738                f"DisulfideConstructionException: {message}\n",
 739                "Exception ignored.\n",
 740                "Some atoms may be missing in the data structure.",
 741                DisulfideConstructionWarning,
 742            )
 743        else:
 744            # exceptions are fatal - raise again with new message (including line nr)
 745            raise DisulfideConstructionException(message) from None
 746
 747    @property
 748    def binary_class_string(self):
 749        """
 750        Return a binary string representation of the disulfide bond class.
 751        """
 752        return DisulfideClassManager.class_string_from_dihedral(
 753            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=2
 754        )
 755
 756    @property
 757    def octant_class_string(self):
 758        """
 759        Return the octant string representation of the disulfide bond class.
 760        """
 761        return DisulfideClassManager.class_string_from_dihedral(
 762            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=8
 763        )
 764
 765    @property
 766    def bond_angle_ideality(self):
 767        """
 768        Calculate all bond angles for a disulfide bond and compare them to idealized angles.
 769
 770        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
 771            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
 772        :return: RMS difference between calculated bond angles and idealized bond angles.
 773        :rtype: float
 774        """
 775
 776        atom_coordinates = self.coords_array
 777        verbose = not self.quiet
 778        if verbose:
 779            _logger.setLevel(logging.INFO)
 780
 781        idealized_angles = {
 782            ("N1", "CA1", "C1"): 111.0,
 783            ("N1", "CA1", "CB1"): 108.5,
 784            ("CA1", "CB1", "SG1"): 112.8,
 785            ("CB1", "SG1", "SG2"): 103.8,  # This angle is for the disulfide bond itself
 786            ("SG1", "SG2", "CB2"): 103.8,  # This angle is for the disulfide bond itself
 787            ("SG2", "CB2", "CA2"): 112.8,
 788            ("CB2", "CA2", "N2"): 108.5,
 789            ("N2", "CA2", "C2"): 111.0,
 790        }
 791
 792        # List of triplets for which we need to calculate bond angles
 793        # I am omitting the proximal and distal backbone angle N, Ca, C
 794        # to focus on the disulfide bond angles themselves.
 795        angle_triplets = [
 796            ("N1", "CA1", "C1"),
 797            ("N1", "CA1", "CB1"),
 798            ("CA1", "CB1", "SG1"),
 799            ("CB1", "SG1", "SG2"),
 800            ("SG1", "SG2", "CB2"),
 801            ("SG2", "CB2", "CA2"),
 802            ("CB2", "CA2", "N2"),
 803            ("N2", "CA2", "C2"),
 804        ]
 805
 806        atom_indices = {
 807            "N1": 0,
 808            "CA1": 1,
 809            "C1": 2,
 810            "CB1": 4,
 811            "SG1": 5,
 812            "SG2": 11,
 813            "CB2": 10,
 814            "CA2": 7,
 815            "N2": 6,
 816            "C2": 8,
 817        }
 818
 819        calculated_angles = []
 820        for triplet in angle_triplets:
 821            a = atom_coordinates[atom_indices[triplet[0]]]
 822            b = atom_coordinates[atom_indices[triplet[1]]]
 823            c = atom_coordinates[atom_indices[triplet[2]]]
 824            ideal = idealized_angles[triplet]
 825            try:
 826                angle = calculate_bond_angle(a, b, c)
 827            except ValueError as e:
 828                print(f"Error calculating angle for atoms {triplet}: {e}")
 829                return None
 830            calculated_angles.append(angle)
 831            if verbose:
 832                _logger.info(
 833                    f"Calculated angle for atoms {triplet}: {angle:.2f}, Ideal angle: {ideal:.2f}"
 834                )
 835
 836        # Convert idealized angles to a list
 837        idealized_angles_list = [
 838            idealized_angles[triplet] for triplet in angle_triplets
 839        ]
 840
 841        # Calculate RMS difference
 842        rms_diff = rms_difference(
 843            np.array(calculated_angles), np.array(idealized_angles_list)
 844        )
 845
 846        if verbose:
 847            _logger.info(f"RMS bond angle deviation:, {rms_diff:.2f}")
 848
 849        return rms_diff
 850
 851    @property
 852    def bond_length_ideality(self):
 853        """
 854        Calculate bond lengths for a disulfide bond and compare them to idealized lengths.
 855
 856        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
 857            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
 858        :return: RMS difference between calculated bond lengths and idealized bond lengths.
 859        :rtype: float
 860        """
 861
 862        atom_coordinates = self.coords_array
 863        verbose = not self.quiet
 864        if verbose:
 865            _logger.setLevel(logging.INFO)
 866
 867        idealized_bonds = {
 868            ("N1", "CA1"): 1.46,
 869            ("CA1", "C1"): 1.52,
 870            ("CA1", "CB1"): 1.52,
 871            ("CB1", "SG1"): 1.86,
 872            ("SG1", "SG2"): 2.044,  # This angle is for the disulfide bond itself
 873            ("SG2", "CB2"): 1.86,
 874            ("CB2", "CA2"): 1.52,
 875            ("CA2", "C2"): 1.52,
 876            ("N2", "CA2"): 1.46,
 877        }
 878
 879        # List of triplets for which we need to calculate bond angles
 880        # I am omitting the proximal and distal backbone angle N, Ca, C
 881        # to focus on the disulfide bond angles themselves.
 882        distance_pairs = [
 883            ("N1", "CA1"),
 884            ("CA1", "C1"),
 885            ("CA1", "CB1"),
 886            ("CB1", "SG1"),
 887            ("SG1", "SG2"),  # This angle is for the disulfide bond itself
 888            ("SG2", "CB2"),
 889            ("CB2", "CA2"),
 890            ("CA2", "C2"),
 891            ("N2", "CA2"),
 892        ]
 893
 894        atom_indices = {
 895            "N1": 0,
 896            "CA1": 1,
 897            "C1": 2,
 898            "CB1": 4,
 899            "SG1": 5,
 900            "SG2": 11,
 901            "CB2": 10,
 902            "CA2": 7,
 903            "N2": 6,
 904            "C2": 8,
 905        }
 906
 907        calculated_distances = []
 908        for pair in distance_pairs:
 909            a = atom_coordinates[atom_indices[pair[0]]]
 910            b = atom_coordinates[atom_indices[pair[1]]]
 911            ideal = idealized_bonds[pair]
 912            try:
 913                distance = math.dist(a, b)
 914            except ValueError as e:
 915                _logger.error(f"Error calculating bond length for atoms {pair}: {e}")
 916                return None
 917            calculated_distances.append(distance)
 918            if verbose:
 919                _logger.info(
 920                    f"Calculated distance for atoms {pair}: {distance:.2f}A, Ideal distance: {ideal:.2f}A"
 921                )
 922
 923        # Convert idealized distances to a list
 924        idealized_distance_list = [idealized_bonds[pair] for pair in distance_pairs]
 925
 926        # Calculate RMS difference
 927        rms_diff = rms_difference(
 928            np.array(calculated_distances), np.array(idealized_distance_list)
 929        )
 930
 931        if verbose:
 932            _logger.info(
 933                f"RMS distance deviation from ideality for SS atoms: {rms_diff:.2f}"
 934            )
 935
 936            # Reset logger level
 937            _logger.setLevel(logging.WARNING)
 938
 939        return rms_diff
 940
 941    @property
 942    def internal_coords_array(self):
 943        """
 944        Return an array of internal coordinates for the disulfide bond.
 945
 946        This function collects the coordinates of the backbone atoms involved in the
 947        disulfide bond and returns them as a numpy array.
 948
 949        :param self: The instance of the Disulfide class.
 950        :type self: Disulfide
 951        :return: A numpy array containing the coordinates of the atoms.
 952        :rtype: np.ndarray
 953        """
 954        coords = []
 955        coords.append(self._n_prox.get_array())
 956        coords.append(self._ca_prox.get_array())
 957        coords.append(self._c_prox.get_array())
 958        coords.append(self._o_prox.get_array())
 959        coords.append(self._cb_prox.get_array())
 960        coords.append(self._sg_prox.get_array())
 961        coords.append(self._n_dist.get_array())
 962        coords.append(self._ca_dist.get_array())
 963        coords.append(self._c_dist.get_array())
 964        coords.append(self._o_dist.get_array())
 965        coords.append(self._cb_dist.get_array())
 966        coords.append(self._sg_dist.get_array())
 967
 968        return np.array(coords)
 969
 970    @property
 971    def coords_array(self):
 972        """
 973        Return an array of coordinates for the disulfide bond.
 974
 975        This function collects the coordinates of backbone atoms involved in the
 976        disulfide bond and returns them as a numpy array.
 977
 978        :param self: The instance of the Disulfide class.
 979        :type self: Disulfide
 980        :return: A numpy array containing the coordinates of the atoms.
 981        :rtype: np.ndarray
 982        """
 983        coords = []
 984        coords.append(self.n_prox.get_array())
 985        coords.append(self.ca_prox.get_array())
 986        coords.append(self.c_prox.get_array())
 987        coords.append(self.o_prox.get_array())
 988        coords.append(self.cb_prox.get_array())
 989        coords.append(self.sg_prox.get_array())
 990        coords.append(self.n_dist.get_array())
 991        coords.append(self.ca_dist.get_array())
 992        coords.append(self.c_dist.get_array())
 993        coords.append(self.o_dist.get_array())
 994        coords.append(self.cb_dist.get_array())
 995        coords.append(self.sg_dist.get_array())
 996
 997        return np.array(coords)
 998
 999    @property
1000    def dihedrals(self) -> list:
1001        """
1002        Return a list containing the dihedral angles for the disulfide.
1003
1004        """
1005        return [self.chi1, self.chi2, self.chi3, self.chi4, self.chi5]
1006
1007    @dihedrals.setter
1008    def dihedrals(self, dihedrals: list) -> None:
1009        """
1010        Sets the disulfide dihedral angles to the inputs specified in the list and
1011        computes the torsional energy and length of the disulfide bond.
1012
1013        :param dihedrals: list of dihedral angles.
1014        """
1015        self.chi1 = dihedrals[0]
1016        self.chi2 = dihedrals[1]
1017        self.chi3 = dihedrals[2]
1018        self.chi4 = dihedrals[3]
1019        self.chi5 = dihedrals[4]
1020        self.torsion_array = np.array(dihedrals)
1021        self._compute_torsional_energy()
1022        self._compute_torsion_length()
1023        self._compute_rho()
1024
1025    def bounding_box(self) -> np.array:
1026        """
1027        Return the bounding box array for the given disulfide.
1028
1029        :return: np.array
1030            Array containing the min, max for X, Y, and Z respectively.
1031            Does not currently take the atom's radius into account.
1032        """
1033        coords = self.internal_coords
1034
1035        xmin, ymin, zmin = coords.min(axis=0)
1036        xmax, ymax, zmax = coords.max(axis=0)
1037
1038        res = np.array([[xmin, xmax], [ymin, ymax], [zmin, zmax]])
1039
1040        return res
1041
1042    def build_yourself(self) -> None:
1043        """
1044        Build a model Disulfide based its internal dihedral state
1045        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1046        Cb, with N on left), builds disulfide, and updates the object's internal
1047        coordinates. It also adds the distal protein backbone,
1048        and computes the disulfide conformational energy.
1049        """
1050        self.build_model(self.chi1, self.chi2, self.chi3, self.chi4, self.chi5)
1051
1052    def build_model(
1053        self, chi1: float, chi2: float, chi3: float, chi4: float, chi5: float
1054    ) -> None:
1055        """
1056        Build a model Disulfide based on the input dihedral angles.
1057        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1058        Cb, with N on left), builds disulfide, and updates the object's internal
1059        coordinates. It also adds the distal protein backbone,
1060        and computes the disulfide conformational energy.
1061
1062        :param chi1: Chi1 (degrees)
1063        :param chi2: Chi2 (degrees)
1064        :param chi3: Chi3 (degrees)
1065        :param chi4: Chi4 (degrees)
1066        :param chi5: Chi5 (degrees)
1067
1068        Example:
1069        >>> import proteusPy as pp
1070        >>> modss = pp.Disulfide('model')
1071        >>> modss.build_model(-60, -60, -90, -60, -60)
1072        >>> modss.display(style='sb', light="auto")
1073        """
1074
1075        self.dihedrals = [chi1, chi2, chi3, chi4, chi5]
1076        self.proximal = 1
1077        self.distal = 2
1078
1079        tmp = Turtle3D("tmp")
1080        tmp.Orientation = 1
1081
1082        n = ORIGIN
1083        ca = ORIGIN
1084        cb = ORIGIN
1085        c = ORIGIN
1086
1087        self.ca_prox = tmp._position
1088        tmp.schain_to_bbone()
1089        n, ca, cb, c = build_residue(tmp)
1090
1091        self.n_prox = n
1092        self.ca_prox = ca
1093        self.c_prox = c
1094        self.cb_prox = cb
1095
1096        tmp.bbone_to_schain()
1097        tmp.move(1.53)
1098        tmp.roll(self.chi1)
1099        tmp.yaw(112.8)
1100        self.cb_prox = Vector3D(tmp._position)
1101
1102        tmp.move(1.86)
1103        tmp.roll(self.chi2)
1104        tmp.yaw(103.8)
1105        self.sg_prox = Vector3D(tmp._position)
1106
1107        tmp.move(2.044)
1108        tmp.roll(self.chi3)
1109        tmp.yaw(103.8)
1110        self.sg_dist = Vector3D(tmp._position)
1111
1112        tmp.move(1.86)
1113        tmp.roll(self.chi4)
1114        tmp.yaw(112.8)
1115        self.cb_dist = Vector3D(tmp._position)
1116
1117        tmp.move(1.53)
1118        tmp.roll(self.chi5)
1119        tmp.pitch(180.0)
1120
1121        tmp.schain_to_bbone()
1122
1123        n, ca, cb, c = build_residue(tmp)
1124
1125        self.n_dist = n
1126        self.ca_dist = ca
1127        self.c_dist = c
1128        self._compute_torsional_energy()
1129        self._compute_local_coords()
1130        self._compute_torsion_length()
1131        self._compute_rho()
1132        self.ca_distance = distance3d(self.ca_prox, self.ca_dist)
1133        self.cb_distance = distance3d(self.cb_prox, self.cb_dist)
1134        self.sg_distance = distance3d(self.sg_prox, self.sg_dist)
1135        self.torsion_array = np.array([chi1, chi2, chi3, chi4, chi5])
1136        self.missing_atoms = True
1137        self.modelled = True
1138
1139    @property
1140    def cofmass(self) -> np.array:
1141        """
1142        Return the geometric center of mass for the internal coordinates of
1143        the given Disulfide. Missing atoms are not included.
1144
1145        :return: 3D array for the geometric center of mass
1146        """
1147
1148        res = self.internal_coords.mean(axis=0)
1149        return res
1150
1151    @property
1152    def coord_cofmass(self) -> np.array:
1153        """
1154        Return the geometric center of mass for the global coordinates of
1155        the given Disulfide. Missing atoms are not included.
1156
1157        :return: 3D array for the geometric center of mass
1158        """
1159
1160        res = self.coords.mean(axis=0)
1161        return res
1162
1163    def copy(self):
1164        """
1165        Copy the Disulfide.
1166
1167        :return: A copy of self.
1168        """
1169        return copy.deepcopy(self)
1170
1171    def _compute_local_coords(self) -> None:
1172        """
1173        Compute the internal coordinates for a properly initialized Disulfide Object.
1174
1175        :param self: SS initialized Disulfide object
1176        :returns: None, modifies internal state of the input
1177        """
1178
1179        turt = Turtle3D("tmp")
1180        # get the coordinates as np.array for Turtle3D use.
1181        cpp = self.c_prev_prox.get_array()
1182        nnp = self.n_next_prox.get_array()
1183
1184        n = self.n_prox.get_array()
1185        ca = self.ca_prox.get_array()
1186        c = self.c_prox.get_array()
1187        cb = self.cb_prox.get_array()
1188        o = self.o_prox.get_array()
1189        sg = self.sg_prox.get_array()
1190
1191        sg2 = self.sg_dist.get_array()
1192        cb2 = self.cb_dist.get_array()
1193        ca2 = self.ca_dist.get_array()
1194        c2 = self.c_dist.get_array()
1195        n2 = self.n_dist.get_array()
1196        o2 = self.o_dist.get_array()
1197
1198        cpd = self.c_prev_dist.get_array()
1199        nnd = self.n_next_dist.get_array()
1200
1201        turt.orient_from_backbone(n, ca, c, cb, ORIENT_SIDECHAIN)
1202
1203        # internal (local) coordinates, stored as Vector objects
1204        # to_local returns np.array objects
1205
1206        self._n_prox = Vector3D(turt.to_local(n))
1207        self._ca_prox = Vector3D(turt.to_local(ca))
1208        self._c_prox = Vector3D(turt.to_local(c))
1209        self._o_prox = Vector3D(turt.to_local(o))
1210        self._cb_prox = Vector3D(turt.to_local(cb))
1211        self._sg_prox = Vector3D(turt.to_local(sg))
1212
1213        self._c_prev_prox = Vector3D(turt.to_local(cpp))
1214        self._n_next_prox = Vector3D(turt.to_local(nnp))
1215        self._c_prev_dist = Vector3D(turt.to_local(cpd))
1216        self._n_next_dist = Vector3D(turt.to_local(nnd))
1217
1218        self._n_dist = Vector3D(turt.to_local(n2))
1219        self._ca_dist = Vector3D(turt.to_local(ca2))
1220        self._c_dist = Vector3D(turt.to_local(c2))
1221        self._o_dist = Vector3D(turt.to_local(o2))
1222        self._cb_dist = Vector3D(turt.to_local(cb2))
1223        self._sg_dist = Vector3D(turt.to_local(sg2))
1224
1225    def _compute_torsional_energy(self) -> float:
1226        """
1227        Compute the approximate torsional energy for the Disulfide's
1228        conformation and sets its internal state.
1229
1230        :return: Energy (kcal/mol)
1231        """
1232        # @TODO find citation for the ss bond energy calculation
1233
1234        def torad(deg):
1235            return np.radians(deg)
1236
1237        chi1 = self.chi1
1238        chi2 = self.chi2
1239        chi3 = self.chi3
1240        chi4 = self.chi4
1241        chi5 = self.chi5
1242
1243        energy = 2.0 * (cos(torad(3.0 * chi1)) + cos(torad(3.0 * chi5)))
1244        energy += cos(torad(3.0 * chi2)) + cos(torad(3.0 * chi4))
1245        energy += 3.5 * cos(torad(2.0 * chi3)) + 0.6 * cos(torad(3.0 * chi3)) + 10.1
1246
1247        self.energy = energy
1248        return energy
1249
1250    def display(
1251        self, single=True, style="sb", light="auto", shadows=False, winsize=WINSIZE
1252    ) -> None:
1253        """
1254        Display the Disulfide bond in the specific rendering style.
1255
1256        :param single: Display the bond in a single panel in the specific style.
1257        :param style:  Rendering style: One of:
1258            * 'sb' - split bonds
1259            * 'bs' - ball and stick
1260            * 'cpk' - CPK style
1261            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1262            * 'plain' - boring single color
1263        :param light: If True, light background, if False, dark
1264
1265        Example:
1266        >>> import proteusPy as pp
1267
1268        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
1269        >>> ss = PDB_SS[0]
1270        >>> ss.display(style='cpk', light="auto")
1271        >>> ss.screenshot(style='bs', fname='proteus_logo_sb.png')
1272        """
1273        src = self.pdb_id
1274        enrg = self.energy
1275
1276        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å, Sg: {self.sg_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1277
1278        set_pyvista_theme(light)
1279        fontsize = 8
1280
1281        if single:
1282            _pl = pv.Plotter(window_size=winsize)
1283            _pl.add_title(title=title, font_size=fontsize)
1284            _pl.enable_anti_aliasing("msaa")
1285
1286            self._render(
1287                _pl,
1288                style=style,
1289            )
1290            _pl.reset_camera()
1291            if shadows:
1292                _pl.enable_shadows()
1293            _pl.show()
1294
1295        else:
1296            pl = pv.Plotter(window_size=winsize, shape=(2, 2))
1297            pl.subplot(0, 0)
1298
1299            pl.add_title(title=title, font_size=fontsize)
1300            pl.enable_anti_aliasing("msaa")
1301
1302            # pl.add_camera_orientation_widget()
1303
1304            self._render(
1305                pl,
1306                style="cpk",
1307            )
1308
1309            pl.subplot(0, 1)
1310            pl.add_title(title=title, font_size=fontsize)
1311
1312            self._render(
1313                pl,
1314                style="bs",
1315            )
1316
1317            pl.subplot(1, 0)
1318            pl.add_title(title=title, font_size=fontsize)
1319
1320            self._render(
1321                pl,
1322                style="sb",
1323            )
1324
1325            pl.subplot(1, 1)
1326            pl.add_title(title=title, font_size=fontsize)
1327
1328            self._render(
1329                pl,
1330                style="pd",
1331            )
1332
1333            pl.link_views()
1334            pl.reset_camera()
1335            if shadows:
1336                pl.enable_shadows()
1337            pl.show()
1338        return
1339
1340    @property
1341    def TorsionEnergy(self) -> float:
1342        """
1343        Return the energy of the Disulfide bond.
1344        """
1345        return self._compute_torsional_energy()
1346
1347    @property
1348    def TorsionLength(self) -> float:
1349        """
1350        Return the energy of the Disulfide bond.
1351        """
1352        return self._compute_torsion_length()
1353
1354    def Distance_neighbors(self, others: DisulfideList, cutoff: float) -> DisulfideList:
1355        """
1356        Return list of Disulfides whose RMS atomic distance is within
1357        the cutoff (Å) in the others list.
1358
1359        :param others: DisulfideList to search
1360        :param cutoff: Distance cutoff (Å)
1361        :return: DisulfideList within the cutoff
1362        """
1363
1364        res = [ss.copy() for ss in others if self.Distance_RMS(ss) < cutoff]
1365        return DisulfideList(res, "neighbors")
1366
1367    def Distance_RMS(self, other) -> float:
1368        """
1369        Calculate the RMS distance between the internal coordinates of self and another Disulfide.
1370        :param other: Comparison Disulfide
1371        :return: RMS distance (Å)
1372        """
1373
1374        # Get internal coordinates of both objects
1375        ic1 = self.internal_coords
1376        ic2 = other.internal_coords
1377
1378        # Compute the sum of squared differences between corresponding internal coordinates
1379        totsq = sum(math.dist(p1, p2) ** 2 for p1, p2 in zip(ic1, ic2))
1380
1381        # Compute the mean of the squared distances
1382        totsq /= len(ic1)
1383
1384        # Take the square root of the mean to get the RMS distance
1385        return math.sqrt(totsq)
1386
1387    def Torsion_RMS(self, other) -> float:
1388        """
1389        Calculate the RMS distance between the dihedral angles of self and another Disulfide.
1390
1391        :param other: Disulfide object to compare against.
1392        :type other: Disulfide
1393        :return: RMS distance in degrees.
1394        :rtype: float
1395        :raises ValueError: If the torsion arrays of self and other are not of equal length.
1396        """
1397        # Get internal coordinates of both objects
1398        ic1 = self.torsion_array
1399        ic2 = other.torsion_array
1400
1401        # Ensure both torsion arrays have the same length
1402        if len(ic1) != len(ic2):
1403            raise ValueError("Torsion arrays must be of the same length.")
1404
1405        # Compute the total squared difference between corresponding internal coordinates
1406        total_squared_diff = sum(
1407            (angle1 - angle2) ** 2 for angle1, angle2 in zip(ic1, ic2)
1408        )
1409        mean_squared_diff = total_squared_diff / len(ic1)
1410
1411        # Return the square root of the mean squared difference as the RMS distance
1412        return math.sqrt(mean_squared_diff)
1413
1414    def get_chains(self) -> tuple:
1415        """
1416        Return the proximal and distal chain IDs for the Disulfide.
1417
1418        :return: tuple (proximal, distal) chain IDs
1419        """
1420        prox = self.proximal_chain
1421        dist = self.distal_chain
1422        return tuple(prox, dist)
1423
1424    def get_full_id(self) -> tuple:
1425        """
1426        Return the Disulfide full IDs (Used with BIO.PDB)
1427
1428        :return: Disulfide full IDs
1429        """
1430        return (self.proximal, self.distal)
1431
1432    @property
1433    def internal_coords(self) -> np.array:
1434        """
1435        Return the internal coordinates for the Disulfide.
1436
1437        :return: Array containing the coordinates, [16][3].
1438        """
1439        return self._internal_coords()
1440
1441    def _internal_coords(self) -> np.array:
1442        """
1443        Return the internal coordinates for the Disulfide.
1444        If there are missing atoms the extra atoms for the proximal
1445        and distal N and C are set to [0,0,0]. This is needed for the center of
1446        mass calculations, used when rendering.
1447
1448        :return: Array containing the coordinates, [16][3].
1449        """
1450
1451        # if we don't have the prior and next atoms we initialize those
1452        # atoms to the origin so as to not effect the center of mass calculations
1453        if self.missing_atoms:
1454            res_array = np.array(
1455                (
1456                    self._n_prox.get_array(),
1457                    self._ca_prox.get_array(),
1458                    self._c_prox.get_array(),
1459                    self._o_prox.get_array(),
1460                    self._cb_prox.get_array(),
1461                    self._sg_prox.get_array(),
1462                    self._n_dist.get_array(),
1463                    self._ca_dist.get_array(),
1464                    self._c_dist.get_array(),
1465                    self._o_dist.get_array(),
1466                    self._cb_dist.get_array(),
1467                    self._sg_dist.get_array(),
1468                    [0, 0, 0],
1469                    [0, 0, 0],
1470                    [0, 0, 0],
1471                    [0, 0, 0],
1472                )
1473            )
1474        else:
1475            res_array = np.array(
1476                (
1477                    self._n_prox.get_array(),
1478                    self._ca_prox.get_array(),
1479                    self._c_prox.get_array(),
1480                    self._o_prox.get_array(),
1481                    self._cb_prox.get_array(),
1482                    self._sg_prox.get_array(),
1483                    self._n_dist.get_array(),
1484                    self._ca_dist.get_array(),
1485                    self._c_dist.get_array(),
1486                    self._o_dist.get_array(),
1487                    self._cb_dist.get_array(),
1488                    self._sg_dist.get_array(),
1489                    self._c_prev_prox.get_array(),
1490                    self._n_next_prox.get_array(),
1491                    self._c_prev_dist.get_array(),
1492                    self._n_next_dist.get_array(),
1493                )
1494            )
1495        return res_array
1496
1497    @property
1498    def coords(self) -> np.array:
1499        """
1500        Return the coordinates for the Disulfide as an array.
1501
1502        :return: Array containing the coordinates, [16][3].
1503        """
1504        return self._coords()
1505
1506    def _coords(self) -> np.array:
1507        """
1508        Return the coordinates for the Disulfide as an array.
1509        If there are missing atoms the extra atoms for the proximal
1510        and distal N and C are set to [0,0,0]. This is needed for the center of
1511        mass calculations, used when rendering.
1512
1513        :return: Array containing the coordinates, [16][3].
1514        """
1515
1516        # if we don't have the prior and next atoms we initialize those
1517        # atoms to the origin so as to not effect the center of mass calculations
1518        if self.missing_atoms:
1519            res_array = np.array(
1520                (
1521                    self.n_prox.get_array(),
1522                    self.ca_prox.get_array(),
1523                    self.c_prox.get_array(),
1524                    self.o_prox.get_array(),
1525                    self.cb_prox.get_array(),
1526                    self.sg_prox.get_array(),
1527                    self.n_dist.get_array(),
1528                    self.ca_dist.get_array(),
1529                    self.c_dist.get_array(),
1530                    self.o_dist.get_array(),
1531                    self.cb_dist.get_array(),
1532                    self.sg_dist.get_array(),
1533                    [0, 0, 0],
1534                    [0, 0, 0],
1535                    [0, 0, 0],
1536                    [0, 0, 0],
1537                )
1538            )
1539        else:
1540            res_array = np.array(
1541                (
1542                    self.n_prox.get_array(),
1543                    self.ca_prox.get_array(),
1544                    self.c_prox.get_array(),
1545                    self.o_prox.get_array(),
1546                    self.cb_prox.get_array(),
1547                    self.sg_prox.get_array(),
1548                    self.n_dist.get_array(),
1549                    self.ca_dist.get_array(),
1550                    self.c_dist.get_array(),
1551                    self.o_dist.get_array(),
1552                    self.cb_dist.get_array(),
1553                    self.sg_dist.get_array(),
1554                    self.c_prev_prox.get_array(),
1555                    self.n_next_prox.get_array(),
1556                    self.c_prev_dist.get_array(),
1557                    self.n_next_dist.get_array(),
1558                )
1559            )
1560        return res_array
1561
1562    def internal_coords_res(self, resnumb) -> np.array:
1563        """
1564        Return the internal coordinates for the Disulfide. Missing atoms are not included.
1565
1566        :return: Array containing the coordinates, [12][3].
1567        """
1568        return self._internal_coords_res(resnumb)
1569
1570    def _internal_coords_res(self, resnumb) -> np.array:
1571        """
1572        Return the internal coordinates for the internal coordinates of
1573        the given Disulfide. Missing atoms are not included.
1574
1575        :param resnumb: Residue number for disulfide
1576        :raises DisulfideConstructionWarning: Warning raised if the residue number is invalid
1577        :return: Array containing the internal coordinates for the disulfide
1578        """
1579        res_array = np.zeros(shape=(6, 3))
1580
1581        if resnumb == self.proximal:
1582            res_array = np.array(
1583                (
1584                    self._n_prox.get_array(),
1585                    self._ca_prox.get_array(),
1586                    self._c_prox.get_array(),
1587                    self._o_prox.get_array(),
1588                    self._cb_prox.get_array(),
1589                    self._sg_prox.get_array(),
1590                )
1591            )
1592            return res_array
1593
1594        elif resnumb == self.distal:
1595            res_array = np.array(
1596                (
1597                    self._n_dist.get_array(),
1598                    self._ca_dist.get_array(),
1599                    self._c_dist.get_array(),
1600                    self._o_dist.get_array(),
1601                    self._cb_dist.get_array(),
1602                    self._sg_dist.get_array(),
1603                )
1604            )
1605            return res_array
1606        else:
1607            mess = f"-> Disulfide._internal_coords(): Invalid argument. \
1608             Unable to find residue: {resnumb} "
1609            raise DisulfideConstructionWarning(mess)
1610
1611    def make_movie(
1612        self, style="sb", fname="ssbond.mp4", verbose=False, steps=360
1613    ) -> None:
1614        """
1615        Create an animation for ```self``` rotating one revolution about the Y axis,
1616        in the given ```style```, saving to ```filename```.
1617
1618        :param style: Rendering style, defaults to 'sb', one of:
1619        * 'sb' - split bonds
1620        * 'bs' - ball and stick
1621        * 'cpk' - CPK style
1622        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1623        * 'plain' - boring single color
1624
1625        :param fname: Output filename, defaults to ```ssbond.mp4```
1626        :param verbose: Verbosity, defaults to False
1627        :param steps: Number of steps for one complete rotation, defaults to 360.
1628        """
1629
1630        # src = self.pdb_id
1631        # name = self.name
1632        # enrg = self.energy
1633        # title = f"{src} {name}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1634
1635        if verbose:
1636            print(f"Rendering animation to {fname}...")
1637
1638        pl = pv.Plotter(window_size=WINSIZE, off_screen=True, theme="document")
1639        pl.open_movie(fname)
1640        path = pl.generate_orbital_path(n_points=steps)
1641
1642        #
1643        # pl.add_title(title=title, font_size=FONTSIZE)
1644        pl.enable_anti_aliasing("msaa")
1645        pl = self._render(
1646            pl,
1647            style=style,
1648        )
1649        pl.reset_camera()
1650        pl.orbit_on_path(path, write_frames=True)
1651        pl.close()
1652
1653        if verbose:
1654            print(f"Saved mp4 animation to: {fname}")
1655
1656    def spin(self, style="sb", verbose=False, steps=360, theme="auto") -> None:
1657        """
1658        Spin the object by rotating it one revolution about the Y axis in the given style.
1659
1660        :param style: Rendering style, defaults to 'sb', one of:
1661            * 'sb' - split bonds
1662            * 'bs' - ball and stick
1663            * 'cpk' - CPK style
1664            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1665            * 'plain' - boring single color
1666
1667        :param verbose: Verbosity, defaults to False
1668        :param steps: Number of steps for one complete rotation, defaults to 360.
1669        """
1670
1671        src = self.pdb_id
1672        enrg = self.energy
1673
1674        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1675
1676        set_pyvista_theme(theme)
1677
1678        if verbose:
1679            _logger.info("Spinning object: %d steps...", steps)
1680
1681        # Create a Plotter instance
1682        pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
1683        pl.add_title(title=title, font_size=FONTSIZE)
1684
1685        # Enable anti-aliasing for smoother rendering
1686        pl.enable_anti_aliasing("msaa")
1687
1688        # Generate an orbital path for spinning
1689        path = pl.generate_orbital_path(n_points=steps)
1690
1691        # Render the object in the specified style
1692        pl = self._render(pl, style=style)
1693
1694        pl.reset_camera()
1695        pl.show(auto_close=False)
1696
1697        # Orbit the camera along the generated path
1698        pl.orbit_on_path(path, write_frames=False, step=1 / steps)
1699
1700        if verbose:
1701            print("Spinning completed.")
1702
1703    def plot(
1704        self, pl, single=True, style="sb", light="True", shadows=False
1705    ) -> pv.Plotter:
1706        """
1707        Return the pyVista Plotter object for the Disulfide bond in the specific rendering style.
1708
1709        :param single: Display the bond in a single panel in the specific style.
1710        :param style:  Rendering style: One of:
1711            * 'sb' - split bonds
1712            * 'bs' - ball and stick
1713            * 'cpk' - CPK style
1714            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1715            * 'plain' - boring single color
1716        :param light: If True, light background, if False, dark
1717        """
1718        src = self.pdb_id
1719        enrg = self.energy
1720        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1721
1722        if light:
1723            pv.set_plot_theme("document")
1724        else:
1725            pv.set_plot_theme("dark")
1726
1727        pl.clear()
1728
1729        if single:
1730            pl = pv.Plotter(window_size=WINSIZE)
1731            pl.add_title(title=title, font_size=FONTSIZE)
1732            pl.enable_anti_aliasing("msaa")
1733            # pl.add_camera_orientation_widget()
1734
1735            self._render(
1736                pl,
1737                style=style,
1738                bs_scale=BS_SCALE,
1739                spec=SPECULARITY,
1740                specpow=SPEC_POWER,
1741            )
1742            pl.reset_camera()
1743            if shadows:
1744                pl.enable_shadows()
1745        else:
1746            pl = pv.Plotter(shape=(2, 2))
1747            pl.subplot(0, 0)
1748
1749            # pl.add_title(title=title, font_size=FONTSIZE)
1750            pl.enable_anti_aliasing("msaa")
1751
1752            # pl.add_camera_orientation_widget()
1753
1754            self._render(
1755                pl,
1756                style="cpk",
1757            )
1758
1759            pl.subplot(0, 1)
1760
1761            self._render(
1762                pl,
1763                style="bs",
1764            )
1765
1766            pl.subplot(1, 0)
1767
1768            self._render(
1769                pl,
1770                style="sb",
1771            )
1772
1773            pl.subplot(1, 1)
1774            self._render(
1775                pl,
1776                style="pd",
1777            )
1778
1779            pl.link_views()
1780            pl.reset_camera()
1781            if shadows:
1782                pl.enable_shadows()
1783        return pl
1784
1785    def pprint(self) -> None:
1786        """
1787        Pretty print general info for the Disulfide
1788        """
1789        s1 = self.repr_ss_info()
1790        s2 = self.repr_ss_ca_dist()
1791        s2b = self.repr_ss_sg_dist()
1792        s3 = self.repr_ss_conformation()
1793        s4 = self.repr_ss_torsion_length()
1794        res = f"{s1} \n{s3} \n{s2} \n{s2b} \n{s4}>"
1795        print(res)
1796
1797    def pprint_all(self) -> None:
1798        """
1799        Pretty print all info for the Disulfide
1800        """
1801        s1 = self.repr_ss_info() + "\n"
1802        s2 = self.repr_ss_coords()
1803        s3 = self.repr_ss_local_coords()
1804        s4 = self.repr_ss_conformation()
1805        s4b = self.repr_phipsi()
1806        s6 = self.repr_ss_ca_dist()
1807        s6b = self.repr_ss_cb_dist()
1808        s6c = self.repr_ss_sg_dist()
1809        s7 = self.repr_ss_torsion_length()
1810        s8 = self.repr_ss_secondary_structure()
1811
1812        res = f"{s1} {s2} {s3} {s4}\n {s4b}\n {s6}\n {s6b}\n {s6c}\n {s7}\n {s8}>"
1813
1814        print(res)
1815
1816    # repr functions. The class is large, so I split it up into sections
1817    def repr_ss_info(self) -> str:
1818        """
1819        Representation for the Disulfide class
1820        """
1821        s1 = f"<Disulfide {self.name}, Source: {self.pdb_id}, Resolution: {self.resolution} Å"
1822        return s1
1823
1824    def repr_ss_coords(self) -> str:
1825        """
1826        Representation for Disulfide coordinates
1827        """
1828        s2 = f"\nProximal Coordinates:\n   N: {self.n_prox}\n   Cα: {self.ca_prox}\n   C: {self.c_prox}\n   O: {self.o_prox}\n   Cβ: {self.cb_prox}\n   Sγ: {self.sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1829        s3 = f"Distal Coordinates:\n   N: {self.n_dist}\n   Cα: {self.ca_dist}\n   C: {self.c_dist}\n   O: {self.o_dist}\n   Cβ: {self.cb_dist}\n   Sγ: {self.sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n\n"
1830        stot = f"{s2} {s3}"
1831        return stot
1832
1833    def repr_ss_conformation(self) -> str:
1834        """
1835        Representation for Disulfide conformation
1836        """
1837        s4 = f"Χ1-Χ5: {self.chi1:.2f}°, {self.chi2:.2f}°, {self.chi3:.2f}°, {self.chi4:.2f}° {self.chi5:.2f}°, {self.rho:.2f}°, {self.energy:.2f} kcal/mol"
1838        stot = f"{s4}"
1839        return stot
1840
1841    def repr_ss_local_coords(self) -> str:
1842        """
1843        Representation for the Disulfide internal coordinates.
1844        """
1845        s2i = f"Proximal Internal Coords:\n   N: {self._n_prox}\n   Cα: {self._ca_prox}\n   C: {self._c_prox}\n   O: {self._o_prox}\n   Cβ: {self._cb_prox}\n   Sγ: {self._sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1846        s3i = f"Distal Internal Coords:\n   N: {self._n_dist}\n   Cα: {self._ca_dist}\n   C: {self._c_dist}\n   O: {self._o_dist}\n   Cβ: {self._cb_dist}\n   Sγ: {self._sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n"
1847        stot = f"{s2i}{s3i}"
1848        return stot
1849
1850    def repr_ss_residue_ids(self) -> str:
1851        """
1852        Representation for Disulfide chain IDs
1853        """
1854        return f"Proximal Residue fullID: <{self.proximal}> Distal Residue fullID: <{self.distal}>"
1855
1856    def repr_ss_ca_dist(self) -> str:
1857        """
1858        Representation for Disulfide Ca distance
1859        """
1860        s1 = f"Cα Distance: {self.ca_distance:.2f} Å"
1861        return s1
1862
1863    def repr_ss_cb_dist(self) -> str:
1864        """
1865        Representation for Disulfide Ca distance
1866        """
1867        s1 = f"Cβ Distance: {self.cb_distance:.2f} Å"
1868        return s1
1869
1870    def repr_ss_sg_dist(self) -> str:
1871        """
1872        Representation for Disulfide Ca distance
1873        """
1874        s1 = f"Sγ Distance: {self.sg_distance:.2f} Å"
1875        return s1
1876
1877    def repr_ss_torsion_length(self) -> str:
1878        """
1879        Representation for Disulfide torsion length
1880        """
1881        s1 = f"Torsion length: {self.torsion_length:.2f} deg"
1882        return s1
1883
1884    def repr_ss_secondary_structure(self) -> str:
1885        """
1886        Representation for Disulfide secondary structure
1887        """
1888        s1 = f"Proximal secondary: {self.proximal_secondary} Distal secondary: {self.distal_secondary}"
1889        return s1
1890
1891    def repr_phipsi(self) -> str:
1892        """
1893        Representation for Disulfide phi psi angles
1894        """
1895        s1 = f"PhiProx: {self.phiprox:.2f}° PsiProx: {self.psiprox:.2f}°, PhiDist: {self.phidist:.2f}° PsiDist: {self.psidist:.2f}°"
1896        return s1
1897
1898    def repr_all(self) -> str:
1899        """
1900        Return a string representation for all Disulfide information
1901        contained in self.
1902        """
1903
1904        s1 = self.repr_ss_info() + "\n"
1905        s2 = self.repr_ss_coords()
1906        s3 = self.repr_ss_local_coords()
1907        s4 = self.repr_ss_conformation()
1908        s4b = self.repr_phipsi()
1909        s6 = self.repr_ss_ca_dist()
1910        s8 = self.repr_ss_cb_dist()
1911        s7 = self.repr_ss_torsion_length()
1912        s9 = self.repr_ss_secondary_structure()
1913
1914        res = f"{s1} {s2} {s3} {s4} {s4b} {s6} {s7} {s8} {s9}>"
1915        return res
1916
1917    def repr_compact(self) -> str:
1918        """
1919        Return a compact representation of the Disulfide object
1920        :return: string
1921        """
1922        return f"{self.repr_ss_info()} {self.repr_ss_conformation()}"
1923
1924    def repr_conformation(self) -> str:
1925        """
1926        Return a string representation of the Disulfide object's conformation.
1927        :return: string
1928        """
1929        return f"{self.repr_ss_conformation()}"
1930
1931    def repr_coords(self) -> str:
1932        """
1933        Return a string representation of the Disulfide object's coordinates.
1934        :return: string
1935        """
1936        return f"{self.repr_ss_coords()}"
1937
1938    def repr_internal_coords(self) -> str:
1939        """
1940        Return a string representation of the Disulfide object's internal coordinaes.
1941        :return: string
1942        """
1943        return f"{self.repr_ss_local_coords()}"
1944
1945    def repr_chain_ids(self) -> str:
1946        """
1947        Return a string representation of the Disulfide object's chain ids.
1948        :return: string
1949        """
1950        return f"{self.repr_ss_residue_ids()}"
1951
1952    @property
1953    def rho(self) -> float:
1954        """
1955        Return the dihedral angle rho for the Disulfide.
1956        """
1957        return self._compute_rho()
1958
1959    @rho.setter
1960    def rho(self, value: float):
1961        """
1962        Set the dihedral angle rho for the Disulfide.
1963        """
1964        self._rho = value
1965
1966    def _compute_rho(self) -> float:
1967        """
1968        Compute the dihedral angle rho for a Disulfide object and
1969        sets the internal state of the object.
1970        """
1971
1972        v1 = self.n_prox - self.ca_prox
1973        v2 = self.c_prox - self.ca_prox
1974        n1 = np.cross(v2.get_array(), v1.get_array())
1975
1976        v4 = self.n_dist - self.ca_dist
1977        v3 = self.c_dist - self.ca_dist
1978        n2 = np.cross(v4.get_array(), v3.get_array())
1979        self._rho = calc_dihedral(
1980            Vector3D(n1), self.ca_prox, self.ca_dist, Vector3D(n2)
1981        )
1982        return self._rho
1983
1984    def reset(self) -> None:
1985        """
1986        Resets the disulfide object to its initial state. All distances,
1987        angles and positions are reset. The name is unchanged.
1988        """
1989        self.__init__(self)
1990
1991    def same_chains(self) -> bool:
1992        """
1993        Function checks if the Disulfide is cross-chain or not.
1994
1995        Returns
1996        -------
1997        bool \n
1998            True if the proximal and distal residues are on the same chains,
1999            False otherwise.
2000        """
2001
2002        (prox, dist) = self.get_chains()
2003        return prox == dist
2004
2005    def screenshot(
2006        self,
2007        single=True,
2008        style="sb",
2009        fname="ssbond.png",
2010        verbose=False,
2011        shadows=False,
2012        light="Auto",
2013    ) -> None:
2014        """
2015        Create and save a screenshot of the Disulfide in the given style
2016        and filename
2017
2018        :param single: Display a single vs panel view, defaults to True
2019        :param style: Rendering style, one of:
2020        * 'sb' - split bonds
2021        * 'bs' - ball and stick
2022        * 'cpk' - CPK style
2023        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2024        * 'plain' - boring single color,
2025        :param fname: output filename,, defaults to 'ssbond.png'
2026        :param verbose: Verbosit, defaults to False
2027        :param shadows: Enable shadows, defaults to False
2028        """
2029
2030        src = self.pdb_id
2031        enrg = self.energy
2032        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Cβ: {self.cb_distance:.2f} Å, Sγ: {self.sg_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
2033
2034        set_pyvista_theme(light)
2035
2036        if verbose:
2037            _logger.info("Rendering screenshot to file {fname}")
2038
2039        if single:
2040            pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
2041            pl.add_title(title=title, font_size=FONTSIZE)
2042            pl.enable_anti_aliasing("msaa")
2043            self._render(
2044                pl,
2045                style=style,
2046            )
2047            pl.reset_camera()
2048            if shadows:
2049                pl.enable_shadows()
2050
2051            pl.show(auto_close=False)  # allows for manipulation
2052            # Take the screenshot after ensuring the plotter is still active
2053            try:
2054                pl.screenshot(fname)
2055            except RuntimeError as e:
2056                _logger.error(f"Error saving screenshot: {e}")
2057
2058        else:
2059            pl = pv.Plotter(window_size=WINSIZE, shape=(2, 2), off_screen=False)
2060            pl.subplot(0, 0)
2061
2062            pl.add_title(title=title, font_size=FONTSIZE)
2063            pl.enable_anti_aliasing("msaa")
2064
2065            # pl.add_camera_orientation_widget()
2066            self._render(
2067                pl,
2068                style="cpk",
2069            )
2070
2071            pl.subplot(0, 1)
2072            pl.add_title(title=title, font_size=FONTSIZE)
2073            self._render(
2074                pl,
2075                style="pd",
2076            )
2077
2078            pl.subplot(1, 0)
2079            pl.add_title(title=title, font_size=FONTSIZE)
2080            self._render(
2081                pl,
2082                style="bs",
2083            )
2084
2085            pl.subplot(1, 1)
2086            pl.add_title(title=title, font_size=FONTSIZE)
2087            self._render(
2088                pl,
2089                style="sb",
2090            )
2091
2092            pl.link_views()
2093            pl.reset_camera()
2094            if shadows:
2095                pl.enable_shadows()
2096
2097            # Take the screenshot after ensuring the plotter is still active
2098            pl.show(auto_close=False)  # allows for manipulation
2099
2100            try:
2101                pl.screenshot(fname)
2102            except RuntimeError as e:
2103                _logger.error(f"Error saving screenshot: {e}")
2104
2105        if verbose:
2106            print(f"Screenshot saved as: {fname}")
2107
2108    def save_meshes_as_stl(self, meshes, filename) -> None:
2109        """Save a list of meshes as a single STL file.
2110
2111        Args:
2112            meshes (list): List of pyvista mesh objects to save.
2113            filename (str): Path to save the STL file to.
2114        """
2115        merged_mesh = pv.UnstructuredGrid()
2116        for mesh in meshes:
2117            merged_mesh += mesh
2118        merged_mesh.save(filename)
2119
2120    def export(self, style="sb", verbose=True, fname="ssbond_plt") -> None:
2121        """
2122        Create and save a screenshot of the Disulfide in the given style and filename.
2123
2124        :param single: Display a single vs panel view, defaults to True
2125        :param style: Rendering style, one of:
2126        * 'sb' - split bonds
2127        * 'bs' - ball and stick
2128        * 'cpk' - CPK style
2129        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2130        * 'plain' - boring single color,
2131
2132        :param fname: output filename,, defaults to 'ssbond.stl'
2133        :param verbose: Verbosit, defaults to False
2134        """
2135
2136        if verbose:
2137            print(f"-> screenshot(): Rendering screenshot to file {fname}")
2138
2139        pl = pv.PolyData()
2140
2141        self._plot(
2142            pl,
2143            style=style,
2144        )
2145
2146        self.save_meshes_as_stl(pl, fname)
2147
2148        return
2149
2150    def set_positions(
2151        self,
2152        n_prox: Vector3D,
2153        ca_prox: Vector3D,
2154        c_prox: Vector3D,
2155        o_prox: Vector3D,
2156        cb_prox: Vector3D,
2157        sg_prox: Vector3D,
2158        n_dist: Vector3D,
2159        ca_dist: Vector3D,
2160        c_dist: Vector3D,
2161        o_dist: Vector3D,
2162        cb_dist: Vector3D,
2163        sg_dist: Vector3D,
2164        c_prev_prox: Vector3D,
2165        n_next_prox: Vector3D,
2166        c_prev_dist: Vector3D,
2167        n_next_dist: Vector3D,
2168    ) -> None:
2169        """
2170        Set the atomic coordinates for all atoms in the Disulfide object.
2171
2172        :param n_prox: Proximal N position
2173        :param ca_prox: Proximal Cα position
2174        :param c_prox: Proximal C' position
2175        :param o_prox: Proximal O position
2176        :param cb_prox: Proximal Cβ position
2177        :param sg_prox: Proximal Sγ position
2178        :param n_dist: Distal N position
2179        :param ca_dist: Distal Cα position
2180        :param c_dist: Distal C' position
2181        :param o_dist: Distal O position
2182        :param cb_dist: Distal Cβ position
2183        :param sg_dist: Distal Sγ position
2184        :param c_prev_prox: Proximal previous C'
2185        :param n_next_prox: Proximal next N
2186        :param c_prev_dist: Distal previous C'
2187        :param n_next_dist: Distal next N
2188        """
2189
2190        # deep copy
2191        self.n_prox = n_prox.copy()
2192        self.ca_prox = ca_prox.copy()
2193        self.c_prox = c_prox.copy()
2194        self.o_prox = o_prox.copy()
2195        self.cb_prox = cb_prox.copy()
2196        self.sg_prox = sg_prox.copy()
2197        self.sg_dist = sg_dist.copy()
2198        self.cb_dist = cb_dist.copy()
2199        self.ca_dist = ca_dist.copy()
2200        self.n_dist = n_dist.copy()
2201        self.c_dist = c_dist.copy()
2202        self.o_dist = o_dist.copy()
2203
2204        self.c_prev_prox = c_prev_prox.copy()
2205        self.n_next_prox = n_next_prox.copy()
2206        self.c_prev_dist = c_prev_dist.copy()
2207        self.n_next_dist = n_next_dist.copy()
2208        self._compute_local_coords()
2209
2210    def set_name(self, namestr="Disulfide") -> None:
2211        """
2212        Set the Disulfide's name.
2213
2214        :param namestr: Name, by default "Disulfide"
2215        """
2216        self.name = namestr
2217
2218    def set_resnum(self, proximal: int, distal: int) -> None:
2219        """
2220        Set the proximal and residue numbers for the Disulfide.
2221
2222        :param proximal: Proximal residue number
2223        :param distal: Distal residue number
2224        """
2225        self.proximal = proximal
2226        self.distal = distal
2227
2228    def _compute_torsion_length(self) -> float:
2229        """
2230        Compute the 5D Euclidean length of the Disulfide object. Update the disulfide internal state.
2231
2232        :return: Torsion length (Degrees)
2233        """
2234        # Use numpy array to compute element-wise square
2235        tors2 = np.square(self.torsion_array)
2236
2237        # Compute the sum of squares using numpy's sum function
2238        dist = math.sqrt(np.sum(tors2))
2239
2240        # Update the internal state
2241        self.torsion_length = dist
2242
2243        return dist
2244
2245    def torsion_distance(self, other) -> float:
2246        """
2247        Calculate the 5D Euclidean distance between `self` and another Disulfide
2248        object. This is used to compare Disulfide Bond torsion angles to
2249        determine their torsional similarity via a 5-Dimensional Euclidean distance metric.
2250
2251        :param other: Comparison Disulfide
2252        :raises ProteusPyWarning: Warning if `other` is not a Disulfide object
2253        :return: Euclidean distance (Degrees) between `self` and `other`.
2254        """
2255
2256        # Check length of torsion arrays
2257        if len(self.torsion_array) != 5 or len(other.torsion_array) != 5:
2258            raise ProteusPyWarning(
2259                "--> Torsion_Distance() requires vectors of length 5!"
2260            )
2261
2262        # Convert to numpy arrays
2263        p1 = np.array(self.torsion_array)
2264        p2 = np.array(other.torsion_array)
2265
2266        # Compute the difference and handle angle wrapping
2267        diff = np.abs(p1 - p2)
2268        diff = np.where(diff > 180, 360 - diff, diff)
2269
2270        # Compute the 5D Euclidean distance using numpy's linalg.norm function
2271        dist = np.linalg.norm(diff)
2272
2273        return dist
2274
2275    def torsion_neighbors(self, others, cutoff) -> DisulfideList:
2276        """
2277        Return a list of Disulfides within the angular cutoff in the others list.
2278        This routine is used to find Disulfides having the same torsion length
2279        within the others list. This is used to find families of Disulfides with
2280        similar conformations. Assumes self is properly initialized.
2281
2282        *NB* The routine will not distinguish between +/-
2283        dihedral angles. *i.e.* [-60, -60, -90, -60, -60] would have the same
2284        torsion length as [60, 60, 90, 60, 60], two clearly different structures.
2285
2286        :param others: ```DisulfideList``` to search
2287        :param cutoff: Dihedral angle degree cutoff
2288        :return: DisulfideList within the cutoff
2289
2290        Example:
2291        In this example we load the disulfide database subset, find the disulfides with
2292        the lowest and highest energies, and then find the nearest conformational neighbors.
2293        Finally, we display the neighbors overlaid against a common reference frame.
2294
2295        >>> import proteusPy as pp
2296        >>> _theme = pp.set_pyvista_theme("auto")
2297        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
2298        >>> ss_list = pp.DisulfideList([], 'tmp')
2299
2300        We point to the complete list to search for lowest and highest energies.
2301        >>> sslist = PDB_SS.SSList
2302        >>> ssmin_enrg, ssmax_enrg = PDB_SS.SSList.minmax_energy
2303
2304        Make an empty list and find the nearest neighbors within 10 degrees avg RMS in
2305        sidechain dihedral angle space.
2306
2307        >>> low_energy_neighbors = DisulfideList([],'Neighbors')
2308        >>> low_energy_neighbors = ssmin_enrg.torsion_neighbors(sslist, 8)
2309
2310        Display the number found, and then display them overlaid onto their common reference frame.
2311
2312        >>> tot = low_energy_neighbors.length
2313        >>> print(f'Neighbors: {tot}')
2314        Neighbors: 8
2315        >>> low_energy_neighbors.display_overlay(light="auto")
2316
2317        """
2318
2319        res = [ss for ss in others if self.torsion_distance(ss) <= cutoff]
2320        return DisulfideList(res, "neighbors")
2321
2322    def translate(self, translation_vector: Vector3D):
2323        """Translate the Disulfide object by the given vector."""
2324
2325        self.n_prox += translation_vector
2326        self.ca_prox += translation_vector
2327        self.c_prox += translation_vector
2328        self.o_prox += translation_vector
2329        self.cb_prox += translation_vector
2330        self.sg_prox += translation_vector
2331        self.sg_dist += translation_vector
2332        self.cb_dist += translation_vector
2333        self.ca_dist += translation_vector
2334        self.n_dist += translation_vector
2335        self.c_dist += translation_vector
2336        self.o_dist += translation_vector
2337
2338        self.c_prev_prox += translation_vector
2339        self.n_next_prox += translation_vector
2340        self.c_prev_dist += translation_vector
2341        self.n_next_dist += translation_vector
2342        self._compute_local_coords()

This class provides a Python object and methods representing a physical disulfide bond either extracted from the RCSB protein databank or built using the proteusPy.Turtle3D class. The disulfide bond is an important intramolecular stabilizing structural element and is characterized by:

  • Atomic coordinates for the atoms N, Cα, Cβ, C', Sγ for both residues. These are stored as both raw atomic coordinates as read from the RCSB file and internal local coordinates.
  • The dihedral angles Χ1 - Χ5 for the disulfide bond
  • A name, by default {pdb_id}{prox_resnumb}{prox_chain}_{distal_resnum}{distal_chain}
  • Proximal residue number
  • Distal residue number
  • Approximate bond torsional energy (kcal/mol):

$$ E_{kcal/mol} \approx 2.0 * cos(3.0 * \chi_{1}) + cos(3.0 * \chi_{5}) + cos(3.0 * \chi_{2}) + $$ $$ cos(3.0 * \chi_{4}) + 3.5 * cos(2.0 * \chi_{3}) + 0.6 * cos(3.0 * \chi_{3}) + 10.1 $$

The equation embodies the typical 3-fold rotation barriers associated with single bonds, (Χ1, Χ5, Χ2, Χ4) and a high 2-fold barrier for Χ3, resulting from the partial double bond character of the S-S bond. This property leads to two major disulfide families, characterized by the sign of Χ3. Left-handed disulfides have Χ3 < 0° and right-handed disulfides have Χ3 > 0°. Within this breakdown there are numerous subfamilies, broadly known as the hook, spiral and staple. These are under characgterization.

  • Euclidean length of the dihedral angles (degrees) defined as: $$\sqrt(\chi_{1}^{2} + \chi_{2}^{2} + \chi_{3}^{2} + \chi_{4}^{2} + \chi_{5}^{2})$$
  • Cα - Cα distance (Å)
  • Cβ - Cβ distance (Å)
  • Sγ - Sγ distance (Å)
  • The previous C' and next N for both the proximal and distal residues. These are needed to calculate the backbone dihedral angles Φ and Ψ.
  • Backbone dihedral angles Φ and Ψ, when possible. Not all structures are complete and in those cases the atoms needed may be undefined. In this case the Φ and Ψ angles are set to -180°.

The class also provides a rendering capabilities using the excellent PyVista library, and can display disulfides interactively in a variety of display styles:

  • 'sb' - Split Bonds style - bonds colored by their atom type
  • 'bs' - Ball and Stick style - split bond coloring with small atoms
  • 'pd' - Proximal/Distal style - bonds colored Red for proximal residue and Green for the distal residue.
  • 'cpk' - CPK style rendering, colored by atom type:
    • Carbon - Grey
    • Nitrogen - Blue
    • Sulfur - Yellow
    • Oxygen - Red
    • Hydrogen - White

Individual renderings can be saved to a file, and animations created.

Disulfide( name: str = 'SSBOND', proximal: int = -1, distal: int = -1, proximal_chain: str = 'A', distal_chain: str = 'A', pdb_id: str = '1egs', quiet: bool = True, torsions: list = None)
144    def __init__(
145        self,
146        name: str = "SSBOND",
147        proximal: int = -1,
148        distal: int = -1,
149        proximal_chain: str = "A",
150        distal_chain: str = "A",
151        pdb_id: str = "1egs",
152        quiet: bool = True,
153        torsions: list = None,
154    ) -> None:
155        """
156        Initialize the class to defined internal values. If torsions are provided, the
157        Disulfide object is built using the torsions and initialized.
158
159        :param name: Disulfide name, by default "SSBOND"
160        :param proximal: Proximal residue number, by default -1
161        :param distal: Distal residue number, by default -1
162        :param proximal_chain: Chain identifier for the proximal residue, by default "A"
163        :param distal_chain: Chain identifier for the distal residue, by default "A"
164        :param pdb_id: PDB identifier, by default "1egs"
165        :param quiet: If True, suppress output, by default True
166        :param torsions: List of torsion angles, by default None
167        """
168        self.name = name
169        self.proximal = proximal
170        self.distal = distal
171        self.energy = _FLOAT_INIT
172        self.proximal_chain = proximal_chain
173        self.distal_chain = distal_chain
174        self.pdb_id = pdb_id
175        self.quiet = quiet
176        self.proximal_secondary = "Nosecondary"
177        self.distal_secondary = "Nosecondary"
178        self.ca_distance = _FLOAT_INIT
179        self.cb_distance = _FLOAT_INIT
180        self.sg_distance = _FLOAT_INIT
181        self.torsion_array = np.array(
182            (_ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT, _ANG_INIT)
183        )
184        self.phiprox = _ANG_INIT
185        self.psiprox = _ANG_INIT
186        self.phidist = _ANG_INIT
187        self.psidist = _ANG_INIT
188
189        # global coordinates for the Disulfide, typically as
190        # returned from the PDB file
191
192        self.n_prox = ORIGIN
193        self.ca_prox = ORIGIN
194        self.c_prox = ORIGIN
195        self.o_prox = ORIGIN
196        self.cb_prox = ORIGIN
197        self.sg_prox = ORIGIN
198        self.sg_dist = ORIGIN
199        self.cb_dist = ORIGIN
200        self.ca_dist = ORIGIN
201        self.n_dist = ORIGIN
202        self.c_dist = ORIGIN
203        self.o_dist = ORIGIN
204
205        # set when we can't find previous or next prox or distal
206        # C' or N atoms.
207        self.missing_atoms = False
208        self.modelled = False
209        self.resolution = -1.0
210
211        # need these to calculate backbone dihedral angles
212        self.c_prev_prox = ORIGIN
213        self.n_next_prox = ORIGIN
214        self.c_prev_dist = ORIGIN
215        self.n_next_dist = ORIGIN
216
217        # local coordinates for the Disulfide, computed using the Turtle3D in
218        # Orientation #1. these are generally private.
219
220        self._n_prox = ORIGIN
221        self._ca_prox = ORIGIN
222        self._c_prox = ORIGIN
223        self._o_prox = ORIGIN
224        self._cb_prox = ORIGIN
225        self._sg_prox = ORIGIN
226        self._sg_dist = ORIGIN
227        self._cb_dist = ORIGIN
228        self._ca_dist = ORIGIN
229        self._n_dist = ORIGIN
230        self._c_dist = ORIGIN
231        self._o_dist = ORIGIN
232
233        # need these to calculate backbone dihedral angles
234        self._c_prev_prox = ORIGIN
235        self._n_next_prox = ORIGIN
236        self._c_prev_dist = ORIGIN
237        self._n_next_dist = ORIGIN
238
239        # Dihedral angles for the disulfide bond itself, set to _ANG_INIT
240        self.chi1 = _ANG_INIT
241        self.chi2 = _ANG_INIT
242        self.chi3 = _ANG_INIT
243        self.chi4 = _ANG_INIT
244        self.chi5 = _ANG_INIT
245        self._rho = _ANG_INIT  # new dihedral angle: Nprox - Ca_prox - Ca_dist - N_dist
246
247        self.torsion_length = _FLOAT_INIT
248
249        if torsions is not None and len(torsions) == 5:
250            # computes energy, torsion length and rho
251            self.dihedrals = torsions
252            self.build_yourself()

Initialize the class to defined internal values. If torsions are provided, the Disulfide object is built using the torsions and initialized.

Parameters
  • name: Disulfide name, by default "SSBOND"
  • proximal: Proximal residue number, by default -1
  • distal: Distal residue number, by default -1
  • proximal_chain: Chain identifier for the proximal residue, by default "A"
  • distal_chain: Chain identifier for the distal residue, by default "A"
  • pdb_id: PDB identifier, by default "1egs"
  • quiet: If True, suppress output, by default True
  • torsions: List of torsion angles, by default None
name
proximal
distal
energy
proximal_chain
distal_chain
pdb_id
quiet
proximal_secondary
distal_secondary
ca_distance
cb_distance
sg_distance
torsion_array
phiprox
psiprox
phidist
psidist
n_prox
ca_prox
c_prox
o_prox
cb_prox
sg_prox
sg_dist
cb_dist
ca_dist
n_dist
c_dist
o_dist
missing_atoms
modelled
resolution
c_prev_prox
n_next_prox
c_prev_dist
n_next_dist
chi1
chi2
chi3
chi4
chi5
torsion_length
binary_class_string
747    @property
748    def binary_class_string(self):
749        """
750        Return a binary string representation of the disulfide bond class.
751        """
752        return DisulfideClassManager.class_string_from_dihedral(
753            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=2
754        )

Return a binary string representation of the disulfide bond class.

octant_class_string
756    @property
757    def octant_class_string(self):
758        """
759        Return the octant string representation of the disulfide bond class.
760        """
761        return DisulfideClassManager.class_string_from_dihedral(
762            self.chi1, self.chi2, self.chi3, self.chi4, self.chi5, base=8
763        )

Return the octant string representation of the disulfide bond class.

bond_angle_ideality
765    @property
766    def bond_angle_ideality(self):
767        """
768        Calculate all bond angles for a disulfide bond and compare them to idealized angles.
769
770        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
771            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
772        :return: RMS difference between calculated bond angles and idealized bond angles.
773        :rtype: float
774        """
775
776        atom_coordinates = self.coords_array
777        verbose = not self.quiet
778        if verbose:
779            _logger.setLevel(logging.INFO)
780
781        idealized_angles = {
782            ("N1", "CA1", "C1"): 111.0,
783            ("N1", "CA1", "CB1"): 108.5,
784            ("CA1", "CB1", "SG1"): 112.8,
785            ("CB1", "SG1", "SG2"): 103.8,  # This angle is for the disulfide bond itself
786            ("SG1", "SG2", "CB2"): 103.8,  # This angle is for the disulfide bond itself
787            ("SG2", "CB2", "CA2"): 112.8,
788            ("CB2", "CA2", "N2"): 108.5,
789            ("N2", "CA2", "C2"): 111.0,
790        }
791
792        # List of triplets for which we need to calculate bond angles
793        # I am omitting the proximal and distal backbone angle N, Ca, C
794        # to focus on the disulfide bond angles themselves.
795        angle_triplets = [
796            ("N1", "CA1", "C1"),
797            ("N1", "CA1", "CB1"),
798            ("CA1", "CB1", "SG1"),
799            ("CB1", "SG1", "SG2"),
800            ("SG1", "SG2", "CB2"),
801            ("SG2", "CB2", "CA2"),
802            ("CB2", "CA2", "N2"),
803            ("N2", "CA2", "C2"),
804        ]
805
806        atom_indices = {
807            "N1": 0,
808            "CA1": 1,
809            "C1": 2,
810            "CB1": 4,
811            "SG1": 5,
812            "SG2": 11,
813            "CB2": 10,
814            "CA2": 7,
815            "N2": 6,
816            "C2": 8,
817        }
818
819        calculated_angles = []
820        for triplet in angle_triplets:
821            a = atom_coordinates[atom_indices[triplet[0]]]
822            b = atom_coordinates[atom_indices[triplet[1]]]
823            c = atom_coordinates[atom_indices[triplet[2]]]
824            ideal = idealized_angles[triplet]
825            try:
826                angle = calculate_bond_angle(a, b, c)
827            except ValueError as e:
828                print(f"Error calculating angle for atoms {triplet}: {e}")
829                return None
830            calculated_angles.append(angle)
831            if verbose:
832                _logger.info(
833                    f"Calculated angle for atoms {triplet}: {angle:.2f}, Ideal angle: {ideal:.2f}"
834                )
835
836        # Convert idealized angles to a list
837        idealized_angles_list = [
838            idealized_angles[triplet] for triplet in angle_triplets
839        ]
840
841        # Calculate RMS difference
842        rms_diff = rms_difference(
843            np.array(calculated_angles), np.array(idealized_angles_list)
844        )
845
846        if verbose:
847            _logger.info(f"RMS bond angle deviation:, {rms_diff:.2f}")
848
849        return rms_diff

Calculate all bond angles for a disulfide bond and compare them to idealized angles.

Parameters
  • np.ndarray atom_coordinates: Array containing coordinates of atoms in the order: N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
Returns

RMS difference between calculated bond angles and idealized bond angles.

bond_length_ideality
851    @property
852    def bond_length_ideality(self):
853        """
854        Calculate bond lengths for a disulfide bond and compare them to idealized lengths.
855
856        :param np.ndarray atom_coordinates: Array containing coordinates of atoms in the order:
857            N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
858        :return: RMS difference between calculated bond lengths and idealized bond lengths.
859        :rtype: float
860        """
861
862        atom_coordinates = self.coords_array
863        verbose = not self.quiet
864        if verbose:
865            _logger.setLevel(logging.INFO)
866
867        idealized_bonds = {
868            ("N1", "CA1"): 1.46,
869            ("CA1", "C1"): 1.52,
870            ("CA1", "CB1"): 1.52,
871            ("CB1", "SG1"): 1.86,
872            ("SG1", "SG2"): 2.044,  # This angle is for the disulfide bond itself
873            ("SG2", "CB2"): 1.86,
874            ("CB2", "CA2"): 1.52,
875            ("CA2", "C2"): 1.52,
876            ("N2", "CA2"): 1.46,
877        }
878
879        # List of triplets for which we need to calculate bond angles
880        # I am omitting the proximal and distal backbone angle N, Ca, C
881        # to focus on the disulfide bond angles themselves.
882        distance_pairs = [
883            ("N1", "CA1"),
884            ("CA1", "C1"),
885            ("CA1", "CB1"),
886            ("CB1", "SG1"),
887            ("SG1", "SG2"),  # This angle is for the disulfide bond itself
888            ("SG2", "CB2"),
889            ("CB2", "CA2"),
890            ("CA2", "C2"),
891            ("N2", "CA2"),
892        ]
893
894        atom_indices = {
895            "N1": 0,
896            "CA1": 1,
897            "C1": 2,
898            "CB1": 4,
899            "SG1": 5,
900            "SG2": 11,
901            "CB2": 10,
902            "CA2": 7,
903            "N2": 6,
904            "C2": 8,
905        }
906
907        calculated_distances = []
908        for pair in distance_pairs:
909            a = atom_coordinates[atom_indices[pair[0]]]
910            b = atom_coordinates[atom_indices[pair[1]]]
911            ideal = idealized_bonds[pair]
912            try:
913                distance = math.dist(a, b)
914            except ValueError as e:
915                _logger.error(f"Error calculating bond length for atoms {pair}: {e}")
916                return None
917            calculated_distances.append(distance)
918            if verbose:
919                _logger.info(
920                    f"Calculated distance for atoms {pair}: {distance:.2f}A, Ideal distance: {ideal:.2f}A"
921                )
922
923        # Convert idealized distances to a list
924        idealized_distance_list = [idealized_bonds[pair] for pair in distance_pairs]
925
926        # Calculate RMS difference
927        rms_diff = rms_difference(
928            np.array(calculated_distances), np.array(idealized_distance_list)
929        )
930
931        if verbose:
932            _logger.info(
933                f"RMS distance deviation from ideality for SS atoms: {rms_diff:.2f}"
934            )
935
936            # Reset logger level
937            _logger.setLevel(logging.WARNING)
938
939        return rms_diff

Calculate bond lengths for a disulfide bond and compare them to idealized lengths.

Parameters
  • np.ndarray atom_coordinates: Array containing coordinates of atoms in the order: N1, CA1, C1, O1, CB1, SG1, N2, CA2, C2, O2, CB2, SG2
Returns

RMS difference between calculated bond lengths and idealized bond lengths.

internal_coords_array
941    @property
942    def internal_coords_array(self):
943        """
944        Return an array of internal coordinates for the disulfide bond.
945
946        This function collects the coordinates of the backbone atoms involved in the
947        disulfide bond and returns them as a numpy array.
948
949        :param self: The instance of the Disulfide class.
950        :type self: Disulfide
951        :return: A numpy array containing the coordinates of the atoms.
952        :rtype: np.ndarray
953        """
954        coords = []
955        coords.append(self._n_prox.get_array())
956        coords.append(self._ca_prox.get_array())
957        coords.append(self._c_prox.get_array())
958        coords.append(self._o_prox.get_array())
959        coords.append(self._cb_prox.get_array())
960        coords.append(self._sg_prox.get_array())
961        coords.append(self._n_dist.get_array())
962        coords.append(self._ca_dist.get_array())
963        coords.append(self._c_dist.get_array())
964        coords.append(self._o_dist.get_array())
965        coords.append(self._cb_dist.get_array())
966        coords.append(self._sg_dist.get_array())
967
968        return np.array(coords)

Return an array of internal coordinates for the disulfide bond.

This function collects the coordinates of the backbone atoms involved in the disulfide bond and returns them as a numpy array.

Parameters
  • self: The instance of the Disulfide class.
Returns

A numpy array containing the coordinates of the atoms.

coords_array
970    @property
971    def coords_array(self):
972        """
973        Return an array of coordinates for the disulfide bond.
974
975        This function collects the coordinates of backbone atoms involved in the
976        disulfide bond and returns them as a numpy array.
977
978        :param self: The instance of the Disulfide class.
979        :type self: Disulfide
980        :return: A numpy array containing the coordinates of the atoms.
981        :rtype: np.ndarray
982        """
983        coords = []
984        coords.append(self.n_prox.get_array())
985        coords.append(self.ca_prox.get_array())
986        coords.append(self.c_prox.get_array())
987        coords.append(self.o_prox.get_array())
988        coords.append(self.cb_prox.get_array())
989        coords.append(self.sg_prox.get_array())
990        coords.append(self.n_dist.get_array())
991        coords.append(self.ca_dist.get_array())
992        coords.append(self.c_dist.get_array())
993        coords.append(self.o_dist.get_array())
994        coords.append(self.cb_dist.get_array())
995        coords.append(self.sg_dist.get_array())
996
997        return np.array(coords)

Return an array of coordinates for the disulfide bond.

This function collects the coordinates of backbone atoms involved in the disulfide bond and returns them as a numpy array.

Parameters
  • self: The instance of the Disulfide class.
Returns

A numpy array containing the coordinates of the atoms.

dihedrals: list
 999    @property
1000    def dihedrals(self) -> list:
1001        """
1002        Return a list containing the dihedral angles for the disulfide.
1003
1004        """
1005        return [self.chi1, self.chi2, self.chi3, self.chi4, self.chi5]

Return a list containing the dihedral angles for the disulfide.

def bounding_box(self) -> <built-in function array>:
1025    def bounding_box(self) -> np.array:
1026        """
1027        Return the bounding box array for the given disulfide.
1028
1029        :return: np.array
1030            Array containing the min, max for X, Y, and Z respectively.
1031            Does not currently take the atom's radius into account.
1032        """
1033        coords = self.internal_coords
1034
1035        xmin, ymin, zmin = coords.min(axis=0)
1036        xmax, ymax, zmax = coords.max(axis=0)
1037
1038        res = np.array([[xmin, xmax], [ymin, ymax], [zmin, zmax]])
1039
1040        return res

Return the bounding box array for the given disulfide.

Returns

np.array Array containing the min, max for X, Y, and Z respectively. Does not currently take the atom's radius into account.

def build_yourself(self) -> None:
1042    def build_yourself(self) -> None:
1043        """
1044        Build a model Disulfide based its internal dihedral state
1045        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1046        Cb, with N on left), builds disulfide, and updates the object's internal
1047        coordinates. It also adds the distal protein backbone,
1048        and computes the disulfide conformational energy.
1049        """
1050        self.build_model(self.chi1, self.chi2, self.chi3, self.chi4, self.chi5)

Build a model Disulfide based its internal dihedral state Routine assumes turtle is in orientation #1 (at Ca, headed toward Cb, with N on left), builds disulfide, and updates the object's internal coordinates. It also adds the distal protein backbone, and computes the disulfide conformational energy.

def build_model( self, chi1: float, chi2: float, chi3: float, chi4: float, chi5: float) -> None:
1052    def build_model(
1053        self, chi1: float, chi2: float, chi3: float, chi4: float, chi5: float
1054    ) -> None:
1055        """
1056        Build a model Disulfide based on the input dihedral angles.
1057        Routine assumes turtle is in orientation #1 (at Ca, headed toward
1058        Cb, with N on left), builds disulfide, and updates the object's internal
1059        coordinates. It also adds the distal protein backbone,
1060        and computes the disulfide conformational energy.
1061
1062        :param chi1: Chi1 (degrees)
1063        :param chi2: Chi2 (degrees)
1064        :param chi3: Chi3 (degrees)
1065        :param chi4: Chi4 (degrees)
1066        :param chi5: Chi5 (degrees)
1067
1068        Example:
1069        >>> import proteusPy as pp
1070        >>> modss = pp.Disulfide('model')
1071        >>> modss.build_model(-60, -60, -90, -60, -60)
1072        >>> modss.display(style='sb', light="auto")
1073        """
1074
1075        self.dihedrals = [chi1, chi2, chi3, chi4, chi5]
1076        self.proximal = 1
1077        self.distal = 2
1078
1079        tmp = Turtle3D("tmp")
1080        tmp.Orientation = 1
1081
1082        n = ORIGIN
1083        ca = ORIGIN
1084        cb = ORIGIN
1085        c = ORIGIN
1086
1087        self.ca_prox = tmp._position
1088        tmp.schain_to_bbone()
1089        n, ca, cb, c = build_residue(tmp)
1090
1091        self.n_prox = n
1092        self.ca_prox = ca
1093        self.c_prox = c
1094        self.cb_prox = cb
1095
1096        tmp.bbone_to_schain()
1097        tmp.move(1.53)
1098        tmp.roll(self.chi1)
1099        tmp.yaw(112.8)
1100        self.cb_prox = Vector3D(tmp._position)
1101
1102        tmp.move(1.86)
1103        tmp.roll(self.chi2)
1104        tmp.yaw(103.8)
1105        self.sg_prox = Vector3D(tmp._position)
1106
1107        tmp.move(2.044)
1108        tmp.roll(self.chi3)
1109        tmp.yaw(103.8)
1110        self.sg_dist = Vector3D(tmp._position)
1111
1112        tmp.move(1.86)
1113        tmp.roll(self.chi4)
1114        tmp.yaw(112.8)
1115        self.cb_dist = Vector3D(tmp._position)
1116
1117        tmp.move(1.53)
1118        tmp.roll(self.chi5)
1119        tmp.pitch(180.0)
1120
1121        tmp.schain_to_bbone()
1122
1123        n, ca, cb, c = build_residue(tmp)
1124
1125        self.n_dist = n
1126        self.ca_dist = ca
1127        self.c_dist = c
1128        self._compute_torsional_energy()
1129        self._compute_local_coords()
1130        self._compute_torsion_length()
1131        self._compute_rho()
1132        self.ca_distance = distance3d(self.ca_prox, self.ca_dist)
1133        self.cb_distance = distance3d(self.cb_prox, self.cb_dist)
1134        self.sg_distance = distance3d(self.sg_prox, self.sg_dist)
1135        self.torsion_array = np.array([chi1, chi2, chi3, chi4, chi5])
1136        self.missing_atoms = True
1137        self.modelled = True

Build a model Disulfide based on the input dihedral angles. Routine assumes turtle is in orientation #1 (at Ca, headed toward Cb, with N on left), builds disulfide, and updates the object's internal coordinates. It also adds the distal protein backbone, and computes the disulfide conformational energy.

Parameters
  • chi1: Chi1 (degrees)
  • chi2: Chi2 (degrees)
  • chi3: Chi3 (degrees)
  • chi4: Chi4 (degrees)
  • chi5: Chi5 (degrees)

Example:

>>> import proteusPy as pp
>>> modss = pp.Disulfide('model')
>>> modss.build_model(-60, -60, -90, -60, -60)
>>> modss.display(style='sb', light="auto")
cofmass: <built-in function array>
1139    @property
1140    def cofmass(self) -> np.array:
1141        """
1142        Return the geometric center of mass for the internal coordinates of
1143        the given Disulfide. Missing atoms are not included.
1144
1145        :return: 3D array for the geometric center of mass
1146        """
1147
1148        res = self.internal_coords.mean(axis=0)
1149        return res

Return the geometric center of mass for the internal coordinates of the given Disulfide. Missing atoms are not included.

Returns

3D array for the geometric center of mass

coord_cofmass: <built-in function array>
1151    @property
1152    def coord_cofmass(self) -> np.array:
1153        """
1154        Return the geometric center of mass for the global coordinates of
1155        the given Disulfide. Missing atoms are not included.
1156
1157        :return: 3D array for the geometric center of mass
1158        """
1159
1160        res = self.coords.mean(axis=0)
1161        return res

Return the geometric center of mass for the global coordinates of the given Disulfide. Missing atoms are not included.

Returns

3D array for the geometric center of mass

def copy(self):
1163    def copy(self):
1164        """
1165        Copy the Disulfide.
1166
1167        :return: A copy of self.
1168        """
1169        return copy.deepcopy(self)

Copy the Disulfide.

Returns

A copy of self.

def display( self, single=True, style='sb', light='auto', shadows=False, winsize=(1024, 1024)) -> None:
1250    def display(
1251        self, single=True, style="sb", light="auto", shadows=False, winsize=WINSIZE
1252    ) -> None:
1253        """
1254        Display the Disulfide bond in the specific rendering style.
1255
1256        :param single: Display the bond in a single panel in the specific style.
1257        :param style:  Rendering style: One of:
1258            * 'sb' - split bonds
1259            * 'bs' - ball and stick
1260            * 'cpk' - CPK style
1261            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1262            * 'plain' - boring single color
1263        :param light: If True, light background, if False, dark
1264
1265        Example:
1266        >>> import proteusPy as pp
1267
1268        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
1269        >>> ss = PDB_SS[0]
1270        >>> ss.display(style='cpk', light="auto")
1271        >>> ss.screenshot(style='bs', fname='proteus_logo_sb.png')
1272        """
1273        src = self.pdb_id
1274        enrg = self.energy
1275
1276        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å, Sg: {self.sg_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1277
1278        set_pyvista_theme(light)
1279        fontsize = 8
1280
1281        if single:
1282            _pl = pv.Plotter(window_size=winsize)
1283            _pl.add_title(title=title, font_size=fontsize)
1284            _pl.enable_anti_aliasing("msaa")
1285
1286            self._render(
1287                _pl,
1288                style=style,
1289            )
1290            _pl.reset_camera()
1291            if shadows:
1292                _pl.enable_shadows()
1293            _pl.show()
1294
1295        else:
1296            pl = pv.Plotter(window_size=winsize, shape=(2, 2))
1297            pl.subplot(0, 0)
1298
1299            pl.add_title(title=title, font_size=fontsize)
1300            pl.enable_anti_aliasing("msaa")
1301
1302            # pl.add_camera_orientation_widget()
1303
1304            self._render(
1305                pl,
1306                style="cpk",
1307            )
1308
1309            pl.subplot(0, 1)
1310            pl.add_title(title=title, font_size=fontsize)
1311
1312            self._render(
1313                pl,
1314                style="bs",
1315            )
1316
1317            pl.subplot(1, 0)
1318            pl.add_title(title=title, font_size=fontsize)
1319
1320            self._render(
1321                pl,
1322                style="sb",
1323            )
1324
1325            pl.subplot(1, 1)
1326            pl.add_title(title=title, font_size=fontsize)
1327
1328            self._render(
1329                pl,
1330                style="pd",
1331            )
1332
1333            pl.link_views()
1334            pl.reset_camera()
1335            if shadows:
1336                pl.enable_shadows()
1337            pl.show()
1338        return

Display the Disulfide bond in the specific rendering style.

Parameters
  • single: Display the bond in a single panel in the specific style.
  • style: Rendering style: One of:
    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color
  • light: If True, light background, if False, dark

Example:

>>> import proteusPy as pp
>>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
>>> ss = PDB_SS[0]
>>> ss.display(style='cpk', light="auto")
>>> ss.screenshot(style='bs', fname='proteus_logo_sb.png')
TorsionEnergy: float
1340    @property
1341    def TorsionEnergy(self) -> float:
1342        """
1343        Return the energy of the Disulfide bond.
1344        """
1345        return self._compute_torsional_energy()

Return the energy of the Disulfide bond.

TorsionLength: float
1347    @property
1348    def TorsionLength(self) -> float:
1349        """
1350        Return the energy of the Disulfide bond.
1351        """
1352        return self._compute_torsion_length()

Return the energy of the Disulfide bond.

def Distance_neighbors( self, others: proteusPy.DisulfideList.DisulfideList, cutoff: float) -> proteusPy.DisulfideList.DisulfideList:
1354    def Distance_neighbors(self, others: DisulfideList, cutoff: float) -> DisulfideList:
1355        """
1356        Return list of Disulfides whose RMS atomic distance is within
1357        the cutoff (Å) in the others list.
1358
1359        :param others: DisulfideList to search
1360        :param cutoff: Distance cutoff (Å)
1361        :return: DisulfideList within the cutoff
1362        """
1363
1364        res = [ss.copy() for ss in others if self.Distance_RMS(ss) < cutoff]
1365        return DisulfideList(res, "neighbors")

Return list of Disulfides whose RMS atomic distance is within the cutoff (Å) in the others list.

Parameters
  • others: DisulfideList to search
  • cutoff: Distance cutoff (Å)
Returns

DisulfideList within the cutoff

def Distance_RMS(self, other) -> float:
1367    def Distance_RMS(self, other) -> float:
1368        """
1369        Calculate the RMS distance between the internal coordinates of self and another Disulfide.
1370        :param other: Comparison Disulfide
1371        :return: RMS distance (Å)
1372        """
1373
1374        # Get internal coordinates of both objects
1375        ic1 = self.internal_coords
1376        ic2 = other.internal_coords
1377
1378        # Compute the sum of squared differences between corresponding internal coordinates
1379        totsq = sum(math.dist(p1, p2) ** 2 for p1, p2 in zip(ic1, ic2))
1380
1381        # Compute the mean of the squared distances
1382        totsq /= len(ic1)
1383
1384        # Take the square root of the mean to get the RMS distance
1385        return math.sqrt(totsq)

Calculate the RMS distance between the internal coordinates of self and another Disulfide.

Parameters
  • other: Comparison Disulfide
Returns

RMS distance (Å)

def Torsion_RMS(self, other) -> float:
1387    def Torsion_RMS(self, other) -> float:
1388        """
1389        Calculate the RMS distance between the dihedral angles of self and another Disulfide.
1390
1391        :param other: Disulfide object to compare against.
1392        :type other: Disulfide
1393        :return: RMS distance in degrees.
1394        :rtype: float
1395        :raises ValueError: If the torsion arrays of self and other are not of equal length.
1396        """
1397        # Get internal coordinates of both objects
1398        ic1 = self.torsion_array
1399        ic2 = other.torsion_array
1400
1401        # Ensure both torsion arrays have the same length
1402        if len(ic1) != len(ic2):
1403            raise ValueError("Torsion arrays must be of the same length.")
1404
1405        # Compute the total squared difference between corresponding internal coordinates
1406        total_squared_diff = sum(
1407            (angle1 - angle2) ** 2 for angle1, angle2 in zip(ic1, ic2)
1408        )
1409        mean_squared_diff = total_squared_diff / len(ic1)
1410
1411        # Return the square root of the mean squared difference as the RMS distance
1412        return math.sqrt(mean_squared_diff)

Calculate the RMS distance between the dihedral angles of self and another Disulfide.

Parameters
  • other: Disulfide object to compare against.
Returns

RMS distance in degrees.

Raises
  • ValueError: If the torsion arrays of self and other are not of equal length.
def get_chains(self) -> tuple:
1414    def get_chains(self) -> tuple:
1415        """
1416        Return the proximal and distal chain IDs for the Disulfide.
1417
1418        :return: tuple (proximal, distal) chain IDs
1419        """
1420        prox = self.proximal_chain
1421        dist = self.distal_chain
1422        return tuple(prox, dist)

Return the proximal and distal chain IDs for the Disulfide.

Returns

tuple (proximal, distal) chain IDs

def get_full_id(self) -> tuple:
1424    def get_full_id(self) -> tuple:
1425        """
1426        Return the Disulfide full IDs (Used with BIO.PDB)
1427
1428        :return: Disulfide full IDs
1429        """
1430        return (self.proximal, self.distal)

Return the Disulfide full IDs (Used with BIO.PDB)

Returns

Disulfide full IDs

internal_coords: <built-in function array>
1432    @property
1433    def internal_coords(self) -> np.array:
1434        """
1435        Return the internal coordinates for the Disulfide.
1436
1437        :return: Array containing the coordinates, [16][3].
1438        """
1439        return self._internal_coords()

Return the internal coordinates for the Disulfide.

Returns

Array containing the coordinates, [16][3].

coords: <built-in function array>
1497    @property
1498    def coords(self) -> np.array:
1499        """
1500        Return the coordinates for the Disulfide as an array.
1501
1502        :return: Array containing the coordinates, [16][3].
1503        """
1504        return self._coords()

Return the coordinates for the Disulfide as an array.

Returns

Array containing the coordinates, [16][3].

def internal_coords_res(self, resnumb) -> <built-in function array>:
1562    def internal_coords_res(self, resnumb) -> np.array:
1563        """
1564        Return the internal coordinates for the Disulfide. Missing atoms are not included.
1565
1566        :return: Array containing the coordinates, [12][3].
1567        """
1568        return self._internal_coords_res(resnumb)

Return the internal coordinates for the Disulfide. Missing atoms are not included.

Returns

Array containing the coordinates, [12][3].

def make_movie(self, style='sb', fname='ssbond.mp4', verbose=False, steps=360) -> None:
1611    def make_movie(
1612        self, style="sb", fname="ssbond.mp4", verbose=False, steps=360
1613    ) -> None:
1614        """
1615        Create an animation for ```self``` rotating one revolution about the Y axis,
1616        in the given ```style```, saving to ```filename```.
1617
1618        :param style: Rendering style, defaults to 'sb', one of:
1619        * 'sb' - split bonds
1620        * 'bs' - ball and stick
1621        * 'cpk' - CPK style
1622        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1623        * 'plain' - boring single color
1624
1625        :param fname: Output filename, defaults to ```ssbond.mp4```
1626        :param verbose: Verbosity, defaults to False
1627        :param steps: Number of steps for one complete rotation, defaults to 360.
1628        """
1629
1630        # src = self.pdb_id
1631        # name = self.name
1632        # enrg = self.energy
1633        # title = f"{src} {name}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1634
1635        if verbose:
1636            print(f"Rendering animation to {fname}...")
1637
1638        pl = pv.Plotter(window_size=WINSIZE, off_screen=True, theme="document")
1639        pl.open_movie(fname)
1640        path = pl.generate_orbital_path(n_points=steps)
1641
1642        #
1643        # pl.add_title(title=title, font_size=FONTSIZE)
1644        pl.enable_anti_aliasing("msaa")
1645        pl = self._render(
1646            pl,
1647            style=style,
1648        )
1649        pl.reset_camera()
1650        pl.orbit_on_path(path, write_frames=True)
1651        pl.close()
1652
1653        if verbose:
1654            print(f"Saved mp4 animation to: {fname}")

Create an animation for self rotating one revolution about the Y axis, in the given style, saving to filename.

Parameters
  • style: Rendering style, defaults to 'sb', one of:

    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color
  • fname: Output filename, defaults to ssbond.mp4

  • verbose: Verbosity, defaults to False
  • steps: Number of steps for one complete rotation, defaults to 360.
def spin(self, style='sb', verbose=False, steps=360, theme='auto') -> None:
1656    def spin(self, style="sb", verbose=False, steps=360, theme="auto") -> None:
1657        """
1658        Spin the object by rotating it one revolution about the Y axis in the given style.
1659
1660        :param style: Rendering style, defaults to 'sb', one of:
1661            * 'sb' - split bonds
1662            * 'bs' - ball and stick
1663            * 'cpk' - CPK style
1664            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1665            * 'plain' - boring single color
1666
1667        :param verbose: Verbosity, defaults to False
1668        :param steps: Number of steps for one complete rotation, defaults to 360.
1669        """
1670
1671        src = self.pdb_id
1672        enrg = self.energy
1673
1674        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
1675
1676        set_pyvista_theme(theme)
1677
1678        if verbose:
1679            _logger.info("Spinning object: %d steps...", steps)
1680
1681        # Create a Plotter instance
1682        pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
1683        pl.add_title(title=title, font_size=FONTSIZE)
1684
1685        # Enable anti-aliasing for smoother rendering
1686        pl.enable_anti_aliasing("msaa")
1687
1688        # Generate an orbital path for spinning
1689        path = pl.generate_orbital_path(n_points=steps)
1690
1691        # Render the object in the specified style
1692        pl = self._render(pl, style=style)
1693
1694        pl.reset_camera()
1695        pl.show(auto_close=False)
1696
1697        # Orbit the camera along the generated path
1698        pl.orbit_on_path(path, write_frames=False, step=1 / steps)
1699
1700        if verbose:
1701            print("Spinning completed.")

Spin the object by rotating it one revolution about the Y axis in the given style.

Parameters
  • style: Rendering style, defaults to 'sb', one of:

    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color
  • verbose: Verbosity, defaults to False

  • steps: Number of steps for one complete rotation, defaults to 360.
def plot( self, pl, single=True, style='sb', light='True', shadows=False) -> pyvista.plotting.plotter.Plotter:
1703    def plot(
1704        self, pl, single=True, style="sb", light="True", shadows=False
1705    ) -> pv.Plotter:
1706        """
1707        Return the pyVista Plotter object for the Disulfide bond in the specific rendering style.
1708
1709        :param single: Display the bond in a single panel in the specific style.
1710        :param style:  Rendering style: One of:
1711            * 'sb' - split bonds
1712            * 'bs' - ball and stick
1713            * 'cpk' - CPK style
1714            * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
1715            * 'plain' - boring single color
1716        :param light: If True, light background, if False, dark
1717        """
1718        src = self.pdb_id
1719        enrg = self.energy
1720        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol. Cα: {self.ca_distance:.2f} Å Cβ: {self.cb_distance:.2f} Å Tors: {self.torsion_length:.2f}°"
1721
1722        if light:
1723            pv.set_plot_theme("document")
1724        else:
1725            pv.set_plot_theme("dark")
1726
1727        pl.clear()
1728
1729        if single:
1730            pl = pv.Plotter(window_size=WINSIZE)
1731            pl.add_title(title=title, font_size=FONTSIZE)
1732            pl.enable_anti_aliasing("msaa")
1733            # pl.add_camera_orientation_widget()
1734
1735            self._render(
1736                pl,
1737                style=style,
1738                bs_scale=BS_SCALE,
1739                spec=SPECULARITY,
1740                specpow=SPEC_POWER,
1741            )
1742            pl.reset_camera()
1743            if shadows:
1744                pl.enable_shadows()
1745        else:
1746            pl = pv.Plotter(shape=(2, 2))
1747            pl.subplot(0, 0)
1748
1749            # pl.add_title(title=title, font_size=FONTSIZE)
1750            pl.enable_anti_aliasing("msaa")
1751
1752            # pl.add_camera_orientation_widget()
1753
1754            self._render(
1755                pl,
1756                style="cpk",
1757            )
1758
1759            pl.subplot(0, 1)
1760
1761            self._render(
1762                pl,
1763                style="bs",
1764            )
1765
1766            pl.subplot(1, 0)
1767
1768            self._render(
1769                pl,
1770                style="sb",
1771            )
1772
1773            pl.subplot(1, 1)
1774            self._render(
1775                pl,
1776                style="pd",
1777            )
1778
1779            pl.link_views()
1780            pl.reset_camera()
1781            if shadows:
1782                pl.enable_shadows()
1783        return pl

Return the pyVista Plotter object for the Disulfide bond in the specific rendering style.

Parameters
  • single: Display the bond in a single panel in the specific style.
  • style: Rendering style: One of:
    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color
  • light: If True, light background, if False, dark
def pprint(self) -> None:
1785    def pprint(self) -> None:
1786        """
1787        Pretty print general info for the Disulfide
1788        """
1789        s1 = self.repr_ss_info()
1790        s2 = self.repr_ss_ca_dist()
1791        s2b = self.repr_ss_sg_dist()
1792        s3 = self.repr_ss_conformation()
1793        s4 = self.repr_ss_torsion_length()
1794        res = f"{s1} \n{s3} \n{s2} \n{s2b} \n{s4}>"
1795        print(res)

Pretty print general info for the Disulfide

def pprint_all(self) -> None:
1797    def pprint_all(self) -> None:
1798        """
1799        Pretty print all info for the Disulfide
1800        """
1801        s1 = self.repr_ss_info() + "\n"
1802        s2 = self.repr_ss_coords()
1803        s3 = self.repr_ss_local_coords()
1804        s4 = self.repr_ss_conformation()
1805        s4b = self.repr_phipsi()
1806        s6 = self.repr_ss_ca_dist()
1807        s6b = self.repr_ss_cb_dist()
1808        s6c = self.repr_ss_sg_dist()
1809        s7 = self.repr_ss_torsion_length()
1810        s8 = self.repr_ss_secondary_structure()
1811
1812        res = f"{s1} {s2} {s3} {s4}\n {s4b}\n {s6}\n {s6b}\n {s6c}\n {s7}\n {s8}>"
1813
1814        print(res)

Pretty print all info for the Disulfide

def repr_ss_info(self) -> str:
1817    def repr_ss_info(self) -> str:
1818        """
1819        Representation for the Disulfide class
1820        """
1821        s1 = f"<Disulfide {self.name}, Source: {self.pdb_id}, Resolution: {self.resolution} Å"
1822        return s1

Representation for the Disulfide class

def repr_ss_coords(self) -> str:
1824    def repr_ss_coords(self) -> str:
1825        """
1826        Representation for Disulfide coordinates
1827        """
1828        s2 = f"\nProximal Coordinates:\n   N: {self.n_prox}\n   Cα: {self.ca_prox}\n   C: {self.c_prox}\n   O: {self.o_prox}\n   Cβ: {self.cb_prox}\n   Sγ: {self.sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1829        s3 = f"Distal Coordinates:\n   N: {self.n_dist}\n   Cα: {self.ca_dist}\n   C: {self.c_dist}\n   O: {self.o_dist}\n   Cβ: {self.cb_dist}\n   Sγ: {self.sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n\n"
1830        stot = f"{s2} {s3}"
1831        return stot

Representation for Disulfide coordinates

def repr_ss_conformation(self) -> str:
1833    def repr_ss_conformation(self) -> str:
1834        """
1835        Representation for Disulfide conformation
1836        """
1837        s4 = f"Χ1-Χ5: {self.chi1:.2f}°, {self.chi2:.2f}°, {self.chi3:.2f}°, {self.chi4:.2f}° {self.chi5:.2f}°, {self.rho:.2f}°, {self.energy:.2f} kcal/mol"
1838        stot = f"{s4}"
1839        return stot

Representation for Disulfide conformation

def repr_ss_local_coords(self) -> str:
1841    def repr_ss_local_coords(self) -> str:
1842        """
1843        Representation for the Disulfide internal coordinates.
1844        """
1845        s2i = f"Proximal Internal Coords:\n   N: {self._n_prox}\n   Cα: {self._ca_prox}\n   C: {self._c_prox}\n   O: {self._o_prox}\n   Cβ: {self._cb_prox}\n   Sγ: {self._sg_prox}\n   Cprev {self.c_prev_prox}\n   Nnext: {self.n_next_prox}\n"
1846        s3i = f"Distal Internal Coords:\n   N: {self._n_dist}\n   Cα: {self._ca_dist}\n   C: {self._c_dist}\n   O: {self._o_dist}\n   Cβ: {self._cb_dist}\n   Sγ: {self._sg_dist}\n   Cprev {self.c_prev_dist}\n   Nnext: {self.n_next_dist}\n"
1847        stot = f"{s2i}{s3i}"
1848        return stot

Representation for the Disulfide internal coordinates.

def repr_ss_residue_ids(self) -> str:
1850    def repr_ss_residue_ids(self) -> str:
1851        """
1852        Representation for Disulfide chain IDs
1853        """
1854        return f"Proximal Residue fullID: <{self.proximal}> Distal Residue fullID: <{self.distal}>"

Representation for Disulfide chain IDs

def repr_ss_ca_dist(self) -> str:
1856    def repr_ss_ca_dist(self) -> str:
1857        """
1858        Representation for Disulfide Ca distance
1859        """
1860        s1 = f"Cα Distance: {self.ca_distance:.2f} Å"
1861        return s1

Representation for Disulfide Ca distance

def repr_ss_cb_dist(self) -> str:
1863    def repr_ss_cb_dist(self) -> str:
1864        """
1865        Representation for Disulfide Ca distance
1866        """
1867        s1 = f"Cβ Distance: {self.cb_distance:.2f} Å"
1868        return s1

Representation for Disulfide Ca distance

def repr_ss_sg_dist(self) -> str:
1870    def repr_ss_sg_dist(self) -> str:
1871        """
1872        Representation for Disulfide Ca distance
1873        """
1874        s1 = f"Sγ Distance: {self.sg_distance:.2f} Å"
1875        return s1

Representation for Disulfide Ca distance

def repr_ss_torsion_length(self) -> str:
1877    def repr_ss_torsion_length(self) -> str:
1878        """
1879        Representation for Disulfide torsion length
1880        """
1881        s1 = f"Torsion length: {self.torsion_length:.2f} deg"
1882        return s1

Representation for Disulfide torsion length

def repr_ss_secondary_structure(self) -> str:
1884    def repr_ss_secondary_structure(self) -> str:
1885        """
1886        Representation for Disulfide secondary structure
1887        """
1888        s1 = f"Proximal secondary: {self.proximal_secondary} Distal secondary: {self.distal_secondary}"
1889        return s1

Representation for Disulfide secondary structure

def repr_phipsi(self) -> str:
1891    def repr_phipsi(self) -> str:
1892        """
1893        Representation for Disulfide phi psi angles
1894        """
1895        s1 = f"PhiProx: {self.phiprox:.2f}° PsiProx: {self.psiprox:.2f}°, PhiDist: {self.phidist:.2f}° PsiDist: {self.psidist:.2f}°"
1896        return s1

Representation for Disulfide phi psi angles

def repr_all(self) -> str:
1898    def repr_all(self) -> str:
1899        """
1900        Return a string representation for all Disulfide information
1901        contained in self.
1902        """
1903
1904        s1 = self.repr_ss_info() + "\n"
1905        s2 = self.repr_ss_coords()
1906        s3 = self.repr_ss_local_coords()
1907        s4 = self.repr_ss_conformation()
1908        s4b = self.repr_phipsi()
1909        s6 = self.repr_ss_ca_dist()
1910        s8 = self.repr_ss_cb_dist()
1911        s7 = self.repr_ss_torsion_length()
1912        s9 = self.repr_ss_secondary_structure()
1913
1914        res = f"{s1} {s2} {s3} {s4} {s4b} {s6} {s7} {s8} {s9}>"
1915        return res

Return a string representation for all Disulfide information contained in self.

def repr_compact(self) -> str:
1917    def repr_compact(self) -> str:
1918        """
1919        Return a compact representation of the Disulfide object
1920        :return: string
1921        """
1922        return f"{self.repr_ss_info()} {self.repr_ss_conformation()}"

Return a compact representation of the Disulfide object

Returns

string

def repr_conformation(self) -> str:
1924    def repr_conformation(self) -> str:
1925        """
1926        Return a string representation of the Disulfide object's conformation.
1927        :return: string
1928        """
1929        return f"{self.repr_ss_conformation()}"

Return a string representation of the Disulfide object's conformation.

Returns

string

def repr_coords(self) -> str:
1931    def repr_coords(self) -> str:
1932        """
1933        Return a string representation of the Disulfide object's coordinates.
1934        :return: string
1935        """
1936        return f"{self.repr_ss_coords()}"

Return a string representation of the Disulfide object's coordinates.

Returns

string

def repr_internal_coords(self) -> str:
1938    def repr_internal_coords(self) -> str:
1939        """
1940        Return a string representation of the Disulfide object's internal coordinaes.
1941        :return: string
1942        """
1943        return f"{self.repr_ss_local_coords()}"

Return a string representation of the Disulfide object's internal coordinaes.

Returns

string

def repr_chain_ids(self) -> str:
1945    def repr_chain_ids(self) -> str:
1946        """
1947        Return a string representation of the Disulfide object's chain ids.
1948        :return: string
1949        """
1950        return f"{self.repr_ss_residue_ids()}"

Return a string representation of the Disulfide object's chain ids.

Returns

string

rho: float
1952    @property
1953    def rho(self) -> float:
1954        """
1955        Return the dihedral angle rho for the Disulfide.
1956        """
1957        return self._compute_rho()

Return the dihedral angle rho for the Disulfide.

def reset(self) -> None:
1984    def reset(self) -> None:
1985        """
1986        Resets the disulfide object to its initial state. All distances,
1987        angles and positions are reset. The name is unchanged.
1988        """
1989        self.__init__(self)

Resets the disulfide object to its initial state. All distances, angles and positions are reset. The name is unchanged.

def same_chains(self) -> bool:
1991    def same_chains(self) -> bool:
1992        """
1993        Function checks if the Disulfide is cross-chain or not.
1994
1995        Returns
1996        -------
1997        bool \n
1998            True if the proximal and distal residues are on the same chains,
1999            False otherwise.
2000        """
2001
2002        (prox, dist) = self.get_chains()
2003        return prox == dist

Function checks if the Disulfide is cross-chain or not.

Returns

bool

True if the proximal and distal residues are on the same chains,
False otherwise.
def screenshot( self, single=True, style='sb', fname='ssbond.png', verbose=False, shadows=False, light='Auto') -> None:
2005    def screenshot(
2006        self,
2007        single=True,
2008        style="sb",
2009        fname="ssbond.png",
2010        verbose=False,
2011        shadows=False,
2012        light="Auto",
2013    ) -> None:
2014        """
2015        Create and save a screenshot of the Disulfide in the given style
2016        and filename
2017
2018        :param single: Display a single vs panel view, defaults to True
2019        :param style: Rendering style, one of:
2020        * 'sb' - split bonds
2021        * 'bs' - ball and stick
2022        * 'cpk' - CPK style
2023        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2024        * 'plain' - boring single color,
2025        :param fname: output filename,, defaults to 'ssbond.png'
2026        :param verbose: Verbosit, defaults to False
2027        :param shadows: Enable shadows, defaults to False
2028        """
2029
2030        src = self.pdb_id
2031        enrg = self.energy
2032        title = f"{src}: {self.proximal}{self.proximal_chain}-{self.distal}{self.distal_chain}: {enrg:.2f} kcal/mol, Cα: {self.ca_distance:.2f} Å, Cβ: {self.cb_distance:.2f} Å, Sγ: {self.sg_distance:.2f} Å, Tors: {self.torsion_length:.2f}"
2033
2034        set_pyvista_theme(light)
2035
2036        if verbose:
2037            _logger.info("Rendering screenshot to file {fname}")
2038
2039        if single:
2040            pl = pv.Plotter(window_size=WINSIZE, off_screen=False)
2041            pl.add_title(title=title, font_size=FONTSIZE)
2042            pl.enable_anti_aliasing("msaa")
2043            self._render(
2044                pl,
2045                style=style,
2046            )
2047            pl.reset_camera()
2048            if shadows:
2049                pl.enable_shadows()
2050
2051            pl.show(auto_close=False)  # allows for manipulation
2052            # Take the screenshot after ensuring the plotter is still active
2053            try:
2054                pl.screenshot(fname)
2055            except RuntimeError as e:
2056                _logger.error(f"Error saving screenshot: {e}")
2057
2058        else:
2059            pl = pv.Plotter(window_size=WINSIZE, shape=(2, 2), off_screen=False)
2060            pl.subplot(0, 0)
2061
2062            pl.add_title(title=title, font_size=FONTSIZE)
2063            pl.enable_anti_aliasing("msaa")
2064
2065            # pl.add_camera_orientation_widget()
2066            self._render(
2067                pl,
2068                style="cpk",
2069            )
2070
2071            pl.subplot(0, 1)
2072            pl.add_title(title=title, font_size=FONTSIZE)
2073            self._render(
2074                pl,
2075                style="pd",
2076            )
2077
2078            pl.subplot(1, 0)
2079            pl.add_title(title=title, font_size=FONTSIZE)
2080            self._render(
2081                pl,
2082                style="bs",
2083            )
2084
2085            pl.subplot(1, 1)
2086            pl.add_title(title=title, font_size=FONTSIZE)
2087            self._render(
2088                pl,
2089                style="sb",
2090            )
2091
2092            pl.link_views()
2093            pl.reset_camera()
2094            if shadows:
2095                pl.enable_shadows()
2096
2097            # Take the screenshot after ensuring the plotter is still active
2098            pl.show(auto_close=False)  # allows for manipulation
2099
2100            try:
2101                pl.screenshot(fname)
2102            except RuntimeError as e:
2103                _logger.error(f"Error saving screenshot: {e}")
2104
2105        if verbose:
2106            print(f"Screenshot saved as: {fname}")

Create and save a screenshot of the Disulfide in the given style and filename

Parameters
  • single: Display a single vs panel view, defaults to True
  • style: Rendering style, one of:
    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color,
  • fname: output filename,, defaults to 'ssbond.png'
  • verbose: Verbosit, defaults to False
  • shadows: Enable shadows, defaults to False
def save_meshes_as_stl(self, meshes, filename) -> None:
2108    def save_meshes_as_stl(self, meshes, filename) -> None:
2109        """Save a list of meshes as a single STL file.
2110
2111        Args:
2112            meshes (list): List of pyvista mesh objects to save.
2113            filename (str): Path to save the STL file to.
2114        """
2115        merged_mesh = pv.UnstructuredGrid()
2116        for mesh in meshes:
2117            merged_mesh += mesh
2118        merged_mesh.save(filename)

Save a list of meshes as a single STL file.

Args: meshes (list): List of pyvista mesh objects to save. filename (str): Path to save the STL file to.

def export(self, style='sb', verbose=True, fname='ssbond_plt') -> None:
2120    def export(self, style="sb", verbose=True, fname="ssbond_plt") -> None:
2121        """
2122        Create and save a screenshot of the Disulfide in the given style and filename.
2123
2124        :param single: Display a single vs panel view, defaults to True
2125        :param style: Rendering style, one of:
2126        * 'sb' - split bonds
2127        * 'bs' - ball and stick
2128        * 'cpk' - CPK style
2129        * 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
2130        * 'plain' - boring single color,
2131
2132        :param fname: output filename,, defaults to 'ssbond.stl'
2133        :param verbose: Verbosit, defaults to False
2134        """
2135
2136        if verbose:
2137            print(f"-> screenshot(): Rendering screenshot to file {fname}")
2138
2139        pl = pv.PolyData()
2140
2141        self._plot(
2142            pl,
2143            style=style,
2144        )
2145
2146        self.save_meshes_as_stl(pl, fname)
2147
2148        return

Create and save a screenshot of the Disulfide in the given style and filename.

Parameters
  • single: Display a single vs panel view, defaults to True
  • style: Rendering style, one of:

    • 'sb' - split bonds
    • 'bs' - ball and stick
    • 'cpk' - CPK style
    • 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
    • 'plain' - boring single color,
  • fname: output filename,, defaults to 'ssbond.stl'

  • verbose: Verbosit, defaults to False
2150    def set_positions(
2151        self,
2152        n_prox: Vector3D,
2153        ca_prox: Vector3D,
2154        c_prox: Vector3D,
2155        o_prox: Vector3D,
2156        cb_prox: Vector3D,
2157        sg_prox: Vector3D,
2158        n_dist: Vector3D,
2159        ca_dist: Vector3D,
2160        c_dist: Vector3D,
2161        o_dist: Vector3D,
2162        cb_dist: Vector3D,
2163        sg_dist: Vector3D,
2164        c_prev_prox: Vector3D,
2165        n_next_prox: Vector3D,
2166        c_prev_dist: Vector3D,
2167        n_next_dist: Vector3D,
2168    ) -> None:
2169        """
2170        Set the atomic coordinates for all atoms in the Disulfide object.
2171
2172        :param n_prox: Proximal N position
2173        :param ca_prox: Proximal Cα position
2174        :param c_prox: Proximal C' position
2175        :param o_prox: Proximal O position
2176        :param cb_prox: Proximal Cβ position
2177        :param sg_prox: Proximal Sγ position
2178        :param n_dist: Distal N position
2179        :param ca_dist: Distal Cα position
2180        :param c_dist: Distal C' position
2181        :param o_dist: Distal O position
2182        :param cb_dist: Distal Cβ position
2183        :param sg_dist: Distal Sγ position
2184        :param c_prev_prox: Proximal previous C'
2185        :param n_next_prox: Proximal next N
2186        :param c_prev_dist: Distal previous C'
2187        :param n_next_dist: Distal next N
2188        """
2189
2190        # deep copy
2191        self.n_prox = n_prox.copy()
2192        self.ca_prox = ca_prox.copy()
2193        self.c_prox = c_prox.copy()
2194        self.o_prox = o_prox.copy()
2195        self.cb_prox = cb_prox.copy()
2196        self.sg_prox = sg_prox.copy()
2197        self.sg_dist = sg_dist.copy()
2198        self.cb_dist = cb_dist.copy()
2199        self.ca_dist = ca_dist.copy()
2200        self.n_dist = n_dist.copy()
2201        self.c_dist = c_dist.copy()
2202        self.o_dist = o_dist.copy()
2203
2204        self.c_prev_prox = c_prev_prox.copy()
2205        self.n_next_prox = n_next_prox.copy()
2206        self.c_prev_dist = c_prev_dist.copy()
2207        self.n_next_dist = n_next_dist.copy()
2208        self._compute_local_coords()

Set the atomic coordinates for all atoms in the Disulfide object.

Parameters
  • n_prox: Proximal N position
  • ca_prox: Proximal Cα position
  • c_prox: Proximal C' position
  • o_prox: Proximal O position
  • cb_prox: Proximal Cβ position
  • sg_prox: Proximal Sγ position
  • n_dist: Distal N position
  • ca_dist: Distal Cα position
  • c_dist: Distal C' position
  • o_dist: Distal O position
  • cb_dist: Distal Cβ position
  • sg_dist: Distal Sγ position
  • c_prev_prox: Proximal previous C'
  • n_next_prox: Proximal next N
  • c_prev_dist: Distal previous C'
  • n_next_dist: Distal next N
def set_name(self, namestr='Disulfide') -> None:
2210    def set_name(self, namestr="Disulfide") -> None:
2211        """
2212        Set the Disulfide's name.
2213
2214        :param namestr: Name, by default "Disulfide"
2215        """
2216        self.name = namestr

Set the Disulfide's name.

Parameters
  • namestr: Name, by default "Disulfide"
def set_resnum(self, proximal: int, distal: int) -> None:
2218    def set_resnum(self, proximal: int, distal: int) -> None:
2219        """
2220        Set the proximal and residue numbers for the Disulfide.
2221
2222        :param proximal: Proximal residue number
2223        :param distal: Distal residue number
2224        """
2225        self.proximal = proximal
2226        self.distal = distal

Set the proximal and residue numbers for the Disulfide.

Parameters
  • proximal: Proximal residue number
  • distal: Distal residue number
def torsion_distance(self, other) -> float:
2245    def torsion_distance(self, other) -> float:
2246        """
2247        Calculate the 5D Euclidean distance between `self` and another Disulfide
2248        object. This is used to compare Disulfide Bond torsion angles to
2249        determine their torsional similarity via a 5-Dimensional Euclidean distance metric.
2250
2251        :param other: Comparison Disulfide
2252        :raises ProteusPyWarning: Warning if `other` is not a Disulfide object
2253        :return: Euclidean distance (Degrees) between `self` and `other`.
2254        """
2255
2256        # Check length of torsion arrays
2257        if len(self.torsion_array) != 5 or len(other.torsion_array) != 5:
2258            raise ProteusPyWarning(
2259                "--> Torsion_Distance() requires vectors of length 5!"
2260            )
2261
2262        # Convert to numpy arrays
2263        p1 = np.array(self.torsion_array)
2264        p2 = np.array(other.torsion_array)
2265
2266        # Compute the difference and handle angle wrapping
2267        diff = np.abs(p1 - p2)
2268        diff = np.where(diff > 180, 360 - diff, diff)
2269
2270        # Compute the 5D Euclidean distance using numpy's linalg.norm function
2271        dist = np.linalg.norm(diff)
2272
2273        return dist

Calculate the 5D Euclidean distance between self and another Disulfide object. This is used to compare Disulfide Bond torsion angles to determine their torsional similarity via a 5-Dimensional Euclidean distance metric.

Parameters
  • other: Comparison Disulfide
Raises
  • ProteusPyWarning: Warning if other is not a Disulfide object
Returns

Euclidean distance (Degrees) between self and other.

def torsion_neighbors(self, others, cutoff) -> proteusPy.DisulfideList.DisulfideList:
2275    def torsion_neighbors(self, others, cutoff) -> DisulfideList:
2276        """
2277        Return a list of Disulfides within the angular cutoff in the others list.
2278        This routine is used to find Disulfides having the same torsion length
2279        within the others list. This is used to find families of Disulfides with
2280        similar conformations. Assumes self is properly initialized.
2281
2282        *NB* The routine will not distinguish between +/-
2283        dihedral angles. *i.e.* [-60, -60, -90, -60, -60] would have the same
2284        torsion length as [60, 60, 90, 60, 60], two clearly different structures.
2285
2286        :param others: ```DisulfideList``` to search
2287        :param cutoff: Dihedral angle degree cutoff
2288        :return: DisulfideList within the cutoff
2289
2290        Example:
2291        In this example we load the disulfide database subset, find the disulfides with
2292        the lowest and highest energies, and then find the nearest conformational neighbors.
2293        Finally, we display the neighbors overlaid against a common reference frame.
2294
2295        >>> import proteusPy as pp
2296        >>> _theme = pp.set_pyvista_theme("auto")
2297        >>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
2298        >>> ss_list = pp.DisulfideList([], 'tmp')
2299
2300        We point to the complete list to search for lowest and highest energies.
2301        >>> sslist = PDB_SS.SSList
2302        >>> ssmin_enrg, ssmax_enrg = PDB_SS.SSList.minmax_energy
2303
2304        Make an empty list and find the nearest neighbors within 10 degrees avg RMS in
2305        sidechain dihedral angle space.
2306
2307        >>> low_energy_neighbors = DisulfideList([],'Neighbors')
2308        >>> low_energy_neighbors = ssmin_enrg.torsion_neighbors(sslist, 8)
2309
2310        Display the number found, and then display them overlaid onto their common reference frame.
2311
2312        >>> tot = low_energy_neighbors.length
2313        >>> print(f'Neighbors: {tot}')
2314        Neighbors: 8
2315        >>> low_energy_neighbors.display_overlay(light="auto")
2316
2317        """
2318
2319        res = [ss for ss in others if self.torsion_distance(ss) <= cutoff]
2320        return DisulfideList(res, "neighbors")

Return a list of Disulfides within the angular cutoff in the others list. This routine is used to find Disulfides having the same torsion length within the others list. This is used to find families of Disulfides with similar conformations. Assumes self is properly initialized.

NB The routine will not distinguish between +/- dihedral angles. i.e. [-60, -60, -90, -60, -60] would have the same torsion length as [60, 60, 90, 60, 60], two clearly different structures.

Parameters
  • others: DisulfideList to search
  • cutoff: Dihedral angle degree cutoff
Returns

DisulfideList within the cutoff

Example: In this example we load the disulfide database subset, find the disulfides with the lowest and highest energies, and then find the nearest conformational neighbors. Finally, we display the neighbors overlaid against a common reference frame.

>>> import proteusPy as pp
>>> _theme = pp.set_pyvista_theme("auto")
>>> PDB_SS = pp.Load_PDB_SS(verbose=False, subset=True)
>>> ss_list = pp.DisulfideList([], 'tmp')

We point to the complete list to search for lowest and highest energies.

>>> sslist = PDB_SS.SSList
>>> ssmin_enrg, ssmax_enrg = PDB_SS.SSList.minmax_energy

Make an empty list and find the nearest neighbors within 10 degrees avg RMS in sidechain dihedral angle space.

>>> low_energy_neighbors = DisulfideList([],'Neighbors')
>>> low_energy_neighbors = ssmin_enrg.torsion_neighbors(sslist, 8)

Display the number found, and then display them overlaid onto their common reference frame.

>>> tot = low_energy_neighbors.length
>>> print(f'Neighbors: {tot}')
Neighbors: 8
>>> low_energy_neighbors.display_overlay(light="auto")
def translate(self, translation_vector: proteusPy.vector3D.Vector3D):
2322    def translate(self, translation_vector: Vector3D):
2323        """Translate the Disulfide object by the given vector."""
2324
2325        self.n_prox += translation_vector
2326        self.ca_prox += translation_vector
2327        self.c_prox += translation_vector
2328        self.o_prox += translation_vector
2329        self.cb_prox += translation_vector
2330        self.sg_prox += translation_vector
2331        self.sg_dist += translation_vector
2332        self.cb_dist += translation_vector
2333        self.ca_dist += translation_vector
2334        self.n_dist += translation_vector
2335        self.c_dist += translation_vector
2336        self.o_dist += translation_vector
2337
2338        self.c_prev_prox += translation_vector
2339        self.n_next_prox += translation_vector
2340        self.c_prev_dist += translation_vector
2341        self.n_next_dist += translation_vector
2342        self._compute_local_coords()

Translate the Disulfide object by the given vector.

def disulfide_energy_function(x: list) -> float:
2348def disulfide_energy_function(x: list) -> float:
2349    """
2350    Compute the approximate torsional energy (kcal/mpl) for the input dihedral angles.
2351
2352    :param x: A list of dihedral angles: [chi1, chi2, chi3, chi4, chi5]
2353    :return: Energy in kcal/mol
2354
2355    Example:
2356    >>> from proteusPy import disulfide_energy_function
2357    >>> dihed = [-60.0, -60.0, -90.0, -60.0, -90.0]
2358    >>> res = disulfide_energy_function(dihed)
2359    >>> float(res)
2360    2.5999999999999996
2361    """
2362
2363    chi1, chi2, chi3, chi4, chi5 = x
2364    energy = 2.0 * (np.cos(np.deg2rad(3.0 * chi1)) + np.cos(np.deg2rad(3.0 * chi5)))
2365    energy += np.cos(np.deg2rad(3.0 * chi2)) + np.cos(np.deg2rad(3.0 * chi4))
2366    energy += (
2367        3.5 * np.cos(np.deg2rad(2.0 * chi3))
2368        + 0.6 * np.cos(np.deg2rad(3.0 * chi3))
2369        + 10.1
2370    )
2371    return energy

Compute the approximate torsional energy (kcal/mpl) for the input dihedral angles.

Parameters
  • x: A list of dihedral angles: [chi1, chi2, chi3, chi4, chi5]
Returns

Energy in kcal/mol

Example:

>>> from proteusPy import disulfide_energy_function
>>> dihed = [-60.0, -60.0, -90.0, -60.0, -90.0]
>>> res = disulfide_energy_function(dihed)
>>> float(res)
2.5999999999999996
def minimize_ss_energy(inputSS: Disulfide) -> Disulfide:
2374def minimize_ss_energy(inputSS: Disulfide) -> Disulfide:
2375    """
2376    Minimizes the energy of a Disulfide object using the Nelder-Mead optimization method.
2377
2378    Parameters:
2379        inputSS (Disulfide): The Disulfide object to be minimized.
2380
2381    Returns:
2382        Disulfide: The minimized Disulfide object.
2383
2384    """
2385
2386    initial_guess = inputSS.torsion_array
2387    result = minimize(disulfide_energy_function, initial_guess, method="Nelder-Mead")
2388    minimum_conformation = result.x
2389    modelled_min = Disulfide("minimized", minimum_conformation)
2390    # modelled_min.dihedrals = minimum_conformation
2391    # modelled_min.build_yourself()
2392    return modelled_min

Minimizes the energy of a Disulfide object using the Nelder-Mead optimization method.

Parameters: inputSS (Disulfide): The Disulfide object to be minimized.

Returns: Disulfide: The minimized Disulfide object.

def Initialize_Disulfide_From_Coords( ssbond_atom_data, pdb_id, proximal_chain_id, distal_chain_id, proximal, distal, resolution, proximal_secondary, distal_secondary, verbose=False, quiet=True, dbg=False) -> Disulfide:
2395def Initialize_Disulfide_From_Coords(
2396    ssbond_atom_data,
2397    pdb_id,
2398    proximal_chain_id,
2399    distal_chain_id,
2400    proximal,
2401    distal,
2402    resolution,
2403    proximal_secondary,
2404    distal_secondary,
2405    verbose=False,
2406    quiet=True,
2407    dbg=False,
2408) -> Disulfide:
2409    """
2410    Initialize a new Disulfide object with atomic coordinates from
2411    the proximal and distal coordinates, typically taken from a PDB file.
2412    This routine is primarily used internally when building the compressed
2413    database.
2414
2415    :param ssbond_atom_data: Dictionary containing atomic data for the disulfide bond.
2416    :type ssbond_atom_data: dict
2417    :param pdb_id: PDB identifier for the structure.
2418    :type pdb_id: str
2419    :param proximal_chain_id: Chain identifier for the proximal residue.
2420    :type proximal_chain_id: str
2421    :param distal_chain_id: Chain identifier for the distal residue.
2422    :type distal_chain_id: str
2423    :param proximal: Residue number for the proximal residue.
2424    :type proximal: int
2425    :param distal: Residue number for the distal residue.
2426    :type distal: int
2427    :param resolution: Structure resolution.
2428    :type resolution: float
2429    :param verbose: If True, enables verbose logging. Defaults to False.
2430    :type verbose: bool, optional
2431    :param quiet: If True, suppresses logging output. Defaults to True.
2432    :type quiet: bool, optional
2433    :param dbg: If True, enables debug mode. Defaults to False.
2434    :type dbg: bool, optional
2435    :return: An instance of the Disulfide class initialized with the provided coordinates.
2436    :rtype: Disulfide
2437    :raises DisulfideConstructionWarning: Raised when the disulfide bond is not parsed correctly.
2438
2439    """
2440
2441    ssbond_name = f"{pdb_id}_{proximal}{proximal_chain_id}_{distal}{distal_chain_id}"
2442    new_ss = Disulfide(ssbond_name)
2443
2444    new_ss.pdb_id = pdb_id
2445    new_ss.resolution = resolution
2446    new_ss.proximal_secondary = proximal_secondary
2447    new_ss.distal_secondary = distal_secondary
2448    prox_atom_list = []
2449    dist_atom_list = []
2450
2451    if quiet:
2452        _logger.setLevel(logging.ERROR)
2453        logging.getLogger().setLevel(logging.CRITICAL)
2454
2455    # set the objects proximal and distal values
2456    new_ss.set_resnum(proximal, distal)
2457
2458    if resolution is not None:
2459        new_ss.resolution = resolution
2460    else:
2461        new_ss.resolution = -1.0
2462
2463    new_ss.proximal_chain = proximal_chain_id
2464    new_ss.distal_chain = distal_chain_id
2465
2466    # restore loggins
2467    if quiet:
2468        _logger.setLevel(logging.ERROR)
2469        logging.getLogger().setLevel(logging.CRITICAL)  ## may want to be CRITICAL
2470
2471    # Get the coordinates for the proximal and distal residues as vectors
2472    # so we can do math on them later. Trap errors here to avoid problems
2473    # with missing residues or atoms.
2474
2475    # proximal residue
2476
2477    try:
2478        prox_atom_list = get_residue_atoms_coordinates(
2479            ssbond_atom_data, proximal_chain_id, proximal
2480        )
2481
2482        n1 = prox_atom_list[0]
2483        ca1 = prox_atom_list[1]
2484        c1 = prox_atom_list[2]
2485        o1 = prox_atom_list[3]
2486        cb1 = prox_atom_list[4]
2487        sg1 = prox_atom_list[5]
2488
2489    except KeyError:
2490        # i'm torn on this. there are a lot of missing coordinates, so is
2491        # it worth the trouble to note them? I think so.
2492        _logger.error(f"Invalid/missing coordinates for: {id}, proximal: {proximal}")
2493        return None
2494
2495    # distal residue
2496    try:
2497        dist_atom_list = get_residue_atoms_coordinates(
2498            ssbond_atom_data, distal_chain_id, distal
2499        )
2500        n2 = dist_atom_list[0]
2501        ca2 = dist_atom_list[1]
2502        c2 = dist_atom_list[2]
2503        o2 = dist_atom_list[3]
2504        cb2 = dist_atom_list[4]
2505        sg2 = dist_atom_list[5]
2506
2507    except KeyError:
2508        _logger.error(f"Invalid/missing coordinates for: {id}, distal: {distal}")
2509        return False
2510
2511    # previous residue and next residue - optional, used for phi, psi calculations
2512    prevprox_atom_list = get_phipsi_atoms_coordinates(
2513        ssbond_atom_data, proximal_chain_id, "proximal-1"
2514    )
2515
2516    nextprox_atom_list = get_phipsi_atoms_coordinates(
2517        ssbond_atom_data, proximal_chain_id, "proximal+1"
2518    )
2519
2520    prevdist_atom_list = get_phipsi_atoms_coordinates(
2521        ssbond_atom_data, distal_chain_id, "distal-1"
2522    )
2523
2524    nextdist_atom_list = get_phipsi_atoms_coordinates(
2525        ssbond_atom_data, distal_chain_id, "distal+1"
2526    )
2527
2528    if len(prevprox_atom_list) != 0:
2529        cprev_prox = prevprox_atom_list[1]
2530        new_ss.phiprox = calc_dihedral(cprev_prox, n1, ca1, c1)
2531
2532    else:
2533        cprev_prox = Vector3D(-1.0, -1.0, -1.0)
2534        new_ss.missing_atoms = True
2535        if verbose:
2536            _logger.warning(
2537                f"Missing Proximal coords for: {id} {proximal}-1. SS: {proximal}-{distal}, phi/psi not computed."
2538            )
2539
2540    if len(prevdist_atom_list) != 0:
2541        # list is N, C
2542        cprev_dist = prevdist_atom_list[1]
2543        new_ss.phidist = calc_dihedral(cprev_dist, n2, ca2, c2)
2544    else:
2545        cprev_dist = nnext_dist = Vector3D(-1.0, -1.0, -1.0)
2546        new_ss.missing_atoms = True
2547        if verbose:
2548            _logger.warning(
2549                f"Missing Distal coords for: {id} {distal}-1). S:S {proximal}-{distal}, phi/psi not computed."
2550            )
2551
2552    if len(nextprox_atom_list) != 0:
2553        nnext_prox = nextprox_atom_list[0]
2554        new_ss.psiprox = calc_dihedral(n1, ca1, c1, nnext_prox)
2555    else:
2556        nnext_prox = Vector3D(-1.0, -1.0, -1.0)
2557        new_ss.missing_atoms = True
2558        _logger.warning(
2559            f"Missing Proximal coords for: {id} {proximal}+1). SS: {proximal}-{distal}, phi/psi not computed."
2560        )
2561
2562    if len(nextdist_atom_list) != 0:
2563        nnext_dist = nextdist_atom_list[0]
2564        new_ss.psidist = calc_dihedral(n2, ca2, c2, nnext_dist)
2565    else:
2566        nnext_dist = Vector3D(-1.0, -1.0, -1.0)
2567        new_ss.missing_atoms = True
2568        _logger.warning(
2569            f"Missing Distal coords for: {id} {distal}+1). SS: {proximal}-{distal}, phi/psi not computed."
2570        )
2571
2572    # update the positions and conformation
2573    new_ss.set_positions(
2574        n1,
2575        ca1,
2576        c1,
2577        o1,
2578        cb1,
2579        sg1,
2580        n2,
2581        ca2,
2582        c2,
2583        o2,
2584        cb2,
2585        sg2,
2586        cprev_prox,
2587        nnext_prox,
2588        cprev_dist,
2589        nnext_dist,
2590    )
2591
2592    # calculate and set the disulfide dihedral angles
2593    new_ss.chi1 = calc_dihedral(n1, ca1, cb1, sg1)
2594    new_ss.chi2 = calc_dihedral(ca1, cb1, sg1, sg2)
2595    new_ss.chi3 = calc_dihedral(cb1, sg1, sg2, cb2)
2596    new_ss.chi4 = calc_dihedral(sg1, sg2, cb2, ca2)
2597    new_ss.chi5 = calc_dihedral(sg2, cb2, ca2, n2)
2598    new_ss.ca_distance = distance3d(new_ss.ca_prox, new_ss.ca_dist)
2599    new_ss.cb_distance = distance3d(new_ss.cb_prox, new_ss.cb_dist)
2600    new_ss.sg_distance = distance3d(new_ss.sg_prox, new_ss.sg_dist)
2601
2602    new_ss.torsion_array = np.array(
2603        (new_ss.chi1, new_ss.chi2, new_ss.chi3, new_ss.chi4, new_ss.chi5)
2604    )
2605    new_ss._compute_torsion_length()
2606
2607    # calculate and set the SS bond torsional energy
2608    new_ss._compute_torsional_energy()
2609
2610    # compute and set the local coordinates
2611    new_ss._compute_local_coords()
2612
2613    # compute rho
2614    new_ss._compute_rho()
2615
2616    # turn warnings back on
2617    if quiet:
2618        _logger.setLevel(logging.ERROR)
2619
2620    if verbose:
2621        _logger.info(f"Disulfide {ssbond_name} initialized.")
2622
2623    return new_ss

Initialize a new Disulfide object with atomic coordinates from the proximal and distal coordinates, typically taken from a PDB file. This routine is primarily used internally when building the compressed database.

Parameters
  • ssbond_atom_data: Dictionary containing atomic data for the disulfide bond.
  • pdb_id: PDB identifier for the structure.
  • proximal_chain_id: Chain identifier for the proximal residue.
  • distal_chain_id: Chain identifier for the distal residue.
  • proximal: Residue number for the proximal residue.
  • distal: Residue number for the distal residue.
  • resolution: Structure resolution.
  • verbose: If True, enables verbose logging. Defaults to False.
  • quiet: If True, suppresses logging output. Defaults to True.
  • dbg: If True, enables debug mode. Defaults to False.
Returns

An instance of the Disulfide class initialized with the provided coordinates.

Raises
  • DisulfideConstructionWarning: Raised when the disulfide bond is not parsed correctly.