diff --git a/.gitignore b/.gitignore index 9d824fe64a4c864975d4b78b493ca11df2fcb1a9..043b66a95a35968ee555bd349ade5dddc7a2dc0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ **/__pycache__ +build dist *.egg *.egg-info* @@ -8,6 +9,7 @@ dist report.xml htmlcov docs +env !tests/resources/*.hdf5 *.ipynb_checkpoints diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e9300a2487b01778102c5d10a11ba8053384113c..d579b9a074657960670aa4eedd0dcf8c2bc73fed 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,23 +1,23 @@ stages: - - test - package + - test - deploy .centos: tags: [linux, docker] image: git.imp.fu-berlin.de:5000/bioroboticslab/auto/ci/centos:latest +.macos: + tags: [macos, shell] + .windows: tags: [windows, docker] image: git.imp.fu-berlin.de:5000/bioroboticslab/auto/ci/windows:latest-devel before_script: - . $Profile.AllUsersAllHosts -.cpy37: &cpy37 - PYTHON_EXECUTABLE: "python3.7" - -.cpy38: &cpy38 - PYTHON_EXECUTABLE: "python3.8" +.python38: &python38 + PYTHON_VERSION: "3.8" .test: &test stage: test @@ -28,79 +28,53 @@ stages: script: - ./ci/test.py -test centos[cpy37]: - extends: .centos - <<: *test - variables: - <<: [*cpy37] - -test centos[cpy38]: - extends: .centos - <<: *test - variables: - <<: [*cpy38] - -.test windows[cpy37]: - extends: .windows - <<: *test - .package: &package stage: package artifacts: paths: - dist expire_in: 1 week + reports: + dotenv: build.env script: - ./ci/package.py -package centos[cpy37]: +package: extends: .centos - dependencies: - - test centos[cpy37] <<: *package variables: - <<: [*cpy37] + <<: [*python38] -package centos[cpy38]: +"test: [centos, 3.8]": extends: .centos - dependencies: - - test centos[cpy38] - <<: *package - variables: - <<: [*cpy38] + <<: *test -.package windows[cpy37]: - extends: .windows - dependencies: - - test windows[cpy37] - <<: *package +"test: [macos, 3.8]": + extends: .macos + <<: *test -deploy centos[cpy37]: - extends: .centos - stage: deploy - only: - - tags - dependencies: - - package centos[cpy37] - script: - - ./ci/deploy.py +"test: [windows, 3.8]": + extends: .windows + <<: *test -.deploy centos[cpy38]: +deploy to staging: extends: .centos stage: deploy only: + - master - tags + allow_failure: true dependencies: - - package centos[cpy38] + - package script: - ./ci/deploy.py -.deploy windows[cpy37]: +deploy to production: extends: .centos stage: deploy only: - tags dependencies: - - package windows[cpy37] + - package script: - - ./ci/deploy.py + - ./ci/deploy.py --production diff --git a/README.md b/README.md index 3e195a064a107c23890aed7667a7d1cde879105d..5da734e05530add0c7ef723c9990f3634dbcd57f 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ This repository implements an easy to use interface, to create, save, load, and Quick variant: ``` -pip install robofish-trackviewer robofish-io --extra-index-url https://git.imp.fu-berlin.de/api/v4/projects/6392/packages/pypi/simple +pip3 install robofish-trackviewer robofish-io --extra-index-url https://git.imp.fu-berlin.de/api/v4/projects/6392/packages/pypi/simple ``` Better variant: - Follow instructions at [Artifacts repository](https://git.imp.fu-berlin.de/bioroboticslab/robofish/artifacts) -- ```pip install robofish-io``` +- ```pip3 install robofish-io``` ## Usage @@ -31,14 +31,14 @@ We show a simple example below. More examples can be found in ```examples/``` import robofish.io import numpy as np -filename = "example.hdf5" +# Create a new robofish io file f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) f.attrs["experiment_setup"] = "This is a simple example with made up data." -# Create a single robot with 30 timesteps -# positions are passed separately -# orientations are passed as with two columns -> orientation_x and orientation_y +# Create a new robot entity. Positions and orientations are passed +# separately in this example. Since the orientations have two columns, +# unit vectors are assumed (orientation_x, orientation_y) f.create_entity( category="robot", name="robot", @@ -46,7 +46,9 @@ f.create_entity( orientations=np.ones((100, 2)) * [0, 1], ) -# Create fishes with 30 poses (x, y, orientation_rad) +# Create a new fish entity. +# In this case, we pass positions and orientations together (x, y, rad). +# Since it is a 3 column array, orientations in radiants are assumed. poses = np.zeros((100, 3)) poses[:, 0] = np.arange(-50, 50) poses[:, 1] = np.arange(-50, 50) @@ -57,12 +59,9 @@ fish.attrs["fish_standard_length_cm"] = 10 # Show and save the file print(f) -print("Poses Shape: ", f.get_poses().shape) - -# Saving also validates the file -f.save(filename) -print(f"Saved to {filename}") +print("Poses Shape: ", f.entity_poses.shape) +f.save_as(path) ``` ### Evaluation @@ -72,7 +71,7 @@ Current modes are: - turn - tank_positions - trajectories -- follow_ii +- follow_iid ## LICENSE diff --git a/ci/deploy.py b/ci/deploy.py index 6dda82b345943f9e96b35acb1d1858343e51a1c8..0437457696a996d508cb3b4235ba6feaeb49d52c 100755 --- a/ci/deploy.py +++ b/ci/deploy.py @@ -3,22 +3,31 @@ from os import environ as env from subprocess import check_call -from pathlib import Path from platform import system +from argparse import ArgumentParser if __name__ == "__main__": if system() != "Linux": raise Exception("Uploading python package only supported on Linux") + p = ArgumentParser() + p.add_argument("--production", default=False, action="store_const", const=True) + args = p.parse_args() + env["TWINE_USERNAME"] = "gitlab-ci-token" env["TWINE_PASSWORD"] = env["CI_JOB_TOKEN"] + if args.production: + target_project_id = env['ARTIFACTS_REPOSITORY_PROJECT_ID'] + else: + target_project_id = env['CI_PROJECT_ID'] + command = ["python3"] command += ["-m", "twine", "upload", "dist/*"] command += [ "--repository-url", - f"https://git.imp.fu-berlin.de/api/v4/projects/{env['ARTIFACTS_REPOSITORY_PROJECT_ID']}/packages/pypi", + f"https://git.imp.fu-berlin.de/api/v4/projects/{target_project_id}/packages/pypi", ] check_call(command) diff --git a/ci/package.py b/ci/package.py index 4be105e3f11445e3c9c8e9d183882fcac116d8ba..38ef2d7f540eb37068ae0a0df5123173962e2789 100755 --- a/ci/package.py +++ b/ci/package.py @@ -2,16 +2,17 @@ # SPDX-License-Identifier: LGPL-3.0-or-later from os import environ as env -from platform import system from subprocess import check_call -from sys import executable +from pathlib import Path +from platform import system +from shutil import which def python_executable(): if system() == "Windows": - return executable - elif system() == "Linux": - return env["PYTHON_EXECUTABLE"] + return f"/Python{''.join(env['PYTHON_VERSION'].split('.'))}/python.exe" + elif system() == "Linux" or system() == "Darwin": + return which(f"python{env['PYTHON_VERSION']}") assert False @@ -20,3 +21,6 @@ if __name__ == "__main__": command += ["setup.py", "bdist_wheel"] check_call(command) + + with open("build.env", "w") as f: + f.write(f"PYTHON_VERSION={env['PYTHON_VERSION']}\n") diff --git a/ci/test.py b/ci/test.py index 992a7cf573a716ff839a26989fd45dab6ce66c6c..ab41980d6b6bae18e8bf990f162b20b57edd82af 100755 --- a/ci/test.py +++ b/ci/test.py @@ -1,28 +1,50 @@ #! /usr/bin/env python3 # SPDX-License-Identifier: LGPL-3.0-or-later -from os import environ as env +from os import environ as env, pathsep from subprocess import check_call +from pathlib import Path from platform import system -from sys import executable +from shutil import which def python_executable(): if system() == "Windows": - return executable - elif system() == "Linux": - return env["PYTHON_EXECUTABLE"] + return f"/Python{''.join(env['PYTHON_VERSION'].split('.'))}/python.exe" + elif system() == "Linux" or system() == "Darwin": + return which(f"python{env['PYTHON_VERSION']}") + assert False + + +def python_venv_executable(): + if system() == "Windows": + return str(Path(".venv/Scripts/python.exe").resolve()) + elif system() == "Linux" or system() == "Darwin": + return str(Path(".venv/bin/python").resolve()) assert False if __name__ == "__main__": - check_call([python_executable(), "-m", "pip", "install", "pytest"]) - # check_call([python_executable(), "-m", "pip", "install", "pytest-cov"]) - check_call([python_executable(), "-m", "pip", "install", "h5py"]) - check_call([python_executable(), "-m", "pip", "install", "pandas"]) - check_call([python_executable(), "-m", "pip", "install", "deprecation"]) + check_call( + [ + python_executable(), + "-m", + "venv", + "--system-site-packages", + "--prompt", + "ci", + ".venv", + ] + ) - command = [python_executable()] - command += ["-m", "pytest", "--junitxml=report.xml"] + check_call( + [ + python_venv_executable(), + "-m", + "pip", + "install", + str(sorted(Path("dist").glob("*.whl"))[-1].resolve()), + ] + ) - check_call(command) + check_call([python_venv_executable(), "-m", "pytest", "--junitxml=report.xml"]) diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/examples/example_basic.ipynb b/examples/example_basic.ipynb index 253ff2fceacac9ad10c6399edf19a2211ed55b9e..eba81c56c7c497751bffce4c944b4ae3212dac15 100644 --- a/examples/example_basic.ipynb +++ b/examples/example_basic.ipynb @@ -2,7 +2,65 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "import robofish.io\n", + "import numpy as np\n", + "\n", + "\n", + "def create_example_file(path):\n", + " # Create a new io file object with a 100x100cm world\n", + " f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)\n", + "\n", + " # create a simple obstacle, fixed in place, fixed outline\n", + " obstacle_outline = [[[-10, -10], [-10, 0], [0, 0], [0, -10]]]\n", + " obstacle_name = f.create_entity(\n", + " \"obstacle\", positions=[[50, 50]], orientations=[[0]], outlines=obstacle_outline\n", + " )\n", + "\n", + " # create a robofish with 1000 timesteps. If we would not give a name, the name would be generated to be robot_1.\n", + " robofish_timesteps = 1000\n", + " robofish_poses = np.tile([50, 50, 1, 0], (robofish_timesteps, 1))\n", + " robot = f.create_entity(\"robot\", name=\"robot\", poses=robofish_poses)\n", + "\n", + " # create multiple fishes with timestamps. Since we don't specify names, but only the type \"fish\" the fishes will be named [\"fish_1\", \"fish_2\", \"fish_3\"]\n", + " agents = 3\n", + " timesteps = 1000\n", + " # timestamps = np.linspace(0, timesteps + 1, timesteps)\n", + " agent_poses = np.random.random((agents, timesteps, 3))\n", + "\n", + " fishes = f.create_multiple_entities(\"fish\", agent_poses)\n", + "\n", + " # This would throw an exception if the file was invalid\n", + " f.validate()\n", + "\n", + " # Save file validates aswell\n", + " f.save_as(path)\n", + "\n", + " # Closing and opening files (just for demonstration). When opening with r+, we can read and write afterwards.\n", + " f.close()\n", + " f = robofish.io.File(path, \"r+\")\n", + "\n", + " print(\"\\nEntity Names\")\n", + " print(f.entity_names)\n", + "\n", + " # Get an array with all poses. As the length of poses varies per agent, it is filled up with nans.\n", + " print(\"\\nAll poses\")\n", + " print(f.entity_poses)\n", + "\n", + " # Select all entities with the category fish\n", + " print(\"\\nFish poses\")\n", + " print(f.select_entity_poses(lambda e: e.category == \"fish\"))\n", + "\n", + " print(\"\\nFinal file\")\n", + " print(f)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -14,156 +72,116 @@ "['fish_1', 'fish_2', 'fish_3', 'obstacle_1', 'robot']\n", "\n", "All poses\n", - "[[[8.86584103e-01 2.35670820e-01 5.41754842e-01 4.49850202e-01]\n", - " [8.15511882e-01 4.78223324e-01 6.29803419e-01 1.12592392e-01]\n", - " [1.53732300e-01 7.24954247e-01 9.38574493e-01 4.65665817e-01]\n", - " [9.10354614e-01 4.47880208e-01 3.81429136e-01 9.67544317e-01]\n", - " [6.07822955e-01 5.20158827e-01 8.17965686e-01 8.42760384e-01]]\n", + "[[[5.20669639e-01 4.58831601e-02 9.51813161e-01 3.06678474e-01]\n", + " [4.54089224e-01 4.62401301e-01 7.62699127e-01 6.46753490e-01]\n", + " [9.67400968e-01 5.63468635e-01 9.99894381e-01 1.45336846e-02]\n", + " ...\n", + " [8.80278572e-02 3.80126536e-01 9.97331321e-01 7.30081499e-02]\n", + " [3.28493923e-01 6.17436647e-01 9.20374036e-01 3.91039193e-01]\n", + " [7.53752515e-02 2.74342328e-01 7.84065127e-01 6.20678544e-01]]\n", "\n", - " [[2.29353935e-01 8.80753636e-01 7.94585168e-01 2.22074524e-01]\n", - " [6.13970399e-01 1.33511815e-02 2.89155185e-01 2.65219092e-01]\n", - " [6.62197351e-01 6.47982001e-01 9.46004018e-02 6.59599364e-01]\n", - " [4.86104101e-01 4.23153102e-01 1.39821902e-01 3.11809748e-01]\n", - " [8.03322852e-01 9.52799857e-01 3.89638603e-01 6.43237352e-01]]\n", + " [[4.45818096e-01 5.39128423e-01 5.66779137e-01 8.23869765e-01]\n", + " [4.38290328e-01 3.78231764e-01 9.88384008e-01 1.51977092e-01]\n", + " [1.82629541e-01 8.08078349e-01 8.27510357e-01 5.61450422e-01]\n", + " ...\n", + " [5.48624545e-02 9.00771558e-01 8.00554574e-01 5.99259913e-01]\n", + " [8.34412515e-01 2.47002933e-02 8.98045361e-01 4.39902902e-01]\n", + " [2.35550269e-01 5.17610013e-01 7.89557755e-01 6.13676250e-01]]\n", "\n", - " [[9.70978260e-01 6.75936878e-01 6.23196602e-01 8.42264950e-01]\n", - " [4.07079160e-01 8.46290290e-01 5.64092159e-01 3.56871307e-01]\n", - " [4.84096229e-01 8.60232174e-01 1.39015794e-01 7.82253265e-01]\n", - " [1.24170482e-01 2.21511930e-01 8.88282284e-02 4.53450561e-01]\n", - " [1.28404438e-01 2.87771430e-02 4.57022637e-01 9.80571806e-01]]\n", + " [[4.05882373e-02 9.17375386e-01 9.94780242e-01 1.02040365e-01]\n", + " [8.21412623e-01 6.68225110e-01 9.35957074e-01 3.52114081e-01]\n", + " [8.17060947e-01 2.38414750e-01 9.85274673e-01 1.70979008e-01]\n", + " ...\n", + " [1.20162942e-01 4.16668624e-01 9.17142212e-01 3.98560166e-01]\n", + " [9.19857025e-01 7.85686851e-01 6.58272266e-01 7.52779961e-01]\n", + " [8.69306684e-01 5.62790215e-01 9.19795334e-01 3.92398477e-01]]\n", "\n", - " [[5.00000000e+01 5.00000000e+01 0.00000000e+00 0.00000000e+00]\n", + " [[5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", " [ nan nan nan nan]\n", " [ nan nan nan nan]\n", + " ...\n", + " [ nan nan nan nan]\n", " [ nan nan nan nan]\n", " [ nan nan nan nan]]\n", "\n", - " [[0.00000000e+00 0.00000000e+00 0.00000000e+00 0.00000000e+00]\n", - " [0.00000000e+00 0.00000000e+00 0.00000000e+00 0.00000000e+00]\n", - " [0.00000000e+00 0.00000000e+00 0.00000000e+00 0.00000000e+00]\n", - " [0.00000000e+00 0.00000000e+00 0.00000000e+00 0.00000000e+00]\n", - " [ nan nan nan nan]]]\n", + " [[5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", + " [5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", + " [5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", + " ...\n", + " [5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", + " [5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]\n", + " [5.00000000e+01 5.00000000e+01 1.00000000e+00 0.00000000e+00]]]\n", "\n", "Fish poses\n", - "[[[0.8865841 0.23567082 0.54175484 0.4498502 ]\n", - " [0.81551188 0.47822332 0.62980342 0.11259239]\n", - " [0.1537323 0.72495425 0.93857449 0.46566582]\n", - " [0.91035461 0.44788021 0.38142914 0.96754432]\n", - " [0.60782295 0.52015883 0.81796569 0.84276038]]\n", + "[[[0.52066964 0.04588316 0.95181316 0.30667847]\n", + " [0.45408922 0.4624013 0.76269913 0.64675349]\n", + " [0.96740097 0.56346864 0.99989438 0.01453368]\n", + " ...\n", + " [0.08802786 0.38012654 0.99733132 0.07300815]\n", + " [0.32849392 0.61743665 0.92037404 0.39103919]\n", + " [0.07537525 0.27434233 0.78406513 0.62067854]]\n", "\n", - " [[0.22935393 0.88075364 0.79458517 0.22207452]\n", - " [0.6139704 0.01335118 0.28915519 0.26521909]\n", - " [0.66219735 0.647982 0.0946004 0.65959936]\n", - " [0.4861041 0.4231531 0.1398219 0.31180975]\n", - " [0.80332285 0.95279986 0.3896386 0.64323735]]\n", + " [[0.4458181 0.53912842 0.56677914 0.82386976]\n", + " [0.43829033 0.37823176 0.98838401 0.15197709]\n", + " [0.18262954 0.80807835 0.82751036 0.56145042]\n", + " ...\n", + " [0.05486245 0.90077156 0.80055457 0.59925991]\n", + " [0.83441252 0.02470029 0.89804536 0.4399029 ]\n", + " [0.23555027 0.51761001 0.78955775 0.61367625]]\n", "\n", - " [[0.97097826 0.67593688 0.6231966 0.84226495]\n", - " [0.40707916 0.84629029 0.56409216 0.35687131]\n", - " [0.48409623 0.86023217 0.13901579 0.78225327]\n", - " [0.12417048 0.22151193 0.08882823 0.45345056]\n", - " [0.12840444 0.02877714 0.45702264 0.98057181]]]\n", + " [[0.04058824 0.91737539 0.99478024 0.10204037]\n", + " [0.82141262 0.66822511 0.93595707 0.35211408]\n", + " [0.81706095 0.23841475 0.98527467 0.17097901]\n", + " ...\n", + " [0.12016294 0.41666862 0.91714221 0.39856017]\n", + " [0.91985703 0.78568685 0.65827227 0.75277996]\n", + " [0.86930668 0.56279022 0.91979533 0.39239848]]]\n", "\n", "File structure\n", - " version:\t[1 0]\n", - " world size:\t[100. 100.]\n", + " format_url:\thttps://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format/-/releases/1.0\n", + " format_version:\t[1 0]\n", + " world_size_cm:\t[100. 100.]\n", "| entities\n", "|---| fish_1\n", - "|---|--- type:\tfish\n", - "|---|--- poses:\t Shape (5, 4)\n", - "|---|---| time\n", - "|---|---|--- monotonic points:\t Shape (5,)\n", + "|---|--- category:\tfish\n", + "|---|--- orientations:\t Shape (1000, 2)\n", + "|---|--- positions:\t Shape (1000, 2)\n", "|---| fish_2\n", - "|---|--- type:\tfish\n", - "|---|--- poses:\t Shape (5, 4)\n", - "|---|---| time\n", - "|---|---|--- monotonic points:\t Shape (5,)\n", + "|---|--- category:\tfish\n", + "|---|--- orientations:\t Shape (1000, 2)\n", + "|---|--- positions:\t Shape (1000, 2)\n", "|---| fish_3\n", - "|---|--- type:\tfish\n", - "|---|--- poses:\t Shape (5, 4)\n", - "|---|---| time\n", - "|---|---|--- monotonic points:\t Shape (5,)\n", + "|---|--- category:\tfish\n", + "|---|--- orientations:\t Shape (1000, 2)\n", + "|---|--- positions:\t Shape (1000, 2)\n", "|---| obstacle_1\n", - "|---|--- type:\tobstacle\n", + "|---|--- category:\tobstacle\n", + "|---|--- orientations:\t Shape (1, 2)\n", "|---|--- outlines:\t Shape (1, 4, 2)\n", - "|---|--- poses:\t Shape (1, 4)\n", - "|---|---| time\n", + "|---|--- positions:\t Shape (1, 2)\n", "|---| robot\n", - "|---|--- type:\trobot\n", - "|---|--- poses:\t Shape (4, 4)\n", - "|---|---| time\n", - "|---|---|--- monotonic step:\t40\n", + "|---|--- category:\trobot\n", + "|---|--- orientations:\t Shape (1000, 2)\n", + "|---|--- positions:\t Shape (1000, 2)\n", + "| samplings\n", + "|--- default:\t25 hz\n", + "|---| 25 hz\n", + "|---|--- frequency_hz:\t25.0\n", "\n" ] } ], "source": [ - "#! /usr/bin/env python3\n", - "\n", - "import robofish.io\n", - "import numpy as np\n", - "from pathlib import Path\n", - "import os\n", - "\n", - "\n", - "# Helper function to enable relative paths from this file\n", - "def full_path(path):\n", - " return (Path(os.path.abspath(\"__file__\")).parent / path).resolve()\n", - "\n", - "\n", "if __name__ == \"__main__\":\n", - " # Create a new io file object with a 100x100cm world\n", - " sf = robofish.io.File(world_size=[100, 100])\n", - "\n", - " # create a simple obstacle, fixed in place, fixed outline\n", - " obstacle_pose = [[50, 50, 0, 0]]\n", - " obstacle_outline = [[[-10, -10], [-10, 0], [0, 0], [0, -10]]]\n", - " obstacle_name = sf.create_entity(\n", - " \"obstacle\", poses=obstacle_pose, outlines=obstacle_outline\n", - " )\n", - "\n", - " # create a robofish with 100 timesteps and 40ms between the timesteps. If we would not give a name, the name would be generated to be robot_1.\n", - " robofish_timesteps = 4\n", - " robofish_poses = np.zeros((robofish_timesteps, 4))\n", - " sf.create_entity(\"robot\", robofish_poses, name=\"robot\", monotonic_step=40)\n", - "\n", - " # create multiple fishes with timestamps. Since we don't specify names, but only the type \"fish\" the fishes will be named [\"fish_1\", \"fish_2\", \"fish_3\"]\n", - " agents = 3\n", - " timesteps = 5\n", - " timestamps = np.linspace(0, timesteps + 1, timesteps)\n", - " agent_poses = np.random.random((agents, timesteps, 4))\n", - "\n", - " fish_names = sf.create_multiple_entities(\n", - " \"fish\", agent_poses, monotonic_points=timestamps\n", - " )\n", - "\n", - " # This would throw an exception if the file was invalid\n", - " sf.validate()\n", - "\n", - " # Save file validates aswell\n", - " example_file = full_path(\"example.hdf5\")\n", - " sf.save(example_file)\n", - "\n", - " # Closing and opening files (just for demonstration)\n", - " sf.close()\n", - " sf = robofish.io.File(path=example_file)\n", - "\n", - " print(\"\\nEntity Names\")\n", - " print(sf.get_entity_names())\n", - "\n", - " # Get an array with all poses. As the length of poses varies per agent, it\n", - " # is filled up with nans. The result is not interpolated and the time scales\n", - " # per agent are different. It is planned to create a warning in the case of\n", - " # different time scales and have another function, which generates an\n", - " # interpolated array.\n", - " print(\"\\nAll poses\")\n", - " print(sf.get_poses_array())\n", - "\n", - " print(\"\\nFish poses\")\n", - " print(sf.get_poses_array(fish_names))\n", - "\n", - " print(\"\\nFile structure\")\n", - " print(sf)\n" + " create_example_file(\"example.hdf5\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/examples/example_basic.py b/examples/example_basic.py index e328d380144d327512f7476e3493e687b57b43d8..34e092296f6edde6301a13623c15f77a46ddb70a 100755 --- a/examples/example_basic.py +++ b/examples/example_basic.py @@ -1,59 +1,54 @@ -#! /usr/bin/env python3 - import robofish.io import numpy as np -from pathlib import Path -import os - -# Helper function to enable relative paths from this file -def full_path(path): - return (Path(os.path.abspath("__file__")).parent / path).resolve() - -if __name__ == "__main__": +def create_example_file(path): # Create a new io file object with a 100x100cm world - sf = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) + f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) # create a simple obstacle, fixed in place, fixed outline obstacle_outline = [[[-10, -10], [-10, 0], [0, 0], [0, -10]]] - obstacle_name = sf.create_entity( + obstacle_name = f.create_entity( "obstacle", positions=[[50, 50]], orientations=[[0]], outlines=obstacle_outline ) - # create a robofish with 100 timesteps and 40ms between the timesteps. If we would not give a name, the name would be generated to be robot_1. + # create a robofish with 1000 timesteps. If we would not give a name, the name would be generated to be robot_1. robofish_timesteps = 1000 - robofish_poses = np.ones((robofish_timesteps, 4)) * 50 - robot = sf.create_entity("robot", robofish_poses, name="robot") + robofish_poses = np.tile([50, 50, 1, 0], (robofish_timesteps, 1)) + robot = f.create_entity("robot", name="robot", poses=robofish_poses) # create multiple fishes with timestamps. Since we don't specify names, but only the type "fish" the fishes will be named ["fish_1", "fish_2", "fish_3"] agents = 3 timesteps = 1000 # timestamps = np.linspace(0, timesteps + 1, timesteps) - agent_poses = np.random.random((agents, timesteps, 4)) + agent_poses = np.random.random((agents, timesteps, 3)) - fishes = sf.create_multiple_entities("fish", agent_poses) + fishes = f.create_multiple_entities("fish", agent_poses) # This would throw an exception if the file was invalid - sf.validate() + f.validate() # Save file validates aswell - example_file = full_path("example.hdf5") - sf.save(example_file) + f.save_as(path) - # Closing and opening files (just for demonstration) - sf.close() - sf = robofish.io.File(path=example_file) + # Closing and opening files (just for demonstration). When opening with r+, we can read and write afterwards. + f.close() + f = robofish.io.File(path, "r+") print("\nEntity Names") - print(sf.get_entity_names()) + print(f.entity_names) # Get an array with all poses. As the length of poses varies per agent, it is filled up with nans. print("\nAll poses") - print(sf.get_poses()) + print(f.entity_poses) + # Select all entities with the category fish print("\nFish poses") - print(sf.get_poses(category="fish")) + print(f.select_entity_poses(lambda e: e.category == "fish")) - print("\nFile structure") - print(sf) + print("\nFinal file") + print(f) + + +if __name__ == "__main__": + create_example_file("example.hdf5") diff --git a/examples/example_readme.py b/examples/example_readme.py index 0ec1c8deb0279722bff22f79a982592af2526a98..62df2336f89281a0d9306428839e9aee2921ae4b 100644 --- a/examples/example_readme.py +++ b/examples/example_readme.py @@ -1,34 +1,39 @@ import robofish.io import numpy as np -filename = "example.hdf5" - -f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) -f.attrs["experiment_setup"] = "This is a simple example with made up data." - -# Create a single robot with 30 timesteps -# positions are passed separately -# orientations are passed as with two columns -> orientation_x and orientation_y -f.create_entity( - category="robot", - name="robot", - positions=np.zeros((100, 2)), - orientations=np.ones((100, 2)) * [0, 1], -) - -# Create fishes with 30 poses (x, y, orientation_rad) -poses = np.zeros((100, 3)) -poses[:, 0] = np.arange(-50, 50) -poses[:, 1] = np.arange(-50, 50) -poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 100) -fish = f.create_entity("fish", poses=poses) -fish.attrs["species"] = "My rotating spaghetti fish" -fish.attrs["fish_standard_length_cm"] = 10 - -# Show and save the file -print(f) -print("Poses Shape: ", f.get_poses().shape) - -# Saving also validates the file -f.save(filename) -print(f"Saved to {filename}") + +def create_example_file(path): + # Create a new robofish io file + f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) + f.attrs["experiment_setup"] = "This is a simple example with made up data." + + # Create a new robot entity. Positions and orientations are passed + # separately in this example. Since the orientations have two columns, + # unit vectors are assumed (orientation_x, orientation_y) + f.create_entity( + category="robot", + name="robot", + positions=np.zeros((100, 2)), + orientations=np.ones((100, 2)) * [0, 1], + ) + + # Create a new fish entity. + # In this case, we pass positions and orientations together (x, y, rad). + # Since it is a 3 column array, orientations in radiants are assumed. + poses = np.zeros((100, 3)) + poses[:, 0] = np.arange(-50, 50) + poses[:, 1] = np.arange(-50, 50) + poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 100) + fish = f.create_entity("fish", poses=poses) + fish.attrs["species"] = "My rotating spaghetti fish" + fish.attrs["fish_standard_length_cm"] = 10 + + # Show and save the file + print(f) + print("Poses Shape: ", f.entity_poses.shape) + + f.save_as(path) + + +if __name__ == "__main__": + path = create_example_file("example.hdf5") diff --git a/setup.py b/setup.py index ce7fa01d0447d8634eda4aa8e6a8ad47b685f6c5..552f54e96c461e588672a3238c82b98cee1b8030 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ # SPDX-License-Identifier: LGPL-3.0-or-later +from subprocess import run, PIPE + from setuptools import setup, find_packages @@ -11,12 +13,42 @@ entry_points = { "robofish-io-evaluate=robofish.evaluate.app:evaluate", ] } + + +def source_version(): + version_parts = ( + run( + ["git", "describe", "--tags", "--dirty"], + check=True, + stdout=PIPE, + encoding="utf-8", + ) + .stdout.strip() + .split("-") + ) + + if version_parts[-1] == "dirty": + dirty = True + version_parts = version_parts[:-1] + else: + dirty = False + + version = version_parts[0] + if len(version_parts) == 3: + version += ".post0" + version += f".dev{version_parts[1]}+{version_parts[2]}" + if dirty: + version += "+dirty" + + return version + + setup( name="robofish-io", - version="0.1", + version=source_version(), author="", author_email="", - install_requires=["h5py>=3", "numpy", "seaborn", "pandas", "deprecation"], + install_requires=["h5py>=2.10.0", "numpy", "seaborn", "pandas", "deprecation"], classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -27,7 +59,7 @@ setup( "Programming Language :: Python :: 3.8", ], python_requires=">=3.6", - packages=find_packages("src"), + packages=[f"robofish.{p}" for p in find_packages("src/robofish")], package_dir={"": "src"}, zip_safe=True, entry_points=entry_points, diff --git a/src/conversion_scripts/convert_marc.py b/src/conversion_scripts/convert_marc.py index bf68ef3b115baa54f29ec020d0b68635df678b5b..ae130f2a4a9d6e63ebc4daec0733164c70d1b1c7 100644 --- a/src/conversion_scripts/convert_marc.py +++ b/src/conversion_scripts/convert_marc.py @@ -6,7 +6,7 @@ This script converts csv files created by Marc to IO files. The script is not maintained, since Marc changed his code to generate io files. """ -# Jan 2021 Andreas Gerken, Berlin, Germany +# Jan 2021 Marc Groeling, Berlin, Germany # Released under GNU 3.0 License # email andi.gerken@gmail.com @@ -38,4 +38,4 @@ def convertTrajectory(path, save_path, categories): ) new.validate() - new.save(save_path) \ No newline at end of file + new.save(save_path) diff --git a/src/robofish/evaluate/evaluate.py b/src/robofish/evaluate/evaluate.py index 3ab73916aa1f91dcda906595ed01c1707c16d5cb..d47e0d56472b36076009340f03bdd31087f85dca 100644 --- a/src/robofish/evaluate/evaluate.py +++ b/src/robofish/evaluate/evaluate.py @@ -19,8 +19,13 @@ from typing import Iterable from scipy import stats +<<<<<<< HEAD # def get_all_poses_from_paths(paths: Iterable(str)): # """This function reads all poses from given paths. +======= +def get_all_poses_from_paths(paths: Iterable[str]): + """This function reads all poses from given paths. +>>>>>>> master # Args: # paths: An array of strings, with files or folders @@ -30,8 +35,13 @@ from scipy import stats # # Open all files, shape (paths, files) # files_per_path = [robofish.io.read_multiple_files(p) for p in paths] +<<<<<<< HEAD # # Read all poses from the files, shape (paths, files) # poses_per_path = [[f.get_poses() for f in files] for files in files_per_path] +======= + # Read all poses from the files, shape (paths, files) + poses_per_path = [[f.entity_poses for f in files] for files in files_per_path] +>>>>>>> master # # close all files # for p in files_per_path: @@ -49,6 +59,7 @@ def evaluate_speed( for k, files in enumerate(files_per_path): path_speeds = [] for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -57,7 +68,7 @@ def evaluate_speed( ) for e_poses in poses: e_speeds = np.linalg.norm(np.diff(e_poses[:, :2], axis=0), axis=1) - e_speeds *= file.get_frequency() + e_speeds *= file.frequency path_speeds.extend(e_speeds) speeds.append(path_speeds) @@ -87,6 +98,7 @@ def evaluate_turn( for k, files in enumerate(files_per_path): path_turns = [] for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -95,7 +107,7 @@ def evaluate_turn( ) # Todo check if all frequencies are the same - frequency = file.get_frequency() + frequency = file.frequency for e_poses in poses: # convert ori_x, ori_y to radians @@ -104,7 +116,7 @@ def evaluate_turn( e_turns = ori_rad[1:] - ori_rad[:-1] e_turns = np.where(e_turns < -np.pi, e_turns + 2 * np.pi, e_turns) e_turns = np.where(e_turns > np.pi, e_turns - 2 * np.pi, e_turns) - # e_turns *= file.get_frequency() + # e_turns *= file.frequency e_turns *= 180 / np.pi path_turns.extend(e_turns) turns.append(path_turns) @@ -135,6 +147,7 @@ def evaluate_orientation( orientations = [] for k, files in enumerate(files_per_path): for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -206,13 +219,14 @@ def evaluate_relativeOrientation( for k, files in enumerate(files_per_path): path_orientations = [] for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None if consider_categories is None else consider_categories[k], ) - all_poses = file.get_poses() + all_poses = file.entity_poses for i in range(len(poses)): for j in range(len(all_poses)): if (poses[i] != all_poses[j]).any(): @@ -255,6 +269,7 @@ def evaluate_distanceToWall( for p, file in files.items(): worldBoundsX.append(file.attrs["world_size_cm"][0]) worldBoundsY.append(file.attrs["world_size_cm"][1]) + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -326,6 +341,7 @@ def evaluate_tankpositions( for k, files in enumerate(files_per_path): path_x_pos, path_y_pos = [], [] for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -371,6 +387,7 @@ def evaluate_trajectories( world_bounds = [] for k, files in enumerate(files_per_path): for p, file in files.items(): + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None @@ -445,13 +462,14 @@ def evaluate_positionVec( for p, file in files.items(): worldBoundsX.append(file.attrs["world_size_cm"][0]) worldBoundsY.append(file.attrs["world_size_cm"][1]) + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None if consider_categories is None else consider_categories[k], ) - all_poses = file.get_poses() + all_poses = file.entity_poses # calculate posVec for every fish combination for i in range(len(poses)): for j in range(len(all_poses)): @@ -461,8 +479,7 @@ def evaluate_positionVec( ) path_posVec.append( calculate_posVec( - posVec_input, - np.arctan2(poses[i, :, 3], poses[i, :, 2]), + posVec_input, np.arctan2(poses[i, :, 3], poses[i, :, 2]) ) ) posVec.append(np.concatenate(path_posVec, axis=0)) @@ -506,13 +523,14 @@ def evaluate_follow_iid( for p, file in files.items(): worldBoundsX.append(file.attrs["world_size_cm"][0]) worldBoundsY.append(file.attrs["world_size_cm"][1]) + # TODO: Change to select_poses() poses = file.get_poses( names=None if consider_names is None else consider_names[k], category=None if consider_categories is None else consider_categories[k], ) - all_poses = file.get_poses() + all_poses = file.entity_poses for i in range(len(poses)): for j in range(len(all_poses)): if (poses[i] != all_poses[j]).any(): diff --git a/src/robofish/io/__init__.py b/src/robofish/io/__init__.py index 7801db3eba7dcbc1979b5e1b77542906fa79285a..5e84ac01da5eccb45e7879027dc2589f851895e7 100644 --- a/src/robofish/io/__init__.py +++ b/src/robofish/io/__init__.py @@ -7,6 +7,7 @@ from robofish.io.file import * from robofish.io.entity import * from robofish.io.validation import * from robofish.io.io import * +from robofish.io.utils import * import robofish.io.app diff --git a/src/robofish/io/entity.py b/src/robofish/io/entity.py index e5329988e71b9f8e54018aee9e339b614ad73f64..669ac1746ab15dd5c24e6eae1b6986bf97c6a49b 100644 --- a/src/robofish/io/entity.py +++ b/src/robofish/io/entity.py @@ -1,4 +1,6 @@ import robofish.io +import robofish.io.utils as utils + import h5py import numpy as np from typing import Iterable, Union @@ -24,6 +26,16 @@ class Entity(h5py.Group): outlines: Iterable = None, sampling: str = None, ): + poses, positions, orientations, outlines = utils.np_array( + poses, positions, orientations, outlines + ) + + assert poses is None or (poses.ndim == 2 and poses.shape[1] in [3, 4]) + assert positions is None or (positions.ndim == 2 and positions.shape[1] == 2) + assert orientations is None or ( + orientations.ndim == 2 and orientations.shape[1] in [1, 2] + ) + # If no name is given, create one from type and an id if name is None: i = 1 @@ -47,15 +59,28 @@ class Entity(h5py.Group): @classmethod def convert_rad_to_vector(cla, orientations_rad): - ori_rad = np.array(orientations_rad) + if min(orientations_rad) < 0 or max(orientations_rad) > 2 * np.pi: + logging.warning( + "Converting orientations, from a bigger range than [0, 2 * pi]. When passing the orientations, they are assumed to be in radiants." + ) + ori_rad = utils.np_array(orientations_rad) assert ori_rad.shape[1] == 1 ori_vec = np.empty((ori_rad.shape[0], 2)) ori_vec[:, 0] = np.cos(ori_rad[:, 0]) ori_vec[:, 1] = np.sin(ori_rad[:, 0]) return ori_vec - def getName(self): - return self.name.split("/")[-1] + @property + def group_name(self): + return super().name + + @property + def name(self): + return self.group_name.split("/")[-1] + + @property + def category(self): + return self.attrs["category"] def create_outlines(self, outlines: Iterable, sampling=None): outlines = self.create_dataset("outlines", data=outlines, dtype=np.float32) @@ -69,6 +94,8 @@ class Entity(h5py.Group): orientations: Iterable = None, sampling: str = None, ): + poses, positions, orientations = utils.np_array(poses, positions, orientations) + # Either poses or positions not both assert ( poses is None or positions is None @@ -80,8 +107,8 @@ class Entity(h5py.Group): ) else: if poses is not None: - poses = np.array(poses) - assert poses.shape[1] == 3 or poses.shape[1] == 4 + + assert poses.shape[1] in [3, 4] positions = poses[:, :2] orientations = poses[:, 2:] if orientations is not None and orientations.shape[1] == 1: @@ -90,20 +117,33 @@ class Entity(h5py.Group): positions = self.create_dataset( "positions", data=positions, dtype=np.float32 ) - orientations = self.create_dataset( - "orientations", data=orientations, dtype=np.float32 - ) + if orientations is not None: + orientations = self.create_dataset( + "orientations", data=orientations, dtype=np.float32 + ) if sampling is not None: positions.attrs["sampling"] = sampling orientations.attrs["sampling"] = sampling - def get_poses(self): - poses = np.concatenate([self["positions"], self["orientations"]], axis=1) - return poses + @property + def positions(self): + return self["positions"] + + @property + def orientations(self): + if not "orientations" in self: + # If no orientation is given, the default direction is to the right + return np.tile([1, 0], (self.positions.shape[0], 1)) + return self["orientations"] + + @property + def poses(self): + return np.concatenate([self.positions, self.orientations], axis=1) - def get_poses_rad(self): - poses = self.get_poses() + @property + def poses_rad(self): + poses = self.poses # calculate the angles from the orientation vectors, write them to the third row and delete the fourth row poses[:, 2] = np.arctan2(poses[:, 3], poses[:, 2]) poses = poses[:, :3] diff --git a/src/robofish/io/file.py b/src/robofish/io/file.py index deefcc9a771d6e54966224be4be1dc1abbb69c16..3b2f1fc7a3fefb825fb46fd79e94f3fc7dd44bb4 100644 --- a/src/robofish/io/file.py +++ b/src/robofish/io/file.py @@ -28,8 +28,6 @@ import tempfile import uuid import deprecation - -temp_dir = tempfile.TemporaryDirectory() default_format_version = np.array([1, 0], dtype=np.int32) default_format_url = ( @@ -38,16 +36,18 @@ default_format_url = ( class File(h5py.File): - """ Represents a hdf5 file, which should be used to store data about the - movement and shape of individuals or swarms in time. + """ Represents a RoboFish Track Format file, which should be used to store tracking data of individual animals or swarms. - This class extends the h5py.File class, and behaves in the same way. - robofish io Files can be created, saved, loaded, and manipulated. + Files can be opened (with optional creation), modified inplace, and have copies of them saved. """ + _temp_dir = None + def __init__( self, path: Union[str, Path] = None, + mode: str = "r", + *, # PEP 3102 world_size_cm: [int, int] = None, strict_validate: bool = False, format_version: [int, int] = default_format_version, @@ -57,25 +57,55 @@ class File(h5py.File): monotonic_time_points_us: Iterable = None, calendar_time_points: Iterable = None, ): - """ Constructor for the File class. - - The constructor should either be called with a path, when loading an existing file, - or when a new file should be created with a world size. - - Args: - path: optional path to a io file as a string or path object. The file will be loaded. - world_size_cm: optional integer array of the world size in cm - strict_validate: optional boolean, if the file should be strictly validated, when loaded from a path. The default is False. - format_version: optional version [major, minor] of the trackformat specification + """Create a new RoboFish Track Format object. + + When called with a path, it is loaded, otherwise a new temporary file is created. + + Parameters + ---------- + path : str or Path, optional + Location of file to be opened. If not provided, mode is ignored. + + mode : str + r Readonly, file must exist (default) + r+ Read/write, file must exist + w Create file, truncate if exists + x Create file, fail if exists + a Read/write if exists, create otherwise + + world_size_cm + optional integer array of the world size in cm + strict_validate + optional boolean, if the file should be strictly validated, when loaded from a path. The default is False. + format_version + optional version [major, minor] of the trackformat specification """ - self._name = str(uuid.uuid4()) - self._tf_path = Path(temp_dir.name) / self._name - self.load(path, strict_validate) - - if path is None or not Path(path).exists(): + if path is None: + if type(self)._temp_dir is None: + type(self)._temp_dir = tempfile.TemporaryDirectory( + prefix="robofish-io-" + ) + super().__init__( + Path(type(self)._temp_dir.name) / str(uuid.uuid4()), + mode="x", + driver="core", + backing_store=True, + libver=("earliest", "v110"), + ) + initialize = True + else: + # mode + # r Readonly, file must exist (default) + # r+ Read/write, file must exist + # w Create file, truncate if exists + # x Create file, fail if exists + # a Read/write if exists, create otherwise + logging.info(f"Opening File {path}") + initialize = not Path(path).exists() + super().__init__(path, mode, libver=("earliest", "v110")) - # Initialize new file + if initialize: assert world_size_cm is not None and format_version is not None self.attrs["world_size_cm"] = np.array(world_size_cm, dtype=np.float32) @@ -93,58 +123,34 @@ class File(h5py.File): calendar_time_points=calendar_time_points, default=True, ) + self.validate(strict_validate) - #### File Handling #### - def load(self, path: Union[str, Path], strict_validate: bool = False) -> None: - """ Load a new file from a path + def __enter__(self): + return self - Args: - path: path to a io file as a string or path object - strict_validate: optional boolean, if the file should be strictly validated, when loaded from a path. The default is False. - """ - if path is not None: - self._f_path = Path(path) - - if path is not None and Path(path).exists(): - logging.info(f"Opening File {path}") - shutil.copyfile(self._f_path, self._tf_path) - super().__init__(self._tf_path, "r+") - self.validate(strict_validate) - else: - super().__init__(self._tf_path, "w") + def __exit__(self, type, value, traceback): + # Check if the context was left under normal circumstances + if (type, value, traceback) == (None, None, None): + self.validate() + self.close() - def save(self, path: Union[str, Path] = None, strict_validate: bool = True): - """ Load a new file from a path + def save_as(self, path: Union[str, Path], strict_validate: bool = True): + """ Save a copy of the file Args: - path: optional path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used. + path: path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used. strict_validate: optional boolean, if the file should be strictly validated, before saving. The default is True. """ - # Normaly only valid files can be saved self.validate(strict_validate=strict_validate) - # Find the correct path - if path is None: - if self._f_path is None: - raise Exception( - "path was not specified and there was no saved path from loading or an earlier save" - ) - else: - self._f_path = Path(path) - - # Close the temporary file - self.close() + # Ensure all buffered data has been written to disk + self.flush() - # Create the parent folder if it does not exist - if not self._f_path.parent.exists(): - self._f_path.parent.mkdir(parents=True, exist_ok=True) + path = Path(path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) - # Copy the temporaryself.create_sampling(frequency_hz, monotonic_time_points_us) file to the path - shutil.copyfile(self._tf_path, self._f_path) - - # Reopen the temporary file - super().__init__(self._tf_path, "r+") + shutil.copyfile(Path(self.filename).resolve(), path) def create_sampling( self, @@ -202,11 +208,22 @@ class File(h5py.File): ) if default: - self.default_sampling = name - self["samplings"].attrs["default"] = self.default_sampling + self["samplings"].attrs["default"] = name return name - def get_frequency(self): + @property + def world_size(self): + return self.attrs["world_size_cm"] + + @property + def default_sampling(self): + if "default" in self["samplings"].attrs: + return self["samplings"].attrs["default"] + return None + + @property + def frequency(self): + # NOTE: Only works if default sampling availabe and specified with frequency_hz. default_sampling = self["samplings"].attrs["default"] return self["samplings"][default_sampling].attrs["frequency_hz"] @@ -233,11 +250,10 @@ class File(h5py.File): Name of the created entity """ - if sampling is None: - if not hasattr(self, "default_sampling"): - raise Exception( - "There was no sampling specified, when creating the file, nor when creating the entity." - ) + if sampling is None and self.default_sampling is None: + raise Exception( + "There was no sampling specified, when creating the file, nor when creating the entity." + ) entity = robofish.io.Entity.create_entity( self["entities"], @@ -273,18 +289,18 @@ class File(h5py.File): """ assert poses.ndim == 3 - assert poses.shape[2] == 4 - n = poses.shape[0] + assert poses.shape[2] in [3, 4] + agents = poses.shape[0] timesteps = poses.shape[1] - returned_names = [] + entity_names = [] - for i in range(n): + for i in range(agents): e_name = None if names is None else names[i] e_outline = ( outlines if outlines is None or outlines.ndim == 3 else outlines[i] ) - returned_names.append( + entity_names.append( self.create_entity( category=category, sampling=sampling, @@ -293,15 +309,10 @@ class File(h5py.File): outlines=e_outline, ) ) - return returned_names - - def get_entities(self): - return { - e_name: robofish.io.Entity.from_h5py_group(e_group) - for e_name, e_group in self["entities"].items() - } + return entity_names - def get_entity_names(self) -> Iterable[str]: + @property + def entity_names(self) -> Iterable[str]: """ Getter for the names of all entities Returns: @@ -309,8 +320,24 @@ class File(h5py.File): """ return sorted(self["entities"].keys()) - def get_poses(self, names: Iterable = None, category: str = None) -> Iterable: - """ Get an array of the poses of entities + @property + def entities(self): + return [ + robofish.io.Entity.from_h5py_group(self["entities"][name]) + for name in self.entity_names + ] + + @property + def entity_poses(self): + return self.select_entity_poses(None) + + @property + def entity_poses_rad(self): + return self.select_entity_poses(None, rad=True) + + def select_entity_poses(self, predicate=None, rad=False) -> Iterable: + """ TODO: Rework + Select an array of the poses of entities If no name or category is specified, all entities will be selected. @@ -321,61 +348,51 @@ class File(h5py.File): An three dimensional array of all poses with the shape (entity, time, 4) """ - if names is not None and category is not None: - logging.error("Specify either names or a category, not both.") - raise Exception - - # collect the names of all entities with the correct category - if category is not None: - names = [ - e_name - for e_name, e_data in self["entities"].items() - if e_data.attrs["category"] == category - ] - - entities = self.get_entities() - - # If no names or category are given, select all - if names is None: - names = sorted(entities.keys()) - - # Entity objects given as names - if all([type(name) == robofish.io.Entity for name in names]): - names = [entity.getName() for entity in names] + entities = self.entities + if predicate is not None: + entities = [e for e in entities if predicate(e)] - if not all([type(name) == str for name in names]): - raise Exception( - "Given names were not strings. Instead names were %s" % names - ) - - max_timesteps = ( - 0 - if len(names) == 0 - else max([entities[e_name]["positions"].shape[0] for e_name in names]) - ) + max_timesteps = max([0] + [e.positions.shape[0] for e in entities]) # Initialize poses output array - poses_output = np.empty((len(names), max_timesteps, 4)) + pose_len = 3 if rad else 4 + poses_output = np.empty((len(entities), max_timesteps, pose_len)) poses_output[:] = np.nan # Fill poses output array i = 0 custom_sampling = None - for name in names: - entity = entities[name] + for entity in entities: if "sampling" in entity["positions"].attrs: if custom_sampling is None: custom_sampling = entity["positions"].attrs["sampling"] elif custom_sampling != entity["positions"].attrs["sampling"]: raise Exception( - "Multiple samplings found, which can not be given back by the get_poses function collectively." + "Multiple samplings found, preventing return of a single array." ) - poses = entity.get_poses() + poses = entity.poses_rad if rad else entity.poses poses_output[i][: poses.shape[0]] = poses i += 1 return poses_output + @deprecation.deprecated( + deprecated_in="1.1.2", + removed_in="1.2", + details="get_poses() is deprecated and was replaced with the attribute 'poses' or the function select_poses(), when ", + ) + def get_poses(self, *, category=None, names=None): + + if category is not None: + predicate = lambda e: e.category == category + if names is not None: + predicate = lambda e: e.category == category and e.name in names + elif names is not None: + predicate = lambda e: e.name in names + else: + predicate = None + return self.select_entity_poses(predicate) + def validate(self, strict_validate: bool = True) -> (bool, str): """Validate the file to the specification. diff --git a/src/robofish/io/utils.py b/src/robofish/io/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..71396419de0211166baf6ca2e7f752666ba14d84 --- /dev/null +++ b/src/robofish/io/utils.py @@ -0,0 +1,15 @@ +import robofish.io +import numpy as np +from pathlib import Path +import os + + +def np_array(*arrays): + result = tuple(np.array(a) if a is not None else None for a in arrays) + if len(result) == 1: + result = result[0] + return result + + +def full_path(current_file, path): + return (Path(current_file).parent / path).resolve() diff --git a/src/robofish/io/validation.py b/src/robofish/io/validation.py index 776984b606fb89469a0d02cc860ac4489439ea63..a1bde527f16a2b28431c6b1148a4ecfec50d2354 100644 --- a/src/robofish/io/validation.py +++ b/src/robofish/io/validation.py @@ -150,7 +150,9 @@ def validate(iofile: File, strict_validate: bool = True) -> (bool, str): # validate entities assert_validate("entities" in iofile, "entities not found") - for e_name, entity in iofile.get_entities().items(): + for entity in iofile.entities: + e_name = entity.name + assert_validate( type(entity) == Entity, "Entity group was not a robofish.io.Entity object", @@ -198,7 +200,9 @@ def validate(iofile: File, strict_validate: bool = True) -> (bool, str): if positions.shape[0] > 0: # validate range of poses - validate_poses_range(iofile, positions, e_name) + validate_positions_range( + iofile.attrs["world_size_cm"], positions, e_name + ) if "orientations" in entity: assert_validate( @@ -226,6 +230,8 @@ def validate(iofile: File, strict_validate: bool = True) -> (bool, str): e_name, ) + validate_orientations_length(orientations, e_name) + # outlines if "outlines" in entity: outlines = entity["outlines"] @@ -289,37 +295,48 @@ def validate(iofile: File, strict_validate: bool = True) -> (bool, str): return (True, "") -def validate_poses_range(iofile, poses, e_name): - # Poses which are just a bit over the world edge are fine +def validate_positions_range(world_size, positions, e_name): + # positions which are just a bit over the world edge are fine error_allowance = 1.01 allowed_x = [ - -1 * iofile.attrs["world_size_cm"][0] * error_allowance / 2, - iofile.attrs["world_size_cm"][0] * error_allowance / 2, + -1 * world_size[0] * error_allowance / 2, + world_size[0] * error_allowance / 2, ] - real_x = [poses[:, 0].min(), poses[:, 0].max()] + real_x = [positions[:, 0].min(), positions[:, 0].max()] allowed_y = [ - -1 * iofile.attrs["world_size_cm"][1] * error_allowance / 2.0, - iofile.attrs["world_size_cm"][1] * error_allowance / 2.0, + -1 * world_size[1] * error_allowance / 2.0, + world_size[1] * error_allowance / 2.0, ] - real_y = [poses[:, 1].min(), poses[:, 1].max()] + real_y = [positions[:, 1].min(), positions[:, 1].max()] assert_validate( allowed_x[0] <= real_x[0] and real_x[1] <= allowed_x[1], - "Poses of x axis were not in range. The allowed range is [%.1f, %.1f], which was [%.1f, %.1f] in the poses" + "Positions of x axis were not in range. The allowed range is [%.1f, %.1f], which was [%.1f, %.1f] in the Positions" % (allowed_x[0], allowed_x[1], real_x[0], real_x[1]), e_name, ) assert_validate( allowed_y[0] <= real_y[0] and real_y[1] <= allowed_y[1], - "Poses of y axis were not in range. The allowed range is [%.1f, %.1f], which was [%.1f, %.1f] in the poses" + "Positions of y axis were not in range. The allowed range is [%.1f, %.1f], which was [%.1f, %.1f] in the Positions" % (allowed_y[0], allowed_y[1], real_y[0], real_y[1]), e_name, ) +def validate_orientations_length(orientations, e_name): + ori_lengths = np.linalg.norm(orientations, axis=1) + + assert_validate( + np.isclose(ori_lengths, 1).all(), + "The orientation vectors were not unit vectors. Their length was in the range [%.2f, %.2f] when it should be 1" + % (min(ori_lengths), max(ori_lengths)), + e_name, + ) + + def validate_iso8601(str_val: str) -> bool: """This function validates strings to match the ISO8601 format. diff --git a/tests/resources/valid.hdf5 b/tests/resources/valid.hdf5 index 4da5ba546684a3f7abbd98172432639d096241f2..0c14ecdad78385832491ff147824f4e259701a05 100644 Binary files a/tests/resources/valid.hdf5 and b/tests/resources/valid.hdf5 differ diff --git a/tests/robofish/evaluate/test_app_evaluate.py b/tests/robofish/evaluate/test_app_evaluate.py new file mode 100644 index 0000000000000000000000000000000000000000..2a7fe486a95c961f41f591ea7c8ab533db6b2ccf --- /dev/null +++ b/tests/robofish/evaluate/test_app_evaluate.py @@ -0,0 +1,28 @@ +import robofish.evaluate.app as app +from robofish.io import utils +import pytest +import logging +from pathlib import Path + +logging.getLogger().setLevel(logging.INFO) + +h5py_files = [utils.full_path(__file__, "../../resources/valid.hdf5")] +graphics_out = utils.full_path(__file__, "output_graph.png") +if graphics_out.exists(): + graphics_out.unlink() + + +def test_app_validate(): + """ This tests the function of the robofish-io-validate command """ + + class DummyArgs: + def __init__(self, analysis_type): + self.analysis_type = analysis_type + self.paths = h5py_files + self.names = None + self.save_path = graphics_out + + # TODO: Get rid of deprecation + with pytest.warns(DeprecationWarning): + app.evaluate(DummyArgs("speed")) + graphics_out.unlink() diff --git a/tests/robofish/evaluate/test_evaluate.py b/tests/robofish/evaluate/test_evaluate.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/robofish/io/test_app.py b/tests/robofish/io/test_app_io.py similarity index 63% rename from tests/robofish/io/test_app.py rename to tests/robofish/io/test_app_io.py index ac1dbe70223575f531f18d523e22986c919fc598..056d3d3191ea35d7a7ee5b62ac05e90b202c8bfd 100644 --- a/tests/robofish/io/test_app.py +++ b/tests/robofish/io/test_app_io.py @@ -1,4 +1,5 @@ import robofish.io.app as app +from robofish.io import utils import pytest import logging from pathlib import Path @@ -6,11 +7,6 @@ from pathlib import Path logging.getLogger().setLevel(logging.INFO) -#### Helpers #### -def full_path(path): - return (Path(__file__).parent / path).resolve() - - def test_app_validate(): """ This tests the function of the robofish-io-validate command """ @@ -19,11 +15,13 @@ def test_app_validate(): self.path = path self.output_format = output_format - raw_output = app.validate(DummyArgs(full_path("../../resources"), "raw")) + raw_output = app.validate( + DummyArgs(utils.full_path(__file__, "../../resources"), "raw") + ) # The three files valid.hdf5, almost_valid.hdf5, and invalid.hdf5 should be found. assert len(raw_output) == 2 - app.validate(DummyArgs(full_path("../../resources"), "human")) + app.validate(DummyArgs(utils.full_path(__file__, "../../resources"), "human")) def test_app_print(): @@ -34,5 +32,9 @@ def test_app_print(): self.path = path self.output_format = output_format - app.print(DummyArgs(full_path("../../resources/valid.hdf5"), "full")) - app.print(DummyArgs(full_path("../../resources/valid.hdf5"), "shape")) + app.print( + DummyArgs(utils.full_path(__file__, "../../resources/valid.hdf5"), "full") + ) + app.print( + DummyArgs(utils.full_path(__file__, "../../resources/valid.hdf5"), "shape") + ) diff --git a/tests/robofish/io/test_entity.py b/tests/robofish/io/test_entity.py index 0ebf09738a53d8f983b8a99f8c54de7c351b8bda..8c4ab6946a68a316bea62d2ac90a3be56176055a 100644 --- a/tests/robofish/io/test_entity.py +++ b/tests/robofish/io/test_entity.py @@ -3,16 +3,11 @@ import h5py import numpy as np -#### Helpers #### -def full_path(path): - return (Path(__file__).parent / path).resolve() - - def test_entity_object(): sf = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25) f = sf.create_entity("fish", positions=[[10, 10]]) assert type(f) == robofish.io.Entity - assert f.getName() == "fish_1" + assert f.name == "fish_1" assert f.attrs["category"] == "fish" print(dir(f)) print(f["positions"]) @@ -26,7 +21,7 @@ def test_entity_object(): f2 = sf.create_entity("fish", poses=poses_rad) assert type(f2["positions"]) == h5py.Dataset assert type(f2["orientations"]) == h5py.Dataset - poses_rad_retrieved = f2.get_poses_rad() + poses_rad_retrieved = f2.poses_rad # Check if retrieved rad poses is close to the original poses. # Internally always ori_x and ori_y are used. When retrieved, the range is from -pi to pi, so for some of our original data 2 pi has to be substracted. diff --git a/tests/robofish/io/test_examples.py b/tests/robofish/io/test_examples.py new file mode 100644 index 0000000000000000000000000000000000000000..c81b1eed99221e9d92a82f0d354352fa869cfbae --- /dev/null +++ b/tests/robofish/io/test_examples.py @@ -0,0 +1,45 @@ +import robofish.io +from robofish.io import utils +from pathlib import Path +from testbook import testbook +import sys + + +sys.path.append(str(utils.full_path(__file__, "../../../examples/"))) + +ipynb_path = utils.full_path(__file__, "../../../examples/example_basic.ipynb") +path = utils.full_path(__file__, "../../../examples/tmp_example.hdf5") +if path.exists(): + path.unlink() + + +def test_example_readme(): + import example_readme + + example_readme.create_example_file(path) + path.unlink() + + +def test_example_basic(): + import example_basic + + example_basic.create_example_file(path) + path.unlink() + + +# This test can be executed manually. The CI/CD System has issues with testbook. +def manual_test_example_basic_ipynb(): + # Executing the notebook should not lead to an exception + with testbook(str(ipynb_path), execute=True) as tb: + pass + # tb.ref("create_example_file")(path) + # path.unlink() + + +if __name__ == "__main__": + print("example_readme.py") + test_example_readme() + print("example_basic.py") + test_example_basic() + print("example_basic.ipynb") + manual_test_example_basic_ipynb() diff --git a/tests/robofish/io/test_file.py b/tests/robofish/io/test_file.py index 3cb5d023681937c8d9334a6017de294820e2a128..31c54bc8382f82aa6376af26f08fcafe257f619a 100644 --- a/tests/robofish/io/test_file.py +++ b/tests/robofish/io/test_file.py @@ -1,4 +1,5 @@ import robofish.io +from robofish.io import utils import numpy as np from pathlib import Path import pytest @@ -7,29 +8,30 @@ import datetime import sys import logging +LOGGER = logging.getLogger(__name__) -#### Helpers #### -def full_path(path): - return (Path(__file__).parent / path).resolve() - - -valid_file_path = full_path("../../resources/valid.hdf5") -created_by_test_path = full_path("../../resources/created_by_test.hdf5") -created_by_test_path_2 = full_path("../../resources/created_by_test_2.hdf5") +valid_file_path = utils.full_path(__file__, "../../resources/valid.hdf5") +created_by_test_path = utils.full_path(__file__, "../../resources/created_by_test.hdf5") +created_by_test_path_2 = utils.full_path( + __file__, "../../resources/created_by_test_2.hdf5" +) def test_constructor(): sf = robofish.io.File(world_size_cm=[100, 100]) - print(sf) sf.validate() +def test_context(): + with robofish.io.File(world_size_cm=[10, 10]) as f: + pass + + def test_new_file_w_path(): sf = robofish.io.File( - created_by_test_path_2, world_size_cm=[100, 100], frequency_hz=25 + created_by_test_path_2, "w", world_size_cm=[100, 100], frequency_hz=25 ) sf.create_entity("fish") - sf.save() sf.validate() @@ -42,6 +44,7 @@ def test_missing_attribute(): def test_single_entity_monotonic_step(): sf = robofish.io.File(world_size_cm=[100, 100], frequency_hz=(1000 / 40)) test_poses = np.ones(shape=(10, 4)) + test_poses[:, 3] = 0 # All Fish pointing right sf.create_entity("robofish", poses=test_poses) sf.create_entity("object_without_poses") print(sf) @@ -53,6 +56,7 @@ def test_single_entity_monotonic_time_points_us(): world_size_cm=[100, 100], monotonic_time_points_us=np.ones(10) ) test_poses = np.ones(shape=(10, 4)) + test_poses[:, 3] = 0 # All Fish pointing right sf.create_entity("robofish", poses=test_poses) print(sf) sf.validate() @@ -64,13 +68,13 @@ def test_multiple_entities(): poses = np.zeros((agents, timesteps, 4)) poses[1] = 1 - poses[2] = 2 + poses[:, :, 2:] = [1, 0] # All agents point to the right m_points = np.arange(timesteps) sf = robofish.io.File(world_size_cm=[100, 100], monotonic_time_points_us=m_points) returned_entities = sf.create_multiple_entities("fish", poses) - returned_names = [entity.getName() for entity in returned_entities] + returned_names = [entity.name for entity in returned_entities] expected_names = ["fish_1", "fish_2", "fish_3"] print(returned_names) @@ -80,24 +84,26 @@ def test_multiple_entities(): sf.validate() # The returned poses should be equal to the inserted poses - returned_poses = sf.get_poses() + returned_poses = sf.entity_poses print(returned_poses) assert (returned_poses == poses).all() # Just get the array for some names - returned_poses = sf.get_poses(["fish_1", "fish_2"]) + returned_poses = sf.select_entity_poses(lambda e: e.name in ["fish_1", "fish_2"]) assert (returned_poses == poses[:2]).all() - # Falsely specify names and category - with pytest.raises(Exception): - sf.get_poses(names=["fish_1"], category="fish") + # Filter on both category and name + returned_poses = sf.select_entity_poses( + lambda e: e.category == "fish" and e.name == "fish_1" + ) + assert (returned_poses == poses[:1]).all() # Insert some random obstacles - returned_names = sf.create_multiple_entities( - "obstacle", poses=np.random.random((agents, timesteps, 4)) - ) + obstacles = 3 + obs_poses = np.random.random((agents, 1, 3)) + returned_names = sf.create_multiple_entities("obstacle", poses=obs_poses) # Obstacles should not be returned when only fish are selected - returned_poses = sf.get_poses(category="fish") + returned_poses = sf.select_entity_poses(lambda e: e.category == "fish") assert (returned_poses == poses).all() # for each of the entities @@ -113,29 +119,66 @@ def test_multiple_entities(): monotonic_time_points_us=m_points, calendar_time_points=c_points ) - try: - sf.create_sampling(frequency_hz=25, monotonic_time_points_us=m_points) - raise Exception("This sampling should have created an error") - except: - pass - returned_names = sf.create_multiple_entities( "fish", poses, outlines=outlines, sampling=new_sampling ) print(returned_names) print(sf) - # pass an poses array in separate parts (positions, orientations) and retreive it with get_poses. + # pass an poses array in separate parts (positions, orientations) and retrieve it with poses. poses_arr = np.random.random((100, 4)) + poses_arr[:, 2:] /= np.atleast_2d( + np.linalg.norm(poses_arr[:, 2:], axis=1) + ).T # Normalization position_orientation_fish = sf.create_entity( "fish", positions=poses_arr[:, :2], orientations=poses_arr[:, 2:] ) - assert np.isclose(poses_arr, position_orientation_fish.get_poses()).all() + assert np.isclose(poses_arr, position_orientation_fish.poses).all() sf.validate() return sf +def test_broken_sampling(caplog): + sf = robofish.io.File(world_size_cm=[10, 10]) + caplog.set_level(logging.ERROR) + broken_sampling = sf.create_sampling( + name="broken sampling", frequency_hz=25, monotonic_time_points_us=np.ones((100)) + ) + assert "ERROR" in caplog.text + + +def test_deprecated_get_poses(): + f = test_multiple_entities() + with pytest.warns(DeprecationWarning): + assert f.get_poses().shape[0] == 10 + assert f.get_poses(category="fish").shape[0] == 7 + assert f.get_poses(names="fish_1").shape[0] == 1 + + +def test_entity_poses_rad(caplog): + with robofish.io.File(world_size_cm=[100, 100], frequency_hz=25) as f: + # Create an entity, using radiants + f.create_entity("fish", poses=np.ones((100, 3))) + + # Read the poses of the file as radiants + np.testing.assert_almost_equal(f.entity_poses_rad, np.ones((1, 100, 3))) + + caplog.set_level(logging.WARNING) + # Passing orientations, which are bigger than 2 pi + f.create_entity("fish", poses=np.ones((100, 3)) * 50) + assert "WARNING" in caplog.text + + +def test_entity_positions_no_orientation(): + with robofish.io.File(world_size_cm=[100, 100], frequency_hz=25) as f: + # Create an entity, using radiants + f.create_entity("fish", positions=np.ones((100, 2))) + + assert f.entity_poses.shape == (1, 100, 4) + assert (f.entity_poses[:, :] == np.array([1, 1, 1, 0])).all() + + def test_load_validate(): sf = robofish.io.File(path=valid_file_path) sf.validate() @@ -143,10 +186,9 @@ def test_load_validate(): def test_get_entity_names(): sf = robofish.io.File(path=valid_file_path) - names = sf.get_entity_names() - assert len(names) == 9 + names = sf.entity_names + assert len(names) == 1 assert names[0] == "fish_1" - assert names[1] == "fish_2" def test_File_without_path_or_worldsize(): @@ -158,12 +200,18 @@ def test_loading_saving(): sf = test_multiple_entities() assert not created_by_test_path.exists() - sf.save(created_by_test_path) + sf.save_as(created_by_test_path) assert created_by_test_path.exists() # After saving, the file should still be accessible and valid sf.validate() + # Open the file again and add another entity + sf = robofish.io.File(created_by_test_path, "r+") + entity = sf.create_entity("fish", positions=np.ones((100, 2))) + sf.entity_poses + entity.poses + def test_validate_created_file_after_reloading(): sf = robofish.io.File(created_by_test_path) @@ -173,8 +221,9 @@ def test_validate_created_file_after_reloading(): # Cleanup test. The z in the name makes sure, that it is executed last in main def test_z_cleanup(): """ This cleans up after all tests and removes all test artifacts """ - created_by_test_path.unlink() - created_by_test_path_2.unlink() + for f in [created_by_test_path, created_by_test_path_2]: + if f.exists(): + f.unlink() if __name__ == "__main__": diff --git a/tests/robofish/io/test_io.py b/tests/robofish/io/test_io.py index bbd59dd70c49e70eaf697f4e0fe3d2c191d9ae29..f0609d2038a50d17a522479f2efbe57f3c13d301 100644 --- a/tests/robofish/io/test_io.py +++ b/tests/robofish/io/test_io.py @@ -1,11 +1,8 @@ import robofish.io +from robofish.io import utils import pytest from pathlib import Path -#### Helpers #### -def full_path(path): - return (Path(__file__).parent / path).resolve() - def test_now_iso8061(): # Example time: 2021-01-05T14:33:40.401+00:00 @@ -15,7 +12,7 @@ def test_now_iso8061(): def test_read_multiple_single(): - path = full_path("../../resources/valid.hdf5") + path = utils.full_path(__file__, "../../resources/valid.hdf5") # Variants path as posix path or as string for sf in [ @@ -29,7 +26,7 @@ def test_read_multiple_single(): def test_read_multiple_folder(): - path = full_path("../../resources/") + path = utils.full_path(__file__, "../../resources/") # Variants path as posix path or as string for sf in [