File: test_PDB_internal_coords.py

package info (click to toggle)
python-biopython 1.85%2Bdfsg-4
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 126,372 kB
  • sloc: xml: 1,047,995; python: 332,722; ansic: 16,944; sql: 1,208; makefile: 140; sh: 81
file content (664 lines) | stat: -rw-r--r-- 27,426 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
# 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)