Repository: duartegroup/autodE

Issue:
Inconsistent Calculation behaviour
**Possible API Improvement**

Currently with a [Calculation](https://github.com/duartegroup/autodE/blob/6505d5bbbd1906f57e4102e13f177510f166bbed/autode/calculation.py#L61)  `calc.get_energy()` returns None if a calculation failed but raises a `CalculationError` for e.g. `calc.get_gradients()`; this is not particularly consistent or user friendly.

Having talked it over with @tristan-j-wood having properties (e.g. `calc.energy`) that are set after `calc.run()`, and may be None (after throwing a logging error), would be easier and more consistent with the rest of the API. Also having access to the calc.molecule properties directly e.g.


```python

class Molecule:

    def __init__(self, x):

        self.x = x


class Calculation:

    def __getattr__(self, item):
        if item in self.molecule.__dict__:
            return self.molecule.__dict__[item]

        return self.__dict__[item]

    def __init__(self, molecule):

        self.molecule = molecule


if __name__ == '__main__':

    calc = Calculation(molecule=Molecule(x=1))

    print(calc.x)  # -> 1

    calc.x = 2
    print(calc.x)  # -> 2
```

would be more readable. Also should throw more useful exceptions if calculations are initialised but not run before getting a property.

Comments:

Patch:
diff --git a/autode/calculation.py b/autode/calculation.py
index b1a7931..a0accc4 100644
--- a/autode/calculation.py
+++ b/autode/calculation.py
@@ -3,6 +3,7 @@ import os
 import hashlib
 import base64
 from typing import Optional, List
+from functools import wraps
 import autode.wrappers.keywords as kws
 import autode.exceptions as ex
 from autode.utils import cached_property
@@ -23,6 +24,22 @@ def execute_calc(calc):
     return calc.execute_calculation()
 
 
+def _requires_set_output_filename(func):
+    """Calculation method requiring the output filename to be set"""
+
+    @wraps(func)
+    def wrapped_function(*args, **kwargs):
+        calc = args[0]
+
+        if calc.output.filename is None:
+            raise ex.CouldNotGetProperty(
+                f'Could not get property from {calc.name}. '
+                f'Has .run() been called?')
+        return func(*args, **kwargs)
+
+    return wrapped_function
+
+
 class Calculation:
 
     def __init__(self,
@@ -238,28 +255,29 @@ class Calculation:
         methods.add(f'{string}.\n')
         return None
 
-    def get_energy(self) -> Optional[PotentialEnergy]:
+    @_requires_set_output_filename
+    def get_energy(self) -> PotentialEnergy:
         """
-        Total electronic potential energy
+        Total electronic potential energy from the final structure in the
+        calculation
 
+        -----------------------------------------------------------------------
         Returns:
             (autode.values.PotentialEnergy | None):
+
+        Raises:
+            (autode.exceptions.CouldNotGetProperty):
         """
         logger.info(f'Getting energy from {self.output.filename}')
 
         if not self.terminated_normally:
-            logger.error('Calculation did not terminate normally. '
-                         'Energy = None')
-            return None
+            logger.error(f'Calculation of {self.molecule} did not terminate '
+                         f'normally')
+            raise ex.CouldNotGetProperty(name='energy')
 
-        try:
-            return PotentialEnergy(self.method.get_energy(self),
-                                   method=self.method,
-                                   keywords=self.input.keywords)
-
-        except ex.CouldNotGetProperty:
-            logger.warning('Could not get energy. Energy = None')
-            return None
+        return PotentialEnergy(self.method.get_energy(self),
+                               method=self.method,
+                               keywords=self.input.keywords)
 
     def optimisation_converged(self) -> bool:
         """
@@ -289,6 +307,7 @@ class Calculation:
 
         return self.method.optimisation_nearly_converged(self)
 
+    @_requires_set_output_filename
     def get_final_atoms(self) -> Atoms:
         """
         Get the atoms from the final step of a geometry optimisation
@@ -317,6 +336,7 @@ class Calculation:
 
         return atoms
 
+    @_requires_set_output_filename
     def get_atomic_charges(self) -> List[float]:
         """
         Get the partial atomic charges from a calculation. The method used to
@@ -338,6 +358,7 @@ class Calculation:
 
         return charges
 
+    @_requires_set_output_filename
     def get_gradients(self) -> Gradient:
         """
         Get the gradient (dE/dr) with respect to atomic displacement from a
@@ -357,6 +378,7 @@ class Calculation:
 
         return gradients
 
+    @_requires_set_output_filename
     def get_hessian(self) -> Hessian:
         """
         Get the Hessian matrix (d^2E/dr^2) i.e. the matrix of second
@@ -628,3 +650,7 @@ class CalculationInput:
             return self.additional_filenames
 
         return [self.filename] + self.additional_filenames
+
+
+
+
diff --git a/autode/species/species.py b/autode/species/species.py
index b8aa2bb..63d009c 100644
--- a/autode/species/species.py
+++ b/autode/species/species.py
@@ -1014,9 +1014,16 @@ class Species(AtomCollection):
 
         if calc is not None and calc.output.exists:
             self.atoms = calc.get_final_atoms()
-            self.energy = calc.get_energy()
             self.hessian = calc.get_hessian()
 
+            try:
+                self.energy = calc.get_energy()
+
+            except CalculationException:
+                logger.warning(f'Failed to get the potential energy from '
+                               f'{calc.name} but not essential for thermo'
+                               f'chemical calculation')
+
         elif self.hessian is None or (calc is not None and not calc.output.exists):
             logger.info('Calculation did not exist or Hessian was None - '
                         'calculating the Hessian')
@@ -1074,13 +1081,7 @@ class Species(AtomCollection):
                          keywords=keywords,
                          n_cores=Config.n_cores if n_cores is None else n_cores)
         sp.run()
-        energy = sp.get_energy()
-
-        if energy is None:
-            raise CalculationException("Failed to calculate a single point "
-                                       f"energy for {self}")
-
-        self.energy = energy
+        self.energy = sp.get_energy()
         return None
 
     @work_in('conformers')
diff --git a/autode/transition_states/base.py b/autode/transition_states/base.py
index 51b943c..4956b08 100644
--- a/autode/transition_states/base.py
+++ b/autode/transition_states/base.py
@@ -295,7 +295,7 @@ class TSbase(Species, ABC):
                                  keywords=method.keywords.low_opt,
                                  reset_graph=True)
 
-                except ex.AtomsNotFound:
+                except ex.CalculationException:
                     logger.error(f'Failed to optimise {mol.name} with '
                                  f'{method}. Assuming no link')
                     return False
diff --git a/doc/changelog.rst b/doc/changelog.rst
index 23ca780..a577f35 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -28,6 +28,8 @@ Usability improvements/Changes
 - Removes :code:`autode.KcalMol` and :code:`KjMol` and enables a reaction to be plotted using a string representation of the units.
 - Allows for keywords to be set using just a list or a string, rather than requiring a specific type
 - Changes :code:`autode.wrappers.keywords.Keyword.has_only_name` to a property
+- Modifies :code:`autode.calculation.Calculation.get_energy` to raise an exception if the energy cannot be extracted
+- Adds a runtime error if e.g. :code:`autode.calculation.Calculation.get_energy` is called on a calculation that has not been run
 
 
 Functionality improvements


Test patch:
diff --git a/tests/test_calculation.py b/tests/test_calculation.py
index d115145..4bec1e7 100644
--- a/tests/test_calculation.py
+++ b/tests/test_calculation.py
@@ -28,12 +28,13 @@ def test_calc_class():
     assert calc.method.name == 'xtb'
     assert len(calc.input.filenames) == 0
 
-    assert calc.get_energy() is None
+    with pytest.raises(ex.CouldNotGetProperty):
+        _ = calc.get_energy()
 
     assert not calc.optimisation_converged()
     assert not calc.optimisation_nearly_converged()
 
-    with pytest.raises(ex.AtomsNotFound):
+    with pytest.raises(ex.CouldNotGetProperty):
         _ = calc.get_final_atoms()
 
     with pytest.raises(ex.CouldNotGetProperty):
diff --git a/tests/test_gaussian_calc.py b/tests/test_gaussian_calc.py
index 34729e1..bf64cea 100644
--- a/tests/test_gaussian_calc.py
+++ b/tests/test_gaussian_calc.py
@@ -10,8 +10,7 @@ from autode.wrappers import keywords as kwds
 from autode.wrappers.basis_sets import def2tzecp, def2tzvp
 from autode.wrappers.functionals import pbe0
 from autode.wrappers.keywords import OptKeywords, SinglePointKeywords
-from autode.exceptions import AtomsNotFound
-from autode.exceptions import NoInputError
+from autode.exceptions import AtomsNotFound, NoInputError, CalculationException
 from autode.point_charges import PointCharge
 from autode.atoms import Atom
 from . import testutils
@@ -185,8 +184,10 @@ def test_bad_gauss_output():
     calc.output_file_lines = []
     calc.rev_output_file_lines = []
 
-    assert calc.get_energy() is None
-    with pytest.raises(AtomsNotFound):
+    with pytest.raises(CalculationException):
+        _ = calc.get_energy()
+
+    with pytest.raises(CalculationException):
         calc.get_final_atoms()
 
     with pytest.raises(NoInputError):
diff --git a/tests/test_mopac_calc.py b/tests/test_mopac_calc.py
index effa78c..0d97689 100644
--- a/tests/test_mopac_calc.py
+++ b/tests/test_mopac_calc.py
@@ -129,7 +129,10 @@ def test_bad_geometry():
     calc.output.filename = 'h2_overlap_opt_mopac.out'
 
     assert not calc.terminated_normally
-    assert calc.get_energy() is None
+
+    with pytest.raises(CouldNotGetProperty):
+        _ = calc.get_energy()
+
     assert not calc.optimisation_converged()
 
 
diff --git a/tests/test_orca_calc.py b/tests/test_orca_calc.py
index f698071..4b0e162 100644
--- a/tests/test_orca_calc.py
+++ b/tests/test_orca_calc.py
@@ -9,6 +9,7 @@ from autode.species.molecule import Molecule
 from autode.input_output import xyz_file_to_atoms
 from autode.wrappers.keywords import SinglePointKeywords, OptKeywords
 from autode.wrappers.keywords import Functional, WFMethod, BasisSet
+from autode.exceptions import CouldNotGetProperty
 from autode.solvent.solvents import ImplicitSolvent
 from autode.transition_states.transition_state import TransitionState
 from autode.transition_states.ts_guess import TSguess
@@ -137,9 +138,11 @@ def test_bad_orca_output():
     calc = Calculation(name='no_output', molecule=test_mol, method=method,
                        keywords=opt_keywords)
 
-    assert calc.get_energy() is None
-    with pytest.raises(ex.AtomsNotFound):
-        calc.get_final_atoms()
+    with pytest.raises(CouldNotGetProperty):
+        _ = calc.get_energy()
+
+    with pytest.raises(ex.CouldNotGetProperty):
+        _ = calc.get_final_atoms()
 
     with pytest.raises(ex.NoInputError):
         calc.execute_calculation()
diff --git a/tests/test_xtb_calc.py b/tests/test_xtb_calc.py
index 2974ac8..8e814fa 100644
--- a/tests/test_xtb_calc.py
+++ b/tests/test_xtb_calc.py
@@ -6,7 +6,7 @@ from autode.wrappers.XTB import XTB
 from autode.calculation import Calculation
 from autode.species.molecule import Molecule
 from autode.point_charges import PointCharge
-from autode.exceptions import AtomsNotFound
+from autode.exceptions import AtomsNotFound, CalculationException
 from autode.config import Config
 from . import testutils
 
@@ -75,7 +75,9 @@ def test_energy_extract_no_energy():
     calc.output.filename = 'h2_sp_xtb_no_energy.out'
 
     assert calc.terminated_normally
-    assert calc.get_energy() is None
+
+    with pytest.raises(CalculationException):
+        _ = calc.get_energy()
 
 
 @testutils.work_in_zipped_dir(os.path.join(here, 'data', 'xtb.zip'))