From b0ea2152f5f26c34007f5ff6390eecb8770bb26b Mon Sep 17 00:00:00 2001
From: Andi Gerken <andi.gerken@gmail.com>
Date: Wed, 30 Mar 2022 14:01:45 +0000
Subject: [PATCH] Changed storage of calculated data from implicit to explicit.

---
 setup.py                         |   3 +-
 src/robofish/io/__init__.py      |   6 --
 src/robofish/io/app.py           |  45 ++++++++-
 src/robofish/io/entity.py        | 157 ++++++++++++++++++-------------
 src/robofish/io/file.py          |  46 ++++++++-
 tests/resources/nan_test.hdf5    | Bin 12296 -> 15576 bytes
 tests/resources/valid_1.hdf5     | Bin 65536 -> 23272 bytes
 tests/resources/valid_2.hdf5     | Bin 15672 -> 22488 bytes
 tests/robofish/io/test_app_io.py |  10 +-
 9 files changed, 186 insertions(+), 81 deletions(-)

diff --git a/setup.py b/setup.py
index 48573d8..a9ebe9c 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,8 @@ entry_points = {
         "robofish-io-validate=robofish.io.app:validate",
         "robofish-io-print=robofish.io.app:print_file",
         "robofish-io-render=robofish.io.app:render",
-        "robofish-io-clear-calculated-data=robofish.io.app:clear_calculated_data",
+        # "robofish-io-clear-calculated-data=robofish.io.app:clear_calculated_data",
+        "robofish-io-update-calculated-data=robofish.io.app:update_calculated_data",
         # TODO: This should be called robofish-evaluate which is not possible because of the package name (guess) ask moritz
         "robofish-io-evaluate=robofish.evaluate.app:evaluate",
     ]
diff --git a/src/robofish/io/__init__.py b/src/robofish/io/__init__.py
index b14b319..5b054c2 100644
--- a/src/robofish/io/__init__.py
+++ b/src/robofish/io/__init__.py
@@ -20,9 +20,3 @@ import robofish.io.app
 
 if not ((3, 7) <= sys.version_info < (4, 0)):
     logging.warning("Unsupported Python version")
-
-warn_when_unable_to_store = True
-
-
-def disable_warning_when_unable_to_store():
-    robofish.io.warn_when_unable_to_store = False
diff --git a/src/robofish/io/app.py b/src/robofish/io/app.py
index 15b6ebe..c6a9385 100644
--- a/src/robofish/io/app.py
+++ b/src/robofish/io/app.py
@@ -15,6 +15,7 @@ from robofish.io import utils
 
 import argparse
 import logging
+import warnings
 
 
 def print_file(args=None):
@@ -53,9 +54,9 @@ def print_file(args=None):
     return not valid
 
 
-def clear_calculated_data(args=None):
+def update_calculated_data(args=None):
     parser = argparse.ArgumentParser(
-        description="This function clears calculated data from robofish.io files."
+        description="This function updates all calculated data from files."
     )
 
     parser.add_argument(
@@ -76,8 +77,42 @@ def clear_calculated_data(args=None):
 
     for fp in files:
         print(f"File {fp}")
-        with robofish.io.File(fp, "r+") as f:
-            f.clear_calculated_data()
+        try:
+            with robofish.io.File(fp, "r+", validate_poses_hash=False) as f:
+                f.update_calculated_data(verbose=True)
+        except Exception as e:
+            warnings.warn(f"The file {fp} could not be updated.")
+            print(e)
+
+
+# This should not be neccessary since the data will always have the calculated data by default.
+# def clear_calculated_data(args=None):
+#     parser = argparse.ArgumentParser(
+#         description="This function clears calculated data from robofish.io files."
+#     )
+
+#     parser.add_argument(
+#         "path",
+#         type=str,
+#         nargs="+",
+#         help="The path to one or multiple files and/or folders.",
+#     )
+#     if args is None:
+#         args = parser.parse_args()
+
+#     files_per_path = utils.get_all_files_from_paths(args.path)
+#     files = [
+#         f for f_in_path in files_per_path for f in f_in_path
+#     ]  # Concatenate all files to one list
+
+#     assert len(files) > 0, f"No files found in path {args.path}."
+
+#     for fp in files:
+#         print(f"File {fp}")
+#         with robofish.io.File(
+#             fp, "r+", validate_poses_hash=False, store_calculated_data=False
+#         ) as f:
+#             f.clear_calculated_data()
 
 
 def validate(args=None):
@@ -162,7 +197,7 @@ def render(args=None):
 
     default_options = {
         "linewidth": 2,
-        "speedup": 4,
+        "speedup": 1,
         "trail": 100,
         "entity_scale": 0.2,
         "fixed_view": False,
diff --git a/src/robofish/io/entity.py b/src/robofish/io/entity.py
index fb0b1a2..3375f37 100644
--- a/src/robofish/io/entity.py
+++ b/src/robofish/io/entity.py
@@ -60,6 +60,8 @@ class Entity(h5py.Group):
         if outlines is not None:
             entity.create_outlines(outlines, sampling)
 
+        entity.update_calculated_data()
+
         return entity
 
     @classmethod
@@ -141,36 +143,6 @@ class Entity(h5py.Group):
             return np.tile([0, 1], (self.positions.shape[0], 1))
         return self["orientations"]
 
-    @property
-    def orientations_rad(self):
-        # If actions_speeds_turns does not exist yet or the poses hash is not correct
-        if (
-            "calculated_orientations_rad" not in self
-            or "poses_hash" not in self.attrs
-            or self.poses_hash != self.attrs["poses_hash"]
-        ):
-
-            ori_rad = utils.limit_angle_range(
-                np.arctan2(self.orientations[:, 1], self.orientations[:, 0]),
-                _range=(0, 2 * np.pi),
-            )[:, np.newaxis]
-
-            try:
-
-                self.attrs["poses_hash"] = self.poses_hash
-                self["calculated_orientations_rad"] = ori_rad.astype(np.float64)
-            except RuntimeError as e:
-                if robofish.io.warn_when_unable_to_store:
-                    print(
-                        "Trying to store calculated orientations_rad in file to reuse it but the file was opened as read-only.\n"
-                        "If you open the file with mode 'r+' (or 'w' if it is still open from creation time) the information can be stored and reused.\n"
-                        "To disable this message execute robofish.io.disable_warning_when_unable_to_store().\n"
-                    )
-        else:
-            ori_rad = self["calculated_orientations_rad"]
-
-        return ori_rad
-
     @property
     @deprecation.deprecated(
         deprecated_in="0.2",
@@ -220,11 +192,19 @@ class Entity(h5py.Group):
 
     @property
     def poses_hash(self):
+        # The hash of h5py datasets changes each time the file is reopened.
+        # Also the hash of casting the array to bytes and calculating the hash changes.
+        def npsumhash(a):
+            return hash(np.nansum(a))
+
         if "orientations" in self:
-            h = (hash(self["positions"]) + hash(self["orientations"])) // 2
+            h = (npsumhash(self["positions"]) + npsumhash(self["orientations"])) // 2
+        elif "positions" in self:
+            print("We found positions")
+            h = npsumhash(self["positions"])
         else:
-            h = hash(self["positions"])
-        return int(h)
+            h = 0
+        return h
 
     @property
     def poses(self):
@@ -267,8 +247,59 @@ class Entity(h5py.Group):
         turn = utils.limit_angle_range(diff[:, 2], _range=(-np.pi, np.pi))
         return np.stack([speed, turn], axis=-1)
 
-    @property
-    def actions_speeds_turns(self):
+    def update_calculated_data(self, verbose=False, force_update=False):
+        if (
+            "poses_hash" not in self.attrs
+            or self.attrs["poses_hash"] != self.poses_hash
+            or "calculated_orientations_rad" not in self
+            or "calculated_actions_speeds_turns" not in self
+            or "unfinished_calculations" in self.attrs
+            or force_update
+        ):
+            try:
+                self.attrs["poses_hash"] = self.poses_hash
+                self.attrs["unfinished_calculations"] = True
+                if "orientations" in self:
+                    ori_rad = self.calculate_orientations_rad()
+                    if "calculated_orientations_rad" in self:
+                        del self["calculated_orientations_rad"]
+                    self["calculated_orientations_rad"] = ori_rad.astype(np.float64)
+
+                    speeds_turns = self.calculate_actions_speeds_turns()
+                    if "calculated_actions_speeds_turns" in self:
+                        del self["calculated_actions_speeds_turns"]
+                    self["calculated_actions_speeds_turns"] = speeds_turns.astype(
+                        np.float64
+                    )
+                    del self.attrs["unfinished_calculations"]
+
+                    if verbose:
+                        print(
+                            f"Updated calculated data for entity {self.name} with poses_hash {self.poses_hash}"
+                        )
+                elif verbose:
+                    print(
+                        "Since there were no orientations in the data, nothing was calculated."
+                    )
+            except RuntimeError as e:
+                print("Trying to update calculated data in a read-only file")
+                raise e
+        else:
+            if verbose:
+                print(
+                    f"Nothing to be updated in entity {self.name}. Poses_hash was {self.attrs['poses_hash']}"
+                )
+
+        assert self.attrs["poses_hash"] == self.poses_hash
+
+    def calculate_orientations_rad(self):
+        ori_rad = utils.limit_angle_range(
+            np.arctan2(self.orientations[:, 1], self.orientations[:, 0]),
+            _range=(0, 2 * np.pi),
+        )[:, np.newaxis]
+        return ori_rad
+
+    def calculate_actions_speeds_turns(self):
         """Calculate the speed, turn and from the recorded positions and orientations.
 
         The turn is calculated by the change of orientation between frames.
@@ -278,36 +309,32 @@ class Entity(h5py.Group):
         Returns:
             An array with shape (number_of_positions -1, 2 (speed in cm/frame, turn in rad/frame).
         """
+        ori = self.orientations
+        ori_rad = self.orientations_rad
+        pos = self.positions
+        turn = utils.limit_angle_range(np.diff(ori_rad, axis=0)[:, 0])
+        pos_diff = np.diff(pos, axis=0)
+        speed = np.array(
+            [np.dot(pos_diff[i], ori[i + 1]) for i in range(pos_diff.shape[0])]
+        )
+        return np.stack([speed, turn], axis=-1)
 
-        # If actions_speeds_turns does not exist yet or the poses hash is not correct
-        if (
-            "calculated_actions_speeds_turns" not in self
-            or "poses_hash" not in self.attrs
-            or self.poses_hash != self.attrs["poses_hash"]
-        ):
-            ori = self.orientations
-            ori_rad = self.orientations_rad
-            pos = self.positions
-            turn = utils.limit_angle_range(np.diff(ori_rad, axis=0)[:, 0])
-            pos_diff = np.diff(pos, axis=0)
-            speed = np.array(
-                [np.dot(pos_diff[i], ori[i + 1]) for i in range(pos_diff.shape[0])]
-            )
-            actions_speeds_turns = np.stack([speed, turn], axis=-1)
-
-            try:
-                self.attrs["poses_hash"] = self.poses_hash
-                self["calculated_actions_speeds_turns"] = actions_speeds_turns.astype(
-                    np.float64
-                )
-            except RuntimeError as e:
-                if robofish.io.warn_when_unable_to_store:
-                    print(
-                        "Trying to store calculated actions_speeds_turns in file to reuse it but the file was opened as read-only.\n"
-                        "If you open the file with mode 'r+' (or 'w' if it is still open from creation time) the information can be stored and reused.\n"
-                        "To disable this message execute robofish.io.disable_warning_when_unable_to_store().\n"
-                    )
+    @property
+    def actions_speeds_turns(self):
+        if "calculated_actions_speeds_turns" in self:
+            assert (
+                self.attrs["poses_hash"] == self.poses_hash
+            ), f"The calculated poses_hash was not identical to the stored poses_hash. Please update the calculated data after changing positions or orientations with entity.update_calculated_data(). stored hash: {self.attrs['poses_hash']}, calculated hash: {self.poses_hash}."
+            return self["calculated_actions_speeds_turns"]
         else:
-            actions_speeds_turns = self["calculated_actions_speeds_turns"]
+            return self.calculate_actions_speeds_turns()
 
-        return actions_speeds_turns
+    @property
+    def orientations_rad(self):
+        if "calculated_orientations_rad" in self:
+            assert (
+                self.attrs["poses_hash"] == self.poses_hash
+            ), f"The calculated poses_hash was not identical to the stored poses_hash. Please update the calculated data after changing positions or orientations with entity.update_calculated_data(). stored hash: {self.attrs['poses_hash']}, calculated hash: {self.poses_hash}."
+            return self["calculated_orientations_rad"]
+        else:
+            return self.calculate_orientations_rad()
diff --git a/src/robofish/io/file.py b/src/robofish/io/file.py
index 40097b5..d57c7bf 100644
--- a/src/robofish/io/file.py
+++ b/src/robofish/io/file.py
@@ -75,6 +75,7 @@ class File(h5py.File):
         monotonic_time_points_us: Iterable = None,
         calendar_time_points: Iterable = None,
         open_copy: bool = False,
+        validate_poses_hash: bool = True,
     ):
         """Create a new RoboFish Track Format object.
 
@@ -214,6 +215,38 @@ class File(h5py.File):
                     calendar_time_points=calendar_time_points,
                     default=True,
                 )
+        else:
+            # A quick validation to find h5py files which are not robofish.io files
+            if any([a not in self.attrs for a in ["world_size_cm", "format_version"]]):
+                msg = f"The opened file {self.path} does not include world_size_cm or format_version. It seems that the file is not a robofish.io.File."
+                if strict_validate:
+                    raise KeyError(msg)
+                else:
+                    warnings.warn(msg)
+                return
+
+            # Validate that the stored poses hash still fits.
+            if validate_poses_hash:
+                for entity in self.entities:
+                    if "poses_hash" in entity.attrs:
+                        if entity.attrs["poses_hash"] != entity.poses_hash:
+                            warnings.warn(
+                                f"The stored hash is not identical with the newly calculated hash. In entity {entity.name} in {self.path}. f.entity_actions_turns_speeds and f.entity_orientation_rad will return wrong results.\n"
+                                f"stored: {entity.attrs['poses_hash']}, calculated: {entity.poses_hash}"
+                            )
+                        assert (
+                            "unfinished_calculations" not in entity.attrs
+                        ), f"The calculated data of file {self.path} is uncomplete and was probably aborted during calculation. please recalculate with `robofish-io-update-calculated-data {self.path}`."
+
+                    else:
+                        warnings.warn(
+                            f"The file did not include pre-calculated data so the actions_speeds_turns "
+                            f"and orientations_rad will have to be be recalculated everytime.\n"
+                            f"Please use `robofish-io-update-calculated-data {self.path}` in the "
+                            f"commandline or\nopen and close the file with robofish.io.File(f, 'r+') "
+                            f"in python.\nIf the data should be recalculated every time open the file "
+                            "with the bool option validate_poses_hash=False."
+                        )
         if validate:
             self.validate(strict_validate)
 
@@ -225,8 +258,14 @@ class File(h5py.File):
         if (type, value, traceback) == (None, None, None):
             if self.mode != "r":  # No need to validate read only files (performance).
                 self.validate()
+
         super().__exit__(type, value, traceback)
 
+    def close(self):
+        if self.mode != "r":
+            self.update_calculated_data()
+        super().close()
+
     def save_as(
         self,
         path: Union[str, Path],
@@ -243,6 +282,7 @@ class File(h5py.File):
             The file itself, so something like f = robofish.io.File().save_as("file.hdf5") works
         """
 
+        self.update_calculated_data()
         self.validate(strict_validate=strict_validate)
 
         # Ensure all buffered data has been written to disk
@@ -471,6 +511,10 @@ class File(h5py.File):
             )
         return entity_names
 
+    def update_calculated_data(self, verbose=False):
+        for e in self.entities:
+            e.update_calculated_data(verbose)
+
     def clear_calculated_data(self, verbose=True):
         """Delete all calculated data from the files."""
         txt = ""
@@ -1129,7 +1173,7 @@ class File(h5py.File):
             frames=n_frames,
             init_func=init,
             blit=platform.system() != "Darwin",
-            interval=self.frequency,
+            interval=1000 / self.frequency,
             repeat=False,
         )
 
diff --git a/tests/resources/nan_test.hdf5 b/tests/resources/nan_test.hdf5
index 3ff017ef600aec8928a7e55c38a68c2821876e22..92c3050e3b5204721b0eb0a0543adf0c4d5f65c0 100644
GIT binary patch
delta 1402
zcmeB3xKTMlgXxCNMy(a>6O+U@zhtkM657B30T~dALBnQql@cG5pa6paScJm>Off`E
z{;1bDSxR*d4-10_M2O*p;lzz{TpCbbg2BX%a+7zce%ky?jgQlohrxn@g@J*Ak%60m
zhk=8kAip@ZI6fn>7;2LM2Ll5KSgQk=WCW884EO*4|F1n+mqm8+FFiFQm?+E=7@wIz
zf&s)9RIrDeV#3W}@}GI~FBJtQ4vWb$stU|?nl6*=RSPFCP*D(6hnmj>rPU{2RFULE
z)s0)B0IEWWHC!H+5W@<XCfDdoz^voXD~>NNNKH*CjxQ-K$}66{pUrdg0glHk^$GgY
zN9-9G7#j4YuYhQU;~Musbc4S16A+#7Tk$1`b~vu_4n#Mcoc<9^>q~zD(Fs%MeFM=9
z3=BW&85kHG5|{o4@fD71`~%SqVqpxR&}=w4oe@me+++gN`qC^83=H)Q4Ub}2K>`U=
z=dpolj|c2v`nMt{m|mC61*Q|1a)W3Gk>@;M`nU!kn6Ap=2hj_}!URCH!iR5Qdcn!*
zf*?M_VhbTK4Rs(xSd}n{-=Hro;=llMGUpx<u)w1jQ4r0r=P#IUm^x1k#D9=yFAky;
zJRX4Q2a1gnApU`1zfT|^!b0$YlO{Ms7C=LEfpqIvkh}ww@8BB=4yglBe!{#3-|Rv5
zH$Vko3SfMg0Sr)im<0?5kSK#W045KM0vLT@;mlVc2f+9Z)4IV?3RABj^y79tSOL_6
zhM>SRU_R6WNJ?U0fLREV-wzUm%7Y9*=7TH%^Yx$(TsZR;m=9GCa|p~l1_p+DaH4?;
zfP9Fo0TcpYKGZ-^2!Z*y4MZ^yt{<u%-TVW=^?skg2I@du40j<cv7pg#A3_bp2!Wu$
zGhp+e@}R&1V<;aKg2*APpd<9-Hdp}a0FVV>K2!rd4nh2h8!IMvC@I({C*~xV<|LM+
hro`tLWv1qpB$j06=M~2nC8mHaLy}KShDf1jegMPULqY%m

delta 60
zcmcan*^w|ogNegnqt*)c$z1GWn_sXuNKM|rB*4_5w7E)&k7=?2i`3*_dKHtUROc`<
RO=eg7Jn=xl=3i=joB(sX6vqGn

diff --git a/tests/resources/valid_1.hdf5 b/tests/resources/valid_1.hdf5
index de8a1dabe0f28ecb0b1600aa01918328858e0f8f..664e40a39b908364ac70cf99dc11715a6ecd2abc 100644
GIT binary patch
delta 5549
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
V0Ng;R0dNDM@*o$3YCmuY006#ENQwXe

delta 198
zcmaE{m9e3LWrBzpBLf2bhXMwNjan<%Cv&ljZGOT2LTd5`CIO}wN}C@k@i9#{aB!Gx
zp!$xHX>z{W=ZOabHYceIa5A!QE;jIG0xP^>H2INI+++je3dRMK8C9DmYn#kwWST5)
j`f;*?LC|I{vuB*l3=ABb9Xa>&Z4ThvKrXTI03Ra&IpalS

diff --git a/tests/resources/valid_2.hdf5 b/tests/resources/valid_2.hdf5
index 10caedaafa4d0d13c4be720f2c8d48dec184a092..eb8c69e1a01f1704c29c94b7c85590c5443275dd 100644
GIT binary patch
delta 5581
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

delta 111
zcmcbyo^eOj1Pvw&+l^W)*e7$bi*0_v-XJx31Csz#gVN+Iqqs>D0-IkbEnu8%z#=u-
z%eZo~wCZd|rpfGTKPMgt*rd+I3D%@wV8H~|RA4kY%jg(L@n$b$5k^L)$?Hu%P7bgu
K*nGgWf*k-!<0V!A

diff --git a/tests/robofish/io/test_app_io.py b/tests/robofish/io/test_app_io.py
index ea4933f..6c3a16f 100644
--- a/tests/robofish/io/test_app_io.py
+++ b/tests/robofish/io/test_app_io.py
@@ -2,7 +2,6 @@ import robofish.io.app as app
 from robofish.io import utils
 import pytest
 import logging
-from pathlib import Path
 
 logging.getLogger().setLevel(logging.INFO)
 
@@ -19,11 +18,16 @@ def test_app_validate():
             self.path = path
             self.output_format = output_format
 
-    raw_output = app.validate(DummyArgs([resources_path], "raw"))
+    # invalid.hdf5 should pass a warning
+    with pytest.warns(UserWarning):
+        raw_output = app.validate(DummyArgs([resources_path], "raw"))
 
     # The three files valid.hdf5, almost_valid.hdf5, and invalid.hdf5 should be found.
     assert len(raw_output) == 4
-    app.validate(DummyArgs([resources_path], "human"))
+
+    # invalid.hdf5 should pass a warning
+    with pytest.warns(UserWarning):
+        app.validate(DummyArgs([resources_path], "human"))
 
 
 def test_app_print():
-- 
GitLab