Source code for hbat.core.interactions

"""
Molecular interaction classes for HBAT analysis.

This module defines the data structures for representing different types of
molecular interactions including hydrogen bonds, halogen bonds, π interactions,
and cooperativity chains.
"""

import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional, Union

from .np_vector import NPVec3D
from .structure import Atom


[docs] class MolecularInteraction(ABC): """Base class for all molecular interactions. This abstract base class defines the unified interface for all types of molecular interactions analyzed by HBAT, including hydrogen bonds, halogen bonds, and π interactions. All interactions have the following core components: - Donor: The electron/proton donor (atom or virtual atom) - Acceptor: The electron/proton acceptor (atom or virtual atom) - Interaction: The mediating atom/point (e.g., hydrogen, π center) - Geometry: Distances and angles defining the interaction - Bonding: The interaction atom must be bonded to the donor atom **Bonding Requirements:** - For H-bonds: Hydrogen must be covalently bonded to the donor - For X-bonds: Halogen is covalently bonded to donor carbon - For X-H...π interactions: Hydrogen must be covalently bonded to the donor - For π-π stacking (future): No bonding requirement - uses centroid distances """
[docs] @abstractmethod def get_donor(self) -> Union[Atom, NPVec3D]: """Get the donor atom or virtual atom. :returns: The donor atom or virtual atom position :rtype: Union[Atom, NPVec3D] """ pass
[docs] @abstractmethod def get_acceptor(self) -> Union[Atom, NPVec3D]: """Get the acceptor atom or virtual atom. :returns: The acceptor atom or virtual atom position :rtype: Union[Atom, NPVec3D] """ pass
[docs] @abstractmethod def get_interaction(self) -> Union[Atom, NPVec3D]: """Get the interaction mediating atom or point. :returns: The mediating atom (e.g., hydrogen) or virtual point (e.g., π center) :rtype: Union[Atom, NPVec3D] """ pass
[docs] @abstractmethod def get_donor_residue(self) -> str: """Get the donor residue identifier. :returns: String identifier for the donor residue :rtype: str """ pass
[docs] @abstractmethod def get_acceptor_residue(self) -> str: """Get the acceptor residue identifier. :returns: String identifier for the acceptor residue :rtype: str """ pass
[docs] @abstractmethod def get_interaction_type(self) -> str: """Get the interaction type. :returns: String identifier for the interaction type :rtype: str """ pass
[docs] @abstractmethod def get_donor_interaction_distance(self) -> float: """Get the donor to interaction distance. :returns: Distance from donor to interaction point in Angstroms :rtype: float """ pass
[docs] @abstractmethod def get_donor_acceptor_distance(self) -> float: """Get the donor to acceptor distance. :returns: Distance from donor to acceptor in Angstroms :rtype: float """ pass
[docs] @abstractmethod def get_donor_interaction_acceptor_angle(self) -> float: """Get the donor-interaction-acceptor angle. :returns: Angle in radians :rtype: float """ pass
[docs] @abstractmethod def is_donor_interaction_bonded(self) -> bool: """Check if the interaction atom is bonded to the donor atom. This is a fundamental requirement for most molecular interactions (except π-π stacking which will be implemented separately). :returns: True if donor and interaction atom are bonded :rtype: bool """ pass
# Legacy and convenience properties @property def donor(self) -> Union[Atom, NPVec3D]: """Property accessor for donor.""" return self.get_donor() @property def acceptor(self) -> Union[Atom, NPVec3D]: """Property accessor for acceptor.""" return self.get_acceptor() @property def interaction(self) -> Union[Atom, NPVec3D]: """Property accessor for interaction.""" return self.get_interaction() @property def donor_residue(self) -> str: """Property accessor for donor residue.""" return self.get_donor_residue() @property def acceptor_residue(self) -> str: """Property accessor for acceptor residue.""" return self.get_acceptor_residue() @property def interaction_type(self) -> str: """Property accessor for interaction type.""" return self.get_interaction_type() @property def donor_interaction_distance(self) -> float: """Property accessor for donor-interaction distance.""" return self.get_donor_interaction_distance() @property def donor_acceptor_distance(self) -> float: """Property accessor for donor-acceptor distance.""" return self.get_donor_acceptor_distance() @property def donor_interaction_acceptor_angle(self) -> float: """Property accessor for donor-interaction-acceptor angle.""" return self.get_donor_interaction_acceptor_angle() # Legacy compatibility methods
[docs] def get_donor_atom(self) -> Optional[Atom]: """Get the donor atom if it's an Atom instance. :returns: The donor atom if it's an Atom, None otherwise :rtype: Optional[Atom] """ donor = self.get_donor() return donor if isinstance(donor, Atom) else None
[docs] def get_acceptor_atom(self) -> Optional[Atom]: """Get the acceptor atom if it's an Atom instance. :returns: The acceptor atom if it's an Atom, None otherwise :rtype: Optional[Atom] """ acceptor = self.get_acceptor() return acceptor if isinstance(acceptor, Atom) else None
@property def distance(self) -> float: """Legacy property for interaction distance. :returns: Donor-interaction distance for backward compatibility :rtype: float """ return self.get_donor_interaction_distance() @property def angle(self) -> float: """Legacy property for interaction angle. :returns: Donor-interaction-acceptor angle for backward compatibility :rtype: float """ return self.get_donor_interaction_acceptor_angle()
[docs] class HydrogenBond(MolecularInteraction): """Represents a hydrogen bond interaction. This class stores all information about a detected hydrogen bond, including the participating atoms, geometric parameters, and classification information. :param donor: The hydrogen bond donor atom :type donor: Atom :param hydrogen: The hydrogen atom in the bond :type hydrogen: Atom :param acceptor: The hydrogen bond acceptor atom :type acceptor: Atom :param distance: H...A distance in Angstroms :type distance: float :param angle: D-H...A angle in radians :type angle: float :param donor_acceptor_distance: D...A distance in Angstroms :type donor_acceptor_distance: float :param bond_type: Classification of the hydrogen bond type :type bond_type: str :param donor_residue: Identifier for donor residue :type donor_residue: str :param acceptor_residue: Identifier for acceptor residue :type acceptor_residue: str """
[docs] def __init__( self, _donor: Atom, hydrogen: Atom, _acceptor: Atom, distance: float, angle: float, _donor_acceptor_distance: float, bond_type: str, _donor_residue: str, _acceptor_residue: str, ): self._donor = _donor self.hydrogen = hydrogen self._acceptor = _acceptor self._distance = distance self._angle = angle self._donor_acceptor_distance = _donor_acceptor_distance self.bond_type = bond_type self._donor_residue = _donor_residue self._acceptor_residue = _acceptor_residue # Generate donor-acceptor property description self._donor_acceptor_properties = self._generate_donor_acceptor_description()
# Backward compatibility properties @property def distance(self) -> float: return self._distance @property def angle(self) -> float: return self._angle @property def donor(self) -> Atom: """Property accessor for donor atom.""" return self._donor @property def acceptor(self) -> Atom: """Property accessor for acceptor atom.""" return self._acceptor # MolecularInteraction interface implementation
[docs] def get_donor(self) -> Union[Atom, NPVec3D]: return self._donor
[docs] def get_acceptor(self) -> Union[Atom, NPVec3D]: return self._acceptor
[docs] def get_interaction(self) -> Union[Atom, NPVec3D]: return self.hydrogen
[docs] def get_donor_residue(self) -> str: return self._donor_residue
[docs] def get_acceptor_residue(self) -> str: return self._acceptor_residue
[docs] def get_interaction_type(self) -> str: return "hydrogen_bond"
[docs] def get_donor_interaction_distance(self) -> float: """Distance from donor to hydrogen.""" return float(self._donor.coords.distance_to(self.hydrogen.coords))
[docs] def get_donor_acceptor_distance(self) -> float: """Distance from donor to acceptor.""" return self._donor_acceptor_distance
[docs] def get_donor_interaction_acceptor_angle(self) -> float: """D-H...A angle.""" return self._angle
[docs] def is_donor_interaction_bonded(self) -> bool: """Check if hydrogen is bonded to donor. For hydrogen bonds, the hydrogen must be covalently bonded to the donor atom. This method assumes the bond has been validated during creation. :returns: True (assumes validation was done during creation) :rtype: bool """ # In practice, this should be validated during object creation # by checking bond lists in the analyzer return True # Assuming validation was done during creation
def _generate_donor_acceptor_description(self) -> str: """Generate donor-acceptor property description string. Describes the hydrogen bond in terms of: - Donor properties: residue type, backbone/sidechain, aromatic - Acceptor properties: residue type, backbone/sidechain, aromatic Format: "donor_props-acceptor_props" (e.g., "PBS-PS", "DS-LN") :returns: Property description string :rtype: str """ # Get donor properties donor_residue_type = getattr(self._donor, "residue_type", "L") donor_backbone_sidechain = getattr(self._donor, "backbone_sidechain", "S") donor_aromatic = getattr(self._donor, "aromatic", "N") # Get acceptor properties acceptor_residue_type = getattr(self._acceptor, "residue_type", "L") acceptor_backbone_sidechain = getattr(self._acceptor, "backbone_sidechain", "S") acceptor_aromatic = getattr(self._acceptor, "aromatic", "N") # Build property strings donor_props = f"{donor_residue_type}{donor_backbone_sidechain}{donor_aromatic}" acceptor_props = ( f"{acceptor_residue_type}{acceptor_backbone_sidechain}{acceptor_aromatic}" ) return f"{donor_props}-{acceptor_props}" @property def donor_acceptor_properties(self) -> str: """Get the donor-acceptor property description. :returns: Property description string :rtype: str """ return self._donor_acceptor_properties
[docs] def get_backbone_sidechain_interaction(self) -> str: """Get simplified backbone/sidechain interaction description. :returns: Interaction type (B-B, B-S, S-B, S-S) :rtype: str """ donor_bs = getattr(self._donor, "backbone_sidechain", "S") acceptor_bs = getattr(self._acceptor, "backbone_sidechain", "S") return f"{donor_bs}-{acceptor_bs}"
def __str__(self) -> str: return ( f"H-Bond: {self.donor_residue}({self._donor.name}) - " f"H - {self.acceptor_residue}({self._acceptor.name}) " f"[{self.distance:.2f}Å, {math.degrees(self.angle):.1f}°] " f"[{self.get_backbone_sidechain_interaction()}] [{self.donor_acceptor_properties}]" )
[docs] class HalogenBond(MolecularInteraction): """Represents a halogen bond interaction. This class stores information about a detected halogen bond, where a halogen atom acts as an electron acceptor. :param halogen: The halogen atom (F, Cl, Br, I) :type halogen: Atom :param acceptor: The electron donor/acceptor atom :type acceptor: Atom :param distance: X...A distance in Angstroms :type distance: float :param angle: C-X...A angle in radians :type angle: float :param bond_type: Classification of the halogen bond type :type bond_type: str :param halogen_residue: Identifier for halogen-containing residue :type halogen_residue: str :param acceptor_residue: Identifier for acceptor residue :type acceptor_residue: str """
[docs] def __init__( self, halogen: Atom, _acceptor: Atom, distance: float, angle: float, bond_type: str, _halogen_residue: str, _acceptor_residue: str, ): self.halogen = halogen self._acceptor = _acceptor self._distance = distance self._angle = angle self.bond_type = bond_type self._halogen_residue = _halogen_residue self._acceptor_residue = _acceptor_residue
# Backward compatibility properties @property def distance(self) -> float: return self._distance @property def angle(self) -> float: return self._angle @property def halogen_residue(self) -> str: """Legacy property for halogen residue.""" return self._halogen_residue @property def donor(self) -> Atom: """Property accessor for donor atom (halogen).""" return self.halogen @property def acceptor(self) -> Atom: """Property accessor for acceptor atom.""" return self._acceptor # MolecularInteraction interface implementation
[docs] def get_donor(self) -> Union[Atom, NPVec3D]: return self.halogen # Halogen acts as electron acceptor (Lewis acid)
[docs] def get_acceptor(self) -> Union[Atom, NPVec3D]: return self._acceptor
[docs] def get_interaction(self) -> Union[Atom, NPVec3D]: return self.halogen # Halogen is both donor and interaction point
[docs] def get_donor_residue(self) -> str: return self._halogen_residue
[docs] def get_acceptor_residue(self) -> str: return self._acceptor_residue
[docs] def get_interaction_type(self) -> str: return "halogen_bond"
[docs] def get_donor_interaction_distance(self) -> float: """Distance from donor to interaction point (0 for halogen bonds).""" return 0.0 # Halogen is both donor and interaction point
[docs] def get_donor_acceptor_distance(self) -> float: """Distance from halogen to acceptor.""" return self._distance
[docs] def get_donor_interaction_acceptor_angle(self) -> float: """C-X...A angle.""" return self._angle
[docs] def is_donor_interaction_bonded(self) -> bool: """Check if halogen is bonded to donor carbon. For halogen bonds, the halogen atom must be covalently bonded to a carbon atom. The halogen serves as both the donor and interaction point. :returns: True (assumes validation was done during creation) :rtype: bool """ # In practice, this should be validated during object creation # by ensuring the halogen is bonded to carbon return True # Assuming validation was done during creation
def __str__(self) -> str: return ( f"X-Bond: {self._halogen_residue}({self.halogen.name}) - " f"{self._acceptor_residue}({self._acceptor.name}) " f"[{self.distance:.2f}Å, {math.degrees(self.angle):.1f}°]" )
[docs] class PiInteraction(MolecularInteraction): """Represents an X-H...π interaction. This class stores information about a detected X-H...π interaction, where a hydrogen bond donor interacts with an aromatic π system. :param donor: The hydrogen bond donor atom :type donor: Atom :param hydrogen: The hydrogen atom :type hydrogen: Atom :param pi_center: Center of the aromatic π system :type pi_center: NPVec3D :param distance: H...π distance in Angstroms :type distance: float :param angle: D-H...π angle in radians :type angle: float :param donor_residue: Identifier for donor residue :type donor_residue: str :param pi_residue: Identifier for π-containing residue :type pi_residue: str """
[docs] def __init__( self, _donor: Atom, hydrogen: Atom, pi_center: NPVec3D, distance: float, angle: float, _donor_residue: str, _pi_residue: str, ): self._donor = _donor self.hydrogen = hydrogen self.pi_center = pi_center self._distance = distance self._angle = angle self._donor_residue = _donor_residue self._pi_residue = _pi_residue # Generate donor-acceptor property description self._donor_acceptor_properties = self._generate_donor_acceptor_description()
# Backward compatibility properties @property def distance(self) -> float: return self._distance @property def angle(self) -> float: return self._angle @property def pi_residue(self) -> str: """Legacy property for π residue.""" return self._pi_residue @property def donor(self) -> Atom: """Property accessor for donor atom.""" return self._donor # MolecularInteraction interface implementation
[docs] def get_donor(self) -> Union[Atom, NPVec3D]: return self._donor
[docs] def get_acceptor(self) -> Union[Atom, NPVec3D]: return self.pi_center # π center is the acceptor
[docs] def get_interaction(self) -> Union[Atom, NPVec3D]: return self.hydrogen
[docs] def get_donor_residue(self) -> str: return self._donor_residue
[docs] def get_acceptor_residue(self) -> str: return self._pi_residue
[docs] def get_interaction_type(self) -> str: return "pi_interaction"
[docs] def get_donor_interaction_distance(self) -> float: """Distance from donor to hydrogen.""" return float(self._donor.coords.distance_to(self.hydrogen.coords))
[docs] def get_donor_acceptor_distance(self) -> float: """Distance from donor to π center.""" return float(self._donor.coords.distance_to(self.pi_center))
[docs] def get_donor_interaction_acceptor_angle(self) -> float: """D-H...π angle.""" return self._angle
[docs] def is_donor_interaction_bonded(self) -> bool: """Check if hydrogen is bonded to donor. For X-H...π interactions, the hydrogen must be covalently bonded to the donor atom. :returns: True (assumes validation was done during creation) :rtype: bool """ # In practice, this should be validated during object creation # by checking bond lists in the analyzer return True # Assuming validation was done during creation
def _generate_donor_acceptor_description(self) -> str: """Generate donor-acceptor property description string. Describes the π interaction in terms of: - Donor properties: residue type, backbone/sidechain, aromatic - Acceptor properties: residue type, backbone/sidechain, aromatic (always aromatic for π) Format: "donor_props-acceptor_props" (e.g., "PSN-PSA") :returns: Property description string :rtype: str """ # Get donor properties donor_residue_type = getattr(self._donor, "residue_type", "L") donor_backbone_sidechain = getattr(self._donor, "backbone_sidechain", "S") donor_aromatic = getattr(self._donor, "aromatic", "N") # For π interactions, we need to determine acceptor properties from the π residue # Since we don't have the actual π atoms, we'll use the residue info from ..constants.pdb_constants import ( DNA_RESIDUES, PROTEIN_RESIDUES, RNA_RESIDUES, ) pi_res_name = ( self._pi_residue.split("_")[0] if "_" in self._pi_residue else self._pi_residue.split(":")[0] ) if pi_res_name in PROTEIN_RESIDUES: acceptor_residue_type = "P" elif pi_res_name in DNA_RESIDUES: acceptor_residue_type = "D" elif pi_res_name in RNA_RESIDUES: acceptor_residue_type = "R" else: acceptor_residue_type = "L" # π system atoms are always sidechain and aromatic acceptor_backbone_sidechain = "S" acceptor_aromatic = "A" # Build property strings donor_props = f"{donor_residue_type}{donor_backbone_sidechain}{donor_aromatic}" acceptor_props = ( f"{acceptor_residue_type}{acceptor_backbone_sidechain}{acceptor_aromatic}" ) return f"{donor_props}-{acceptor_props}" @property def donor_acceptor_properties(self) -> str: """Get the donor-acceptor property description. :returns: Property description string :rtype: str """ return self._donor_acceptor_properties
[docs] def get_backbone_sidechain_interaction(self) -> str: """Get simplified backbone/sidechain interaction description. :returns: Interaction type (B-S, S-S, etc.) :rtype: str """ donor_bs = getattr(self._donor, "backbone_sidechain", "S") # π systems are always sidechain acceptor_bs = "S" return f"{donor_bs}-{acceptor_bs}"
[docs] def get_interaction_type_display(self) -> str: """Get the interaction type for display purposes. :returns: Display format like "D-H...π" :rtype: str """ return "D-H...π"
def __str__(self) -> str: return ( f"π-Int: {self._donor_residue}({self._donor.name}) - H...π - " f"{self._pi_residue} [{self.distance:.2f}Å, {math.degrees(self.angle):.1f}°] " f"[{self.get_backbone_sidechain_interaction()}] [{self.donor_acceptor_properties}]" )
[docs] class CooperativityChain(MolecularInteraction): """Represents a chain of cooperative molecular interactions. This class represents a series of linked molecular interactions where the acceptor of one interaction acts as the donor of the next, creating cooperative effects. :param interactions: List of interactions in the chain :type interactions: List[Union[HydrogenBond, HalogenBond, PiInteraction]] :param chain_length: Number of interactions in the chain :type chain_length: int :param chain_type: Description of the interaction types in the chain :type chain_type: str """
[docs] def __init__( self, interactions: List[Union[HydrogenBond, HalogenBond, PiInteraction]], chain_length: int, chain_type: str, ): self.interactions = interactions self.chain_length = chain_length self.chain_type = chain_type # e.g., "H-Bond -> X-Bond -> π-Int"
# MolecularInteraction interface implementation
[docs] def get_donor(self) -> Union[Atom, NPVec3D]: """Get the donor of the first interaction in the chain.""" if self.interactions: return self.interactions[0].get_donor() return NPVec3D(0, 0, 0) # Return a default NPVec3D instead of None
[docs] def get_acceptor(self) -> Union[Atom, NPVec3D]: """Get the acceptor of the last interaction in the chain.""" if self.interactions: return self.interactions[-1].get_acceptor() return NPVec3D(0, 0, 0) # Return a default NPVec3D instead of None
[docs] def get_interaction(self) -> Union[Atom, NPVec3D]: """Get the center point of the chain (middle interaction point).""" if not self.interactions: return NPVec3D(0, 0, 0) # Return a default NPVec3D instead of None mid_idx = len(self.interactions) // 2 return self.interactions[mid_idx].get_interaction()
[docs] def get_donor_residue(self) -> str: """Get the donor residue of the first interaction.""" return ( self.interactions[0].get_donor_residue() if self.interactions else "Unknown" )
[docs] def get_acceptor_residue(self) -> str: """Get the acceptor residue of the last interaction.""" return ( self.interactions[-1].get_acceptor_residue() if self.interactions else "Unknown" )
[docs] def get_interaction_type(self) -> str: return "cooperativity_chain"
[docs] def get_donor_interaction_distance(self) -> float: """Get the distance from chain start to middle interaction.""" if not self.interactions: return 0.0 first_donor = self.interactions[0].get_donor() mid_idx = len(self.interactions) // 2 mid_interaction = self.interactions[mid_idx].get_interaction() if isinstance(first_donor, Atom) and isinstance(mid_interaction, Atom): return float(first_donor.coords.distance_to(mid_interaction.coords)) elif isinstance(first_donor, Atom) and isinstance(mid_interaction, NPVec3D): return float(first_donor.coords.distance_to(mid_interaction)) return 0.0
[docs] def get_donor_acceptor_distance(self) -> float: """Get the distance from chain start to end.""" if not self.interactions: return 0.0 first_donor = self.interactions[0].get_donor() last_acceptor = self.interactions[-1].get_acceptor() if isinstance(first_donor, Atom) and isinstance(last_acceptor, Atom): return float(first_donor.coords.distance_to(last_acceptor.coords)) elif isinstance(first_donor, Atom) and isinstance(last_acceptor, NPVec3D): return float(first_donor.coords.distance_to(last_acceptor)) return 0.0
[docs] def get_donor_interaction_acceptor_angle(self) -> float: """Get the angle across the chain (donor-middle-acceptor).""" if len(self.interactions) < 2: return 0.0 first_donor = self.interactions[0].get_donor() mid_idx = len(self.interactions) // 2 mid_interaction = self.interactions[mid_idx].get_interaction() last_acceptor = self.interactions[-1].get_acceptor() # Calculate angle between first donor, middle interaction, and last acceptor if ( isinstance(first_donor, Atom) and isinstance(last_acceptor, (Atom, NPVec3D)) and isinstance(mid_interaction, (Atom, NPVec3D)) ): donor_pos = first_donor.coords mid_pos = ( mid_interaction.coords if isinstance(mid_interaction, Atom) else mid_interaction ) acceptor_pos = ( last_acceptor.coords if isinstance(last_acceptor, Atom) else last_acceptor ) # Calculate vectors vec1 = NPVec3D( donor_pos.x - mid_pos.x, donor_pos.y - mid_pos.y, donor_pos.z - mid_pos.z, ) vec2 = NPVec3D( acceptor_pos.x - mid_pos.x, acceptor_pos.y - mid_pos.y, acceptor_pos.z - mid_pos.z, ) # Calculate angle dot_product = vec1.x * vec2.x + vec1.y * vec2.y + vec1.z * vec2.z mag1 = math.sqrt(vec1.x**2 + vec1.y**2 + vec1.z**2) mag2 = math.sqrt(vec2.x**2 + vec2.y**2 + vec2.z**2) if mag1 > 0 and mag2 > 0: cos_angle = dot_product / (mag1 * mag2) cos_angle = max(-1.0, min(1.0, cos_angle)) # Clamp to valid range return math.acos(cos_angle) return 0.0
[docs] def is_donor_interaction_bonded(self) -> bool: """Check if interactions in the chain satisfy bonding requirements. For cooperativity chains, each individual interaction must satisfy its own bonding requirements. :returns: True if all interactions in chain are properly bonded :rtype: bool """ # Check that all interactions in the chain satisfy bonding requirements return all( interaction.is_donor_interaction_bonded() for interaction in self.interactions )
def __str__(self) -> str: if not self.interactions: return "Empty chain" chain_str = [] for i, interaction in enumerate(self.interactions): if i == 0: # First interaction: show donor -> acceptor donor_res = interaction.get_donor_residue() donor_atom = interaction.get_donor_atom() donor_name = donor_atom.name if donor_atom else "?" chain_str.append(f"{donor_res}({donor_name})") acceptor_res = interaction.get_acceptor_residue() acceptor_atom = interaction.get_acceptor_atom() if acceptor_atom: acceptor_name = acceptor_atom.name acceptor_str = f"{acceptor_res}({acceptor_name})" else: acceptor_str = acceptor_res # For π interactions interaction_symbol = self._get_interaction_symbol( interaction.get_interaction_type() ) chain_str.append( f" {interaction_symbol} {acceptor_str} [{interaction.get_donor_interaction_acceptor_angle()*180/3.14159:.1f}°]" ) return f"Potential Cooperative Chain[{self.chain_length}]: " + "".join( chain_str ) def _get_interaction_symbol(self, interaction_type: str) -> str: """Get display symbol for interaction type.""" symbols = { "hydrogen_bond": "->", "halogen_bond": "=X=>", "pi_interaction": "~π~>", } return symbols.get(interaction_type, "->")