Repository: biopython/biopython

Issue:
`get_init_rms()` in `Bio.PDB.qcprot.QCPSuperimposer` returns an error
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```
3.10.10 | packaged by conda-forge | (main, Mar 24 2023, 20:08:06) [GCC 11.3.0]
CPython
Linux-5.4.0-135-generic-x86_64-with-glibc2.35
1.81
```

### Expected behaviour

The function `get_init_rms()` in `Bio.PDB.qcprot.QCPSuperimposer` should return the root mean square deviation of untransformed coordinates.

### Actual behaviour

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[36], line 10
      8 super_imposerQPC.set_atoms([a1, a2, b1, b2], [b1, b2, a1, a2])
      9 super_imposerQPC.run()
---> 10 print(super_imposerQPC.get_init_rms())

File /opt/conda/lib/python3.10/site-packages/Bio/PDB/qcprot.py:349, in QCPSuperimposer.get_init_rms(self)
    347 if self.init_rms is None:
    348     diff = self.coords - self.reference_coords
--> 349     self.init_rms = np.sqrt(np.sum(np.dot(diff, diff), axis=0) / self._natoms)
    350 return self.init_rms

File <__array_function__ internals>:180, in dot(*args, **kwargs)

ValueError: shapes (4,3) and (4,3) not aligned: 3 (dim 1) != 4 (dim 0)
```
### Steps to reproduce
```
import Bio
a1 = Bio.PDB.Atom.Atom("CA", [1, 1, 1], 0, 1, "", " CA ",1, element = "C")
a2 = Bio.PDB.Atom.Atom("CA", [2, 4, 6], 0, 1, "", " CA ",2, element = "C")
b1 = Bio.PDB.Atom.Atom("CA", [2, 2, 2], 0, 1, "", " CA ",3, element = "C")
b2 = Bio.PDB.Atom.Atom("CA", [3, 5, 7], 0, 1, "", " CA ",4, element = "C")

super_imposerQPC = Bio.PDB.qcprot.QCPSuperimposer()
super_imposerQPC.set_atoms([a1, a2, b1, b2], [b1, b2, a1, a2])
super_imposerQPC.run()
print(super_imposerQPC.get_init_rms())
```

### Note

I think that file biopython/Bio/PDB/qcprot.py the useage of np.dot functions is problematic. I think the formula from Superimposer module, but updated for the variable names like `np.sqrt(np.sum(np.sum(diff * diff)) / self.coords.shape[0])` should work.


Discussion:
Absolutely, thank you for catching this! Would you be willing to make a pull request or you prefer we handle it?
This should be included in #4312 if possible.
> Absolutely, thank you for catching this! Would you be willing to make a pull request or you prefer we handle it?

I would prefer if you handle it. 

Resolving patch:
diff --git a/Bio/PDB/QCPSuperimposer/__init__.py b/Bio/PDB/QCPSuperimposer/__init__.py
--- a/Bio/PDB/QCPSuperimposer/__init__.py
+++ b/Bio/PDB/QCPSuperimposer/__init__.py
@@ -63,7 +63,7 @@ def _clear(self):
     def _rms(self, coords1, coords2):
         """Return rms deviations between coords1 and coords2 (PRIVATE)."""
         diff = coords1 - coords2
-        return np.sqrt(sum(np.dot(diff, diff)) / coords1.shape[0])
+        return np.sqrt(sum(np.dot(diff, diff.T)) / coords1.shape[0])
 
     def _inner_product(self, coords1, coords2):
         G1 = np.inner(coords1, coords1).diagonal().sum()
diff --git a/Bio/PDB/qcprot.py b/Bio/PDB/qcprot.py
--- a/Bio/PDB/qcprot.py
+++ b/Bio/PDB/qcprot.py
@@ -96,22 +96,30 @@ def qcp(coords1, coords2, natoms):
         * (negSxymSyx * (SyzpSzy) + (SxzmSzx) * (SxxpSyy - Szz))
     )
 
-    # Newton-Rhapson
-    # Original paper mentions 5 iterations are sufficient (on average)
-    # for convergence up to 10^-6 precision but original code writes 50.
-    # I guess for robustness.
+    # Find largest root of the quaternion polynomial:
+    #   f(x) = x ** 4 + c2 * x ** 2 + c1 * x + c0 = 0 (eq. 8 of Theobald et al.)
+    #   f'(x) = 4 * x ** 3 + 2 * c2 * x + c1
+    #
+    # using Newton-Rhapson and E0 as initial guess. Liu et al. mentions 5
+    # iterations are sufficient (on average) for convergence up to 1e-6
+    # precision but original code writes 50, which we keep.
     nr_it = 50
-    mxEigenV = E0
-    evalprec = 1e-11
+    mxEigenV = E0  # starting guess (x in eqs above)
+    evalprec = 1e-11  # convergence criterion
     for _ in range(nr_it):
         oldg = mxEigenV
+
         x2 = mxEigenV * mxEigenV
         b = (x2 + C2) * mxEigenV
         a = b + C1
-        delta = (a * mxEigenV + C0) / (2.0 * x2 * mxEigenV + b + a)
-        mxEigenV -= delta
-        if abs(mxEigenV - oldg) < abs(evalprec * mxEigenV):
-            break
+
+        f = a * mxEigenV + C0
+        f_prime = 2.0 * x2 * mxEigenV + b + a
+
+        delta = f / (f_prime + evalprec)  # avoid division by zero
+        mxEigenV = abs(mxEigenV - delta)
+        if (mxEigenV - oldg) < (evalprec * mxEigenV):
+            break  # convergence
     else:
         print(f"Newton-Rhapson did not converge after {nr_it} iterations")
 
@@ -312,14 +320,14 @@ def run(self):
         coords_ref = self.reference_coords.copy()
 
         # Center Coordinates
-        com1 = np.mean(coords, axis=0)
-        com2 = np.mean(coords_ref, axis=0)
+        com_coords = np.mean(coords, axis=0)
+        com_ref = np.mean(coords_ref, axis=0)
 
-        coords -= com1
-        coords_ref -= com2
+        coords -= com_coords
+        coords_ref -= com_ref
 
         (self.rms, self.rot, _) = qcp(coords_ref, coords, self._natoms)
-        self.tran = com2 - np.dot(com1, self.rot)
+        self.tran = com_ref - np.dot(com_coords, self.rot)
 
     # Getters
     def get_transformed(self):
@@ -346,7 +354,7 @@ def get_init_rms(self):
 
         if self.init_rms is None:
             diff = self.coords - self.reference_coords
-            self.init_rms = np.sqrt(np.sum(np.dot(diff, diff), axis=0) / self._natoms)
+            self.init_rms = np.sqrt(np.sum(np.sum(diff * diff, axis=1) / self._natoms))
         return self.init_rms
 
     def get_rms(self):


Test patch:
diff --git a/Tests/test_PDB_CEAligner.py b/Tests/test_PDB_CEAligner.py
--- a/Tests/test_PDB_CEAligner.py
+++ b/Tests/test_PDB_CEAligner.py
@@ -43,7 +43,7 @@ def test_cealigner(self):
         aligner.set_reference(s1)
         aligner.align(s2)
 
-        self.assertAlmostEqual(aligner.rms, 3.83, places=2)
+        self.assertAlmostEqual(aligner.rms, 3.74, places=2)
 
         # Assert the transformation was done right by comparing
         # the moved coordinates to a 'ground truth' reference.
@@ -54,7 +54,7 @@ def test_cealigner(self):
 
         diff = refe_coords - s2_f_coords
         rmsd = np.sqrt((diff * diff).sum() / len(refe_coords))
-        self.assertAlmostEqual(rmsd, 0.0, places=2)  # < 0.01
+        self.assertTrue(rmsd < 0.5)
 
     def test_cealigner_no_transform(self):
         """Test aligning 7CFN on 6WQA without transforming 7CFN."""
@@ -72,7 +72,7 @@ def test_cealigner_no_transform(self):
         aligner.align(s2, transform=False)
         s2_coords_final = [list(a.coord) for a in s2.get_atoms()]
 
-        self.assertAlmostEqual(aligner.rms, 3.83, places=2)
+        self.assertAlmostEqual(aligner.rms, 3.74, places=2)
         self.assertEqual(s2_original_coords, s2_coords_final)
 
     def test_cealigner_nucleic(self):
diff --git a/Tests/test_PDB_QCSuperimposer.py b/Tests/test_PDB_QCPSuperimposer.py
similarity index 54%
rename from Tests/test_PDB_QCSuperimposer.py
rename to Tests/test_PDB_QCPSuperimposer.py
--- a/Tests/test_PDB_QCSuperimposer.py
+++ b/Tests/test_PDB_QCPSuperimposer.py
@@ -19,6 +19,7 @@
 
 from Bio.PDB import PDBParser, Selection
 from Bio.PDB.qcprot import QCPSuperimposer
+from Bio.SVDSuperimposer import SVDSuperimposer
 
 
 class QCPSuperimposerTest(unittest.TestCase):
@@ -41,50 +42,68 @@ def setUp(self):
             ]
         )
 
-        self.sup = QCPSuperimposer()
-        self.sup.set(self.x, self.y)
-
-    def _arr_to_list(self, a):
-        """Return the array cast as a list, rounded to 3 decimals."""
-        return np.around(a, decimals=3).tolist()
-
     # Public methods
     def test_set(self):
         """Test setting of initial parameters."""
-        self.assertEqual(
-            self._arr_to_list(self.sup.reference_coords), self._arr_to_list(self.x)
-        )
-        self.assertEqual(self._arr_to_list(self.sup.coords), self._arr_to_list(self.y))
-        self.assertIsNone(self.sup.transformed_coords)
-        self.assertIsNone(self.sup.rot)
-        self.assertIsNone(self.sup.tran)
-        self.assertIsNone(self.sup.rms)
-        self.assertIsNone(self.sup.init_rms)
+        sup = QCPSuperimposer()
+        sup.set(self.x, self.y)
+
+        self.assertTrue(np.allclose(sup.reference_coords, self.x, atol=1e-6))
+        self.assertTrue(np.allclose(sup.coords, self.y, atol=1e-6))
+        self.assertIsNone(sup.transformed_coords)
+        self.assertIsNone(sup.rot)
+        self.assertIsNone(sup.tran)
+        self.assertIsNone(sup.rms)
+        self.assertIsNone(sup.init_rms)
 
     def test_run(self):
         """Test QCP on dummy data."""
-        self.sup.run()
-        self.assertEqual(
-            self._arr_to_list(self.sup.reference_coords), self._arr_to_list(self.x)
-        )
-        self.assertEqual(self._arr_to_list(self.sup.coords), self._arr_to_list(self.y))
-        self.assertIsNone(self.sup.transformed_coords)
+        sup = QCPSuperimposer()
+        sup.set(self.x, self.y)
+
+        sup.run()
+        self.assertTrue(np.allclose(sup.reference_coords, self.x, atol=1e-6))
+        self.assertTrue(np.allclose(sup.coords, self.y, atol=1e-6))
+        self.assertIsNone(sup.transformed_coords)
+
         calc_rot = [
             [0.683, 0.537, 0.495],
             [-0.523, 0.833, -0.181],
             [-0.510, -0.135, 0.849],
         ]
-        self.assertEqual(self._arr_to_list(self.sup.rot), calc_rot)
         calc_tran = [38.786, -20.655, -15.422]
-        self.assertEqual(self._arr_to_list(self.sup.tran), calc_tran)
+
+        self.assertTrue(np.allclose(np.array(calc_rot), sup.rot, atol=1e-3))
+        self.assertTrue(np.allclose(np.array(calc_tran), sup.tran, atol=1e-3))
+
         # We can reduce precision here since we do a similar calculation
         # for a full structure down below.
-        self.assertAlmostEqual(self.sup.rms, 0.003, places=2)
-        self.assertIsNone(self.sup.init_rms)
+        self.assertAlmostEqual(sup.rms, 0.003, places=3)
+        self.assertIsNone(sup.init_rms)
+
+    def test_compare_to_svd(self):
+        """Compare results of QCP to SVD."""
+        sup = QCPSuperimposer()
+        sup.set(self.x, self.y)
+        sup.run()
+
+        svd_sup = SVDSuperimposer()
+        svd_sup.set(self.x, self.y)
+        svd_sup.run()
+
+        self.assertAlmostEqual(svd_sup.get_rms(), sup.rms, places=3)
+        self.assertTrue(np.allclose(svd_sup.rot, sup.rot, atol=1e-3))
+        self.assertTrue(np.allclose(svd_sup.tran, sup.tran, atol=1e-3))
+        self.assertTrue(
+            np.allclose(svd_sup.get_transformed(), sup.get_transformed(), atol=1e-3)
+        )
 
     def test_get_transformed(self):
         """Test transformation of coordinates after QCP."""
-        self.sup.run()
+        sup = QCPSuperimposer()
+        sup.set(self.x, self.y)
+
+        sup.run()
         transformed_coords = [
             [51.652, -1.900, 50.071],
             [50.398, -1.229, 50.649],
@@ -92,21 +111,21 @@ def test_get_transformed(self):
             [50.220, -0.019, 52.853],
         ]
 
-        self.assertEqual(
-            self._arr_to_list(self.sup.get_transformed()), transformed_coords
+        self.assertTrue(
+            np.allclose(sup.get_transformed(), np.array(transformed_coords), atol=1e-3)
         )
 
     def test_get_init_rms(self):
         """Test initial RMS calculation."""
-        x = np.array([[1.1, 1.2, 1.3], [1.4, 1.5, 1.6], [1.7, 1.8, 1.9]])
-        y = np.array([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]])
-        self.sup.set(x, y)
-        self.assertIsNone(self.sup.init_rms)
-        init_rms = [0.812, 0.90, 0.98]
-        self.assertEqual(
-            self._arr_to_list(self.sup.get_init_rms()),
-            init_rms,
-        )
+        x = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
+        y = np.array([[2.0, 0.0, 0.0], [1.0, 1.0, 0.0], [1.0, 0.0, 1.0]])
+
+        sup = QCPSuperimposer()
+        sup.set(x, y)
+        self.assertIsNone(sup.init_rms)
+
+        expected_init_rms = 1.0
+        self.assertAlmostEqual(sup.get_init_rms(), expected_init_rms, places=6)
 
     def test_on_pdb(self):
         """Align a PDB to itself."""
@@ -124,8 +143,8 @@ def test_on_pdb(self):
 
         sup = QCPSuperimposer()
         sup.set_atoms(fixed, moving)
-        self.assertEqual(self._arr_to_list(sup.rotran[0]), self._arr_to_list(rot))
-        self.assertEqual(self._arr_to_list(sup.rotran[1]), self._arr_to_list(-tran))
+        self.assertTrue(np.allclose(sup.rotran[0], rot, atol=1e-3))
+        self.assertTrue(np.allclose(sup.rotran[1], -tran, atol=1e-3))
         self.assertAlmostEqual(sup.rms, 0.0, places=6)