Repository: duartegroup/autodE (python)

Issue:
Rename autode.opt.coordinates.primitive.Distance and use values for optimisers
1. There is a clash between `autode.opt.coordinates.primitives.Distance` and `autode.values.Distance`. Maybe the first one could be renamed to BondDistance?
2. Use `autode.value.Distance` for trust radius in the optimisers.

Pull request:
Refactor primitive coordinates and optimisers
## Resolves #265 


***

## Checklist

* [ ] The changes include an associated explanation of how/why
* [ ] Test pass
* [ ] Documentation has been updated
* [ ] Changelog has been updated


Patch:
diff --git a/autode/bracket/imagepair.py b/autode/bracket/imagepair.py
index bc9d020..bff0705 100644
--- a/autode/bracket/imagepair.py
+++ b/autode/bracket/imagepair.py
@@ -128,12 +128,8 @@ class BaseImagePair(ABC):
         self._left_history = OptimiserHistory()
         self._right_history = OptimiserHistory()
         # push the first coordinates into history
-        self.left_coords = CartesianCoordinates(
-            self._left_image.coordinates.to("ang")
-        )
-        self.right_coords = CartesianCoordinates(
-            self._right_image.coordinates.to("ang")
-        )
+        self.left_coords = CartesianCoordinates(self._left_image.coordinates)
+        self.right_coords = CartesianCoordinates(self._right_image.coordinates)
 
     def _sanity_check(self) -> None:
         """
@@ -519,7 +515,7 @@ class EuclideanImagePair(BaseImagePair, ABC):
             return None
 
         peak = cineb.images[cineb.images.peak_idx]  # type: ignore
-        ci_coords = CartesianCoordinates(peak.coordinates.to("ang"))
+        ci_coords = CartesianCoordinates(peak.coordinates)
         ci_coords.e = peak.energy
         ci_coords.update_g_from_cart_g(peak.gradient)
 
diff --git a/autode/opt/coordinates/cartesian.py b/autode/opt/coordinates/cartesian.py
index 822af1f..423539f 100644
--- a/autode/opt/coordinates/cartesian.py
+++ b/autode/opt/coordinates/cartesian.py
@@ -19,6 +19,11 @@ class CartesianCoordinates(OptCoordinates):
 
     def __new__(cls, input_array, units="Å") -> "CartesianCoordinates":
         """New instance of these coordinates"""
+
+        # if it has units cast into current units
+        if isinstance(input_array, ValueArray):
+            input_array = ValueArray.to(input_array, units=units)
+
         return super().__new__(
             cls, np.array(input_array).flatten(), units=units
         )
diff --git a/autode/opt/coordinates/dic.py b/autode/opt/coordinates/dic.py
index baf7cad..a1d5f12 100644
--- a/autode/opt/coordinates/dic.py
+++ b/autode/opt/coordinates/dic.py
@@ -23,7 +23,7 @@ from autode.geom import proj
 from autode.log import logger
 from autode.opt.coordinates.internals import (
     PIC,
-    InverseDistances,
+    PrimitiveInverseDistances,
     InternalCoordinates,
 )
 from autode.exceptions import CoordinateTransformFailed
@@ -114,7 +114,7 @@ class DIC(InternalCoordinates):  # lgtm [py/missing-equals]
 
         if primitives is None:
             logger.info("Building DICs from all inverse distances")
-            primitives = InverseDistances.from_cartesian(x)
+            primitives = PrimitiveInverseDistances.from_cartesian(x)
 
         q = primitives(x)
         U = cls._calc_U(primitives, x)
diff --git a/autode/opt/coordinates/internals.py b/autode/opt/coordinates/internals.py
index ef4848a..4c0c778 100644
--- a/autode/opt/coordinates/internals.py
+++ b/autode/opt/coordinates/internals.py
@@ -13,10 +13,10 @@ from typing import Any, Optional, Type, List, TYPE_CHECKING
 from abc import ABC, abstractmethod
 from autode.opt.coordinates.base import OptCoordinates, CartesianComponent
 from autode.opt.coordinates.primitives import (
-    InverseDistance,
+    PrimitiveInverseDistance,
     Primitive,
-    Distance,
-    DihedralAngle,
+    PrimitiveDistance,
+    PrimitiveDihedralAngle,
 )
 
 if TYPE_CHECKING:
@@ -133,7 +133,7 @@ class PIC(list, ABC):
         self._calc_B(x)
 
         for i, primitive in enumerate(self):
-            if isinstance(primitive, DihedralAngle):
+            if isinstance(primitive, PrimitiveDihedralAngle):
 
                 dq = q[i] - other[i]
 
@@ -223,20 +223,20 @@ class _FunctionOfDistances(PIC):
         return None
 
 
-class InverseDistances(_FunctionOfDistances):
+class PrimitiveInverseDistances(_FunctionOfDistances):
     """1 / r_ij for all unique pairs i,j. Will be redundant"""
 
     @property
     def _primitive_type(self):
-        return InverseDistance
+        return PrimitiveInverseDistance
 
 
-class Distances(_FunctionOfDistances):
+class PrimitiveDistances(_FunctionOfDistances):
     """r_ij for all unique pairs i,j. Will be redundant"""
 
     @property
     def _primitive_type(self):
-        return Distance
+        return PrimitiveDistance
 
 
 class AnyPIC(PIC):
diff --git a/autode/opt/coordinates/primitives.py b/autode/opt/coordinates/primitives.py
index 7fc95a9..a973485 100644
--- a/autode/opt/coordinates/primitives.py
+++ b/autode/opt/coordinates/primitives.py
@@ -120,7 +120,7 @@ class _DistanceFunction(Primitive, ABC):
         )
 
 
-class InverseDistance(_DistanceFunction):
+class PrimitiveInverseDistance(_DistanceFunction):
     """
     Inverse distance between to atoms:
 
@@ -162,7 +162,7 @@ class InverseDistance(_DistanceFunction):
         return 1.0 / np.linalg.norm(_x[self.i] - _x[self.j])
 
 
-class Distance(_DistanceFunction):
+class PrimitiveDistance(_DistanceFunction):
     """
     Distance between two atoms:
 
@@ -203,7 +203,7 @@ class Distance(_DistanceFunction):
         return f"Distance({self.i}-{self.j})"
 
 
-class ConstrainedDistance(ConstrainedPrimitive, Distance):
+class ConstrainedPrimitiveDistance(ConstrainedPrimitive, PrimitiveDistance):
     def __init__(self, i: int, j: int, value: float):
         """
         Distance constrained to a value
@@ -229,7 +229,7 @@ class ConstrainedDistance(ConstrainedPrimitive, Distance):
         return f"ConstrainedDistance({self.i}-{self.j})"
 
 
-class BondAngle(Primitive):
+class PrimitiveBondAngle(Primitive):
     def __init__(self, o: int, m: int, n: int):
         """Bond angle m-o-n"""
         super().__init__(o, m, n)
@@ -306,7 +306,7 @@ class BondAngle(Primitive):
         return f"Angle({self.m}-{self.o}-{self.n})"
 
 
-class ConstrainedBondAngle(ConstrainedPrimitive, BondAngle):
+class ConstrainedPrimitiveBondAngle(ConstrainedPrimitive, PrimitiveBondAngle):
     def __init__(self, o: int, m: int, n: int, value: float):
         """
         Angle (m-o-n) constrained to a value (in radians)
@@ -336,12 +336,12 @@ class ConstrainedBondAngle(ConstrainedPrimitive, BondAngle):
     def __eq__(self, other: object):
         return (
             super().__eq__(other)
-            and isinstance(other, ConstrainedBondAngle)
+            and isinstance(other, ConstrainedPrimitiveBondAngle)
             and np.isclose(self._theta0, other._theta0)
         )
 
 
-class DihedralAngle(Primitive):
+class PrimitiveDihedralAngle(Primitive):
     def __init__(self, m: int, o: int, p: int, n: int):
         """Dihedral angle: m-o-p-n"""
         super().__init__(m, o, p, n)
diff --git a/autode/opt/optimisers/base.py b/autode/opt/optimisers/base.py
index d43cad7..d500462 100644
--- a/autode/opt/optimisers/base.py
+++ b/autode/opt/optimisers/base.py
@@ -988,7 +988,7 @@ class OptimiserHistory(UserList):
         # take a copy so that original is not modified
         tmp_spc = species.copy()
         for coords in self:
-            tmp_spc.coordinates = coords
+            tmp_spc.coordinates = coords.to("cart")
             tmp_spc.energy = coords.e
             tmp_spc.print_xyz_file(filename=filename, append=True)
 
diff --git a/autode/opt/optimisers/crfo.py b/autode/opt/optimisers/crfo.py
index 73c2308..e53e459 100644
--- a/autode/opt/optimisers/crfo.py
+++ b/autode/opt/optimisers/crfo.py
@@ -5,37 +5,42 @@ Notation follows:
 [2] J. Baker, J. Comput. Chem., 13, 240 Ž1992
 """
 import numpy as np
+from typing import Union
 
 from itertools import combinations
 from autode.log import logger
-from autode.values import GradientRMS, Angle
+from autode.values import GradientRMS, Angle, Distance
 from autode.opt.coordinates import CartesianCoordinates, DICWithConstraints
 from autode.opt.coordinates.internals import PIC, AnyPIC
 from autode.opt.optimisers.rfo import RFOptimiser
 from autode.opt.optimisers.hessian_update import BFGSDampedUpdate, NullUpdate
 from autode.opt.coordinates.primitives import (
-    Distance,
-    BondAngle,
-    DihedralAngle,
-    ConstrainedDistance,
+    PrimitiveDistance,
+    PrimitiveBondAngle,
+    PrimitiveDihedralAngle,
+    ConstrainedPrimitiveDistance,
 )
 
 
 class CRFOptimiser(RFOptimiser):
-    def __init__(self, init_alpha: float = 0.05, *args, **kwargs):
+    def __init__(
+        self, init_alpha: Union[Distance, float] = 0.05, *args, **kwargs
+    ):
         """
         Constrained rational function optimisation
 
         -----------------------------------------------------------------------
         Arguments:
-            init_alpha: Maximum step size
+            init_alpha: Maximum step size, assumed Angstrom if units
+                        not given
 
         See Also:
             :py:meth:`RFOOptimiser <RFOOptimiser.__init__>`
         """
         super().__init__(*args, **kwargs)
 
-        self.alpha = float(init_alpha)
+        self.alpha = Distance(init_alpha, units="ang")
+        assert self.alpha > 0
         self._hessian_update_types = [BFGSDampedUpdate, NullUpdate]
 
     def _step(self) -> None:
@@ -128,7 +133,7 @@ class CRFOptimiser(RFOptimiser):
                 "additional distances"
             )
             for i, j in combinations(range(self._species.n_atoms), 2):
-                primitives.append(Distance(i, j))
+                primitives.append(PrimitiveDistance(i, j))
 
         self._coords = DICWithConstraints.from_cartesian(
             x=cartesian_coords, primitives=primitives
@@ -161,14 +166,14 @@ class CRFOptimiser(RFOptimiser):
             ):
 
                 r = self._species.constraints.distance[(i, j)]
-                pic.append(ConstrainedDistance(i, j, value=r))
+                pic.append(ConstrainedPrimitiveDistance(i, j, value=r))
 
             else:
-                pic.append(Distance(i, j))
+                pic.append(PrimitiveDistance(i, j))
 
         for o in range(self._species.n_atoms):
             for (n, m) in combinations(graph.neighbors(o), r=2):
-                pic.append(BondAngle(o=o, m=m, n=n))
+                pic.append(PrimitiveBondAngle(o=o, m=m, n=n))
 
         if self._species.n_atoms > 2 and not self._species.is_planar():
             for dihedral in _dihedrals(self._species):
@@ -238,4 +243,4 @@ def _dihedrals(species):
                 if np.isclose(species.angle(o, p, n), Angle(np.pi), atol=0.04):
                     continue
 
-                yield DihedralAngle(m, o, p, n)
+                yield PrimitiveDihedralAngle(m, o, p, n)
diff --git a/autode/opt/optimisers/prfo.py b/autode/opt/optimisers/prfo.py
index 81cf0ee..c2e0e4d 100644
--- a/autode/opt/optimisers/prfo.py
+++ b/autode/opt/optimisers/prfo.py
@@ -1,6 +1,9 @@
 """Partitioned rational function optimisation"""
 import numpy as np
+from typing import Union
+
 from autode.log import logger
+from autode.values import Distance
 from autode.opt.optimisers.crfo import CRFOptimiser
 from autode.opt.optimisers.hessian_update import BofillUpdate
 from autode.opt.coordinates.cartesian import CartesianCoordinates
@@ -10,7 +13,7 @@ from autode.exceptions import CalculationException
 class PRFOptimiser(CRFOptimiser):
     def __init__(
         self,
-        init_alpha: float = 0.05,
+        init_alpha: Union[Distance, float] = 0.05,
         recalc_hessian_every: int = 10,
         *args,
         **kwargs,
@@ -22,7 +25,7 @@ class PRFOptimiser(CRFOptimiser):
 
         -----------------------------------------------------------------------
         Arguments:
-            init_alpha: Maximum step size (Å)
+            init_alpha: Maximum step size (default Å if unit not given)
 
             imag_mode_idx: Index of the imaginary mode to follow. Default = 0,
                            the most imaginary mode (i.e. most negative
@@ -33,7 +36,8 @@ class PRFOptimiser(CRFOptimiser):
         """
         super().__init__(*args, **kwargs)
 
-        self.alpha = float(init_alpha)
+        self.alpha = Distance(init_alpha, units="ang")
+        assert self.alpha > 0
         self.recalc_hessian_every = int(recalc_hessian_every)
         self._hessian_update_types = [BofillUpdate]
 
diff --git a/autode/opt/optimisers/rfo.py b/autode/opt/optimisers/rfo.py
index 56561e4..f0304a4 100644
--- a/autode/opt/optimisers/rfo.py
+++ b/autode/opt/optimisers/rfo.py
@@ -1,10 +1,11 @@
 import numpy as np
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Union
 
 from autode.log import logger
 from autode.utils import work_in_tmp_dir
 from autode.opt.optimisers.base import NDOptimiser
 from autode.opt.coordinates import CartesianCoordinates
+from autode.values import Distance
 from autode.opt.optimisers.hessian_update import BFGSPDUpdate, NullUpdate
 
 if TYPE_CHECKING:
@@ -14,14 +15,16 @@ if TYPE_CHECKING:
 class RFOptimiser(NDOptimiser):
     """Rational function optimisation in delocalised internal coordinates"""
 
-    def __init__(self, *args, init_alpha: float = 0.1, **kwargs):
+    def __init__(
+        self, *args, init_alpha: Union[Distance, float] = 0.1, **kwargs
+    ):
         """
         Rational function optimiser (RFO) using a maximum step size of alpha
 
         -----------------------------------------------------------------------
         Arguments:
             init_alpha: Maximum step size, which controls the maximum component
-                        of the step
+                        of the step. If units not given, Angstrom assumed.
 
             args: Additional arguments for ``NDOptimiser``
 
@@ -32,7 +35,8 @@ class RFOptimiser(NDOptimiser):
         """
         super().__init__(*args, **kwargs)
 
-        self.alpha = init_alpha
+        self.alpha = Distance(init_alpha, units="ang")
+        assert self.alpha > 0
         self._hessian_update_types = [BFGSPDUpdate, NullUpdate]
 
     def _step(self) -> None:
diff --git a/autode/opt/optimisers/trm.py b/autode/opt/optimisers/trm.py
index d9ca8c0..32b6741 100644
--- a/autode/opt/optimisers/trm.py
+++ b/autode/opt/optimisers/trm.py
@@ -8,14 +8,14 @@ Only minimiser, this is not TS search/constrained optimiser
 import numpy as np
 from scipy.optimize import root_scalar
 from itertools import combinations
-from typing import TYPE_CHECKING, Tuple, Any
+from typing import TYPE_CHECKING, Tuple, Any, Union
 
 from autode.opt.coordinates import CartesianCoordinates, DIC, OptCoordinates
-from autode.opt.coordinates.primitives import Distance
+from autode.opt.coordinates.primitives import PrimitiveDistance
 from autode.opt.optimisers import CRFOptimiser
 from autode.opt.optimisers.hessian_update import BFGSSR1Update
 from autode.exceptions import OptimiserStepError
-from autode.values import GradientRMS, PotentialEnergy
+from autode.values import GradientRMS, PotentialEnergy, Distance
 from autode.log import logger
 
 if TYPE_CHECKING:
@@ -46,10 +46,10 @@ class HybridTRMOptimiser(CRFOptimiser):
 
     def __init__(
         self,
-        init_trust: float = 0.1,
+        init_trust: Union[Distance, float] = 0.1,
         update_trust: bool = True,
-        min_trust: float = 0.01,
-        max_trust: float = 0.3,
+        min_trust: Union[Distance, float] = 0.01,
+        max_trust: Union[Distance, float] = 0.3,
         damp: bool = True,
         *args: Any,
         **kwargs: Any,
@@ -81,8 +81,8 @@ class HybridTRMOptimiser(CRFOptimiser):
         super().__init__(init_alpha=init_trust, *args, **kwargs)  # type: ignore
 
         assert 0.0 < min_trust < max_trust
-        self._max_alpha = float(max_trust)
-        self._min_alpha = float(min_trust)
+        self._max_alpha = Distance(max_trust, "ang")
+        self._min_alpha = Distance(min_trust, "ang")
         self._upd_alpha = bool(update_trust)
 
         self._damping_on = bool(damp)
@@ -146,7 +146,7 @@ class HybridTRMOptimiser(CRFOptimiser):
                 "additional distances"
             )
             for i, j in combinations(range(self._species.n_atoms), 2):
-                primitives.append(Distance(i, j))
+                primitives.append(PrimitiveDistance(i, j))
 
         self._coords = DIC.from_cartesian(x=cart_coords, primitives=primitives)
         return None
@@ -339,7 +339,8 @@ class HybridTRMOptimiser(CRFOptimiser):
 
         # The value of shift parameter lambda must lie within (-infinity, first_b)
         # Need to find the roots of the 1D function cart_step_length_error
-        int_step_size = self.alpha  # starting guess, so ok to use cartesian
+        int_step_size = float(self.alpha)
+        # starting guess size
         fac = 1.0
         size_min_bound = None
         size_max_bound = None
@@ -422,17 +423,17 @@ class HybridTRMOptimiser(CRFOptimiser):
 
         if ratio < 0.25:
             set_trust = 0.5 * min(self.alpha, cart_step_size)
-            self.alpha = max(set_trust, self._min_alpha)
+            self.alpha = max(Distance(set_trust), self._min_alpha)
         elif 0.25 < ratio < 0.75:
             pass
         elif 0.75 < ratio < 1.25:
             # if step taken is within 5% of the trust radius
             if abs(cart_step_size - self.alpha) / self.alpha < 0.05:
-                self.alpha = min(self.alpha * 1.414, self._max_alpha)
+                self.alpha = min(Distance(1.414 * self.alpha), self._max_alpha)
         elif 1.25 < ratio < 1.5:
             pass
         else:  # ratio > 1.5
-            set_trust = 0.5 * min(self.alpha, cart_step_size)
+            set_trust = 0.5 * min(self.alpha, Distance(cart_step_size))
             self.alpha = max(set_trust, self._min_alpha)
 
         if (ratio < -1.0) or (ratio > 2.0):


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index dc9c762..5c39164 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -37,20 +37,17 @@ jobs:
         activate-environment: test
         channels: conda-forge
 
-    - name: Install mamba on MacOS
-      if: ${{ contains(matrix.os, 'macos') }}
-      run: conda install mamba libarchive
-
-    - name: Install mamba on Linux or Windows
-      if: ${{ !contains(matrix.os, 'macos') }}
-      run: conda install mamba
+    - name: Install mamba solver
+      run: |
+        conda install -n base conda-libmamba-solver
 
     - name: Install
       run: |
-        mamba install \
+        conda install \
           --file requirements.txt \
           --file tests/requirements.txt \
-          --channel conda-forge
+          --channel conda-forge \
+          --solver=libmamba
         python -m pip install . --no-deps
 
     - name: Test
diff --git a/.github/workflows/pytest_cov.yml b/.github/workflows/pytest_cov.yml
index 300a501..0c0573b 100644
--- a/.github/workflows/pytest_cov.yml
+++ b/.github/workflows/pytest_cov.yml
@@ -36,15 +36,19 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
         activate-environment: test
-        mamba-version: "*"
         channels: conda-forge
 
+    - name: Install mamba solver
+      run: |
+        conda install -n base conda-libmamba-solver
+
     - name: Install
       run: |
-        mamba install \
+        conda install \
           --file requirements.txt \
           --file tests/requirements.txt \
-          --channel conda-forge
+          --channel conda-forge \
+          --solver=libmamba
         source .github/scripts/install_xtb_ci.sh
         python -m pip install . --no-deps
 
diff --git a/tests/test_opt/test_coordiantes.py b/tests/test_opt/test_coordiantes.py
index ca43277..7af866c 100644
--- a/tests/test_opt/test_coordiantes.py
+++ b/tests/test_opt/test_coordiantes.py
@@ -6,16 +6,16 @@ from autode.species.molecule import Molecule
 from autode.values import Angle
 from autode.exceptions import CoordinateTransformFailed
 from autode.opt.coordinates.base import CartesianComponent
-from autode.opt.coordinates.internals import InverseDistances, PIC
+from autode.opt.coordinates.internals import PrimitiveInverseDistances, PIC
 from autode.opt.coordinates.cartesian import CartesianCoordinates
 from autode.opt.coordinates.dic import DIC
 from autode.opt.coordinates.primitives import (
-    InverseDistance,
-    Distance,
-    ConstrainedDistance,
-    BondAngle,
-    ConstrainedBondAngle,
-    DihedralAngle,
+    PrimitiveInverseDistance,
+    PrimitiveDistance,
+    ConstrainedPrimitiveDistance,
+    PrimitiveBondAngle,
+    ConstrainedPrimitiveBondAngle,
+    PrimitiveDihedralAngle,
 )
 
 
@@ -25,7 +25,7 @@ def test_inv_dist_primitives():
 
     x = CartesianCoordinates(arr)
 
-    inv_dist = InverseDistance(0, 1)
+    inv_dist = PrimitiveInverseDistance(0, 1)
     assert np.isclose(inv_dist(x), 0.5)  # 1/2.0 = 0.5 Å-1
 
     # Check a couple of derivatives by hand
@@ -49,7 +49,7 @@ def test_dist_primitives():
 
     x = CartesianCoordinates(arr)
 
-    inv_dist = Distance(0, 1)
+    inv_dist = PrimitiveDistance(0, 1)
     assert np.isclose(inv_dist(x), 2.0)
 
     assert np.isclose(
@@ -68,21 +68,23 @@ def test_dist_primitives():
 
 def test_primitive_equality():
 
-    assert InverseDistance(0, 1) != "a"
-    assert InverseDistance(0, 1) == InverseDistance(0, 1)
-    assert InverseDistance(1, 0) == InverseDistance(0, 1)
+    assert PrimitiveInverseDistance(0, 1) != "a"
+    assert PrimitiveInverseDistance(0, 1) == PrimitiveInverseDistance(0, 1)
+    assert PrimitiveInverseDistance(1, 0) == PrimitiveInverseDistance(0, 1)
 
 
 def test_primitives_equality():
 
     x = CartesianCoordinates(h2().coordinates)
-    primitives = InverseDistances.from_cartesian(x)
+    primitives = PrimitiveInverseDistances.from_cartesian(x)
 
     assert primitives != "a"
-    assert primitives == InverseDistances.from_cartesian(x)
+    assert primitives == PrimitiveInverseDistances.from_cartesian(x)
 
     # Order does not matter for equality
-    assert primitives == InverseDistances(InverseDistance(1, 0))
+    assert primitives == PrimitiveInverseDistances(
+        PrimitiveInverseDistance(1, 0)
+    )
 
 
 def test_cartesian_coordinates():
@@ -205,10 +207,10 @@ def test_basic_dic_properties():
 
 def test_invalid_pic_construction():
 
-    # Cannot construct some primitives e.g. InverseDistances from non Primitive
+    # Cannot construct some primitives e.g. PrimitiveInverseDistances from non Primitive
     # internal coordinates
     with pytest.raises(ValueError):
-        _ = InverseDistances("a")
+        _ = PrimitiveInverseDistances("a")
 
 
 def test_cart_to_dic():
@@ -218,7 +220,7 @@ def test_cart_to_dic():
     x = CartesianCoordinates(arr)
 
     # Should only have 1 internal coordinate
-    pic = InverseDistances.from_cartesian(x)
+    pic = PrimitiveInverseDistances.from_cartesian(x)
     assert len(pic) == 1
 
     # and not have a B matrix
@@ -565,7 +567,7 @@ def test_pic_b_no_primitives():
 
 def test_constrained_distance_satisfied():
 
-    d = ConstrainedDistance(0, 1, value=1.0)
+    d = ConstrainedPrimitiveDistance(0, 1, value=1.0)
 
     x = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]])
 
@@ -588,7 +590,7 @@ def test_angle_primitive_derivative():
     m = water_mol()
     init_coords = m.coordinates.copy()
 
-    angle = BondAngle(0, 1, 2)
+    angle = PrimitiveBondAngle(0, 1, 2)
 
     for atom_idx in (0, 1, 2):
         for component in CartesianComponent:
@@ -606,14 +608,14 @@ def test_angle_primitive_derivative():
 
 def test_angle_primitive_equality():
 
-    assert BondAngle(0, 1, 2) == BondAngle(0, 2, 1)
-    assert BondAngle(0, 1, 2) != BondAngle(2, 1, 0)
+    assert PrimitiveBondAngle(0, 1, 2) == PrimitiveBondAngle(0, 2, 1)
+    assert PrimitiveBondAngle(0, 1, 2) != PrimitiveBondAngle(2, 1, 0)
 
 
 def test_dihedral_value():
 
     m = h2o2_mol()
-    dihedral = DihedralAngle(2, 0, 1, 3)
+    dihedral = PrimitiveDihedralAngle(2, 0, 1, 3)
 
     assert np.isclose(
         dihedral(m.coordinates), Angle(100.8, units="deg").to("rad"), atol=1.0
@@ -633,7 +635,7 @@ def test_dihedral_primitive_derivative():
     m = h2o2_mol()
     init_coords = m.coordinates.copy()
 
-    dihedral = DihedralAngle(2, 0, 1, 3)
+    dihedral = PrimitiveDihedralAngle(2, 0, 1, 3)
 
     for atom_idx in (0, 1, 2, 3):
         for component in CartesianComponent:
@@ -649,8 +651,12 @@ def test_dihedral_primitive_derivative():
 
 def test_dihedral_equality():
 
-    assert DihedralAngle(2, 0, 1, 3) == DihedralAngle(2, 0, 1, 3)
-    assert DihedralAngle(2, 0, 1, 3) == DihedralAngle(3, 1, 0, 2)
+    assert PrimitiveDihedralAngle(2, 0, 1, 3) == PrimitiveDihedralAngle(
+        2, 0, 1, 3
+    )
+    assert PrimitiveDihedralAngle(2, 0, 1, 3) == PrimitiveDihedralAngle(
+        3, 1, 0, 2
+    )
 
 
 @pytest.mark.parametrize(
@@ -681,7 +687,7 @@ def test_dihedral_primitive_derivative_over_zero(h_coord):
     m.atoms[2].coord = h_coord
     init_coords = m.coordinates
 
-    dihedral = DihedralAngle(2, 0, 1, 3)
+    dihedral = PrimitiveDihedralAngle(2, 0, 1, 3)
 
     for atom_idx in (0, 1, 2, 3):
         for component in CartesianComponent:
@@ -694,12 +700,12 @@ def test_repr():
     """Test that each primitive has a representation"""
 
     prims = [
-        InverseDistance(0, 1),
-        Distance(0, 1),
-        ConstrainedDistance(0, 1, value=1e-3),
-        BondAngle(0, 1, 2),
-        ConstrainedBondAngle(0, 1, 2, value=1.0),
-        DihedralAngle(0, 1, 2, 3),
+        PrimitiveInverseDistance(0, 1),
+        PrimitiveDistance(0, 1),
+        ConstrainedPrimitiveDistance(0, 1, value=1e-3),
+        PrimitiveBondAngle(0, 1, 2),
+        ConstrainedPrimitiveBondAngle(0, 1, 2, value=1.0),
+        PrimitiveDihedralAngle(0, 1, 2, 3),
     ]
 
     for p in prims:
@@ -709,7 +715,7 @@ def test_repr():
 @pytest.mark.parametrize("sign", [1, -1])
 def test_angle_normal(sign):
 
-    angle = BondAngle(0, 1, 2)
+    angle = PrimitiveBondAngle(0, 1, 2)
     x = np.array([[0.0, 0.0, 0.0], [1.0, sign * 1.0, 1.0], [1.0, 0.0, 1.0]])
 
     assert not np.isinf(angle.derivative(0, 1, x))
@@ -735,7 +741,7 @@ def test_dic_large_step_allowed_unconverged_back_transform():
 
 def test_constrained_angle_delta():
 
-    q = ConstrainedBondAngle(0, 1, 2, value=np.pi)
+    q = ConstrainedPrimitiveBondAngle(0, 1, 2, value=np.pi)
     mol = water_mol()
     theta = mol.angle(1, 0, 2)
     x = CartesianCoordinates(mol.coordinates)
@@ -745,8 +751,8 @@ def test_constrained_angle_delta():
 
 def test_constrained_angle_equality():
 
-    a = ConstrainedBondAngle(0, 1, 2, value=np.pi)
-    b = ConstrainedBondAngle(0, 2, 1, value=np.pi)
+    a = ConstrainedPrimitiveBondAngle(0, 1, 2, value=np.pi)
+    b = ConstrainedPrimitiveBondAngle(0, 2, 1, value=np.pi)
 
     assert a == b
 
@@ -757,7 +763,7 @@ def test_constrained_angle_equality():
 def test_dics_cannot_be_built_with_incomplete_primitives():
 
     x = CartesianCoordinates(methane_mol().coordinates)
-    primitives = PIC(Distance(0, 1))
+    primitives = PIC(PrimitiveDistance(0, 1))
 
     with pytest.raises(RuntimeError):
         _ = DIC.from_cartesian(x=x, primitives=primitives)
diff --git a/tests/test_opt/test_crfo.py b/tests/test_opt/test_crfo.py
index 14a8170..b1002ce 100644
--- a/tests/test_opt/test_crfo.py
+++ b/tests/test_opt/test_crfo.py
@@ -9,7 +9,7 @@ from autode.methods import XTB
 from autode.opt.coordinates.internals import PIC
 from autode.opt.optimisers.crfo import CRFOptimiser
 from autode.opt.coordinates import CartesianCoordinates, DICWithConstraints
-from autode.opt.coordinates.primitives import DihedralAngle
+from autode.opt.coordinates.primitives import PrimitiveDihedralAngle
 from autode.utils import work_in_tmp_dir
 from .molecules import h2o2_mol
 from ..testutils import requires_working_xtb_install
@@ -205,15 +205,15 @@ def test_baker1997_example():
         ]
     )
 
-    r1 = prim.ConstrainedDistance(0, 1, value=1.5)
-    r2 = prim.ConstrainedDistance(3, 4, value=2.5)
-    theta = prim.ConstrainedBondAngle(
+    r1 = prim.ConstrainedPrimitiveDistance(0, 1, value=1.5)
+    r2 = prim.ConstrainedPrimitiveDistance(3, 4, value=2.5)
+    theta = prim.ConstrainedPrimitiveBondAngle(
         1, 0, 5, value=val.Angle(123.0, "º").to("rad")
     )
 
     pic = PIC(r1, r2, theta)
     for pair in ((2, 0), (3, 0), (4, 1), (5, 1)):
-        pic.append(prim.Distance(*pair))
+        pic.append(prim.PrimitiveDistance(*pair))
 
     for triple in (
         (1, 0, 3),
@@ -223,10 +223,10 @@ def test_baker1997_example():
         (5, 1, 0),
         (4, 1, 5),
     ):
-        pic.append(prim.BondAngle(*triple))
+        pic.append(prim.PrimitiveBondAngle(*triple))
 
     for quadruple in ((4, 1, 0, 2), (4, 1, 0, 3), (5, 1, 0, 2), (5, 1, 0, 3)):
-        pic.append(prim.DihedralAngle(*quadruple))
+        pic.append(prim.PrimitiveDihedralAngle(*quadruple))
 
     dic = DICWithConstraints.from_cartesian(
         x=CartesianCoordinates(c2h3f.coordinates), primitives=pic
@@ -349,4 +349,6 @@ def test_linear_dihedrals_are_removed():
     )
 
     dic = crfo_coords(allene)
-    assert not any(isinstance(q, DihedralAngle) for q in dic.primitives)
+    assert not any(
+        isinstance(q, PrimitiveDihedralAngle) for q in dic.primitives
+    )