From 1ce021f8bfb70bbc76deeac120525c29a2d4a791 Mon Sep 17 00:00:00 2001 From: Pascal Merz Date: Thu, 29 Apr 2021 13:42:37 -0600 Subject: [PATCH] Clean up ObservableData (#145) Reduce code duplication Add unit tests --- physical_validation/data/gromacs_parser.py | 6 - physical_validation/data/observable_data.py | 166 ++++++------------ physical_validation/data/trajectory_data.py | 10 +- .../tests/test_data_observable_data.py | 105 +++++++++++ physical_validation/tests/test_util_util.py | 40 +++++ physical_validation/util/util.py | 43 +++++ 6 files changed, 240 insertions(+), 130 deletions(-) create mode 100644 physical_validation/tests/test_data_observable_data.py create mode 100644 physical_validation/tests/test_util_util.py create mode 100644 physical_validation/util/util.py diff --git a/physical_validation/data/gromacs_parser.py b/physical_validation/data/gromacs_parser.py index cfcd173..2190507 100644 --- a/physical_validation/data/gromacs_parser.py +++ b/physical_validation/data/gromacs_parser.py @@ -307,12 +307,6 @@ def get_simulation_data(self, mdp=None, top=None, edr=None, trr=None, gro=None): volume = box[0] * box[1] * box[2] elif box.ndim == 2: volume = box[0, 0] * box[1, 1] * box[2, 2] - else: - warnings.warn( - "Constant volume simulation with undefined volume." - ) - else: - warnings.warn("Constant volume simulation with undefined volume.") if constant_temp and constant_press: ens = "NPT" diff --git a/physical_validation/data/observable_data.py b/physical_validation/data/observable_data.py index ac1916e..acf5552 100644 --- a/physical_validation/data/observable_data.py +++ b/physical_validation/data/observable_data.py @@ -14,10 +14,12 @@ Data structures carrying simulation data. """ import warnings +from typing import Optional import numpy as np -import physical_validation.util.error as pv_error +from ..util import error as pv_error +from ..util.util import array_equal_shape_and_close class ObservableData(object): @@ -67,17 +69,8 @@ def __init__( self.__pressure = None self.__temperature = None self.__constant_of_motion = None - self.__nframes = -1 self.__kinetic_energy_per_molec = None - self.kinetic_energy = kinetic_energy - self.potential_energy = potential_energy - self.total_energy = total_energy - self.volume = volume - self.pressure = pressure - self.temperature = temperature - self.constant_of_motion = constant_of_motion - self.__getters = { "kinetic_energy": ObservableData.kinetic_energy.__get__, "potential_energy": ObservableData.potential_energy.__get__, @@ -98,6 +91,18 @@ def __init__( "constant_of_motion": ObservableData.constant_of_motion.__set__, } + # Consistency check + assert set(self.__getters.keys()) == set(self.__setters.keys()) + assert set(self.__getters.keys()) == set(ObservableData.observables()) + + self.kinetic_energy = kinetic_energy + self.potential_energy = potential_energy + self.total_energy = total_energy + self.volume = volume + self.pressure = pressure + self.temperature = temperature + self.constant_of_motion = constant_of_motion + def get(self, key): return self[key] @@ -114,6 +119,18 @@ def __setitem__(self, key, value): raise KeyError self.__setters[key](self, value) + def __check_value(self, value, key: str) -> Optional[np.ndarray]: + if value is None: + return None + value = np.array(value) + if value.ndim != 1: + raise pv_error.InputError(key, "Expected 1-dimensional array.") + if self.nframes is not None and self.nframes != value.size: + warnings.warn( + "ObservableData: Mismatch in number of frames. Setting `nframes = None`." + ) + return value + @property def kinetic_energy(self): """Get kinetic_energy""" @@ -122,18 +139,7 @@ def kinetic_energy(self): @kinetic_energy.setter def kinetic_energy(self, kinetic_energy): """Set kinetic_energy""" - if kinetic_energy is None: - self.__kinetic_energy = None - return - kinetic_energy = np.array(kinetic_energy) - if kinetic_energy.ndim != 1: - raise pv_error.InputError("kinetic_energy", "Expected 1-dimensional array.") - if self.nframes == -1: - self.__nframes = kinetic_energy.size - elif self.nframes != kinetic_energy.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__kinetic_energy = kinetic_energy + self.__kinetic_energy = self.__check_value(kinetic_energy, "kinetic_energy") @property def potential_energy(self): @@ -143,20 +149,9 @@ def potential_energy(self): @potential_energy.setter def potential_energy(self, potential_energy): """Set potential_energy""" - if potential_energy is None: - self.__potential_energy = None - return - potential_energy = np.array(potential_energy) - if potential_energy.ndim != 1: - raise pv_error.InputError( - "potential_energy", "Expected 1-dimensional array." - ) - if self.nframes == -1: - self.__nframes = potential_energy.size - elif self.nframes != potential_energy.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__potential_energy = potential_energy + self.__potential_energy = self.__check_value( + potential_energy, "potential_energy" + ) @property def total_energy(self): @@ -166,18 +161,7 @@ def total_energy(self): @total_energy.setter def total_energy(self, total_energy): """Set total_energy""" - if total_energy is None: - self.__total_energy = None - return - total_energy = np.array(total_energy) - if total_energy.ndim != 1: - raise pv_error.InputError("total_energy", "Expected 1-dimensional array.") - if self.nframes == -1: - self.__nframes = total_energy.size - elif self.nframes != total_energy.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__total_energy = total_energy + self.__total_energy = self.__check_value(total_energy, "total_energy") @property def volume(self): @@ -187,18 +171,7 @@ def volume(self): @volume.setter def volume(self, volume): """Set volume""" - if volume is None: - self.__volume = None - return - volume = np.array(volume) - if volume.ndim != 1: - raise pv_error.InputError("volume", "Expected 1-dimensional array.") - if self.nframes == -1: - self.__nframes = volume.size - elif self.nframes != volume.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__volume = volume + self.__volume = self.__check_value(volume, "volume") @property def pressure(self): @@ -208,18 +181,7 @@ def pressure(self): @pressure.setter def pressure(self, pressure): """Set pressure""" - if pressure is None: - self.__pressure = None - return - pressure = np.array(pressure) - if pressure.ndim != 1: - raise pv_error.InputError("pressure", "Expected 1-dimensional array.") - if self.nframes == -1: - self.__nframes = pressure.size - elif self.nframes != pressure.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__pressure = pressure + self.__pressure = self.__check_value(pressure, "pressure") @property def temperature(self): @@ -229,18 +191,7 @@ def temperature(self): @temperature.setter def temperature(self, temperature): """Set temperature""" - if temperature is None: - self.__temperature = None - return - temperature = np.array(temperature) - if temperature.ndim != 1: - raise pv_error.InputError("temperature", "Expected 1-dimensional array.") - if self.nframes == -1: - self.__nframes = temperature.size - elif self.nframes != temperature.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__temperature = temperature + self.__temperature = self.__check_value(temperature, "temperature") @property def constant_of_motion(self): @@ -250,30 +201,25 @@ def constant_of_motion(self): @constant_of_motion.setter def constant_of_motion(self, constant_of_motion): """Set constant_of_motion""" - if constant_of_motion is None: - self.__constant_of_motion = None - return - constant_of_motion = np.array(constant_of_motion) - if constant_of_motion.ndim != 1: - raise pv_error.InputError( - "constant_of_motion", "Expected 1-dimensional array." - ) - if self.nframes == -1: - self.__nframes = constant_of_motion.size - elif self.nframes != constant_of_motion.size: - warnings.warn("Mismatch in number of frames. Setting `nframes = None`.") - self.__nframes = None - self.__constant_of_motion = constant_of_motion + self.__constant_of_motion = self.__check_value( + constant_of_motion, "constant_of_motion" + ) @property def nframes(self): """Get number of frames""" - if self.__nframes is None: - warnings.warn( - "A mismatch in the number of frames between observables " - "was detected. Setting `nframes = None`." - ) - return self.__nframes + frames = None + for observable in ObservableData.observables(): + if self.get(observable) is not None: + if frames is not None: + if self.get(observable).size == frames: + continue + else: + return None + else: + frames = self.get(observable).size + + return frames @property def kinetic_energy_per_molecule(self): @@ -283,23 +229,12 @@ def kinetic_energy_per_molecule(self): @kinetic_energy_per_molecule.setter def kinetic_energy_per_molecule(self, kinetic_energy): """Set kinetic_energy per molecule - used internally""" - if kinetic_energy is None: - self.__kinetic_energy_per_molec = None - return - # used internally - check for consistency? self.__kinetic_energy_per_molec = kinetic_energy def __eq__(self, other): if type(other) is not type(self): return False - def array_equal_shape_and_close(array1: np.ndarray, array2: np.ndarray): - if array1 is None and array2 is None: - return True - if array1.shape != array2.shape: - return False - return np.allclose(array1, array2, rtol=1e-12, atol=1e-12) - return ( array_equal_shape_and_close(self.__kinetic_energy, other.__kinetic_energy) and array_equal_shape_and_close( @@ -315,5 +250,4 @@ def array_equal_shape_and_close(array1: np.ndarray, array2: np.ndarray): and array_equal_shape_and_close( self.__kinetic_energy_per_molec, other.__kinetic_energy_per_molec ) - and self.__nframes == other.__nframes ) diff --git a/physical_validation/data/trajectory_data.py b/physical_validation/data/trajectory_data.py index 55d9ede..2e01c72 100644 --- a/physical_validation/data/trajectory_data.py +++ b/physical_validation/data/trajectory_data.py @@ -17,7 +17,8 @@ import numpy as np -import physical_validation.util.error as pv_error +from ..util import error as pv_error +from ..util.util import array_equal_shape_and_close class Box(object): @@ -246,13 +247,6 @@ def __eq__(self, other): if type(other) is not type(self): return False - def array_equal_shape_and_close(array1: np.ndarray, array2: np.ndarray): - if array1 is None and array2 is None: - return True - if array1.shape != array2.shape: - return False - return np.allclose(array1, array2, rtol=1e-12, atol=1e-12) - return ( array_equal_shape_and_close(self.__position, other.__position) and array_equal_shape_and_close(self.__velocity, other.__velocity) diff --git a/physical_validation/tests/test_data_observable_data.py b/physical_validation/tests/test_data_observable_data.py new file mode 100644 index 0000000..dccd449 --- /dev/null +++ b/physical_validation/tests/test_data_observable_data.py @@ -0,0 +1,105 @@ +########################################################################### +# # +# physical_validation, # +# a python package to test the physical validity of MD results # +# # +# Written by Pascal T. Merz # +# Michael R. Shirts # +# # +# Copyright (c) 2017-2021 University of Colorado Boulder # +# (c) 2012 The University of Virginia # +# # +########################################################################### +r""" +This file contains tests for the `physical_validation.util.util` module. +""" +import numpy as np +import pytest + +from ..data.observable_data import ObservableData +from ..util import error as pv_error + + +def test_observable_data_getters_and_setters() -> None: + + # Check that newly create observable data object has None + observable_data = ObservableData() + assert observable_data.nframes is None + + # Check that we can set a observable array, the number of + # frames gets updated, and the two getter methods are equivalent + num_frames = 13 + observable_data.kinetic_energy = np.random.random(num_frames) + assert observable_data.nframes == num_frames + assert np.array_equal( + observable_data["kinetic_energy"], observable_data.kinetic_energy + ) + + # Check that we can set another observable array, the number of + # frames stays correct, and the two getter methods are equivalent + observable_data["potential_energy"] = np.random.random(num_frames) + assert observable_data.nframes == num_frames + assert np.array_equal( + observable_data["potential_energy"], observable_data.potential_energy + ) + + # Check that we can set an observable array back to None, the number of + # frames stays correct, and the two getter methods are equivalent + observable_data.potential_energy = None + assert observable_data.nframes == num_frames + assert observable_data.potential_energy is None + assert np.array_equal( + observable_data["potential_energy"], observable_data.potential_energy + ) + + # Check that it only accepts one-dimensional arrays + with pytest.raises(pv_error.InputError): + # 0 dimensions + observable_data.potential_energy = np.array(0) + with pytest.raises(pv_error.InputError): + # 2 dimensions (reducible) + observable_data.potential_energy = np.random.random((1, 2)) + with pytest.raises(pv_error.InputError): + # 2 dimensions (non-reducible) + observable_data.potential_energy = np.random.random((3, 2)) + with pytest.raises(pv_error.InputError): + # 3 dimensions + observable_data.potential_energy = np.random.random((5, 2, 2)) + + # Check that random set / get strings raise an error + key = "anything" + assert key not in ObservableData.observables() + with pytest.raises(KeyError): + observable_data[key] = np.random.random(num_frames) + with pytest.raises(KeyError): + _ = observable_data[key] + + # Check that the number of frames wasn't changed + assert observable_data.nframes == num_frames + + # Check that different number of frames yields warning + # and `None` number of frames + with pytest.warns(UserWarning): + observable_data.potential_energy = np.random.random(num_frames + 1) + assert observable_data.nframes is None + + # Check that number of frames can be fixed + observable_data.potential_energy = np.random.random(num_frames) + assert observable_data.nframes == num_frames + + # Check that all observables can be read in two ways + for observable in ObservableData.observables(): + observable_data.set(observable, np.random.random(num_frames)) + assert np.array_equal( + observable_data.kinetic_energy, observable_data["kinetic_energy"] + ) + assert np.array_equal( + observable_data.potential_energy, observable_data["potential_energy"] + ) + assert np.array_equal(observable_data.total_energy, observable_data["total_energy"]) + assert np.array_equal(observable_data.volume, observable_data["volume"]) + assert np.array_equal(observable_data.pressure, observable_data["pressure"]) + assert np.array_equal(observable_data.temperature, observable_data["temperature"]) + assert np.array_equal( + observable_data.constant_of_motion, observable_data["constant_of_motion"] + ) diff --git a/physical_validation/tests/test_util_util.py b/physical_validation/tests/test_util_util.py new file mode 100644 index 0000000..d1b71b5 --- /dev/null +++ b/physical_validation/tests/test_util_util.py @@ -0,0 +1,40 @@ +########################################################################### +# # +# physical_validation, # +# a python package to test the physical validity of MD results # +# # +# Written by Pascal T. Merz # +# Michael R. Shirts # +# # +# Copyright (c) 2017-2021 University of Colorado Boulder # +# (c) 2012 The University of Virginia # +# # +########################################################################### +r""" +This file contains tests for the `physical_validation.util.util` module. +""" +import numpy as np + +from ..util.util import array_equal_shape_and_close + + +class TestArrayEqualShapeAndClose: + @staticmethod + def test_none_is_true() -> None: + assert array_equal_shape_and_close(None, None) + + @staticmethod + def test_none_with_array_is_false() -> None: + assert not array_equal_shape_and_close(None, np.array(None)) + assert not array_equal_shape_and_close(None, np.array(0)) + assert not array_equal_shape_and_close(None, np.zeros(0)) + assert not array_equal_shape_and_close(np.array(None), None) + assert not array_equal_shape_and_close(np.array(0), None) + assert not array_equal_shape_and_close(np.zeros(0), None) + + @staticmethod + def test_shape_and_content() -> None: + assert array_equal_shape_and_close(np.ones((4, 5)), np.ones((4, 5))) + assert not array_equal_shape_and_close(np.ones((4, 5)), np.ones((5, 4))) + assert not array_equal_shape_and_close(np.ones(0), np.ones(1)) + assert array_equal_shape_and_close(np.ones(0), np.zeros(0)) diff --git a/physical_validation/util/util.py b/physical_validation/util/util.py new file mode 100644 index 0000000..b37dcb3 --- /dev/null +++ b/physical_validation/util/util.py @@ -0,0 +1,43 @@ +########################################################################### +# # +# physical_validation, # +# a python package to test the physical validity of MD results # +# # +# Written by Pascal T. Merz # +# Michael R. Shirts # +# # +# Copyright (c) 2017-2021 University of Colorado Boulder # +# (c) 2012 The University of Virginia # +# # +########################################################################### +r""" +Miscellaneous utility functions +""" +from typing import Optional + +import numpy as np + + +def array_equal_shape_and_close( + array1: Optional[np.ndarray], array2: Optional[np.ndarray] +): + r""" + Tests whether two arrays have the same shape and all values are close + + Parameters + ---------- + array1 + array2 + + Returns + ------- + True if the two arrays have the same shape and all values are close, + False otherwise + """ + if array1 is None and array2 is None: + return True + if array1 is None or array2 is None: + return False + if array1.shape != array2.shape: + return False + return np.allclose(array1, array2, rtol=1e-12, atol=1e-12)