Repository: shirtsgroup/physical_validation

Issue:
Do a better job of checking whether required data is provided
For example: Attempting a 2D ensemble check with missing volume does not throw a descriptive error, but fails somewhere deep in the calculation. We should check whether the requested calculation can be performed, and fail with a descriptive error message otherwise.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/shirtsgroup/physical_validation/pull/152?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shirtsgroup) Report
> Merging [#152](https://codecov.io/gh/shirtsgroup/physical_validation/pull/152?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shirtsgroup) (77cc60c) into [master](https://codecov.io/gh/shirtsgroup/physical_validation/commit/af8750f90737929da04709217c78aad9c8328764?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shirtsgroup) (af8750f) will **increase** coverage by `0.55%`.
> The diff coverage is `100.00%`.



Patch:
diff --git a/physical_validation/data/simulation_data.py b/physical_validation/data/simulation_data.py
index cea8a62..a4f2c41 100644
--- a/physical_validation/data/simulation_data.py
+++ b/physical_validation/data/simulation_data.py
@@ -13,7 +13,7 @@
 r"""
 Data structures carrying simulation data.
 """
-from typing import Optional
+from typing import List, Optional
 
 from ..util import error as pv_error
 from . import EnsembleData, ObservableData, SystemData, TrajectoryData, UnitData
@@ -213,3 +213,196 @@ class SimulationData(object):
             and self.__observables == other.__observables
             and self.__trajectory == other.__trajectory
         )
+
+    def raise_if_ensemble_is_invalid(
+        self,
+        test_name: str,
+        argument_name: str,
+        check_pressure: bool,
+    ) -> None:
+        r"""
+        Raise InputError if the ensemble data does not hold the required
+        information needed for the tests.
+
+        Parameters
+        ----------
+        test_name
+            String naming the test used for error output
+        argument_name
+            String naming the SimulationData argument used for error output
+        check_pressure
+            Whether to check if the pressure is defined (NPT only).
+        """
+        if self.ensemble is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} does not contain "
+                f"information about the sampled ensemble.",
+            )
+        for ensemble in "NVT", "NPT":
+            if self.ensemble.ensemble == ensemble and self.ensemble.temperature is None:
+                raise pv_error.InputError(
+                    argument_name,
+                    f"SimulationData object provided to {test_name} indicates it was sampled "
+                    f"from an {ensemble} ensemble, but does not specify the temperature.",
+                )
+        if (
+            self.ensemble.ensemble == "NPT"
+            and check_pressure
+            and self.ensemble.pressure is None
+        ):
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} indicates it was sampled "
+                f"from an NPT ensemble, but does not specify the pressure.",
+            )
+
+    def raise_if_system_data_is_invalid(
+        self,
+        test_name: str,
+        argument_name: str,
+        check_full_system_data_only: bool,
+    ) -> None:
+        r"""
+        Raise InputError if the ensemble data does not hold the required
+        information needed for the tests.
+
+        Parameters
+        ----------
+        test_name
+            String naming the test used for error output
+        argument_name
+            String naming the SimulationData argument used for error output
+        check_full_system_data_only
+            Whether to check the full system data only (number of atoms in the system,
+            number of constraints in the system, number of translational and rotational
+            degree of freedom reduction of the system). If false, this also checks the
+            masses per atom and the molecule index and constraints.
+        """
+        if self.system is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} does not contain "
+                f"information about the system.",
+            )
+
+        if self.system.natoms is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the number of atoms in the sampled system.",
+            )
+        if self.system.nconstraints is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the number of constraints in the sampled system.",
+            )
+        if self.system.ndof_reduction_tra is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the reduction of translational degrees of freedom in the sampled system.",
+            )
+        if self.system.ndof_reduction_rot is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the reduction of rotational degrees of freedom in the sampled system.",
+            )
+
+        if check_full_system_data_only:
+            return
+
+        if self.system.mass is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the mass of the atoms in the sampled system.",
+            )
+        if self.system.molecule_idx is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the indices of the molecules in the sampled system.",
+            )
+        if self.system.nconstraints_per_molecule is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} lacks information on "
+                f"the number of constraints per molecule in the sampled system.",
+            )
+
+    def raise_if_observable_data_is_invalid(
+        self,
+        required_observables: List[str],
+        test_name: str,
+        argument_name: str,
+    ) -> None:
+        r"""
+        Raise InputError if there are missing observables entries, or
+        if the required observables don't have the same number of frames
+
+        Parameters
+        ----------
+        required_observables
+            List of strings denoting the observables required
+        test_name
+            String naming the test used for error output
+        argument_name
+            String naming the SimulationData argument used for error output
+        """
+        if self.observables is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} does not contain any observable data.",
+            )
+        for entry in required_observables:
+            if self.observables[entry] is None or self.observables[entry].size == 0:
+                raise pv_error.InputError(
+                    argument_name,
+                    f"SimulationData object provided to {test_name} lacks the observable {entry}.",
+                )
+
+        if len(required_observables) > 1:
+            size_of_first_entry = self.observables[required_observables[0]].size
+            if any(
+                [
+                    self.observables[entry].size != size_of_first_entry
+                    for entry in required_observables
+                ]
+            ):
+                raise pv_error.InputError(
+                    argument_name,
+                    f"SimulationData object provided to {test_name} does not have "
+                    f"equal number of frames for entries "
+                    + ", ".join(required_observables),
+                )
+
+    def raise_if_trajectory_data_is_invalid(
+        self,
+        test_name: str,
+        argument_name: str,
+    ) -> None:
+        r"""
+        Raise InputError if there are missing observables entries, or
+        if the required observables don't have the same number of frames
+
+        Parameters
+        ----------
+        test_name
+            String naming the test used for error output
+        argument_name
+            String naming the SimulationData argument used for error output
+        """
+        if self.trajectory is None:
+            raise pv_error.InputError(
+                argument_name,
+                f"SimulationData object provided to {test_name} does not contain any trajectory data.",
+            )
+        for entry in ["position", "velocity"]:
+            if self.trajectory[entry] is None or self.trajectory[entry].size == 0:
+                raise pv_error.InputError(
+                    argument_name,
+                    f"SimulationData object provided to {test_name} lacks {entry} trajectory data.",
+                )
diff --git a/physical_validation/ensemble.py b/physical_validation/ensemble.py
index a3d3d51..c2734cc 100644
--- a/physical_validation/ensemble.py
+++ b/physical_validation/ensemble.py
@@ -90,6 +90,16 @@ def check(
         raise pv_error.InputError(
             ["data_sim_one", "data_sim_two"], "Simulation data not compatible."
         )
+    data_sim_one.raise_if_ensemble_is_invalid(
+        test_name="ensemble.check",
+        argument_name="data_sim_one",
+        check_pressure=True,
+    )
+    data_sim_two.raise_if_ensemble_is_invalid(
+        test_name="ensemble.check",
+        argument_name="data_sim_two",
+        check_pressure=True,
+    )
 
     if data_sim_one.ensemble.ensemble != data_sim_two.ensemble.ensemble:
         raise pv_error.InputError(
@@ -115,14 +125,8 @@ def check(
         "V": "Volume",
     }
 
-    if total_energy:
-        eneq = "E"
-        e1 = data_sim_one.observables.total_energy
-        e2 = data_sim_two.observables.total_energy
-    else:
-        eneq = "U"
-        e1 = data_sim_one.observables.potential_energy
-        e2 = data_sim_two.observables.potential_energy
+    energy_observable_name = "total_energy" if total_energy else "potential_energy"
+    energy_observable_abbreviation = "E" if total_energy else "U"
 
     energy_units = data_sim_one.units.energy_str
 
@@ -132,13 +136,23 @@ def check(
     tail_cutoff = 0.001  # 0.1%
 
     if sampled_ensemble == "NVT":
+        data_sim_one.raise_if_observable_data_is_invalid(
+            required_observables=[energy_observable_name],
+            test_name="ensemble.check",
+            argument_name="data_sim_one",
+        )
+        data_sim_two.raise_if_observable_data_is_invalid(
+            required_observables=[energy_observable_name],
+            test_name="ensemble.check",
+            argument_name="data_sim_two",
+        )
         quantiles = ensemble.check_1d(
-            traj1=e1,
-            traj2=e2,
+            traj1=data_sim_one.observables[energy_observable_name],
+            traj2=data_sim_two.observables[energy_observable_name],
             param1=data_sim_one.ensemble.temperature,
             param2=data_sim_two.ensemble.temperature,
             kb=data_sim_one.units.kb,
-            quantity=eneq,
+            quantity=energy_observable_abbreviation,
             dtemp=True,
             dpress=False,
             dmu=False,
@@ -152,7 +166,7 @@ def check(
             verbosity=verbosity,
             filename=filename,
             screen=screen,
-            xlabel=labels[eneq],
+            xlabel=labels[energy_observable_abbreviation],
             xunit=energy_units,
             data_is_uncorrelated=data_is_uncorrelated,
         )
@@ -167,8 +181,20 @@ def check(
         equal_temps = temperatures[0] == temperatures[1]
         equal_press = pressures[0] == pressures[1]
 
-        v1 = data_sim_one.observables.volume
-        v2 = data_sim_two.observables.volume
+        required_observables = ["volume"]
+        if equal_press:
+            required_observables.append(energy_observable_name)
+
+        data_sim_one.raise_if_observable_data_is_invalid(
+            required_observables=required_observables,
+            test_name="ensemble.check",
+            argument_name="data_sim_one",
+        )
+        data_sim_two.raise_if_observable_data_is_invalid(
+            required_observables=required_observables,
+            test_name="ensemble.check",
+            argument_name="data_sim_two",
+        )
 
         volume_units = data_sim_one.units.volume_str
 
@@ -194,17 +220,17 @@ def check(
         pvconvert /= data_sim_one.units.energy_conversion
 
         if equal_press and not equal_temps:
-            e1 = e1 + pvconvert * pressures[0] * v1
-            e2 = e2 + pvconvert * pressures[1] * v2
-            if eneq == "U":
-                eneq = "H"
+            if energy_observable_abbreviation == "U":
+                energy_observable_abbreviation = "H"
             quantiles = ensemble.check_1d(
-                traj1=e1,
-                traj2=e2,
+                traj1=data_sim_one.observables[energy_observable_name]
+                + pvconvert * pressures[0] * data_sim_one.observables.volume,
+                traj2=data_sim_two.observables[energy_observable_name]
+                + pvconvert * pressures[1] * data_sim_two.observables.volume,
                 param1=temperatures[0],
                 param2=temperatures[1],
                 kb=data_sim_one.units.kb,
-                quantity=eneq,
+                quantity=energy_observable_abbreviation,
                 dtemp=True,
                 dpress=False,
                 dmu=False,
@@ -218,14 +244,14 @@ def check(
                 verbosity=verbosity,
                 filename=filename,
                 screen=screen,
-                xlabel=labels[eneq],
+                xlabel=labels[energy_observable_abbreviation],
                 xunit=energy_units,
                 data_is_uncorrelated=data_is_uncorrelated,
             )
         elif equal_temps and not equal_press:
             quantiles = ensemble.check_1d(
-                traj1=v1,
-                traj2=v2,
+                traj1=data_sim_one.observables.volume,
+                traj2=data_sim_two.observables.volume,
                 param1=pressures[0],
                 param2=pressures[1],
                 kb=data_sim_one.units.kb,
@@ -248,18 +274,26 @@ def check(
                 data_is_uncorrelated=data_is_uncorrelated,
             )
         else:
-            traj1 = np.array([e1, v1])
-            traj2 = np.array([e2, v2])
             param1 = np.array([temperatures[0], pressures[0]])
             param2 = np.array([temperatures[1], pressures[1]])
             quantiles = ensemble.check_2d(
-                traj1=traj1,
-                traj2=traj2,
+                traj1=np.array(
+                    [
+                        data_sim_one.observables[energy_observable_name],
+                        data_sim_one.observables.volume,
+                    ]
+                ),
+                traj2=np.array(
+                    [
+                        data_sim_two.observables[energy_observable_name],
+                        data_sim_two.observables.volume,
+                    ]
+                ),
                 param1=param1,
                 param2=param2,
                 kb=data_sim_one.units.kb,
                 pvconvert=pvconvert,
-                quantity=[eneq, "V"],
+                quantity=[energy_observable_abbreviation, "V"],
                 dtempdpress=True,
                 dtempdmu=False,
                 cutoff=tail_cutoff,
@@ -330,11 +364,25 @@ def estimate_interval(
             * `'dTdP'`: Suggested combined temperature and pressure interval
 
     """
+    data.raise_if_ensemble_is_invalid(
+        test_name="ensemble.estimate_interval",
+        argument_name="data",
+        check_pressure=True,
+    )
+    if not (data.ensemble.ensemble == "NVT" or data.ensemble.ensemble == "NPT"):
+        raise NotImplementedError(
+            "estimate_interval() not implemented for ensemble " + data.ensemble.ensemble
+        )
 
-    if total_energy:
-        ene = data.observables.total_energy
-    else:
-        ene = data.observables.potential_energy
+    energy_observable_name = "total_energy" if total_energy else "potential_energy"
+    required_observables = [energy_observable_name]
+    if data.ensemble.ensemble == "NPT":
+        required_observables.append("volume")
+    data.raise_if_observable_data_is_invalid(
+        required_observables=required_observables,
+        test_name="ensemble.estimate_interval",
+        argument_name="data",
+    )
 
     tail_cutoff = 0.001  # 0.1%
 
@@ -342,7 +390,7 @@ def estimate_interval(
         result = ensemble.estimate_interval(
             ens_string="NVT",
             ens_temp=data.ensemble.temperature,
-            energy=ene,
+            energy=data.observables[energy_observable_name],
             kb=data.units.kb,
             ens_press=None,
             volume=None,
@@ -353,14 +401,14 @@ def estimate_interval(
             punit="",
             data_is_uncorrelated=data_is_uncorrelated,
         )
-    elif data.ensemble.ensemble == "NPT":
+    else:
         pvconvert = 6.022140857e-2
         pvconvert *= data.units.pressure_conversion * data.units.volume_conversion
         pvconvert /= data.units.energy_conversion
         result = ensemble.estimate_interval(
             ens_string="NPT",
             ens_temp=data.ensemble.temperature,
-            energy=ene,
+            energy=data.observables[energy_observable_name],
             kb=data.units.kb,
             ens_press=data.ensemble.pressure,
             volume=data.observables.volume,
@@ -371,9 +419,5 @@ def estimate_interval(
             punit=data.units.pressure_str,
             data_is_uncorrelated=data_is_uncorrelated,
         )
-    else:
-        raise NotImplementedError(
-            "estimate_interval() not implemented for ensemble " + data.ensemble.ensemble
-        )
 
     return result
diff --git a/physical_validation/integrator.py b/physical_validation/integrator.py
index 8c052d3..31b65d8 100644
--- a/physical_validation/integrator.py
+++ b/physical_validation/integrator.py
@@ -96,6 +96,11 @@ def convergence(
                 "simulations", "Found two simulations with identical timestep"
             )
 
+        s.raise_if_observable_data_is_invalid(
+            required_observables=["constant_of_motion"],
+            test_name="integrator.convergence",
+            argument_name="simulations",
+        )
         constant_of_motion[key] = s.observables.constant_of_motion
 
     return util_integ.check_convergence(
diff --git a/physical_validation/kinetic_energy.py b/physical_validation/kinetic_energy.py
index a4603b1..eb85c6b 100644
--- a/physical_validation/kinetic_energy.py
+++ b/physical_validation/kinetic_energy.py
@@ -127,6 +127,22 @@ def distribution(
               invalidate it.
 
     """
+    data.raise_if_ensemble_is_invalid(
+        test_name="kinetic_energy.distribution",
+        argument_name="data",
+        check_pressure=False,
+    )
+    data.raise_if_system_data_is_invalid(
+        test_name="kinetic_energy.distribution",
+        argument_name="data",
+        check_full_system_data_only=True,
+    )
+    data.raise_if_observable_data_is_invalid(
+        required_observables=["kinetic_energy"],
+        test_name="kinetic_energy.distribution",
+        argument_name="data",
+    )
+
     ndof = (
         data.system.natoms * 3
         - data.system.nconstraints
@@ -262,6 +278,21 @@ def equipartition(
         # data structure to store information. Create it if it doesn't exist.
         data.observables = ObservableData()
 
+    data.raise_if_ensemble_is_invalid(
+        test_name="kinetic_energy.equipartition",
+        argument_name="data",
+        check_pressure=False,
+    )
+    data.raise_if_system_data_is_invalid(
+        test_name="kinetic_energy.equipartition",
+        argument_name="data",
+        check_full_system_data_only=False,
+    )
+    data.raise_if_trajectory_data_is_invalid(
+        test_name="kinetic_energy.equipartition",
+        argument_name="data",
+    )
+
     (
         result,
         data.system.ndof_per_molecule,


Test patch:
diff --git a/physical_validation/tests/test_data_simulation_data.py b/physical_validation/tests/test_data_simulation_data.py
index b2660c7..0962293 100644
--- a/physical_validation/tests/test_data_simulation_data.py
+++ b/physical_validation/tests/test_data_simulation_data.py
@@ -13,6 +13,9 @@
 r"""
 This file contains tests for the `physical_validation.data.simulation_data` module.
 """
+import numpy as np
+import pytest
+
 from ..data import (
     EnsembleData,
     FlatfileParser,
@@ -21,6 +24,7 @@ from ..data import (
     SystemData,
     TrajectoryData,
 )
+from ..util import error as pv_error
 from .test_system_database import database
 
 
@@ -126,3 +130,244 @@ class TestEquality:
         simulation_data_copy.trajectory.position[0][0] += 1
         # Suppressed LGTM warning: See https://github.com/github/codeql/issues/5777
         assert simulation_data != simulation_data_copy  # lgtm [py/redundant-comparison]
+
+
+class TestInvalidityChecks:
+    @staticmethod
+    def test_empty_raises_error() -> None:
+        simulation_data = SimulationData()
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_ensemble_is_invalid(
+                test_name="dummy_test", argument_name="data", check_pressure=True
+            )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=True,
+            )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_observable_data_is_invalid(
+                required_observables=[],
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_trajectory_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+            )
+
+    @staticmethod
+    @pytest.mark.filterwarnings("ignore:NVE|NVT|NPT|muVT with undefined")
+    def test_ensemble_check() -> None:
+        simulation_data = SimulationData()
+        # NVE isn't tested, so passes always
+        simulation_data.ensemble = EnsembleData(ensemble="NVE")
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=True
+        )
+        # muVT isn't tested, so passes always
+        simulation_data.ensemble = EnsembleData(ensemble="muVT")
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=True
+        )
+        # NVT doesn't pass without temperature
+        simulation_data.ensemble = EnsembleData(ensemble="NVT")
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_ensemble_is_invalid(
+                test_name="dummy_test", argument_name="data", check_pressure=False
+            )
+        # Adding temperature fixes it
+        simulation_data.ensemble = EnsembleData(ensemble="NVT", temperature=300)
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=True
+        )
+        # NPT doesn't pass without temperature
+        simulation_data.ensemble = EnsembleData(ensemble="NPT")
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_ensemble_is_invalid(
+                test_name="dummy_test", argument_name="data", check_pressure=False
+            )
+        # Adding temperature fixes it if check_pressure is False
+        simulation_data.ensemble = EnsembleData(ensemble="NPT", temperature=300)
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=False
+        )
+        # But not if pressure is required to be tested
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_ensemble_is_invalid(
+                test_name="dummy_test", argument_name="data", check_pressure=True
+            )
+        # Pressure alone doesn't help
+        simulation_data.ensemble = EnsembleData(ensemble="NPT", pressure=1.0)
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_ensemble_is_invalid(
+                test_name="dummy_test", argument_name="data", check_pressure=True
+            )
+        # Temperature and pressure passes
+        simulation_data.ensemble = EnsembleData(
+            ensemble="NPT", temperature=300, pressure=1.0
+        )
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=False
+        )
+        simulation_data.raise_if_ensemble_is_invalid(
+            test_name="dummy_test", argument_name="data", check_pressure=True
+        )
+
+    @staticmethod
+    def test_system_check() -> None:
+        simulation_data = SimulationData()
+        simulation_data.system = SystemData()
+        # empty fails
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=True,
+            )
+        # adding only number of atoms still fails
+        simulation_data.system.natoms = 100
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=True,
+            )
+        # adding number of constraints still fails
+        simulation_data.system.nconstraints = 300
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=True,
+            )
+        # adding number of translational constraints still fails
+        simulation_data.system.ndof_reduction_tra = 3
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=True,
+            )
+        # adding number of rotational constraints passes
+        simulation_data.system.ndof_reduction_rot = 3
+        simulation_data.raise_if_system_data_is_invalid(
+            test_name="dummy_test",
+            argument_name="data",
+            check_full_system_data_only=True,
+        )
+        # but fails on entire system check
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=False,
+            )
+        # adding masses still fails
+        simulation_data.system.mass = np.ones(100)
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=False,
+            )
+        # adding molecule index still fails
+        simulation_data.system.molecule_idx = np.linspace(
+            0, 100 * 3, 100, endpoint=False, dtype=int
+        )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_system_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+                check_full_system_data_only=False,
+            )
+        # adding number of constraints per molecule passes
+        simulation_data.system.nconstraints_per_molecule = 3 * np.ones(100)
+        simulation_data.raise_if_system_data_is_invalid(
+            test_name="dummy_test",
+            argument_name="data",
+            check_full_system_data_only=False,
+        )
+
+    @staticmethod
+    @pytest.mark.filterwarnings("ignore:ObservableData. Mismatch in number of frames")
+    def test_observable_check() -> None:
+        simulation_data = SimulationData()
+        num_frames = 10
+        simulation_data.observables = ObservableData()
+        # empty object passes if no observables are required
+        simulation_data.raise_if_observable_data_is_invalid(
+            required_observables=[],
+            test_name="dummy_test",
+            argument_name="data",
+        )
+        # empty object fails if at least one quantity is required
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_observable_data_is_invalid(
+                required_observables=["kinetic_energy"],
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        # adding the required quantity makes it pass
+        simulation_data.observables["kinetic_energy"] = np.random.random(num_frames)
+        simulation_data.raise_if_observable_data_is_invalid(
+            required_observables=["kinetic_energy"],
+            test_name="dummy_test",
+            argument_name="data",
+        )
+        # check fails again if additional observable is requested
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_observable_data_is_invalid(
+                required_observables=["kinetic_energy", "potential_energy"],
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        # check fails also if wrong number of frames is added
+        simulation_data.observables["potential_energy"] = np.random.random(
+            num_frames - 1
+        )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_observable_data_is_invalid(
+                required_observables=["kinetic_energy", "potential_energy"],
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        # but passes with identical number of frames
+        simulation_data.observables["potential_energy"] = np.random.random(num_frames)
+        simulation_data.raise_if_observable_data_is_invalid(
+            required_observables=["kinetic_energy", "potential_energy"],
+            test_name="dummy_test",
+            argument_name="data",
+        )
+
+    @staticmethod
+    def test_trajectory_check() -> None:
+        simulation_data = SimulationData()
+        num_atoms = 10
+        num_frames = 2
+        simulation_data.trajectory = TrajectoryData()
+        # empty object fails
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_trajectory_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        # adding only one trajectory still fails
+        simulation_data.trajectory.position = np.random.random(
+            (num_frames, num_atoms, 3)
+        )
+        with pytest.raises(pv_error.InputError):
+            simulation_data.raise_if_trajectory_data_is_invalid(
+                test_name="dummy_test",
+                argument_name="data",
+            )
+        # adding correct velocity trajectory passes
+        simulation_data.trajectory.velocity = np.random.random(
+            (num_frames, num_atoms, 3)
+        )
+        simulation_data.raise_if_trajectory_data_is_invalid(
+            test_name="dummy_test",
+            argument_name="data",
+        )