Repository: MDAnalysis/mdanalysis

Issue:
DCDWriter writes unit cell angles instead of documented cos(angles)
The [docs for DCDReader](https://docs.mdanalysis.org/stable/documentation_pages/coordinates/DCD.html#module-MDAnalysis.coordinates.DCD) state 

> The writer follows recent NAMD/VMD convention for the unitcell (box lengths in Å and angle-cosines, [A, cos(gamma), B, cos(beta), cos(alpha), C]) and writes positions in Å and time in AKMA time units.

since PR #1372 (see https://github.com/MDAnalysis/mdanalysis/blob/5c0563d977e8ec9d8a0f148091e872c64ea3a6b8/package/MDAnalysis/coordinates/DCD.py#L352-L354 )

However, the actual code writes angles directly https://github.com/MDAnalysis/mdanalysis/blob/5c0563d977e8ec9d8a0f148091e872c64ea3a6b8/package/MDAnalysis/coordinates/DCD.py#L470-L473

(My hunch is that at some point during PR #1372 we decided to remove specific decisions of how to write the unitcell information from the low-level C/cython layer but forgot to implement them in the high level Python.)

## Required change

DCDWriter should write modern (NAMD >2.5) unitcells as documented.

This could be done directly in DCDWriter._write_frames() https://github.com/MDAnalysis/mdanalysis/blob/5c0563d977e8ec9d8a0f148091e872c64ea3a6b8/package/MDAnalysis/coordinates/DCD.py#L429

Alternatively, one may consider adding a custom method `convert_dimensions_to_unitcell()` but then the angle cosines are only written if `convert_units=True`; this seems odd behavior, so I would just add the cos() calls directly.

## Impact
* MDAnalysis reads both unitcells without problems as it contains heuristics to guess from the numbers which format the unitcell is stored as. The DCD format is vague and we have to deal with at least three different variants. Thus, if you're using just MDAnalysis, there shouldn't be an issue.
* If you're using other tools then it depends on what they are expecting. Given that storing angles in degrees was only used by NAMD≤2.5 (to my knowledge), modern tools should be coping _better_ with the NAMD>2.5 convention.

## Example
Use a data file with a triclinic box:
```python
In [1]: import MDAnalysis as mda; from MDAnalysisTests import datafiles as data

In [4]: u = mda.Universe(data.PSF_NAMD_TRICLINIC, data.DCD_NAMD_TRICLINIC)

# MDAnalysis correctly reports alpha=90º beta=90º gamma=60.028915º
In [5]: u.trajectory.dimensions
Out[5]:
array([38.426594, 38.3931  , 44.7598  , 90.      , 90.      , 60.028915],
      dtype=float32)

In [6]: u.trajectory._file
Out[6]: <MDAnalysis.lib.formats.libdcd.DCDFile at 0x7f82618af410>

In [7]: u.trajectory._file.header
Out[7]:
{'natoms': 5545,
 'istart': 0,
 'nsavc': 1,
 'delta': 1.0,
 'is_periodic': 1,
 'remarks': 'Created by DCD pluginREMARKS Created 06 July, 2014 at 17:29Y5~CORD,'}

# Note that the raw unitcell array contains 0.49956288, 0, 0
# as the cos(gamma), cos(beta), cos(alpha)
In [8]: u.trajectory._file.read()
Out[8]:
DCDFrame(xyz=array([[-12.172467 , -17.412304 ,  -8.024555 ],
       [-12.546482 , -14.365241 ,  -6.438958 ],
       [-11.075896 , -16.563986 ,  -6.5984635],
       ...,
       [ 44.297    ,   3.8738804, 392.91345  ],
       [ -9.873517 ,  -7.8802557, 423.37915  ],
       [ -3.4658115,  23.216728 ,  -9.77569  ]],
      shape=(5545, 3), dtype=float32), unitcell=array([38.42659378,  0.49956288, 38.39310074,  0.        ,  0.        ,
       44.75979996]))

# Now *write* the same trajectory with DCDWriter

In [11]: u.atoms.write("namd_triclinic.dcd", frames="all")

In [12]: u2 = mda.Universe(data.PSF_NAMD_TRICLINIC, "namd_triclinic.dcd")

# The angles in the new trajectory are represented correctly in MDAnalysis:
In [13]: u2.trajectory.dimensions
Out[13]:
array([38.426594, 38.3931  , 44.7598  , 90.      , 90.      , 60.028915],
      dtype=float32)

In [14]: u2.trajectory._file.header
Out[14]:
{'natoms': 5545,
 'istart': 0,
 'nsavc': 1,
 'delta': 20.454830169677734,
 'is_periodic': 1,
 'remarks': ''}

# However, the unitcell was *stored* as angles in degrees: 
#    gamma=60.02891541 beta=90. alpha=90.

In [15]: u2.trajectory._file.read()
Out[15]:
DCDFrame(xyz=array([[-12.172467 , -17.412304 ,  -8.024555 ],
       [-12.546482 , -14.365241 ,  -6.438958 ],
       [-11.075896 , -16.563986 ,  -6.5984635],
       ...,
       [ 44.297    ,   3.8738804, 392.91345  ],
       [ -9.873517 ,  -7.8802557, 423.37915  ],
       [ -3.4658115,  23.216728 ,  -9.77569  ]],
      shape=(5545, 3), dtype=float32), unitcell=array([38.42659378, 60.02891541, 38.39310074, 90.        , 90.        ,
       44.75979996]))
```


Discussion:

Patch:
diff --git a/package/CHANGELOG b/package/CHANGELOG
index ecf87e8915..d23c8dd2ce 100644
--- a/package/CHANGELOG
+++ b/package/CHANGELOG
@@ -22,6 +22,8 @@ The rules for this file:
  * 2.10.0
 
 Fixes
+ * Fix DCDWriter to correctly write unit cell angles as cosines following 
+   NAMD/VMD convention (Issue #5069)
  * Fixed an integer overflow in large DCD file seeks on Windows
    (Issue #4879, PR #5086)
  * Fix compile failure due to numpy issues in transformations.c (Issue #5061, PR #5068)
diff --git a/package/MDAnalysis/coordinates/DCD.py b/package/MDAnalysis/coordinates/DCD.py
index 88c8d76b3e..672d5323b5 100644
--- a/package/MDAnalysis/coordinates/DCD.py
+++ b/package/MDAnalysis/coordinates/DCD.py
@@ -31,7 +31,7 @@
 :class:`DCDReader`) although there can be issues with the unitcell (simulation
 box) representation (see :attr:`DCDReader.dimensions`). DCDs can also be
 written but the :class:`DCDWriter` follows recent NAMD/VMD convention for the
-unitcell but still writes AKMA time. Reading and writing these trajectories
+unitcell while still writing AKMA time. Reading and writing these trajectories
 within MDAnalysis will work seamlessly but if you process those trajectories
 with other tools you might need to watch out that time and unitcell dimensions
 are correctly interpreted.
@@ -80,21 +80,26 @@ class DCDReader(base.ReaderBase):
     dimensions**, especially for triclinic unitcells (see `Issue 187`_). DCD
     trajectories produced by CHARMM and NAMD( >2.5) record time in AKMA units.
     If other units have been recorded (e.g., ps) then employ the configurable
-    :class:MDAnalysis.coordinates.LAMMPS.DCDReader and set the time unit as a
+    :class:`MDAnalysis.coordinates.LAMMPS.DCDReader` and set the time unit as a
     optional argument. You can find a list of units used in the DCD formats on
     the MDAnalysis `wiki`_.
 
-
-    MDAnalysis always uses ``(*A*, *B*, *C*, *alpha*, *beta*, *gamma*)`` to
-    represent the unit cell. Lengths *A*, *B*, *C* are in the MDAnalysis length
-    unit (Å), and angles are in degrees.
+    MDAnalysis always uses (*A*, *B*, *C*, α, β, γ) to represent the unit
+    cell. Lengths *A*, *B*, *C* are in the MDAnalysis length unit (Å), and
+    angles α, β, γ are in degrees.
 
     The ordering of the angles in the unitcell is the same as in recent
     versions of VMD's DCDplugin_ (2013), namely the `X-PLOR DCD format`_: The
     original unitcell is read as ``[A, gamma, B, beta, alpha, C]`` from the DCD
-    file. If any of these values are < 0 or if any of the angles are > 180
-    degrees then it is assumed it is a new-style CHARMM unitcell (at least
-    since c36b2) in which box vectors were recorded.
+    file. If any of these values are < 0 or if any of the angles are > 180º
+    then it is assumed it is a new-style CHARMM unitcell (possibly ≥22, at
+    least since c36b2) in which symmetric box vectors were recorded. If all
+    angles are numbers between -1 and +1, then they are treated as cosines of
+    the angle (following the modern NAMD > 2.5/VMD convention).
+
+    Ultimately, all unitcell representations are converted to the MDAnalysis
+    standard unit cell representation in :attr:`DCDReader.dimensions`.
+
 
     .. deprecated:: 2.4.0
         DCDReader currently makes independent timesteps
@@ -116,6 +121,7 @@ class DCDReader(base.ReaderBase):
     .. _Issue 187: https://github.com/MDAnalysis/mdanalysis/issues/187
     .. _DCDplugin: http://www.ks.uiuc.edu/Research/vmd/plugins/doxygen/dcdplugin_8c-source.html#l00947
     .. _wiki: https://github.com/MDAnalysis/mdanalysis/wiki/FileFormats#dcd
+
     """
     format = 'DCD'
     flavor = 'CHARMM'
@@ -353,6 +359,21 @@ class DCDWriter(base.WriterBase):
     in Å and angle-cosines, ``[A, cos(gamma), B, cos(beta), cos(alpha), C]``)
     and writes positions in Å and time in AKMA time units.
 
+    .. warning::
+    
+       Multiple conventions exist for how unit cells are written to DCD
+       files. Until 2.10.0 MDAnalysis followed the old X-PLOR/CHARMM/NAMD
+       (≤2.5) convention of storing ``[A, gamma, B, beta, alpha, C]`` while
+       wrongly stating that the new NAMD/VMD convention would be followed. In
+       2.10.0, MDAnalysis switched to following the documented behavior and now
+       stores the box with angle cosines, as described above. The MDAnalysis
+       :class:`DCDReader` can correctly read either format but *if you have
+       code that relies on a specific format for the box information in the raw
+       DCD file, please check your results.*
+
+       Furthermore, modern versions of CHARMM store box vectors and not box
+       length/angles. When reading MDAnalysis-generated DCD files *in CHARMM*,
+       carefully check your results.
 
     .. note::
         When writing out timesteps without ``dimensions`` (i.e. set ``None``)
@@ -360,6 +381,15 @@ class DCDWriter(base.WriterBase):
         ``[0, 0, 0, 0, 0, 0]``). As this behaviour is poorly defined, it may
         not match the expectations of other software.
 
+    .. versionchanged:: 2.10.0    
+       Up to 2.10.0 the :class:`DCDWriter` wrote a unit cell following old NAMD
+       (≤2.5) convention, even though the docs stated that the new NAMD
+       convention was being used. Now the modern NAMD > 2.5 format is
+       written. See `Issue #5069`_ for details.
+
+
+    .. _`Issue #5069`: https://github.com/MDAnalysis/mdanalysis/issues/5069
+
     """
     format = 'DCD'
     multiframe = True
@@ -467,10 +497,14 @@ def _write_next_frame(self, ag):
             xyz = self.convert_pos_to_native(xyz, inplace=True)
             dimensions = self.convert_dimensions_to_unitcell(ts, inplace=True)
 
-        # we only support writing charmm format unit cell info
-        # The DCD unitcell is written as ``[A, gamma, B, beta, alpha, C]``
+        # Convert angles to cosines following NAMD/VMD convention
+        # The DCD unitcell is written as [A, cos(gamma), B, cos(beta), cos(alpha), C]
         _ts_order = [0, 5, 1, 4, 3, 2]
         box = np.take(dimensions, _ts_order)
+        
+        # Convert angles (indices 1, 3, 4) from degrees to the special cosine format
+        # used by NAMD/VMD: cos(angle) = sin(90 - angle); see Issue 5069
+        box[[1, 3, 4]] = np.sin(np.deg2rad(90.0 - box[[1, 3, 4]]))
 
         self._file.write(xyz=xyz, box=box)
 


Test patch:
diff --git a/testsuite/MDAnalysisTests/coordinates/test_dcd.py b/testsuite/MDAnalysisTests/coordinates/test_dcd.py
index 0eea9cb4cd..636f81759d 100644
--- a/testsuite/MDAnalysisTests/coordinates/test_dcd.py
+++ b/testsuite/MDAnalysisTests/coordinates/test_dcd.py
@@ -32,6 +32,7 @@
     assert_array_equal,
     assert_almost_equal,
     assert_array_almost_equal,
+    assert_allclose,
 )
 
 from MDAnalysisTests.datafiles import (
@@ -145,7 +146,9 @@ def ref():
 def test_write_random_unitcell(tmpdir):
     testname = str(tmpdir.join("test.dcd"))
     rstate = np.random.RandomState(1178083)
-    random_unitcells = rstate.uniform(high=80, size=(98, 6)).astype(np.float64)
+    random_unitcells = rstate.uniform(low=5, high=120, size=(98, 6)).astype(
+        np.float64
+    )
 
     u = mda.Universe(PSF, DCD)
     with mda.Writer(testname, n_atoms=u.atoms.n_atoms) as w:
@@ -155,8 +158,10 @@ def test_write_random_unitcell(tmpdir):
 
     u2 = mda.Universe(PSF, testname)
     for index, ts in enumerate(u2.trajectory):
-        assert_array_almost_equal(
-            u2.trajectory.dimensions, random_unitcells[index], decimal=5
+        assert_allclose(
+            u2.trajectory.dimensions,
+            random_unitcells[index],
+            rtol=1e-5,
         )
 
 
@@ -556,3 +561,43 @@ def test_large_dcdfile(large_dcdfile):
     u.trajectory[-1]
 
     assert_array_almost_equal(u.atoms.positions, u_small.atoms.positions)
+
+
+def test_dcd_writer_angle_cosines(tmpdir):
+    """Test that DCDWriter correctly converts angles to cosines as documented.
+
+    This test verifies the fix for issue #5069 where DCDWriter was incorrectly
+    writing angles in degrees instead of angle cosines.
+    """
+    u = mda.Universe(PSF_NAMD_TRICLINIC, DCD_NAMD_TRICLINIC)
+    original_dimensions = u.dimensions.copy()
+
+    outfile = str(tmpdir.join("test_angles.dcd"))
+    u.atoms.write(outfile, frames="all")
+
+    # Read back and check that dimensions are preserved
+    u2 = mda.Universe(PSF_NAMD_TRICLINIC, outfile)
+
+    # The dimensions should be identical when read back
+    assert_allclose(
+        u2.dimensions,
+        original_dimensions,
+        err_msg="DCDWriter failed to preserve unit cell dimensions (frame 0)",
+    )
+
+    # To get the originally stored dimensions, we need to directly access the DCD file.
+    u.trajectory._reopen()
+    frame = u.trajectory._file.read()
+    frame2 = u2.trajectory._file.read()
+
+    # direction cosines should be between -1 and 1
+    assert np.all(
+        np.abs(frame2.unitcell[[4, 3, 1]]) <= 1
+    ), "DCDWriter failed to preserve unit cell angles as cosines"
+
+    # Directly test the stored dimensions from the DCD header
+    assert_allclose(
+        frame2.unitcell,
+        frame.unitcell,  # stored as cos(angle) in the test trajectory
+        err_msg="DCDWriter failed to preserve unit cell dimensions as stored in the DCD file",
+    )