Repository: biopython/biopython

Issue:
Coordinates update after setting chi angle on aromatic residue doesn't work for the whole ring
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

3.11.0 | packaged by conda-forge | (main, Jan 14 2023, 12:27:40) [GCC 11.3.0]
CPython
Linux-5.19.0-43-generic-x86_64-with-glibc2.35
1.81

### Expected behaviour

If you try to set a new chi angle on an aromatic residue, i.e. Histidine, chi2 I would expect that all atoms coordinates involved in the ring are updated accordingly to the new chi angle. 

### Actual behaviour

Just the coordinates of the atoms involved in the chi angle are updated, causing the ring to collapse or losing its aromatic structure.

### Steps to reproduce

```
def apply_transformation(residue, chain, transformation):
            angle = "chi" + str(2)

           # Set the new angle
            residue.internal_coord.set_angle(angle, transformation)

            # Update coordinates
            chain.internal_to_atom_coordinates(verbose=True)
    return residue
```

https://github.com/biopython/biopython/assets/25704885/3fd14d62-6629-4ff8-8577-491d361cadd3




Discussion:
After few more checks it looks like the bug it's not limited to aromatic residues, but to every kind of residue. 
Attached a PDB file example of VAL.

[VAL.zip](https://github.com/biopython/biopython/files/11630936/VAL.zip)

After reading carefully the` internal_coords.py` code I found that the function that I should use to change a chi angle and update its coordinates for all the neighbours too is the `bond_set()`. 
I was misled by the documentation.

Thank you
If you can suggest changes to the documentation to clarify this point, that'd be welcome. Thanks.
This doesn’t seem right, but even as a documentation error it is still an issue. I shall investigate, please reopen.
Thank you for your replies. If it is a documentation error I can help fixing it, but if not would you please explain to me whts the correct way to apply a new chi angle and recompute the coordinates for the whole side chain? Because if I follow the documentation it clearly doesn’t work. Hope you can reproduce my same behavior. 

Resolving patch:
diff --git a/Bio/PDB/PICIO.py b/Bio/PDB/PICIO.py
--- a/Bio/PDB/PICIO.py
+++ b/Bio/PDB/PICIO.py
@@ -181,7 +181,7 @@ def akcache(akstr: str) -> AtomKey:
         # akstr: full AtomKey string read from .pic file, includes residue info
         try:
             return akc[akstr]
-        except (KeyError):
+        except KeyError:
             ak = akc[akstr] = AtomKey(akstr)
             return ak
 
@@ -305,6 +305,9 @@ def process_dihedron(
         accpt = IC_Residue.accept_atoms
         if not all(ek[i].akl[atmNdx] in accpt for i in range(4)):
             return
+        dangle = float(dangle)
+        dangle = dangle if (dangle <= 180.0) else dangle - 360.0
+        dangle = dangle if (dangle >= -180.0) else dangle + 360.0
         da[ek] = float(dangle)
         sbcic.dihedra[ek] = ric.dihedra[ek] = d = Dihedron(ek)
         d.cic = sbcic
@@ -391,12 +394,13 @@ def default_dihedron(ek: List, ric: IC_Residue) -> None:
                 )
 
             if paKey in da:
+                angl = da[paKey] + dihedra_secondary_defaults[rdclass][1]
                 process_dihedron(
                     str(ek[0]),
                     str(ek[1]),
                     str(ek[2]),
                     str(ek[3]),
-                    da[paKey] + dihedra_secondary_defaults[rdclass][1],
+                    angl,
                     ric,
                 )
 
@@ -427,12 +431,13 @@ def default_dihedron(ek: List, ric: IC_Residue) -> None:
                     )
 
                 if paKey in da:
+                    angl = da[paKey] + offset
                     process_dihedron(
                         str(ek[0]),
                         str(ek[1]),
                         str(ek[2]),
                         str(ek[3]),
-                        da[paKey] + offset,
+                        angl,
                         ric,
                     )
 
@@ -588,7 +593,9 @@ def finish_chain() -> None:
             sha = {k: ha[k] for k in sorted(ha)}
             shl12 = {k: hl12[k] for k in sorted(hl12)}
             shl23 = {k: hl23[k] for k in sorted(hl23)}
-            sbcic._hedraDict2chain(shl12, sha, shl23, da, bfacs)
+            # da not in order if generated from seq
+            sda = {k: da[k] for k in sorted(da)}
+            sbcic._hedraDict2chain(shl12, sha, shl23, sda, bfacs)
 
     # read_PIC processing starts here:
     with as_handle(file, mode="r") as handle:
diff --git a/Bio/PDB/ic_data.py b/Bio/PDB/ic_data.py
--- a/Bio/PDB/ic_data.py
+++ b/Bio/PDB/ic_data.py
@@ -17,6 +17,7 @@
 """
 
 # Backbone hedra and dihedra - within residue, no next or prev (no psi, phi, omg).
+# Core backbone angles like psi, omega, phi, tau defined in :meth:`_create_edra`.
 ic_data_backbone = (
     ("N", "CA", "C", "O"),  # locate backbone O
     ("O", "C", "CA", "CB"),  # locate CB
diff --git a/Bio/PDB/internal_coords.py b/Bio/PDB/internal_coords.py
--- a/Bio/PDB/internal_coords.py
+++ b/Bio/PDB/internal_coords.py
@@ -50,6 +50,8 @@
 
     # rotate residue 1 chi2 angle by 120 degrees (loops w/in +/-180)
     r1.internal_coord.set_angle("chi2", r1chi2 + 120.0)
+    # or
+    r1.internal_coord.bond_rotate("chi2", 120.0)
     # update myChain XYZ coordinates with chi2 changed
     myChain.internal_to_atom_coordinates()
     # write new conformation with PDBIO
@@ -450,6 +452,8 @@ class IC_Chain:
     distance_to_internal_coordinates:
         Compute internal coordinates from distance plot and array of dihedral
         angle signs.
+    make_extended:
+        Arbitrarily sets all psi and phi backbone angles to 123 and -104 degrees.
 
     """
 
@@ -1592,7 +1596,6 @@ def internal_to_atom_coordinates(
         #            )
 
         if IC_Chain.ParallelAssembleResidues and not (start or fin):
-
             self.propagate_changes()
             self.init_atom_coords()  # compute initial di/hedra coords
             # transform init di/hedra to chain coord space
@@ -2220,11 +2223,21 @@ def copy_initNCaCs(self, other: "IC_Chain") -> None:
         chain breaks (otherwise each fragment will start at origin).
 
         Also useful if copying internal coordinates from another chain.
+
+        N.B. :meth:`IC_Residue.set_angle()` and :meth:`IC_Residue.set_length()`
+        invalidate their relevant atoms, so apply them before calling this
+        function.
         """
         ndx = [self.atomArrayIndex[ak] for iNCaC in other.initNCaCs for ak in iNCaC]
         self.atomArray[ndx] = other.atomArray[ndx]
         self.atomArrayValid[ndx] = True
 
+    def make_extended(self):
+        """Set all psi and phi angles to extended conformation (123, -104)."""
+        for ric in self.ordered_aa_ic_list:
+            ric.set_angle("psi", 123)
+            ric.set_angle("phi", -104)
+
 
 class IC_Residue:
     """Class to extend Biopython Residue with internal coordinate data.
@@ -2654,7 +2667,7 @@ def rak(self, atm: Union[str, Atom]) -> "AtomKey":
         """Cache calls to AtomKey for this residue."""
         try:
             ak = self.akc[atm]
-        except (KeyError):
+        except KeyError:
             ak = self.akc[atm] = AtomKey(self, atm)
             if isinstance(atm, str):
                 ak.missing = True
@@ -3720,27 +3733,52 @@ def get_angle(self, angle_key: Union[EKT, str]) -> Optional[float]:
             return edron.angle
         return None
 
-    def set_angle(self, angle_key: Union[EKT, str], v: float):
+    def set_angle(self, angle_key: Union[EKT, str], v: float, overlap=True):
         """Set dihedron or hedron angle for specified key.
 
-        See :meth:`.pick_angle` for key specifications.
+        If angle is a `Dihedron` and `overlap` is True (default), overlapping
+        dihedra are also changed as appropriate.  The overlap is a result of
+        protein chain definitions in :mod:`.ic_data` and :meth:`_create_edra`
+        (e.g. psi overlaps N-CA-C-O).
+
+        Te default overlap=True is probably what you want for:
+        `set_angle("chi1", val)`
+
+        The default is probably NOT what you want when processing all dihedrals
+        in a chain or residue (such as copying from another structure), as the
+        overlaping dihedra will likely be in the set as well.
+
+        N.B. setting e.g. PRO chi2 is permitted without error or warning!
+
+        See :meth:`.pick_angle` for angle_key specifications.
+        See :meth:`.bond_rotate` to change a dihedral by a number of degrees
+
+        :param angle_key: angle identifier.
+        :param float v: new angle in degrees (result adjusted to +/-180).
+        :param bool overlap: default True.
+            Modify overlapping dihedra as needed
         """
         edron = self.pick_angle(angle_key)
-        if edron is not None:
+        if edron is None:
+            return
+        elif isinstance(edron, Hedron) or not overlap:
             edron.angle = v
+        else:  # Dihedron, do overlap angles
+            delta = Dihedron.angle_dif(edron.angle, v)
+            self._do_bond_rotate(edron, delta)
 
     def _do_bond_rotate(self, base: "Dihedron", delta: float):
         """Find and modify related dihedra through id3_dh_index."""
         try:
             for dk in self.cic.id3_dh_index[base.id3]:
                 # change all diheds with same first hedron
-                dihed = self.dihedra[dk]
+                dihed = self.cic.dihedra[dk]
                 dihed.angle += delta  # +/- 180 handled in setter
                 # for changed dihed, change any with reverse key 2nd hedron
                 # so change N-Ca-C-N will change O-Ca-C-Cb
                 try:
                     for d2rk in self.cic.id3_dh_index[dihed.id32[::-1]]:
-                        self.dihedra[d2rk].angle += delta
+                        self.cic.dihedra[d2rk].angle += delta
                 except KeyError:
                     pass
         except AttributeError:
@@ -3749,19 +3787,31 @@ def _do_bond_rotate(self, base: "Dihedron", delta: float):
     def bond_rotate(self, angle_key: Union[EKT, str], delta: float):
         """Rotate set of overlapping dihedrals by delta degrees.
 
+        Changes a dihedral angle by a given delta, i.e.
+        new_angle = current_angle + delta
+        Values are adjusted so new_angle iwll be within +/-180.
+
+        Changes overlapping dihedra as in :meth:`.set_angle`
+
         See :meth:`.pick_angle` for key specifications.
         """
         base = self.pick_angle(angle_key)
-        self._do_bond_rotate(base, delta)
+        if base is not None:
+            self._do_bond_rotate(base, delta)
 
     def bond_set(self, angle_key: Union[EKT, str], val: float):
         """Set dihedron to val, update overlapping dihedra by same amount.
 
+        Redundant to :meth:`.set_angle`, retained for compatibility.  Unlike
+        :meth:`.set_angle` this is for dihedra only and no option to not update
+        overlapping dihedra.
+
         See :meth:`.pick_angle` for key specifications.
         """
         base = self.pick_angle(angle_key)
-        delta = Dihedron.angle_dif(base.angle, val)
-        self._do_bond_rotate(base, delta)
+        if base is not None:
+            delta = Dihedron.angle_dif(base.angle, val)
+            self._do_bond_rotate(base, delta)
 
     def pick_length(
         self, ak_spec: Union[str, BKT]
@@ -4396,15 +4446,17 @@ def angle(self, dangle_deg_in: float) -> None:
         Faster to modify IC_Chain level arrays directly.
 
         This is probably not the routine you are looking for.  See
-        :meth:`IC_Residue.bond_set` to change a dihedral angle along with its
-        neighbours, i.e. without clashing atoms.
+        :meth:`IC_Residue.set_angle` or :meth:`IC_Residue.bond_rotate` to change
+        a dihedral angle along with its overlapping dihedra, i.e. without
+        clashing atoms.
 
         N.B. dihedron (i-1)C-N-CA-CB is ignored if O exists.
         C-beta is by default placed using O-C-CA-CB, but O is missing
         in some PDB file residues, which means the sidechain cannot be
         placed.  The alternate CB path (i-1)C-N-CA-CB is provided to
         circumvent this, but if this is needed then it must be adjusted in
-        conjunction with PHI ((i-1)C-N-CA-C) as they overlap.
+        conjunction with PHI ((i-1)C-N-CA-C) as they overlap.  This is handled
+        by the `IC_Residue` routines above.
 
         :param float dangle_deg: new dihedral angle in degrees
         """
@@ -4417,14 +4469,13 @@ def angle(self, dangle_deg_in: float) -> None:
 
         self._dihedral = dangle_deg
         self.needs_update = True
-        # rtm
-        if True:  # try:
-            cic = self.cic
-            dndx = self.ndx
-            cic.dihedraAngle[dndx] = dangle_deg
-            cic.dihedraAngleRads[dndx] = np.deg2rad(dangle_deg)
-            cic.dAtoms_needs_update[dndx] = True
-            cic.atomArrayValid[cic.atomArrayIndex[self.atomkeys[3]]] = False
+
+        cic = self.cic
+        dndx = self.ndx
+        cic.dihedraAngle[dndx] = dangle_deg
+        cic.dihedraAngleRads[dndx] = np.deg2rad(dangle_deg)
+        cic.dAtoms_needs_update[dndx] = True
+        cic.atomArrayValid[cic.atomArrayIndex[self.atomkeys[3]]] = False
 
     @staticmethod
     def angle_dif(a1: Union[float, np.ndarray], a2: Union[float, np.ndarray]):
@@ -4834,7 +4885,6 @@ def _cmp(self, other: "AtomKey") -> Tuple[int, int]:
                 s1d, o1d = s1.isdigit(), o1.isdigit()
                 # if "H" == s0 == o0: # breaks cython
                 if ("H" == s0) and ("H" == o0):
-
                     if (s1 == o1) or (s1d and o1d):
                         enmS = self._endnum_re.findall(s)
                         enmO = self._endnum_re.findall(o)


Test patch:
diff --git a/Tests/test_PDB_internal_coords.py b/Tests/test_PDB_internal_coords.py
--- a/Tests/test_PDB_internal_coords.py
+++ b/Tests/test_PDB_internal_coords.py
@@ -37,7 +37,8 @@
 from Bio.PDB.internal_coords import IC_Residue, IC_Chain, Dihedron, AtomKey
 
 from Bio.PDB.PDBExceptions import PDBConstructionWarning
-from Bio import SeqIO
+from Bio.Seq import Seq
+from Bio.SeqRecord import SeqRecord
 
 
 class Rebuild(unittest.TestCase):
@@ -537,20 +538,66 @@ def test_distplot_rebuild(self):
         self.assertTrue(np.amax(dpdiff) < 0.000001)
 
     def test_seq_as_PIC(self):
-        """Read protein sequence, generate default PIC data, test various."""
-        seqIter = SeqIO.parse("Fasta/f001", "fasta")
-        for _record in seqIter:
-            break
-        pdb_structure = read_PIC_seq(_record)
+        """Read seq as PIC data, extend chain, set each chi angle, check various."""
+        pdb_structure = read_PIC_seq(
+            SeqRecord(
+                Seq("GAVLIMFPSTCNQYWDEHKR"),
+                id="1RND",
+                description="my 20aa sequence",
+            )
+        )
+        chn = next(pdb_structure.get_chains())
+        cic = chn.internal_coord
+        cic.make_extended()
+        for chi in range(1, 6):
+            for ric in chn.internal_coord.ordered_aa_ic_list:
+                targ = "chi" + str(chi)
+                rchi = ric.get_angle(targ)
+                if rchi is not None:
+                    nangl = rchi + 60.0
+                    # nangl = nangl if (nangl <= 180.0) else nangl - 360.0
+                    ric.set_angle(targ, nangl)
+                    # ric.bond_set("chi1", nangl)
+
         pdb_structure.internal_to_atom_coordinates()
-        for _chn in pdb_structure.get_chains():
-            break
-        cic = _chn.internal_coord
+
         self.assertEqual(
-            len(cic.atomArrayValid), 575, msg="wrong number atoms from Fasta/f001"
+            len(cic.atomArrayValid), 168, msg="wrong number atoms from sequence"
         )
+
+        # test make_extended and each sequential chi rotation places selected
+        # atom from each sidechain where expected.
+        posnDict = {
+            "1_G_CA": [0.000, 0.000, 0.000, 1.000],
+            "2_A_CB": [-0.411, 2.505, 4.035, 1.000],
+            "3_V_CG2": [-2.246, -2.942, 4.865, 1.000],
+            "4_L_CD2": [-4.673, 2.124, 6.060, 1.000],
+            "5_I_CD1": [-4.260, -4.000, 10.546, 1.000],
+            "6_M_CE": [-10.035, 4.220, 12.322, 1.000],
+            "7_F_CE2": [-5.835, -1.076, 15.391, 1.000],
+            "8_P_CD": [-15.060, -2.368, 17.751, 1.000],
+            "9_S_OG": [-12.733, 0.388, 24.504, 1.000],
+            "10_T_CG2": [-19.084, -0.423, 22.650, 1.000],
+            "11_C_SG": [-15.455, 2.166, 27.172, 1.000],
+            "12_N_ND2": [-20.734, -3.990, 27.233, 1.000],
+            "13_Q_NE2": [-19.362, 4.465, 30.847, 1.000],
+            "14_Y_CE2": [-20.685, -3.571, 32.854, 1.000],
+            "15_W_CH2": [-23.295, 1.706, 32.883, 1.000],
+            "16_D_OD2": [-24.285, -3.683, 40.620, 1.000],
+            "17_E_OE2": [-31.719, 2.385, 38.887, 1.000],
+            "18_H_NE2": [-25.763, -0.071, 46.356, 1.000],
+            "19_K_NZ": [-36.073, -0.926, 42.383, 1.000],
+            "20_R_NH2": [-28.792, 3.687, 51.738, 1.000],
+        }
+
+        for k, v in posnDict.items():
+            atm = AtomKey(k)
+            ndx = cic.atomArrayIndex[atm]
+            coord = np.round(cic.atomArray[ndx], 3)
+            self.assertTrue(np.array_equal(coord, v), msg=f"position error on atom {k}")
+
         cic.update_dCoordSpace()
-        rt = cic.ordered_aa_ic_list[10]  # pick a residue
+        rt = cic.ordered_aa_ic_list[5]  # pick a residue
         chi1 = rt.pick_angle("chi1")  # chi1 coord space puts CA at origin
         rt.applyMtx(chi1.cst)
         coord = rt.residue.child_dict["CA"].coord  # Biopython API Atom coords
@@ -558,13 +605,15 @@ def test_seq_as_PIC(self):
             np.allclose(coord, [0.0, 0.0, 0.0]), msg="dCoordSpace transform error"
         )
 
+        # test Dihedron repr and all that leads into it
         psi = rt.pick_angle("psi")
 
         self.assertEqual(
             psi.__repr__(),
-            "4-11_M_N:11_M_CA:11_M_C:12_A_N MNMCAMCAN 179.0 ('gi|3318709|pdb|1A91|', 0, 'A', (' ', 11, ' '))",
-            msg="dihedron __repr__ error for M11 psi",
+            "4-6_M_N:6_M_CA:6_M_C:7_F_N MNMCAMCFN 123.0 ('1RND', 0, 'A', (' ', 6, ' '))",
+            msg="dihedron __repr__ error for M6 psi",
         )
+
         m = "Edron rich comparison failed"
         self.assertTrue(chi1 != psi, msg=m)
         self.assertFalse(chi1 == psi, msg=m)
@@ -578,15 +627,20 @@ def test_seq_as_PIC(self):
             msg="covalent radii assignment error for chi1",
         )
 
+        # dihedron atomkeys are all in residue atomkeys as expected, including
+        # i+1 N for psi.
         self.assertTrue(all(ak in rt for ak in chi1.atomkeys))
         self.assertFalse(all(ak in rt for ak in psi.atomkeys))
+
+        # test Hedron repr and all that leads into it
         tau = rt.pick_angle("tau")
         self.assertEqual(
             tau.__repr__(),
-            "3-11_M_N:11_M_CA:11_M_C MNMCAMC 1.46091 110.97184 1.52499",
+            "3-6_M_N:6_M_CA:6_M_C MNMCAMC 1.46091 110.97184 1.52499",
             msg="hedron __repr__ error for M11 tau",
         )
-        # some specific AtomKey compsrisons missed in other tests
+
+        # some specific AtomKey comparisons missed in other tests
         a0, a1 = tau.atomkeys[0], tau.atomkeys[1]
         m = "AtomKey rich comparison failed"
         self.assertTrue(a1 > a0, msg=m)