# Copyright 2020-2022 by Robert T. Miller.  All rights reserved.
# This file is part of the Biopython distribution and governed by your
# choice of the "Biopython License Agreement" or the "BSD 3-Clause License".
# Please see the LICENSE file that should have been included as part of this
# package.

"""Unit tests for internal_coords module of Bio.PDB."""

import copy
import re
import unittest
import warnings

try:
    import numpy as np  # noqa F401
except ImportError:
    from Bio import MissingPythonDependencyError

    raise MissingPythonDependencyError(
        "Install NumPy if you want to use Bio.PDB."
    ) from None

from io import StringIO

from Bio.File import as_handle
from Bio.PDB.ic_rebuild import compare_residues
from Bio.PDB.ic_rebuild import IC_duplicate
from Bio.PDB.ic_rebuild import structure_rebuild_test
from Bio.PDB.internal_coords import AtomKey
from Bio.PDB.internal_coords import Dihedron
from Bio.PDB.internal_coords import IC_Chain
from Bio.PDB.internal_coords import IC_Residue
from Bio.PDB.MMCIFParser import MMCIFParser
from Bio.PDB.mmtf import MMTFParser
from Bio.PDB.PDBExceptions import PDBConstructionWarning
from Bio.PDB.PDBParser import PDBParser
from Bio.PDB.PICIO import read_PIC_seq
from Bio.PDB.SCADIO import write_SCAD
from Bio.Seq import Seq
from Bio.SeqRecord import SeqRecord


class Rebuild(unittest.TestCase):
    """Read PDB and mmCIF structures, convert to/from internal coordinates."""

    PDB_parser = PDBParser(PERMISSIVE=True, QUIET=True)
    CIF_parser = MMCIFParser(QUIET=True)
    MMTF_parser = MMTFParser()
    pdb_1LCD = PDB_parser.get_structure("1LCD", "PDB/1LCD.pdb")
    # cif_1A7G = CIF_parser.get_structure("1A7G", "PDB/1A7G.cif")
    # cif_1A7G2 = CIF_parser.get_structure("1A7G", "PDB/1A7G.cif")
    pdb_2XHE = PDB_parser.get_structure("2XHE", "PDB/2XHE.pdb")
    pdb_2XHE2 = PDB_parser.get_structure("2XHE", "PDB/2XHE.pdb")
    pdb_1A8O = PDB_parser.get_structure("1A8O", "PDB/1A8O.pdb")
    cif_3JQH = CIF_parser.get_structure("3JQH", "PDB/3JQH.cif")
    cif_3JQH2 = CIF_parser.get_structure("3JQH", "PDB/3JQH.cif")
    cif_4CUP = CIF_parser.get_structure("4CUP", "PDB/4CUP.cif")
    cif_4CUP2 = CIF_parser.get_structure("4CUP", "PDB/4CUP.cif")
    cif_4ZHL = CIF_parser.get_structure("4ZHL", "PDB/4ZHL.cif")
    cif_4ZHL2 = CIF_parser.get_structure("4ZHL", "PDB/4ZHL.cif")
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always", PDBConstructionWarning)
        mmtf_1A8O = MMTF_parser.get_structure("PDB/1A8O.mmtf")

    def test_mmtf(self):
        chain = next(self.mmtf_1A8O.get_chains())
        ic_chain = IC_Chain(chain)
        self.assertEqual(len(ic_chain.ordered_aa_ic_list), 70)

    def test_rebuild_1a8o(self):
        """Duplicate tutorial doctests which fail under linux."""
        IC_Chain.MaxPeptideBond = 4.0
        r = structure_rebuild_test(self.pdb_1A8O, False)
        self.assertTrue(r["pass"])

        cic = list(self.pdb_1A8O.get_chains())[0].internal_coord
        distances = cic.distance_plot()
        chirality = cic.dihedral_signs()
        c2 = IC_duplicate(cic.chain)[0]["A"]
        cic2 = c2.internal_coord
        cic2.atomArray = np.zeros((cic2.AAsiz, 4), dtype=np.float64)
        cic2.dihedraAngle[:] = 0.0
        cic2.hedraAngle[:] = 0.0
        cic2.hedraL12[:] = 0.0
        cic2.hedraL23[:] = 0.0
        cic2.copy_initNCaCs(cic)
        cic2.distplot_to_dh_arrays(distances, chirality)
        cic2.distance_to_internal_coordinates()
        c2.internal_to_atom_coordinates()
        np.allclose(cic2.atomArray, cic.atomArray)

    def test_rebuild_multichain_missing(self):
        """Convert multichain missing atom struct to, from internal coords."""
        # 2XHE has regions of missing chain, last residue has only N
        r = structure_rebuild_test(self.pdb_2XHE, False)
        self.assertEqual(r["residues"], 787)
        self.assertEqual(r["rCount"], 835)
        self.assertEqual(r["rMatchCount"], 835)
        self.assertEqual(r["aCount"], 6267)
        self.assertEqual(r["disAtmCount"], 0)
        self.assertEqual(r["aCoordMatchCount"], 6267)
        self.assertEqual(len(r["chains"]), 2)
        self.assertTrue(r["pass"])

    def test_rebuild_disordered_atoms_residues(self):
        """Convert disordered protein to internal coordinates and back."""
        # 3jqh has both disordered residues
        # and disordered atoms in ordered residues

        with warnings.catch_warnings(record=True):
            warnings.simplefilter("always", PDBConstructionWarning)
            r = structure_rebuild_test(self.cif_3JQH, False)
        # print(r)
        self.assertEqual(r["residues"], 26)
        self.assertEqual(r["rCount"], 47)
        self.assertEqual(r["rMatchCount"], 47)
        self.assertEqual(r["aCount"], 217)
        self.assertEqual(r["disAtmCount"], 50)
        self.assertEqual(r["aCoordMatchCount"], 217)
        self.assertEqual(len(r["chains"]), 1)
        self.assertTrue(r["pass"])

        IC_Residue.no_altloc = True
        with warnings.catch_warnings(record=True):
            warnings.simplefilter("always", PDBConstructionWarning)
            r = structure_rebuild_test(self.cif_3JQH2, verbose=False, quick=True)
        self.assertEqual(r["aCoordMatchCount"], 167, msg="no_altloc fail")
        self.assertTrue(r["pass"], msg="no_altloc fail")
        IC_Residue.no_altloc = False

    def test_no_crosstalk(self):
        """Deep copy, change few internal coords, test nothing else changes."""
        # IC_Chain.ParallelAssembleResidues = False
        self.cif_4CUP.atom_to_internal_coordinates()
        cpy4cup = copy.deepcopy(self.cif_4CUP)
        cic0 = self.cif_4CUP.child_list[0].child_list[0].internal_coord
        cic1 = cpy4cup.child_list[0].child_list[0].internal_coord
        alist = [
            "omg",
            "phi",
            "psi",
            "chi1",
            "chi2",
            "chi3",
            "chi4",
            "chi5",
            "tau",
        ]
        delta = 33  # degrees to change
        tdelta = delta / 10.0  # more realistic for bond angle
        targPos = 1
        for ang in alist:
            # skip by 2's with alist along original chain changing angle spec
            ricTarg = cic0.chain.child_list[targPos].internal_coord
            # print(targPos + 1, ricTarg.lc, ang)
            targPos += 2
            try:
                edr = ricTarg.pick_angle(ang)
                andx = edr.ndx
                if ang == "tau":
                    cic0.hedraAngle[andx] += tdelta
                    cic0.hAtoms_needs_update[andx] = True
                    cic0.atomArrayValid[cic0.h2aa[andx]] = False
                    cic0.hAtoms_needs_update[:] = True
                    cic0.atomArrayValid[:] = False
                    cic0.dAtoms_needs_update[:] = True
                else:
                    cic0.dihedraAngle[andx] += delta
                    if cic0.dihedraAngle[andx] > 180.0:
                        cic0.dihedraAngle[andx] -= 360.0
                    cic0.dihedraAngleRads[andx] = np.deg2rad(cic0.dihedraAngle[andx])
                    cic0.dAtoms_needs_update[andx] = True
                    cic0.atomArrayValid[cic0.d2aa[andx]] = False
                    # test Dihedron.bits()
                    pfd = IC_Residue.picFlagsDict
                    if ricTarg.rbase[2] == "P" and ang == "omg":
                        self.assertEqual(edr.bits(), (pfd["omg"] | pfd["pomg"]))
                    else:
                        self.assertEqual(edr.bits(), pfd[ang])

            except AttributeError:
                pass  # skip if residue does not have e.g. chi5
        cic0.internal_to_atom_coordinates()  # move atoms
        cic0.atom_to_internal_coordinates()  # get new internal coords
        # generate hdelta and ddelta difference arrays so can look for what
        # changed
        hdelta = cic0.hedraAngle - cic1.hedraAngle
        hdelta[np.abs(hdelta) < 0.00001] = 0.0
        ddelta = cic0.dihedraAngle - cic1.dihedraAngle
        ddelta[np.abs(ddelta) < 0.00001] = 0.0
        ddelta[ddelta < -180.0] += 360.0  # wrap around circle values
        targPos = 1
        for ang in alist:
            # same skip along original chain looking at hdelta and ddelta
            # if change is as specified, set difference to 0 then we can test
            # for any remaining (spurious) changes
            ricTarg = cic0.chain.child_list[targPos].internal_coord
            # print(targPos + 1, ricTarg.lc, ang)
            targPos += 2
            try:
                andx = ricTarg.pick_angle(ang).ndx
                if ang == "tau":
                    self.assertAlmostEqual(hdelta[andx], tdelta, places=4)
                    hdelta[andx] = 0.0
                    # some other angle has to change to accommodate tau change
                    # N-Ca-Cb is artifact of choices in ic_data
                    # expected change so clear relevant hdelta here
                    adjAngNdx = ricTarg.pick_angle("N:CA:CB").ndx
                    self.assertNotAlmostEqual(hdelta[adjAngNdx], 0.0, places=1)
                    hdelta[adjAngNdx] = 0.0
                else:
                    self.assertAlmostEqual(ddelta[andx], delta, places=4)
                    ddelta[andx] = 0.0
            except AttributeError:
                pass  # if residue does not have e.g. chi5

        hsum = hdelta.sum()
        self.assertEqual(hsum, 0.0)
        dsum = ddelta.sum()
        self.assertEqual(dsum, 0.0)

        # test hedron len12, angle, len23 setters and getters
        hed = list(cic0.hedra.values())[10]
        val = hed.len12 + 0.5
        hed.len12 = val
        self.assertEqual(hed.len12, val)
        val = hed.len23 + 0.5
        hed.len23 = val
        self.assertEqual(hed.len23, val)
        val = hed.angle + 1
        hed.angle = val
        self.assertEqual(hed.angle, val)
        dihed = list(cic0.dihedra.values())[10]
        val = dihed.angle + 196
        dihed.angle = val
        if val > 180.0:
            val -= 360.0
        if val < -180.0:
            val += 360.0
        self.assertEqual(dihed.angle, val)

    def test_model_change_internal_coords(self):
        """Get model internal coords, modify psi and chi1 values and check."""
        mdl = self.pdb_1LCD[1]
        mdl.atom_to_internal_coordinates()
        # other tests show can build with arbitrary internal coords
        # build here so changes below trigger more complicated
        # Atoms_needs_update mask arrays
        mdl.internal_to_atom_coordinates()
        nvt = {}
        nvc1 = {}
        nvpsi = {}
        nvlen = {}
        nvlen2 = {}
        tcount = 0
        l2count = 0
        c1count = 0
        psicount = 0
        lcount = 0
        for r in mdl.get_residues():
            ric = r.internal_coord
            if ric:
                # hedra change
                tau = ric.get_angle("tau")
                if ric.rprev != [] and tau is not None:
                    tcount += 1
                    nv = tau + 0.5
                    ric.set_angle("tau", nv)
                    nvt[str(r)] = nv
                    l2count += 1
                    leng2 = ric.get_length("N:CA")
                    nv = leng2 + 0.05
                    ric.set_length("N:CA", nv)
                    nvlen2[str(r)] = nv

                # sidechain dihedron change
                chi1 = ric.get_angle("chi1")
                if chi1 is not None:
                    c1count += 1
                    nv = chi1 + 90
                    if nv > 180.0:
                        nv -= 360.0
                    # ric.set_angle("chi1", nv)
                    ric.bond_set("chi1", nv)
                    nvc1[str(r)] = nv
                # backbone dihedron change
                psi = ric.get_angle("psi")
                if psi is not None:
                    psicount += 1
                    nv = psi - 90
                    if nv < -180.0:
                        nv += 360.0
                    ric.set_angle("psi", nv)
                    nvpsi[str(r)] = nv
                leng = ric.get_length("CA:CB")
                if leng is not None:
                    lcount += 1
                    nv = leng + 0.05
                    ric.set_length("CA:CB", nv)
                    nvlen[str(r)] = nv

        mdl.internal_to_atom_coordinates()

        # prove not using stored results
        for chn in mdl.get_chains():
            if hasattr(chn, "hedraLen"):
                delattr(chn.internal_coord, "hedraLen")
                delattr(chn.internal_coord, "dihedraLen")
                delattr(chn.internal_coord, "hedraAngle")
                delattr(chn.internal_coord, "dihedraAngle")
                for r in chn.get_residues():
                    r.internal_coord.hedra = {}
                    r.internal_coord.dihedra = {}

        mdl.atom_to_internal_coordinates()
        ttcount = 0
        l2tcount = 0
        c1tcount = 0
        psitcount = 0
        ltcount = 0
        for r in mdl.get_residues():
            ric = r.internal_coord
            if ric:
                tau = ric.get_angle("tau")
                if ric.rprev != [] and tau is not None:
                    ttcount += 1
                    # print(str(r), "tau", tau, nvt[str(r)])
                    self.assertAlmostEqual(tau, nvt[str(r)], places=3)
                    l2tcount += 1
                    l2 = ric.get_length("N:CA")
                    self.assertAlmostEqual(l2, nvlen2[str(r)], places=3)
                chi1 = ric.get_angle("chi1")
                if chi1 is not None:
                    c1tcount += 1
                    # print(str(r), "chi1", chi1, nvc1[str(r)])
                    self.assertAlmostEqual(chi1, nvc1[str(r)], places=3)
                psi = ric.get_angle("psi")
                if psi is not None:
                    psitcount += 1
                    # print(str(r), "psi", psi, nvpsi[str(r)])
                    self.assertAlmostEqual(psi, nvpsi[str(r)], places=3)
                leng = ric.get_length("CA:CB")
                if leng is not None:
                    ltcount += 1
                    self.assertAlmostEqual(leng, nvlen[str(r)], places=3)

        self.assertEqual(tcount, ttcount)
        self.assertEqual(l2count, l2tcount)
        self.assertEqual(c1count, c1tcount)
        self.assertEqual(psicount, psitcount)
        self.assertEqual(lcount, ltcount)
        self.assertGreater(ttcount, 0)
        self.assertGreater(c1count, 0)
        self.assertGreater(psicount, 0)
        self.assertGreater(lcount, 0)

    def test_write_SCAD(self):
        """Check SCAD output plus MaxPeptideBond and Gly CB.

        SCAD tests: scaling, transform mtx, extra bond created (allBonds)
        """
        sf = StringIO()
        write_SCAD(
            self.cif_4CUP2,
            sf,
            10.0,
            pdbid="4cup",
            backboneOnly=True,
            includeCode=False,
        )
        sf.seek(0)
        next_one = False
        with as_handle(sf, mode="r") as handle:
            for aline in handle.readlines():
                if "// (1856_S_CB, 1856_S_CA, 1856_S_C)" in aline:
                    m = re.search(r"\[\s+(\d+\.\d+)\,", aline)
                    if m:
                        # test correctly scaled atom bond length
                        self.assertAlmostEqual(float(m.group(1)), 15.30582, places=3)
                    else:
                        self.fail("scaled atom bond length not found")
                elif '[ 1, "1857M",' in aline:
                    next_one = True
                elif next_one:
                    next_one = False
                    # test last residue transform looks roughly correct
                    # some differences due to sorting issues on different
                    # python versions
                    target = [-12.413, -3.303, 35.771, 1.0]
                    ms = re.findall(  # last column of each row
                        r"\s+(-?\d+\.\d+)\s+\]", aline
                    )
                    if ms:
                        for i in range(3):
                            self.assertAlmostEqual(float(ms[i]), target[i], places=0)
                    else:
                        self.fail("transform not found")

        sf.seek(0)
        IC_Residue.gly_Cbeta = True
        IC_Chain.MaxPeptideBond = 100.0
        chn = self.pdb_2XHE2[0]["A"]
        chn.atom_to_internal_coordinates()
        rt0 = chn.internal_coord.ordered_aa_ic_list[12]
        rt1 = chn.internal_coord.ordered_aa_ic_list[16]
        rt0.set_flexible()
        rt1.set_hbond()

        write_SCAD(
            self.pdb_2XHE2[0]["A"],
            sf,
            10.0,
            pdbid="2xhe",
            # maxPeptideBond=100.0,
            includeCode=False,
            start=10,
            fin=570,
        )
        sf.seek(0)
        allBondsPass = False
        maxPeptideBondPass = False
        glyCbetaFound = False
        startPass = True
        finPass = True
        flexPass = False
        hbPass = False
        with as_handle(sf, mode="r") as handle:
            for aline in handle.readlines():
                # test extra bond created in TRP (allBonds is True)
                if '"Cres", 0, 0, 1, 0, StdBond, "W", 24, "CD2CE3CZ3"' in aline:
                    allBondsPass = True
                # test 509_K-561_E long bond created
                if "509_K" in aline and "561_E" in aline:
                    maxPeptideBondPass = True
                if "(21_G_CB, 21_G_CA, 21_G_C)" in aline:
                    glyCbetaFound = True
                    target = [15.33630, 110.17513, 15.13861]
                    ms = re.findall(r"\s+(-?\d+\.\d+)", aline)
                    if ms:
                        for i in range(3):
                            self.assertAlmostEqual(float(ms[i]), target[i], places=0)
                    else:
                        self.fail("Cbeta internal coords not found")
                if "8_K_CA" in aline:
                    startPass = False
                if "572_N_CA" in aline:
                    finPass = False
                if 'FemaleJoinBond, FemaleJoinBond, "N", 13, "NCAC"' in aline:
                    flexPass = True
                if 'HBond, "R", 16, "CACO"' in aline:
                    hbPass = True

        self.assertTrue(allBondsPass, msg="missing extra ring close bonds")
        self.assertTrue(glyCbetaFound, msg="gly CB not created")
        self.assertTrue(maxPeptideBondPass, msg="ignored maxPeptideBond setting")
        self.assertTrue(startPass, msg="writeSCAD wrote residue before start")
        self.assertTrue(finPass, msg="writeSCAD wrote residue past fin")
        self.assertTrue(flexPass, msg="writeSCAD residue 12 not flexible")
        self.assertTrue(hbPass, msg="writeSCAD residue 16 no hbond")

    def test_i2a_start_fin(self):
        """Test assemble start/fin, default NCaC coordinates, IC_duplicate."""
        chn = self.pdb_1LCD[2]["A"]
        cpy = IC_duplicate(chn)[2]["A"]  # generates internal coords as needed
        cpy.internal_to_atom_coordinates(start=31, fin=45)
        cdict = compare_residues(chn, cpy, quick=True)
        self.assertFalse(cdict["pass"])
        # transform source coordinates to put res 31 tau at origin like
        # fragment
        res = chn[31]
        psi = res.internal_coord.pick_angle("psi")
        cst = np.transpose(psi.cst)
        chn.internal_coord.atomArray[:] = chn.internal_coord.atomArray.dot(cst)
        cdict = compare_residues(chn, cpy, rtol=1e-03, atol=1e-05)
        self.assertEqual(cdict["residues"], 51)
        self.assertEqual(cdict["rMatchCount"], 77)
        self.assertEqual(cdict["aCount"], 497)
        self.assertEqual(cdict["disAtmCount"], 0)
        self.assertEqual(cdict["aCoordMatchCount"], 140)
        self.assertEqual(cdict["aFullIdMatchCount"], 140)
        self.assertEqual(len(cdict["chains"]), 1)
        self.assertEqual(cdict["rCount"], 77)
        self.assertFalse(cdict["pass"])

    def test_distplot_rebuild(self):
        """Build identical structure from distplot and chirality data."""
        # load input chain
        for _chn1 in self.cif_4ZHL.get_chains():
            break
        # create atomArray and compute distplot and dihedral signs array
        _chn1.atom_to_internal_coordinates()
        _c1ic = _chn1.internal_coord
        atmNameNdx = AtomKey.fields.atm
        CaSelect = [
            _c1ic.atomArrayIndex.get(k)
            for k in _c1ic.atomArrayIndex.keys()
            if k.akl[atmNameNdx] == "CA"
        ]
        dplot0 = _chn1.internal_coord.distance_plot(filter=CaSelect)
        self.assertAlmostEqual(
            dplot0[3, 9],
            16.296,
            places=3,
            msg="fail generate distance plot with filter",
        )
        dplot1 = _chn1.internal_coord.distance_plot()
        dsigns = _chn1.internal_coord.dihedral_signs()

        # load second copy (same again) input chain
        for _chn2 in self.cif_4ZHL2.get_chains():
            break
        # create internal coord structures but do not compute di/hedra
        cic2 = _chn2.internal_coord = IC_Chain(_chn2)
        cic2.init_edra()
        # load relevant interatomic distances from chn1 distance plot
        cic2.distplot_to_dh_arrays(dplot1, dsigns)
        # compute di/hedra angles from dh_arrays
        cic2.distance_to_internal_coordinates()

        # clear chn2 atom coordinates
        # cic2.atomArrayValid[:] = False  # done in distance_to_internal_coordinates

        # initialize values but this is redundant to Valid=False above
        cic2.atomArray = np.zeros((cic2.AAsiz, 4), dtype=np.float64)
        cic2.atomArray[:, 3] = 1.0

        # 4zhl has chain breaks so copy initial coords of each segment
        cic2.copy_initNCaCs(_chn1.internal_coord)
        # compute chn2 atom coords from di/hedra data
        cic2.internal_to_atom_coordinates()

        # generate distance plot from second chain, confirm minimal distance
        # from original
        dp2 = cic2.distance_plot()
        dpdiff = np.abs(dplot1 - dp2)
        # print(np.amax(dpdiff))
        self.assertTrue(np.amax(dpdiff) < 0.000001)

    def test_seq_as_PIC(self):
        """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()

        self.assertEqual(
            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[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
        self.assertTrue(
            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-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)
        self.assertTrue(psi < chi1, msg=m)
        self.assertTrue(psi <= chi1, msg=m)
        self.assertTrue(chi1 > psi, msg=m)
        self.assertTrue(chi1 >= psi, msg=m)

        self.assertTrue(
            chi1.cre_class == "NsbCsbCsbCsb",
            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-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 comparisons missed in other tests
        a0, a1 = tau.atomkeys[0], tau.atomkeys[1]
        m = "AtomKey rich comparison failed"
        self.assertTrue(a1 > a0, msg=m)
        self.assertTrue(a1 >= a0, msg=m)
        self.assertTrue(a0 <= a1, msg=m)

    def test_angle_fns(self):
        """Test angle_dif and angle_avg across +/-180 boundaries."""
        arr1 = np.array([179.0, 90.0, 88.0, 1.0])
        arr2 = np.array([-179.0, -90.0, -91.0, -1.0])
        assert (
            Dihedron.angle_dif(arr1, arr2) == np.array([2.0, 180.0, -179.0, -2.0])
        ).all()
        assert Dihedron.angle_avg(np.array([179.0, -179.0])) == 180.0
        assert Dihedron.angle_avg(np.array([1.0, -1.0])) == 0.0
        assert Dihedron.angle_avg(np.array([90.0, -90.0])) == 0.0
        assert Dihedron.angle_avg(np.array([91.0, -91.0])) == 180.0


if __name__ == "__main__":
    runner = unittest.TextTestRunner(verbosity=2)
    unittest.main(testRunner=runner)
