Repository: neuroinformatics-unit/movement

Issue:
Reorder dimensions to start with space and time
**Is your feature request related to a problem? Please describe.**
In our current [dataset structure](http://127.0.0.1:3000/docs/build/html/getting_started/movement_dataset.html) the order of `xarray` dimensions is: `time`, `individuals`, `keypoints`, `space`.

This is not very intuitive, namely because `time` and `space` are the most essential dimensions which should always exist regardless of how motion was tracked. As we move toward supporting more types of data, and possibly making non-spacetime dimensions optional (e.g. create `keypoints` only if more than 1 is present, and ditto for `individuals`), and it makes more sense to put the "essential" dimensions at the beginning and the optional ones at the end.

This was brought up by @sfmig, and we both think this would make sense.

**Describe the solution you'd like**
Reorder dimensions in order of "essentiality": `time`, `space`, `keypoints`,  `individuals`, `views` (if we adopt #196).
This shouldn't be too hard as we could simply add xarray's [`transpose()`](https://docs.xarray.dev/en/stable/user-guide/reshaping.html#reordering-dimensions) methods at the end of our loading functions to get the desired order. But we would have to adapt validators (related to #210), tests and docs.

**Describe alternatives you've considered**
- `space`, `time`, `keypoints`, `individual`, `views`
- Keep things as they are, don't fix it if it ain't broken?

**Additional context**
- [Discussions](https://neuroinformatics.zulipchat.com/#narrow/stream/406001-Movement/topic/Support.20single.20individual.20data.20without.20the.20.60individuals.60.20dim) with @vigji on essential vs optional dimensions.
- Support for multiple views [issue](https://github.com/neuroinformatics-unit/movement/issues/196) and PR #197 
- On re-designing the validators: #210 


Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/neuroinformatics-unit/movement/pull/351?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.78%. Comparing base [(`174817d`)](https://app.codecov.io/gh/neuroinformatics-unit/movement/commit/174817d0dbce0f14915e87aec98b1332686ac376?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit) to head [(`de2414a`)](https://app.codecov.io/gh/neuroinformatics-unit/movement/commit/de2414af842ef83684ccc66986a62f898658e327?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #351   +/-   ##
=======================================
  Coverage   99.78%   99.78%           
=======================================
  Files          14       14           
  Lines         912      914    +2     
=======================================
+ Hits          910      912    +2     
  Misses          2        2           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/neuroinformatics-unit/movement/pull/351?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit).

----
🚨 Try these New Features:

- [Flaky Tests Detection](https://docs.codecov.com/docs/test-result-ingestion-beta) - Detect and resolve failed and flaky tests
sonarcloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [10.7% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [7.9% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [E Reliability Rating on New Code](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) (required ≥ A)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)

##   
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png '') Catch issues before they fail your Quality Gate with our IDE extension ![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png '') [SonarQube for IDE](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=pull-request)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neuroinformatics-unit_movement&pullRequest=351&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neuroinformatics-unit_movement&pullRequest=351&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=351)


lochhh: > Thanks for undertaking this tricky surgery @lochhh, a PR that touches almost all files!
> 
> I couldn't find a fault with it and I'd say this is good to go, after resolving merge conflicts.
> 
> There is one thing I'm puzzled about, which you perhaps can help with. In `compute_norm()`, we have:
> 
> ```python
> if "space" in data.dims:
>         validate_dims_coords(data, {"space": ["x", "y"]})
>         return xr.apply_ufunc(
>             np.linalg.norm,
>             data,
>             input_core_dims=[["space"]],
>             kwargs={"axis": -1},
>         )
> ```
> 
> `kwargs={"axis": -1}` is passed onto `linalg.norm` and I understood it as meaning the "norm along the space axis". You haven't updated this, so by my logic this should be broken now, but the passing tests indicate that it works as expected. What gives?

From [xarray's `apply_ufunc` tutorial](https://tutorial.xarray.dev/advanced/apply_ufunc/core-dimensions.html):
> With `apply_ufunc`, core dimensions are recognized by name, and then moved to the last dimension of any input arguments before applying the given function. This means that for functions that accept an axis argument, you usually need to set axis=-1
niksirbi: > With `apply_ufunc`, core dimensions are recognized by name, and then moved to the last dimension of any input arguments before applying the given function. This means that for functions that accept an axis argument, you usually need to set axis=-1

That's cool, thanks for clarifying! LGTM 🎉 

Patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 5d8414d..58fb1b2 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -110,7 +110,7 @@ sphinx_gallery_conf = {
         "dependencies": ["environment.yml"],
     },
     "reference_url": {"movement": None},
-    "default_thumb_file": "source/_static/data_icon.png",  # default thumbnail image  
+    "default_thumb_file": "source/_static/data_icon.png",  # default thumbnail image
     "remove_config_comments": True,
     # do not render config params set as # sphinx_gallery_config [= value]
 }
@@ -206,6 +206,7 @@ myst_url_schemes = {
 intersphinx_mapping = {
     "xarray": ("https://docs.xarray.dev/en/stable/", None),
     "scipy": ("https://docs.scipy.org/doc/scipy/reference/", None),
+    "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
 }
 
 
diff --git a/docs/source/user_guide/input_output.md b/docs/source/user_guide/input_output.md
index 6cc1f2a..ae62340 100644
--- a/docs/source/user_guide/input_output.md
+++ b/docs/source/user_guide/input_output.md
@@ -93,7 +93,7 @@ with three keypoints each: ``snout``, ``centre``, and ``tail_base``. These keypo
 import numpy as np
 
 ds = load_poses.from_numpy(
-    position_array=np.random.rand((100, 2, 3, 2)),
+    position_array=np.random.rand(100, 2, 3, 2),
     confidence_array=np.ones((100, 2, 3)),
     individual_names=["Alice", "Bob"],
     keypoint_names=["snout", "centre", "tail_base"],
@@ -256,7 +256,7 @@ with open(filepath, mode="w", newline="") as file:
             writer.writerow([frame, individual, x, y, width, height, confidence])
 
 ```
-Alternatively, we can convert the `movement` bounding boxes' dataset to a pandas DataFrame with the {func}`.xarray.DataArray.to_dataframe()` method, wrangle the dataframe as required, and then apply the {func}`.pandas.DataFrame.to_csv()` method to save the data as a .csv file.
+Alternatively, we can convert the `movement` bounding boxes' dataset to a pandas DataFrame with the {meth}`xarray.DataArray.to_dataframe` method, wrangle the dataframe as required, and then apply the {meth}`pandas.DataFrame.to_csv` method to save the data as a .csv file.
 
 
 (target-sample-data)=
diff --git a/docs/source/user_guide/movement_dataset.md b/docs/source/user_guide/movement_dataset.md
index 79106e2..2b080ae 100644
--- a/docs/source/user_guide/movement_dataset.md
+++ b/docs/source/user_guide/movement_dataset.md
@@ -43,15 +43,15 @@ print(ds)
 and we would obtain an output such as:
 ```
 <xarray.Dataset> Size: 27kB
-Dimensions:      (time: 601, individuals: 3, keypoints: 1, space: 2)
+Dimensions:      (time: 601, space: 2, keypoints: 1, individuals: 3)
 Coordinates:
   * time         (time) float64 5kB 0.0 0.02 0.04 0.06 ... 11.96 11.98 12.0
-  * individuals  (individuals) <U10 120B 'AEON3B_NTP' 'AEON3B_TP1' 'AEON3B_TP2'
-  * keypoints    (keypoints) <U8 32B 'centroid'
   * space        (space) <U1 8B 'x' 'y'
+  * keypoints    (keypoints) <U8 32B 'centroid'
+  * individuals  (individuals) <U10 120B 'AEON3B_NTP' 'AEON3B_TP1' 'AEON3B_TP2'
 Data variables:
-    position     (time, individuals, keypoints, space) float32 14kB 770.3 ......
-    confidence   (time, individuals, keypoints) float32 7kB nan nan ... nan nan
+    position     (time, space, keypoints, individuals) float32 14kB 770.3 ......
+    confidence   (time, keypoints, individuals) float32 7kB nan nan ... nan nan
 Attributes:
     fps:              50.0
     time_unit:        seconds
@@ -78,14 +78,14 @@ print(ds)
 and the last command would print out:
 ```
 <xarray.Dataset> Size: 19kB
-Dimensions:      (time: 5, individuals: 86, space: 2)
+Dimensions:      (time: 5, space: 2, individuals: 86)
 Coordinates:
   * time         (time) int64 40B 0 1 2 3 4
-  * individuals  (individuals) <U5 2kB 'id_1' 'id_2' 'id_3' ... 'id_89' 'id_90'
   * space        (space) <U1 8B 'x' 'y'
+  * individuals  (individuals) <U5 2kB 'id_1' 'id_2' 'id_3' ... 'id_89' 'id_90'
 Data variables:
-    position     (time, individuals, space) float64 7kB 871.8 ... 905.3
-    shape        (time, individuals, space) float64 7kB 60.0 53.0 ... 51.0 36.0
+    position     (time, space, individuals) float64 7kB 901.8 ... 923.3
+    shape        (time, space, individuals) float64 7kB 60.0 30.0 ... 72.0 36.0
     confidence   (time, individuals) float64 3kB nan nan nan nan ... nan nan nan
 Attributes:
     fps:              None
@@ -114,25 +114,25 @@ the labelled "ticks" along each axis are called **coordinates** (`coords`).
 :::{tab-item} Poses dataset
 A `movement` poses dataset has the following **dimensions**:
 - `time`, with size equal to the number of frames in the video.
-- `individuals`, with size equal to the number of tracked individuals/instances.
-- `keypoints`, with size equal to the number of tracked keypoints per individual.
 - `space`, which is the number of spatial dimensions. Currently, we support only 2D poses.
+- `keypoints`, with size equal to the number of tracked keypoints per individual.
+- `individuals`, with size equal to the number of tracked individuals/instances.
 :::
 
 :::{tab-item} Bounding boxes' dataset
 A `movement` bounding boxes dataset has the following **dimensions**s:
 - `time`, with size equal to the number of frames in the video.
-- `individuals`, with size equal to the number of tracked individuals/instances.
 - `space`, which is the number of spatial dimensions. Currently, we support only 2D bounding boxes data.
+- `individuals`, with size equal to the number of tracked individuals/instances.
 Notice that these are the same dimensions as for a poses dataset, except for the `keypoints` dimension.
 :::
 ::::
 
 In both cases, appropriate **coordinates** are assigned to each **dimension**.
-- `individuals` are labelled with a list of unique names (e.g. `mouse1`, `mouse2`, etc. or `id_0`, `id_1`, etc.).
-- `keypoints` are likewise labelled with a list of unique body part names, e.g. `snout`, `right_ear`, etc. Note that this dimension only exists in the poses dataset.
-- `space` is labelled with either `x`, `y` (2D) or `x`, `y`, `z` (3D). Note that bounding boxes datasets are restricted to 2D space.
 - `time` is labelled in seconds if `fps` is provided, otherwise the **coordinates** are expressed in frames (ascending 0-indexed integers).
+- `space` is labelled with either `x`, `y` (2D) or `x`, `y`, `z` (3D). Note that bounding boxes datasets are restricted to 2D space.
+- `keypoints` are likewise labelled with a list of unique body part names, e.g. `snout`, `right_ear`, etc. Note that this dimension only exists in the poses dataset.
+- `individuals` are labelled with a list of unique names (e.g. `mouse1`, `mouse2`, etc. or `id_0`, `id_1`, etc.).
 
 :::{dropdown} Additional dimensions
 :color: info
@@ -156,14 +156,14 @@ The specific data variables stored are slightly different between a `movement` p
 ::::{tab-set}
 :::{tab-item} Poses dataset
 A `movement` poses dataset contains two **data variables**:
-- `position`: the 2D or 3D locations of the keypoints over time, with shape (`time`, `individuals`, `keypoints`, `space`).
-- `confidence`: the confidence scores associated with each predicted keypoint (as reported by the pose estimation model), with shape (`time`, `individuals`, `keypoints`).
+- `position`: the 2D or 3D locations of the keypoints over time, with shape (`time`, `space`, `keypoints`, `individuals`).
+- `confidence`: the confidence scores associated with each predicted keypoint (as reported by the pose estimation model), with shape (`time`, `keypoints`, `individuals`).
 :::
 
 :::{tab-item} Bounding boxes' dataset
 A `movement` bounding boxes dataset contains three **data variables**:
-- `position`: the 2D locations of the bounding boxes' centroids over time, with shape (`time`, `individuals`, `space`).
-- `shape`: the width and height of the bounding boxes over time, with shape (`time`, `individuals`, `space`).
+- `position`: the 2D locations of the bounding boxes' centroids over time, with shape (`time`, `space`, `individuals`).
+- `shape`: the width and height of the bounding boxes over time, with shape (`time`, `space`, `individuals`).
 - `confidence`: the confidence scores associated with each predicted bounding box, with shape (`time`, `individuals`).
 :::
 ::::
diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py
index 2841268..c6f3bef 100644
--- a/movement/io/load_bboxes.py
+++ b/movement/io/load_bboxes.py
@@ -36,12 +36,12 @@ def from_numpy(
     Parameters
     ----------
     position_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_space)
+        Array of shape (n_frames, n_space, n_individuals)
         containing the tracks of the bounding boxes' centroids.
         It will be converted to a :class:`xarray.DataArray` object
         named "position".
     shape_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_space)
+        Array of shape (n_frames, n_space, n_individuals)
         containing the shape of the bounding boxes. The shape of a bounding
         box is its width (extent along the x-axis of the image) and height
         (extent along the y-axis of the image). It will be converted to a
@@ -56,7 +56,7 @@ def from_numpy(
         If None (default), bounding boxes are assigned names based on the size
         of the ``position_array``. The names will be in the format of
         ``id_<N>``, where <N>  is an integer from 0 to
-        ``position_array.shape[1]-1`` (i.e., "id_0", "id_1"...).
+        ``position_array.shape[-1]-1`` (i.e., "id_0", "id_1"...).
     frame_array : np.ndarray, optional
         Array of shape (n_frames, 1) containing the frame numbers for which
         bounding boxes are defined. If None (default), frame numbers will
@@ -376,9 +376,9 @@ def _numpy_arrays_from_via_tracks_file(
     The extracted numpy arrays are returned in a dictionary with the following
     keys:
 
-    - position_array (n_frames, n_individuals, n_space):
+    - position_array (n_frames, n_space, n_individuals):
         contains the trajectories of the bounding boxes' centroids.
-    - shape_array (n_frames, n_individuals, n_space):
+    - shape_array (n_frames, n_space, n_individuals):
         contains the shape of the bounding boxes (width and height).
     - confidence_array (n_frames, n_individuals):
         contains the confidence score of each bounding box.
@@ -428,11 +428,11 @@ def _numpy_arrays_from_via_tracks_file(
             df[map_key_to_columns[key]].to_numpy(),
             indices_id_switch,  # indices along axis=0
         )
-        array_dict[key] = np.stack(list_arrays, axis=1)
+        array_dict[key] = np.stack(list_arrays, axis=-1)
 
         # squeeze only last dimension if it is 1
-        if array_dict[key].shape[-1] == 1:
-            array_dict[key] = array_dict[key].squeeze(axis=-1)
+        if array_dict[key].shape[1] == 1:
+            array_dict[key] = array_dict[key].squeeze(axis=1)
 
     # Transform position_array to represent centroid of bbox,
     # rather than top-left corner
@@ -677,21 +677,21 @@ def _ds_from_valid_data(data: ValidBboxesDataset) -> xr.Dataset:
         time_unit = "seconds"
 
     # Convert data to an xarray.Dataset
-    # with dimensions ('time', 'individuals', 'space')
+    # with dimensions ('time', 'space', 'individuals')
     DIM_NAMES = ValidBboxesDataset.DIM_NAMES
-    n_space = data.position_array.shape[-1]
+    n_space = data.position_array.shape[1]
     return xr.Dataset(
         data_vars={
             "position": xr.DataArray(data.position_array, dims=DIM_NAMES),
             "shape": xr.DataArray(data.shape_array, dims=DIM_NAMES),
             "confidence": xr.DataArray(
-                data.confidence_array, dims=DIM_NAMES[:-1]
+                data.confidence_array, dims=DIM_NAMES[:1] + DIM_NAMES[2:]
             ),
         },
         coords={
             DIM_NAMES[0]: time_coords,
-            DIM_NAMES[1]: data.individual_names,
-            DIM_NAMES[2]: ["x", "y", "z"][:n_space],
+            DIM_NAMES[1]: ["x", "y", "z"][:n_space],
+            DIM_NAMES[2]: data.individual_names,
         },
         attrs={
             "fps": data.fps,
diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py
index 4255607..745f64c 100644
--- a/movement/io/load_poses.py
+++ b/movement/io/load_poses.py
@@ -31,11 +31,11 @@ def from_numpy(
     Parameters
     ----------
     position_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_keypoints, n_space)
+        Array of shape (n_frames, n_space, n_keypoints, n_individuals)
         containing the poses. It will be converted to a
         :class:`xarray.DataArray` object named "position".
     confidence_array : np.ndarray, optional
-        Array of shape (n_frames, n_individuals, n_keypoints) containing
+        Array of shape (n_frames, n_keypoints, n_individuals) containing
         the point-wise confidence scores. It will be converted to a
         :class:`xarray.DataArray` object named "confidence".
         If None (default), the scores will be set to an array of NaNs.
@@ -70,7 +70,7 @@ def from_numpy(
     >>> import numpy as np
     >>> from movement.io import load_poses
     >>> ds = load_poses.from_numpy(
-    ...     position_array=np.random.rand((100, 2, 3, 2)),
+    ...     position_array=np.random.rand(100, 2, 3, 2),
     ...     confidence_array=np.ones((100, 2, 3)),
     ...     individual_names=["Alice", "Bob"],
     ...     keypoint_names=["snout", "centre", "tail_base"],
@@ -156,7 +156,7 @@ def from_dlc_style_df(
         be formatted as in DeepLabCut output files (see Notes).
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
     source_software : str, optional
         Name of the pose estimation software from which the data originate.
         Defaults to "DeepLabCut", but it can also be "LightningPose"
@@ -189,20 +189,19 @@ def from_dlc_style_df(
         )
     else:
         individual_names = ["individual_0"]
-
     keypoint_names = (
         df.columns.get_level_values("bodyparts").unique().to_list()
     )
-
-    # reshape the data into (n_frames, n_individuals, n_keypoints, 3)
-    # where the last axis contains "x", "y", "likelihood"
-    tracks_with_scores = df.to_numpy().reshape(
-        (-1, len(individual_names), len(keypoint_names), 3)
+    # reshape the data into (n_frames, 3, n_keypoints, n_individuals)
+    # where the second axis contains "x", "y", "likelihood"
+    tracks_with_scores = (
+        df.to_numpy()
+        .reshape((-1, len(individual_names), len(keypoint_names), 3))
+        .transpose(0, 3, 2, 1)
     )
-
     return from_numpy(
-        position_array=tracks_with_scores[:, :, :, :-1],
-        confidence_array=tracks_with_scores[:, :, :, -1],
+        position_array=tracks_with_scores[:, :-1, :, :],
+        confidence_array=tracks_with_scores[:, -1, :, :],
         individual_names=individual_names,
         keypoint_names=keypoint_names,
         fps=fps,
@@ -223,7 +222,7 @@ def from_sleap_file(
         also be supplied (but this feature is experimental, see Notes).
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
 
     Returns
     -------
@@ -271,16 +270,13 @@ def from_sleap_file(
         expected_permission="r",
         expected_suffix=[".h5", ".slp"],
     )
-
     # Load and validate data
     if file.path.suffix == ".h5":
         ds = _ds_from_sleap_analysis_file(file.path, fps=fps)
     else:  # file.path.suffix == ".slp"
         ds = _ds_from_sleap_labels_file(file.path, fps=fps)
-
     # Add metadata as attrs
     ds.attrs["source_file"] = file.path.as_posix()
-
     logger.info(f"Loaded pose tracks from {file.path}:")
     logger.info(ds)
     return ds
@@ -297,7 +293,7 @@ def from_lp_file(
         Path to the file containing the predicted poses, in .csv format.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
 
     Returns
     -------
@@ -328,7 +324,7 @@ def from_dlc_file(
         or .csv format.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
 
     Returns
     -------
@@ -366,7 +362,7 @@ def from_multiview_files(
         The source software of the file.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
 
     Returns
     -------
@@ -377,12 +373,10 @@ def from_multiview_files(
     """
     views_list = list(file_path_dict.keys())
     new_coord_views = xr.DataArray(views_list, dims="view")
-
     dataset_list = [
         from_file(f, source_software=source_software, fps=fps)
         for f in file_path_dict.values()
     ]
-
     return xr.concat(dataset_list, dim=new_coord_views)
 
 
@@ -402,7 +396,7 @@ def _ds_from_lp_or_dlc_file(
         The source software of the file.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame numbers.
+        the ``time`` coordinates will be in frame numbers.
 
     Returns
     -------
@@ -414,24 +408,20 @@ def _ds_from_lp_or_dlc_file(
     expected_suffix = [".csv"]
     if source_software == "DeepLabCut":
         expected_suffix.append(".h5")
-
     file = ValidFile(
         file_path, expected_permission="r", expected_suffix=expected_suffix
     )
-
     # Load the DeepLabCut poses into a DataFrame
-    if file.path.suffix == ".csv":
-        df = _df_from_dlc_csv(file.path)
-    else:  # file.path.suffix == ".h5"
-        df = _df_from_dlc_h5(file.path)
-
+    df = (
+        _df_from_dlc_csv(file.path)
+        if file.path.suffix == ".csv"
+        else _df_from_dlc_h5(file.path)
+    )
     logger.debug(f"Loaded poses from {file.path} into a DataFrame.")
     # Convert the DataFrame to an xarray dataset
     ds = from_dlc_style_df(df=df, fps=fps, source_software=source_software)
-
     # Add metadata as attrs
     ds.attrs["source_file"] = file.path.as_posix()
-
     logger.info(f"Loaded pose tracks from {file.path}:")
     logger.info(ds)
     return ds
@@ -448,7 +438,7 @@ def _ds_from_sleap_analysis_file(
         Path to the SLEAP analysis file containing predicted pose tracks.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame units.
+        the ``time`` coordinates will be in frame units.
 
     Returns
     -------
@@ -458,12 +448,11 @@ def _ds_from_sleap_analysis_file(
 
     """
     file = ValidHDF5(file_path, expected_datasets=["tracks"])
-
     with h5py.File(file.path, "r") as f:
-        # transpose to shape: (n_frames, n_tracks, n_keypoints, n_space)
-        tracks = f["tracks"][:].transpose((3, 0, 2, 1))
+        # Transpose to shape: (n_frames, n_space, n_keypoints, n_tracks)
+        tracks = f["tracks"][:].transpose(3, 1, 2, 0)
         # Create an array of NaNs for the confidence scores
-        scores = np.full(tracks.shape[:-1], np.nan)
+        scores = np.full(tracks.shape[:1] + tracks.shape[2:], np.nan)
         individual_names = [n.decode() for n in f["track_names"][:]] or None
         if individual_names is None:
             log_warning(
@@ -472,9 +461,9 @@ def _ds_from_sleap_analysis_file(
                 "default individual name."
             )
         # If present, read the point-wise scores,
-        # and transpose to shape: (n_frames, n_tracks, n_keypoints)
+        # and transpose to shape: (n_frames, n_keypoints, n_tracks)
         if "point_scores" in f:
-            scores = f["point_scores"][:].transpose((2, 0, 1))
+            scores = f["point_scores"][:].T
         return from_numpy(
             position_array=tracks.astype(np.float32),
             confidence_array=scores.astype(np.float32),
@@ -496,7 +485,7 @@ def _ds_from_sleap_labels_file(
         Path to the SLEAP labels file containing predicted pose tracks.
     fps : float, optional
         The number of frames per second in the video. If None (default),
-        the `time` coordinates will be in frame units.
+        the ``time`` coordinates will be in frame units.
 
     Returns
     -------
@@ -516,8 +505,8 @@ def _ds_from_sleap_labels_file(
             "default individual name."
         )
     return from_numpy(
-        position_array=tracks_with_scores[:, :, :, :-1],
-        confidence_array=tracks_with_scores[:, :, :, -1],
+        position_array=tracks_with_scores[:, :-1, :, :],
+        confidence_array=tracks_with_scores[:, -1, :, :],
         individual_names=individual_names,
         keypoint_names=[kp.name for kp in labels.skeletons[0].nodes],
         fps=fps,
@@ -538,7 +527,8 @@ def _sleap_labels_to_numpy(labels: Labels) -> np.ndarray:
     Returns
     -------
     numpy.ndarray
-        A NumPy array containing pose tracks and confidence scores.
+        A NumPy array containing pose tracks and confidence scores,
+        with shape ``(n_frames, 3, n_nodes, n_tracks)``.
 
     Notes
     -----
@@ -568,7 +558,7 @@ def _sleap_labels_to_numpy(labels: Labels) -> np.ndarray:
     skeleton = labels.skeletons[-1]  # Assume project only uses last skeleton
     n_nodes = len(skeleton.nodes)
     n_frames = int(last_frame - first_frame + 1)
-    tracks = np.full((n_frames, n_tracks, n_nodes, 3), np.nan, dtype="float32")
+    tracks = np.full((n_frames, 3, n_nodes, n_tracks), np.nan, dtype="float32")
 
     for lf in lfs:
         i = int(lf.frame_idx - first_frame)
@@ -584,12 +574,12 @@ def _sleap_labels_to_numpy(labels: Labels) -> np.ndarray:
             # Use user-labelled instance if available
             if user_track_instances:
                 inst = user_track_instances[-1]
-                tracks[i, j] = np.hstack(
+                tracks[i, ..., j] = np.hstack(
                     (inst.numpy(), np.full((n_nodes, 1), np.nan))
-                )
+                ).T
             elif predicted_track_instances:
                 inst = predicted_track_instances[-1]
-                tracks[i, j] = inst.numpy(scores=True)
+                tracks[i, ..., j] = inst.numpy(scores=True).T
     return tracks
 
 
@@ -613,7 +603,6 @@ def _df_from_dlc_csv(file_path: Path) -> pd.DataFrame:
 
     """
     file = ValidDeepLabCutCSV(file_path)
-
     possible_level_names = ["scorer", "individuals", "bodyparts", "coords"]
     with open(file.path) as f:
         # if line starts with a possible level name, split it into a list
@@ -623,14 +612,12 @@ def _df_from_dlc_csv(file_path: Path) -> pd.DataFrame:
             for line in f.readlines()
             if line.split(",")[0] in possible_level_names
         ]
-
     # Form multi-index column names from the header lines
     level_names = [line[0] for line in header_lines]
     column_tuples = list(
         zip(*[line[1:] for line in header_lines], strict=False)
     )
     columns = pd.MultiIndex.from_tuples(column_tuples, names=level_names)
-
     # Import the DeepLabCut poses as a DataFrame
     df = pd.read_csv(
         file.path,
@@ -679,29 +666,27 @@ def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset:
 
     """
     n_frames = data.position_array.shape[0]
-    n_space = data.position_array.shape[-1]
-
+    n_space = data.position_array.shape[1]
     # Create the time coordinate, depending on the value of fps
     time_coords = np.arange(n_frames, dtype=int)
     time_unit = "frames"
     if data.fps is not None:
         time_coords = time_coords / data.fps
         time_unit = "seconds"
-
     DIM_NAMES = ValidPosesDataset.DIM_NAMES
     # Convert data to an xarray.Dataset
     return xr.Dataset(
         data_vars={
             "position": xr.DataArray(data.position_array, dims=DIM_NAMES),
             "confidence": xr.DataArray(
-                data.confidence_array, dims=DIM_NAMES[:-1]
+                data.confidence_array, dims=DIM_NAMES[:1] + DIM_NAMES[2:]
             ),
         },
         coords={
             DIM_NAMES[0]: time_coords,
-            DIM_NAMES[1]: data.individual_names,
+            DIM_NAMES[1]: ["x", "y", "z"][:n_space],
             DIM_NAMES[2]: data.keypoint_names,
-            DIM_NAMES[3]: ["x", "y", "z"][:n_space],
+            DIM_NAMES[3]: data.individual_names,
         },
         attrs={
             "fps": data.fps,
diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py
index c47d28f..6a58545 100644
--- a/movement/io/save_poses.py
+++ b/movement/io/save_poses.py
@@ -38,11 +38,13 @@ def _ds_to_dlc_style_df(
     tracks_with_scores = np.concatenate(
         (
             ds.position.data,
-            ds.confidence.data[..., np.newaxis],
+            ds.confidence.data[:, np.newaxis, ...],
         ),
-        axis=-1,
+        axis=1,
     )
-
+    # Reverse the order of the dimensions except for the time dimension
+    transpose_order = [0] + list(range(tracks_with_scores.ndim - 1, 0, -1))
+    tracks_with_scores = tracks_with_scores.transpose(transpose_order)
     # Create DataFrame with multi-index columns
     df = pd.DataFrame(
         data=tracks_with_scores.reshape(ds.sizes["time"], -1),
@@ -50,7 +52,6 @@ def _ds_to_dlc_style_df(
         columns=columns,
         dtype=float,
     )
-
     return df
 
 
@@ -320,9 +321,9 @@ def to_sleap_analysis_file(ds: xr.Dataset, file_path: str | Path) -> None:
     n_frames = frame_idxs[-1] - frame_idxs[0] + 1
     pos_x = ds.position.sel(space="x").values
     # Mask denoting which individuals are present in each frame
-    track_occupancy = (~np.all(np.isnan(pos_x), axis=2)).astype(int)
-    tracks = np.transpose(ds.position.data, (1, 3, 2, 0))
-    point_scores = np.transpose(ds.confidence.data, (1, 2, 0))
+    track_occupancy = (~np.all(np.isnan(pos_x), axis=1)).astype(int)
+    tracks = ds.position.data.transpose(3, 1, 2, 0)
+    point_scores = ds.confidence.data.T
     instance_scores = np.full((n_individuals, n_frames), np.nan, dtype=float)
     tracking_scores = np.full((n_individuals, n_frames), np.nan, dtype=float)
     labels_path = (
diff --git a/movement/kinematics.py b/movement/kinematics.py
index 12e1514..7d828e5 100644
--- a/movement/kinematics.py
+++ b/movement/kinematics.py
@@ -273,38 +273,32 @@ def compute_forward_vector(
             "Input data must have exactly 2 spatial dimensions, but "
             f"currently has {len(data.space)}.",
         )
-
     # Validate input keypoints
     if left_keypoint == right_keypoint:
         raise log_error(
             ValueError, "The left and right keypoints may not be identical."
         )
-
     # Define right-to-left vector
     right_to_left_vector = data.sel(
         keypoints=left_keypoint, drop=True
     ) - data.sel(keypoints=right_keypoint, drop=True)
-
     # Define upward vector
     # default: negative z direction in the image coordinate system
-    if camera_view == "top_down":
-        upward_vector = np.array([0, 0, -1])
-    else:
-        upward_vector = np.array([0, 0, 1])
-
+    upward_vector = (
+        np.array([0, 0, -1])
+        if camera_view == "top_down"
+        else np.array([0, 0, 1])
+    )
     upward_vector = xr.DataArray(
         np.tile(upward_vector.reshape(1, -1), [len(data.time), 1]),
         dims=["time", "space"],
     )
-
     # Compute forward direction as the cross product
     # (right-to-left) cross (forward) = up
     forward_vector = xr.cross(
         right_to_left_vector, upward_vector, dim="space"
     )[:, :, :-1]  # keep only the first 2 dimensions of the result
-
     # Return unit vector
-
     return forward_vector / compute_norm(forward_vector)
 
 
@@ -780,7 +774,6 @@ def compute_path_length(
                 time=slice(1, None)
             )  # skip first displacement (always 0)
         ).sum(dim="time", min_count=1)  # return NaN if no valid segment
-
     elif nan_policy == "scale":
         return _compute_scaled_path_length(data)
     else:
@@ -815,7 +808,6 @@ def _warn_about_nan_proportion(
             ValueError,
             "nan_warn_threshold must be between 0 and 1.",
         )
-
     n_nans = data.isnull().any(dim="space").sum(dim="time")
     data_to_warn_about = data.where(
         n_nans > data.sizes["time"] * nan_warn_threshold, drop=True
diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py
index eb1ca97..6e1dc06 100644
--- a/movement/validators/datasets.py
+++ b/movement/validators/datasets.py
@@ -76,10 +76,11 @@ class ValidPosesDataset:
     The validator ensures that within the ``movement poses`` dataset:
 
     - The required ``position_array`` is a numpy array
-      with the last dimension containing 2 or 3 spatial coordinates.
+      with the ``space`` dimension containing 2 or 3 spatial coordinates.
     - The optional ``confidence_array``, if provided, is a numpy array
-      with its shape matching the first three dimensions of the
-      ``position_array``; otherwise, it defaults to an array of NaNs.
+      with its shape matching that of the ``position_array``,
+      excluding the ``space`` dimension;
+      otherwise, it defaults to an array of NaNs.
     - The optional ``individual_names`` and ``keypoint_names``,
       if provided, match the number of individuals and keypoints
       in the dataset, respectively; otherwise, default names are assigned.
@@ -90,10 +91,10 @@ class ValidPosesDataset:
     Attributes
     ----------
     position_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_keypoints, n_space)
+        Array of shape (n_frames, n_space, n_keypoints, n_individuals)
         containing the poses.
     confidence_array : np.ndarray, optional
-        Array of shape (n_frames, n_individuals, n_keypoints) containing
+        Array of shape (n_frames, n_keypoints, n_individuals) containing
         the point-wise confidence scores.
         If None (default), the scores will be set to an array of NaNs.
     individual_names : list of str, optional
@@ -143,32 +144,39 @@ class ValidPosesDataset:
     )
 
     # Class variables
-    DIM_NAMES: ClassVar[tuple] = ("time", "individuals", "keypoints", "space")
+    DIM_NAMES: ClassVar[tuple] = ("time", "space", "keypoints", "individuals")
     VAR_NAMES: ClassVar[tuple] = ("position", "confidence")
 
     # Add validators
     @position_array.validator
     def _validate_position_array(self, attribute, value):
         _validate_type_ndarray(value)
-        if value.ndim != 4:
+        n_dims = value.ndim
+        if n_dims != 4:
             raise log_error(
                 ValueError,
                 f"Expected '{attribute.name}' to have 4 dimensions, "
-                f"but got {value.ndim}.",
+                f"but got {n_dims}.",
             )
-        if value.shape[-1] not in [2, 3]:
+        space_dim_shape = value.shape[1]
+        if space_dim_shape not in [2, 3]:
             raise log_error(
                 ValueError,
                 f"Expected '{attribute.name}' to have 2 or 3 spatial "
-                f"dimensions, but got {value.shape[-1]}.",
+                f"dimensions, but got {space_dim_shape}.",
             )
 
     @confidence_array.validator
     def _validate_confidence_array(self, attribute, value):
         if value is not None:
             _validate_type_ndarray(value)
+            # Expected shape is the same as position_array,
+            # but without the `space` dim
+            expected_shape = (
+                self.position_array.shape[:1] + self.position_array.shape[2:]
+            )
             _validate_array_shape(
-                attribute, value, expected_shape=self.position_array.shape[:-1]
+                attribute, value, expected_shape=expected_shape
             )
 
     @individual_names.validator
@@ -178,7 +186,7 @@ class ValidPosesDataset:
             _validate_list_length(attribute, value, 1)
         else:
             _validate_list_length(
-                attribute, value, self.position_array.shape[1]
+                attribute, value, self.position_array.shape[-1]
             )
 
     @keypoint_names.validator
@@ -187,9 +195,12 @@ class ValidPosesDataset:
 
     def __attrs_post_init__(self):
         """Assign default values to optional attributes (if None)."""
+        position_array_shape = self.position_array.shape
         if self.confidence_array is None:
             self.confidence_array = np.full(
-                (self.position_array.shape[:-1]), np.nan, dtype="float32"
+                position_array_shape[:1] + position_array_shape[2:],
+                np.nan,
+                dtype="float32",
             )
             log_warning(
                 "Confidence array was not provided."
@@ -197,7 +208,7 @@ class ValidPosesDataset:
             )
         if self.individual_names is None:
             self.individual_names = [
-                f"individual_{i}" for i in range(self.position_array.shape[1])
+                f"individual_{i}" for i in range(position_array_shape[-1])
             ]
             log_warning(
                 "Individual names were not provided. "
@@ -205,7 +216,7 @@ class ValidPosesDataset:
             )
         if self.keypoint_names is None:
             self.keypoint_names = [
-                f"keypoint_{i}" for i in range(self.position_array.shape[2])
+                f"keypoint_{i}" for i in range(position_array_shape[2])
             ]
             log_warning(
                 "Keypoint names were not provided. "
@@ -221,10 +232,11 @@ class ValidBboxesDataset:
     within the ``movement bboxes`` dataset:
 
     - The required ``position_array`` and ``shape_array`` are numpy arrays,
-      with the last dimension containing 2 spatial coordinates.
+      with the ``space`` dimension containing 2 spatial coordinates.
     - The optional ``confidence_array``, if provided, is a numpy array
-      with its shape matching the first two dimensions of the
-      ``position_array``; otherwise, it defaults to an array of NaNs.
+      with its shape matching that of the ``position_array``,
+      excluding the ``space`` dimension;
+      otherwise, it defaults to an array of NaNs.
     - The optional ``individual_names``, if provided, match the number of
       individuals in the dataset; otherwise, default names are assigned.
     - The optional ``frame_array``, if provided, is a column vector
@@ -237,10 +249,10 @@ class ValidBboxesDataset:
     Attributes
     ----------
     position_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_space)
+        Array of shape (n_frames, n_space, n_individuals)
         containing the tracks of the bounding boxes' centroids.
     shape_array : np.ndarray
-        Array of shape (n_frames, n_individuals, n_space)
+        Array of shape (n_frames, n_space, n_individuals)
         containing the shape of the bounding boxes. The shape of a bounding
         box is its width (extent along the x-axis of the image) and height
         (extent along the y-axis of the image).
@@ -297,7 +309,7 @@ class ValidBboxesDataset:
         validator=validators.optional(validators.instance_of(str)),
     )
 
-    DIM_NAMES: ClassVar[tuple] = ("time", "individuals", "space")
+    DIM_NAMES: ClassVar[tuple] = ("time", "space", "individuals")
     VAR_NAMES: ClassVar[tuple] = ("position", "shape", "confidence")
 
     # Validators
@@ -305,20 +317,22 @@ class ValidBboxesDataset:
     @shape_array.validator
     def _validate_position_and_shape_arrays(self, attribute, value):
         _validate_type_ndarray(value)
-        # check last dimension (spatial) has 2 coordinates
+        # check `space` dim (at idx 1) has 2 coordinates
         n_expected_spatial_coordinates = 2
-        if value.shape[-1] != n_expected_spatial_coordinates:
+        n_spatial_coordinates = value.shape[1]
+        if n_spatial_coordinates != n_expected_spatial_coordinates:
             raise log_error(
                 ValueError,
-                f"Expected '{attribute.name}' to have 2 spatial coordinates, "
-                f"but got {value.shape[-1]}.",
+                f"Expected '{attribute.name}' to have "
+                f"{n_expected_spatial_coordinates} spatial coordinates, "
+                f"but got {n_spatial_coordinates}.",
             )
 
     @individual_names.validator
     def _validate_individual_names(self, attribute, value):
         if value is not None:
             _validate_list_length(
-                attribute, value, self.position_array.shape[1]
+                attribute, value, self.position_array.shape[-1]
             )
             # check n_individual_names are unique
             # NOTE: combined with the requirement above, we are enforcing
@@ -335,8 +349,13 @@ class ValidBboxesDataset:
     def _validate_confidence_array(self, attribute, value):
         if value is not None:
             _validate_type_ndarray(value)
+            # Expected shape is the same as position_array,
+            # but without the `space` dim
+            expected_shape = (
+                self.position_array.shape[:1] + self.position_array.shape[2:]
+            )
             _validate_array_shape(
-                attribute, value, expected_shape=self.position_array.shape[:-1]
+                attribute, value, expected_shape=expected_shape
             )
 
     @frame_array.validator
@@ -365,10 +384,11 @@ class ValidBboxesDataset:
         If no individual names are provided, assign them unique IDs per frame,
         starting with 0 ("id_0").
         """
+        position_array_shape = self.position_array.shape
         # assign default confidence_array
         if self.confidence_array is None:
             self.confidence_array = np.full(
-                (self.position_array.shape[:-1]),
+                position_array_shape[:1] + position_array_shape[2:],
                 np.nan,
                 dtype="float32",
             )
@@ -379,7 +399,7 @@ class ValidBboxesDataset:
         # assign default individual_names
         if self.individual_names is None:
             self.individual_names = [
-                f"id_{i}" for i in range(self.position_array.shape[1])
+                f"id_{i}" for i in range(position_array_shape[-1])
             ]
             log_warning(
                 "Individual names for the bounding boxes "
@@ -389,7 +409,7 @@ class ValidBboxesDataset:
             )
         # assign default frame_array
         if self.frame_array is None:
-            n_frames = self.position_array.shape[0]
+            n_frames = position_array_shape[0]
             self.frame_array = np.arange(n_frames).reshape(-1, 1)
             log_warning(
                 "Frame numbers were not provided. "


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 2a78e3a..5443941 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -223,10 +223,10 @@ def valid_bboxes_arrays_all_zeros():
     ValidBboxesDataset.
     """
     # define the shape of the arrays
-    n_frames, n_individuals, n_space = (10, 2, 2)
+    n_frames, n_space, n_individuals = (10, 2, 2)
 
     # build a valid array for position or shape with all zeros
-    valid_bbox_array_all_zeros = np.zeros((n_frames, n_individuals, n_space))
+    valid_bbox_array_all_zeros = np.zeros((n_frames, n_space, n_individuals))
 
     # return as a dict
     return {
@@ -252,21 +252,23 @@ def valid_bboxes_arrays():
     - Individual 1 at frames 2, 3
     """
     # define the shape of the arrays
-    n_frames, n_individuals, n_space = (10, 2, 2)
+    n_frames, n_space, n_individuals = (10, 2, 2)
 
     # build a valid array for position
     # make bbox with id_i move along x=((-1)**(i))*y line from the origin
     # if i is even: along x = y line
     # if i is odd: along x = -y line
     # moving one unit along each axis in each frame
-    position = np.empty((n_frames, n_individuals, n_space))
+    position = np.zeros((n_frames, n_space, n_individuals))
     for i in range(n_individuals):
-        position[:, i, 0] = np.arange(n_frames)
-        position[:, i, 1] = (-1) ** i * np.arange(n_frames)
+        position[:, 0, i] = np.arange(n_frames)
+        position[:, 1, i] = (-1) ** i * np.arange(n_frames)
 
     # build a valid array for constant bbox shape (60, 40)
     constant_shape = (60, 40)  # width, height in pixels
-    shape = np.tile(constant_shape, (n_frames, n_individuals, 1))
+    shape = np.tile(constant_shape, (n_frames, n_individuals, 1)).transpose(
+        0, 2, 1
+    )
 
     # build an array of confidence values, all 0.9
     confidence = np.full((n_frames, n_individuals), 0.9)
@@ -304,12 +306,14 @@ def valid_bboxes_dataset(
         data_vars={
             "position": xr.DataArray(position_array, dims=dim_names),
             "shape": xr.DataArray(shape_array, dims=dim_names),
-            "confidence": xr.DataArray(confidence_array, dims=dim_names[:-1]),
+            "confidence": xr.DataArray(
+                confidence_array, dims=dim_names[:1] + dim_names[2:]
+            ),
         },
         coords={
             dim_names[0]: np.arange(n_frames),
-            dim_names[1]: [f"id_{id}" for id in range(n_individuals)],
-            dim_names[2]: ["x", "y"],
+            dim_names[1]: ["x", "y"],
+            dim_names[2]: [f"id_{id}" for id in range(n_individuals)],
         },
         attrs={
             "fps": None,
@@ -354,10 +358,10 @@ def valid_position_array():
     def _valid_position_array(array_type):
         """Return a valid position array."""
         # Unless specified, default is a multi_individual_array with
-        # 10 frames, 2 individuals, and 2 keypoints.
+        # 10 frames, 2 keypoints, and 2 individuals.
         n_frames = 10
-        n_individuals = 2
         n_keypoints = 2
+        n_individuals = 2
         base = np.arange(n_frames, dtype=float)[
             :, np.newaxis, np.newaxis, np.newaxis
         ]
@@ -365,10 +369,10 @@ def valid_position_array():
             n_keypoints = 1
         elif array_type == "single_individual_array":
             n_individuals = 1
-        x_points = np.repeat(base * base, n_individuals * n_keypoints)
-        y_points = np.repeat(base * 4, n_individuals * n_keypoints)
-        position_array = np.ravel(np.column_stack((x_points, y_points)))
-        return position_array.reshape(n_frames, n_individuals, n_keypoints, 2)
+        x_points = np.repeat(base * base, n_keypoints * n_individuals)
+        y_points = np.repeat(base * 4, n_keypoints * n_individuals)
+        position_array = np.vstack((x_points, y_points))
+        return position_array.reshape(n_frames, 2, n_keypoints, n_individuals)
 
     return _valid_position_array
 
@@ -383,23 +387,25 @@ def valid_poses_dataset(valid_position_array, request):
     except AttributeError:
         array_format = "multi_individual_array"
     position_array = valid_position_array(array_format)
-    n_frames, n_individuals, n_keypoints = position_array.shape[:3]
+    n_frames, n_keypoints, n_individuals = (
+        position_array.shape[:1] + position_array.shape[2:]
+    )
     return xr.Dataset(
         data_vars={
             "position": xr.DataArray(position_array, dims=dim_names),
             "confidence": xr.DataArray(
                 np.repeat(
                     np.linspace(0.1, 1.0, n_frames),
-                    n_individuals * n_keypoints,
-                ).reshape(position_array.shape[:-1]),
-                dims=dim_names[:-1],
+                    n_keypoints * n_individuals,
+                ).reshape(position_array.shape[:1] + position_array.shape[2:]),
+                dims=dim_names[:1] + dim_names[2:],  # exclude "space"
             ),
         },
         coords={
             "time": np.arange(n_frames),
-            "individuals": [f"ind{i}" for i in range(1, n_individuals + 1)],
-            "keypoints": [f"key{i}" for i in range(1, n_keypoints + 1)],
             "space": ["x", "y"],
+            "keypoints": [f"key{i}" for i in range(1, n_keypoints + 1)],
+            "individuals": [f"ind{i}" for i in range(1, n_individuals + 1)],
         },
         attrs={
             "fps": None,
@@ -437,7 +443,7 @@ def valid_poses_array_uniform_linear_motion():
     - Individual 1 at frames 2, 3
     """
     # define the shape of the arrays
-    n_frames, n_individuals, n_keypoints, n_space = (10, 2, 3, 2)
+    n_frames, n_space, n_keypoints, n_individuals = (10, 2, 3, 2)
 
     # define centroid (index=0) trajectory in position array
     # for each individual, the centroid moves along
@@ -446,9 +452,9 @@ def valid_poses_array_uniform_linear_motion():
     # - individual 1 moves along x = -y line
     # They move one unit along x and y axes in each frame
     frames = np.arange(n_frames)
-    position = np.empty((n_frames, n_individuals, n_keypoints, n_space))
-    position[:, :, 0, 0] = frames[:, None]  # reshape to (n_frames, 1)
-    position[:, 0, 0, 1] = frames
+    position = np.zeros((n_frames, n_space, n_keypoints, n_individuals))
+    position[:, 0, 0, :] = frames[:, None]  # reshape to (n_frames, 1)
+    position[:, 1, 0, 0] = frames
     position[:, 1, 0, 1] = -frames
 
     # define trajectory of left and right keypoints
@@ -464,21 +470,21 @@ def valid_poses_array_uniform_linear_motion():
     ]
     for i in range(n_individuals):
         for kpt in range(1, n_keypoints):
-            position[:, i, kpt, 0] = (
-                position[:, i, 0, 0] + offsets[i][kpt - 1][0]
+            position[:, 0, kpt, i] = (
+                position[:, 0, 0, i] + offsets[i][kpt - 1][0]
             )
-            position[:, i, kpt, 1] = (
-                position[:, i, 0, 1] + offsets[i][kpt - 1][1]
+            position[:, 1, kpt, i] = (
+                position[:, 1, 0, i] + offsets[i][kpt - 1][1]
             )
 
     # build an array of confidence values, all 0.9
-    confidence = np.full((n_frames, n_individuals, n_keypoints), 0.9)
+    confidence = np.full((n_frames, n_keypoints, n_individuals), 0.9)
     # set 5 low-confidence values
     # - set 3 confidence values for individual id_0's centroid to 0.1
     # - set 2 confidence values for individual id_1's centroid to 0.1
     idx_start = 2
     confidence[idx_start : idx_start + 3, 0, 0] = 0.1
-    confidence[idx_start : idx_start + 2, 1, 0] = 0.1
+    confidence[idx_start : idx_start + 2, 0, 1] = 0.1
 
     return {"position": position, "confidence": confidence}
 
@@ -495,18 +501,20 @@ def valid_poses_dataset_uniform_linear_motion(
     position_array = valid_poses_array_uniform_linear_motion["position"]
     confidence_array = valid_poses_array_uniform_linear_motion["confidence"]
 
-    n_frames, n_individuals, _, _ = position_array.shape
+    n_frames, _, _, n_individuals = position_array.shape
 
     return xr.Dataset(
         data_vars={
             "position": xr.DataArray(position_array, dims=dim_names),
-            "confidence": xr.DataArray(confidence_array, dims=dim_names[:-1]),
+            "confidence": xr.DataArray(
+                confidence_array, dims=dim_names[:1] + dim_names[2:]
+            ),
         },
         coords={
             dim_names[0]: np.arange(n_frames),
-            dim_names[1]: [f"id_{i}" for i in range(1, n_individuals + 1)],
+            dim_names[1]: ["x", "y"],
             dim_names[2]: ["centroid", "left", "right"],
-            dim_names[3]: ["x", "y"],
+            dim_names[3]: [f"id_{i}" for i in range(1, n_individuals + 1)],
         },
         attrs={
             "fps": None,
@@ -874,3 +882,72 @@ class Helpers:
 def helpers():
     """Return an instance of the ``Helpers`` class."""
     return Helpers
+
+
+# --------- movement dataset assertion fixtures ---------
+class MovementDatasetAsserts:
+    """Class for asserting valid ``movement`` poses or bboxes datasets."""
+
+    @staticmethod
+    def valid_dataset(dataset, expected_values):
+        """Assert the dataset is a proper ``movement`` Dataset.
+
+        Parameters
+        ----------
+        dataset : xr.Dataset
+            The dataset to validate.
+        expected_values : dict
+            A dictionary containing the expected values for the dataset.
+            It must contain the following keys:
+
+            - dim_names: list of expected dimension names as defined in
+              movement.validators.datasets
+            - vars_dims: dictionary of data variable names and the
+              corresponding dimension sizes
+
+            Optional keys include:
+
+            - file_path: Path to the source file
+            - fps: int, frames per second
+            - source_software: str, name of the software used to generate
+              the dataset
+
+        """
+        expected_dim_names = expected_values.get("dim_names")
+        expected_file_path = expected_values.get("file_path")
+        assert isinstance(dataset, xr.Dataset)
+        # Expected variables are present and of right shape/type
+        for var, ndim in expected_values.get("vars_dims").items():
+            data_var = dataset.get(var)
+            assert isinstance(data_var, xr.DataArray)
+            assert data_var.ndim == ndim
+        position_shape = dataset.position.shape
+        # Confidence has the same shape as position, except for the space dim
+        assert (
+            dataset.confidence.shape == position_shape[:1] + position_shape[2:]
+        )
+        # Check the dims and coords
+        expected_dim_length_dict = dict(
+            zip(expected_dim_names, position_shape, strict=True)
+        )
+        assert expected_dim_length_dict == dataset.sizes
+        # Check the coords
+        for dim in expected_dim_names[1:]:
+            assert all(isinstance(s, str) for s in dataset.coords[dim].values)
+        assert all(coord in dataset.coords["space"] for coord in ["x", "y"])
+        # Check the metadata attributes
+        assert dataset.source_file == (
+            expected_file_path.as_posix()
+            if expected_file_path is not None
+            else None
+        )
+        assert dataset.source_software == expected_values.get(
+            "source_software"
+        )
+        assert dataset.fps == expected_values.get("fps")
+
+
+@pytest.fixture
+def movement_dataset_asserts():
+    """Return an instance of the ``MovementDatasetAsserts`` class."""
+    return MovementDatasetAsserts
diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py
index e91f4d6..c1a3ce9 100644
--- a/tests/test_integration/test_kinematics_vector_transform.py
+++ b/tests/test_integration/test_kinematics_vector_transform.py
@@ -72,16 +72,16 @@ def test_cart2pol_transform_on_kinematics(
 
     # Build expected data array
     expected_array_pol = xr.DataArray(
-        np.stack(expected_kinematics_polar, axis=1),
+        np.stack(expected_kinematics_polar, axis=-1),
         # Stack along the "individuals" axis
-        dims=["time", "individuals", "space"],
+        dims=["time", "space", "individuals"],
     )
     if "keypoints" in ds.position.coords:
         expected_array_pol = expected_array_pol.expand_dims(
             {"keypoints": ds.position.coords["keypoints"].size}
         )
         expected_array_pol = expected_array_pol.transpose(
-            "time", "individuals", "keypoints", "space"
+            "time", "space", "keypoints", "individuals"
         )
 
     # Compare the values of the kinematic_array against the expected_array
diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py
index 4a68a23..6e1d3ad 100644
--- a/tests/test_unit/test_kinematics.py
+++ b/tests/test_unit/test_kinematics.py
@@ -86,19 +86,19 @@ def test_kinematics_uniform_linear_motion(
     # and in the final xarray.DataArray
     expected_dims = ["time", "individuals"]
     if kinematic_variable in ["displacement", "velocity", "acceleration"]:
-        expected_dims.append("space")
+        expected_dims.insert(1, "space")
 
     # Build expected data array from the expected numpy array
     expected_array = xr.DataArray(
         # Stack along the "individuals" axis
-        np.stack(expected_kinematics, axis=1),
+        np.stack(expected_kinematics, axis=-1),
         dims=expected_dims,
     )
     if "keypoints" in position.coords:
         expected_array = expected_array.expand_dims(
             {"keypoints": position.coords["keypoints"].size}
         )
-        expected_dims.insert(2, "keypoints")
+        expected_dims.insert(-1, "keypoints")
         expected_array = expected_array.transpose(*expected_dims)
 
     # Compare the values of the kinematic_array against the expected_array
@@ -140,19 +140,15 @@ def test_kinematics_with_dataset_with_nans(
     kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")(
         position
     )
-
     # compute n nans in kinematic array per individual
     n_nans_kinematics_per_indiv = [
         helpers.count_nans(kinematic_array.isel(individuals=i))
         for i in range(valid_dataset.sizes["individuals"])
     ]
-
     # expected nans per individual adjusted for space and keypoints dimensions
-    if "space" in kinematic_array.dims:
-        n_space_dims = position.sizes["space"]
-    else:
-        n_space_dims = 1
-
+    n_space_dims = (
+        position.sizes["space"] if "space" in kinematic_array.dims else 1
+    )
     expected_nans_adjusted = [
         n * n_space_dims * valid_dataset.sizes.get("keypoints", 1)
         for n in expected_nans_per_individual
@@ -263,11 +259,11 @@ def test_path_length_across_time_ranges(
             num_segments -= 9 - np.floor(min(9, stop))
 
         expected_path_length = xr.DataArray(
-            np.ones((2, 3)) * np.sqrt(2) * num_segments,
-            dims=["individuals", "keypoints"],
+            np.ones((3, 2)) * np.sqrt(2) * num_segments,
+            dims=["keypoints", "individuals"],
             coords={
-                "individuals": position.coords["individuals"],
                 "keypoints": position.coords["keypoints"],
+                "individuals": position.coords["individuals"],
             },
         )
         xr.testing.assert_allclose(path_length, expected_path_length)
diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py
index 6509bf2..a15e264 100644
--- a/tests/test_unit/test_load_bboxes.py
+++ b/tests/test_unit/test_load_bboxes.py
@@ -8,7 +8,6 @@ from unittest.mock import patch
 import numpy as np
 import pandas as pd
 import pytest
-import xarray as xr
 
 from movement.io import load_bboxes
 from movement.validators.datasets import ValidBboxesDataset
@@ -23,7 +22,6 @@ def get_expected_attributes_dict():
         the input VIA file.
         """
         attributes_dict_per_test_file = {}
-
         # add expected attributes for the first 3 rows of
         # VIA_single-crab_MOCA-crab-1.csv
         attributes_dict_per_test_file["VIA_single-crab_MOCA-crab-1.csv"] = {
@@ -48,7 +46,6 @@ def get_expected_attributes_dict():
                 "track": np.ones((3,)),
             },
         }
-
         # add expected attributes for the first 3 rows of
         # "VIA_multiple-crabs_5-frames_labels.csv"
         attributes_dict_per_test_file[
@@ -71,7 +68,6 @@ def get_expected_attributes_dict():
                 "track": np.array([71, 70, 69]),
             },
         }
-
         # return ValueError if the filename is not defined
         if via_file_name not in attributes_dict_per_test_file:
             raise ValueError(
@@ -105,11 +101,9 @@ def create_df_input_via_tracks():
         """
         # read the VIA tracks .csv file as a dataframe
         df = pd.read_csv(via_file_path, sep=",", header=0)
-
         # optionally return the first 3 rows only
         if small:
             df = df.loc[:2, :]
-
         # optionally modify the dataframe to include data
         # under an attribute column
         if attribute_column_additions is None:
@@ -130,20 +124,17 @@ def update_attribute_column(
     """Update an attributes column in the dataframe."""
     # copy the dataframe
     df = df_input.copy()
-
     # update each attribute column in the dataframe
     for attribute_column_name in attribute_column_additions:
         # get the list of dicts to append to the column
         list_dicts_to_append = attribute_column_additions[
             attribute_column_name
         ]
-
         # get the column to update, and convert it to a list of dicts
         # (one dict per row)
         attributes_dicts = [
             ast.literal_eval(d) for d in df[attribute_column_name]
         ]
-
         # update each dict in the list
         # (if we only have one dict to append, append it to all rows)
         if len(list_dicts_to_append) == 1:
@@ -154,11 +145,9 @@ def update_attribute_column(
                 attributes_dicts, list_dicts_to_append, strict=True
             ):
                 d.update(dict_to_append)
-
         # update the relevant column in the dataframe and format
         # back to string
         df[attribute_column_name] = [str(d) for d in attributes_dicts]
-
     return df
 
 
@@ -166,8 +155,8 @@ def update_attribute_column(
 def create_valid_from_numpy_inputs():
     """Define a factory of valid inputs to "from_numpy" function."""
     n_frames = 5
-    n_individuals = 86
     n_space = 2
+    n_individuals = 86
     individual_names_array = np.arange(n_individuals).reshape(-1, 1)
     first_frame_number = 1  # should match sample file
 
@@ -176,64 +165,22 @@ def create_valid_from_numpy_inputs():
     def _create_valid_from_numpy_inputs(with_frame_array=False):
         """Return a dictionary of valid inputs to the `from_numpy` function."""
         required_inputs = {
-            "position_array": rng.random((n_frames, n_individuals, n_space)),
-            "shape_array": rng.random((n_frames, n_individuals, n_space)),
+            "position_array": rng.random((n_frames, n_space, n_individuals)),
+            "shape_array": rng.random((n_frames, n_space, n_individuals)),
             "confidence_array": rng.random((n_frames, n_individuals)),
             "individual_names": [
                 f"id_{id}" for id in individual_names_array.squeeze()
             ],
         }
-
         if with_frame_array:
             required_inputs["frame_array"] = np.arange(
                 first_frame_number, first_frame_number + n_frames
             ).reshape(-1, 1)
-
         return required_inputs
 
     return _create_valid_from_numpy_inputs
 
 
-def assert_dataset(
-    dataset, file_path=None, expected_source_software=None, expected_fps=None
-):
-    """Assert that the dataset is a proper ``movement`` Dataset."""
-    assert isinstance(dataset, xr.Dataset)
-
-    # Expected variables are present and of right shape/type
-    for var in ["position", "shape", "confidence"]:
-        assert var in dataset.data_vars
-        assert isinstance(dataset[var], xr.DataArray)
-    assert dataset.position.ndim == 3
-    assert dataset.shape.ndim == 3
-    assert dataset.confidence.shape == dataset.position.shape[:-1]
-
-    # Check the dims and coords
-    DIM_NAMES = ValidBboxesDataset.DIM_NAMES
-    assert all([i in dataset.dims for i in DIM_NAMES])
-    for d, dim in enumerate(DIM_NAMES[1:]):
-        assert dataset.sizes[dim] == dataset.position.shape[d + 1]
-        assert all([isinstance(s, str) for s in dataset.coords[dim].values])
-    assert all([i in dataset.coords["space"] for i in ["x", "y"]])
-
-    # Check the metadata attributes
-    assert (
-        dataset.source_file is None
-        if file_path is None
-        else dataset.source_file == file_path.as_posix()
-    )
-    assert (
-        dataset.source_software is None
-        if expected_source_software is None
-        else dataset.source_software == expected_source_software
-    )
-    assert (
-        dataset.fps is None
-        if expected_fps is None
-        else dataset.fps == expected_fps
-    )
-
-
 def assert_time_coordinates(ds, fps, start_frame=None, frame_array=None):
     """Assert that the time coordinates are as expected, depending on
     fps value and start_frame or time_array. start_frame takes precedence
@@ -241,7 +188,6 @@ def assert_time_coordinates(ds, fps, start_frame=None, frame_array=None):
     """
     # scale time coordinates with 1/fps if provided
     scale = 1 / fps if fps else 1
-
     # build frame array from start_frame if provided
     if start_frame is not None:
         frame_array = np.array(
@@ -253,7 +199,6 @@ def assert_time_coordinates(ds, fps, start_frame=None, frame_array=None):
             "start_frame takes precedence over frame_array if "
             "both are provided."
         )
-
     # assert numpy array of time coordinates
     np.testing.assert_allclose(
         ds.coords["time"].data, np.array([f * scale for f in frame_array])
@@ -273,7 +218,6 @@ def test_from_file(
     software_to_loader = {
         "VIA-tracks": "movement.io.load_bboxes.from_via_tracks_file",
     }
-
     if source_software == "Unknown":
         with pytest.raises(ValueError, match="Unsupported source"):
             load_bboxes.from_file(
@@ -300,6 +244,12 @@ def test_from_file(
             )
 
 
+expected_values_bboxes = {
+    "vars_dims": {"position": 3, "shape": 3, "confidence": 2},
+    "dim_names": ValidBboxesDataset.DIM_NAMES,
+}
+
+
 @pytest.mark.parametrize(
     "via_file_path",
     [
@@ -315,6 +265,7 @@ def test_from_via_tracks_file(
     fps,
     use_frame_numbers_from_file,
     frame_regexp,
+    movement_dataset_asserts,
 ):
     """Test that loading tracked bounding box data from
     a valid VIA tracks .csv file returns a proper Dataset.
@@ -326,7 +277,13 @@ def test_from_via_tracks_file(
         **({"frame_regexp": frame_regexp} if frame_regexp is not None else {}),
     }
     ds = load_bboxes.from_via_tracks_file(**kwargs)
-    assert_dataset(ds, via_file_path, "VIA-tracks", fps)
+    expected_values = {
+        **expected_values_bboxes,
+        "source_software": "VIA-tracks",
+        "fps": fps,
+        "file_path": via_file_path,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
 
 
 @pytest.mark.parametrize(
@@ -385,28 +342,31 @@ def test_from_numpy(
     with_frame_array,
     fps,
     source_software,
+    movement_dataset_asserts,
 ):
     """Test that loading bounding boxes trajectories from the input
     numpy arrays returns a proper Dataset.
     """
     # get the input arrays
     from_numpy_inputs = create_valid_from_numpy_inputs(with_frame_array)
-
     # run general dataset checks
     ds = load_bboxes.from_numpy(
         **from_numpy_inputs,
         fps=fps,
         source_software=source_software,
     )
-    assert_dataset(
-        ds, expected_source_software=source_software, expected_fps=fps
-    )
-
+    expected_values = {
+        **expected_values_bboxes,
+        "source_software": source_software,
+        "fps": fps,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
     # check time coordinates are as expected
-    if "frame_array" in from_numpy_inputs:
-        start_frame = from_numpy_inputs["frame_array"][0, 0]
-    else:
-        start_frame = 0
+    start_frame = (
+        from_numpy_inputs["frame_array"][0, 0]
+        if "frame_array" in from_numpy_inputs
+        else 0
+    )
     assert_time_coordinates(ds, fps, start_frame)
 
 
@@ -461,7 +421,6 @@ def test_via_attribute_column_to_numpy(
         list_keys=list_keys,
         cast_fn=cast_fn,
     )
-
     attributes_dict = get_expected_attributes_dict(
         via_file_path.name
     )  # returns results for the first 3 rows
@@ -516,9 +475,7 @@ def test_extract_confidence_from_via_tracks_df(
         )
     else:
         df = create_df_input_via_tracks(via_file_path, small=True)
-
     confidence_array = load_bboxes._extract_confidence_from_via_tracks_df(df)
-
     assert np.array_equal(
         confidence_array, expected_confidence_array, equal_nan=True
     )
@@ -550,15 +507,12 @@ def test_extract_frame_number_from_via_tracks_df_filenames(
         via_file_path,
         small=True,
     )
-
     # the VIA tracks .csv files have no frames defined under the
     # "file_attributes" so the frame numbers should be extracted
     # from the filenames
     assert not all(["frame" in row for row in df["file_attributes"]])
-
     # extract frame number from df
     frame_array = load_bboxes._extract_frame_number_from_via_tracks_df(df)
-
     assert np.array_equal(frame_array, expected_frame_array)
 
 
@@ -605,11 +559,9 @@ def test_extract_frame_number_from_via_tracks_df_file_attributes(
         small=True,
         attribute_column_additions=attribute_column_additions,
     )
-
     # extract frame number from the dataframe
     # (should take precedence over the frame numbers in the filenames)
     frame_array = load_bboxes._extract_frame_number_from_via_tracks_df(df)
-
     assert np.array_equal(frame_array, expected_frame_array)
 
 
@@ -655,16 +607,13 @@ def test_fps_and_time_coords(
         fps=fps,
         use_frame_numbers_from_file=use_frame_numbers_from_file,
     )
-
     # check time unit
     assert ds.time_unit == expected_time_unit
-
     # check fps is as expected
     if bool(expected_fps):
         assert ds.fps == expected_fps
     else:
         assert ds.fps is None
-
     # check loading frame numbers from file
     if use_frame_numbers_from_file:
         assert_time_coordinates(
@@ -691,10 +640,7 @@ def test_df_from_via_tracks_file(
     """Test that the `_df_from_via_tracks_file` helper function correctly
     reads the VIA tracks .csv file as a dataframe.
     """
-    df = load_bboxes._df_from_via_tracks_file(
-        via_file_path,
-    )
-
+    df = load_bboxes._df_from_via_tracks_file(via_file_path)
     assert isinstance(df, pd.DataFrame)
     assert len(df.frame_number.unique()) == expected_n_frames
     assert len(df.ID.unique()) == expected_n_individuals
@@ -727,10 +673,8 @@ def test_position_numpy_array_from_via_tracks_file(via_file_path):
     bboxes_arrays = load_bboxes._numpy_arrays_from_via_tracks_file(
         via_file_path
     )
-
     # Read VIA tracks .csv file as a dataframe
     df = load_bboxes._df_from_via_tracks_file(via_file_path)
-
     # Compute centroid positions from the dataframe
     # (go through in the same order as ID array)
     list_derived_centroids = []
@@ -740,9 +684,8 @@ def test_position_numpy_array_from_via_tracks_file(via_file_path):
             [df_one_id.x + df_one_id.w / 2, df_one_id.y + df_one_id.h / 2]
         ).T  # frames, xy
         list_derived_centroids.append(centroid_position)
-
     # Compare to extracted position array
     assert np.allclose(
-        bboxes_arrays["position_array"],  # frames, individuals, xy
-        np.stack(list_derived_centroids, axis=1),
+        bboxes_arrays["position_array"],  # frames, xy, individuals
+        np.stack(list_derived_centroids, axis=-1),
     )
diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py
index b5145fc..91bee07 100644
--- a/tests/test_unit/test_load_poses.py
+++ b/tests/test_unit/test_load_poses.py
@@ -1,3 +1,5 @@
+"""Test suite for the load_poses module."""
+
 from unittest.mock import patch
 
 import h5py
@@ -12,308 +14,307 @@ from movement.io import load_poses
 from movement.validators.datasets import ValidPosesDataset
 
 
-class TestLoadPoses:
-    """Test suite for the load_poses module."""
-
-    @pytest.fixture
-    def sleap_slp_file_without_tracks(self, tmp_path):
-        """Mock and return the path to a SLEAP .slp file without tracks."""
-        sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.predictions.slp")
-        labels = read_labels(sleap_file)
-        file_path = tmp_path / "track_is_none.slp"
-        lfs = []
-        for lf in labels.labeled_frames:
-            instances = []
-            for inst in lf.instances:
-                inst.track = None
-                inst.tracking_score = 0
-                instances.append(inst)
-            lfs.append(
-                LabeledFrame(
-                    video=lf.video, frame_idx=lf.frame_idx, instances=instances
-                )
+@pytest.fixture
+def sleap_slp_file_without_tracks(tmp_path):
+    """Mock and return the path to a SLEAP .slp file without tracks."""
+    sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.predictions.slp")
+    labels = read_labels(sleap_file)
+    file_path = tmp_path / "track_is_none.slp"
+    lfs = []
+    for lf in labels.labeled_frames:
+        instances = []
+        for inst in lf.instances:
+            inst.track = None
+            inst.tracking_score = 0
+            instances.append(inst)
+        lfs.append(
+            LabeledFrame(
+                video=lf.video, frame_idx=lf.frame_idx, instances=instances
             )
-        write_labels(
-            file_path,
-            Labels(
-                labeled_frames=lfs,
-                videos=labels.videos,
-                skeletons=labels.skeletons,
-            ),
         )
-        return file_path
-
-    @pytest.fixture
-    def sleap_h5_file_without_tracks(self, tmp_path):
-        """Mock and return the path to a SLEAP .h5 file without tracks."""
-        sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5")
-        file_path = tmp_path / "track_is_none.h5"
-        with h5py.File(sleap_file, "r") as f1, h5py.File(file_path, "w") as f2:
-            for key in list(f1.keys()):
-                if key == "track_names":
-                    f2.create_dataset(key, data=[])
-                else:
-                    f1.copy(key, f2, name=key)
-        return file_path
-
-    @pytest.fixture(
-        params=[
-            "sleap_h5_file_without_tracks",
-            "sleap_slp_file_without_tracks",
-        ]
+    write_labels(
+        file_path,
+        Labels(
+            labeled_frames=lfs,
+            videos=labels.videos,
+            skeletons=labels.skeletons,
+        ),
     )
-    def sleap_file_without_tracks(self, request):
-        """Fixture to parametrize the SLEAP files without tracks."""
-        return request.getfixturevalue(request.param)
-
-    def assert_dataset(
-        self, dataset, file_path=None, expected_source_software=None
-    ):
-        """Assert that the dataset is a proper xarray Dataset."""
-        assert isinstance(dataset, xr.Dataset)
-        # Expected variables are present and of right shape/type
-        for var in ["position", "confidence"]:
-            assert var in dataset.data_vars
-            assert isinstance(dataset[var], xr.DataArray)
-        assert dataset.position.ndim == 4
-        assert dataset.confidence.shape == dataset.position.shape[:-1]
-        # Check the dims and coords
-        DIM_NAMES = ValidPosesDataset.DIM_NAMES
-        assert all([i in dataset.dims for i in DIM_NAMES])
-        for d, dim in enumerate(DIM_NAMES[1:]):
-            assert dataset.sizes[dim] == dataset.position.shape[d + 1]
-            assert all(
-                [isinstance(s, str) for s in dataset.coords[dim].values]
-            )
-        assert all([i in dataset.coords["space"] for i in ["x", "y"]])
-        # Check the metadata attributes
-        assert (
-            dataset.source_file is None
-            if file_path is None
-            else dataset.source_file == file_path.as_posix()
-        )
-        assert (
-            dataset.source_software is None
-            if expected_source_software is None
-            else dataset.source_software == expected_source_software
-        )
-        assert dataset.fps is None
-
-    def test_load_from_sleap_file(self, sleap_file):
-        """Test that loading pose tracks from valid SLEAP files
-        returns a proper Dataset.
-        """
-        ds = load_poses.from_sleap_file(sleap_file)
-        self.assert_dataset(ds, sleap_file, "SLEAP")
-
-    def test_load_from_sleap_file_without_tracks(
-        self, sleap_file_without_tracks
-    ):
-        """Test that loading pose tracks from valid SLEAP files
-        with tracks removed returns a dataset that matches the
-        original file, except for the individual names which are
-        set to default.
-        """
-        ds_from_trackless = load_poses.from_sleap_file(
-            sleap_file_without_tracks
-        )
-        ds_from_tracked = load_poses.from_sleap_file(
-            DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5")
-        )
-        # Check if the "individuals" coordinate matches
-        # the assigned default "individuals_0"
-        assert ds_from_trackless.individuals == ["individual_0"]
-        xr.testing.assert_allclose(
-            ds_from_trackless.drop_vars("individuals"),
-            ds_from_tracked.drop_vars("individuals"),
-        )
+    return file_path
+
+
+@pytest.fixture
+def sleap_h5_file_without_tracks(tmp_path):
+    """Mock and return the path to a SLEAP .h5 file without tracks."""
+    sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5")
+    file_path = tmp_path / "track_is_none.h5"
+    with h5py.File(sleap_file, "r") as f1, h5py.File(file_path, "w") as f2:
+        for key in list(f1.keys()):
+            if key == "track_names":
+                f2.create_dataset(key, data=[])
+            else:
+                f1.copy(key, f2, name=key)
+    return file_path
+
+
+@pytest.fixture(
+    params=[
+        "sleap_h5_file_without_tracks",
+        "sleap_slp_file_without_tracks",
+    ]
+)
+def sleap_file_without_tracks(request):
+    """Fixture to parametrize the SLEAP files without tracks."""
+    return request.getfixturevalue(request.param)
 
-    @pytest.mark.parametrize(
-        "slp_file, h5_file",
-        [
-            (
-                "SLEAP_single-mouse_EPM.analysis.h5",
-                "SLEAP_single-mouse_EPM.predictions.slp",
-            ),
-            (
-                "SLEAP_three-mice_Aeon_proofread.analysis.h5",
-                "SLEAP_three-mice_Aeon_proofread.predictions.slp",
-            ),
-            (
-                "SLEAP_three-mice_Aeon_mixed-labels.analysis.h5",
-                "SLEAP_three-mice_Aeon_mixed-labels.predictions.slp",
-            ),
-        ],
+
+expected_values_poses = {
+    "vars_dims": {"position": 4, "confidence": 3},
+    "dim_names": ValidPosesDataset.DIM_NAMES,
+}
+
+
+def test_load_from_sleap_file(sleap_file, movement_dataset_asserts):
+    """Test that loading pose tracks from valid SLEAP files
+    returns a proper Dataset.
+    """
+    ds = load_poses.from_sleap_file(sleap_file)
+    expected_values = {
+        **expected_values_poses,
+        "source_software": "SLEAP",
+        "file_path": sleap_file,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
+
+
+def test_load_from_sleap_file_without_tracks(sleap_file_without_tracks):
+    """Test that loading pose tracks from valid SLEAP files
+    with tracks removed returns a dataset that matches the
+    original file, except for the individual names which are
+    set to default.
+    """
+    ds_from_trackless = load_poses.from_sleap_file(sleap_file_without_tracks)
+    ds_from_tracked = load_poses.from_sleap_file(
+        DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5")
     )
-    def test_load_from_sleap_slp_file_or_h5_file_returns_same(
-        self, slp_file, h5_file
-    ):
-        """Test that loading pose tracks from SLEAP .slp and .h5 files
-        return the same Dataset.
-        """
-        slp_file_path = DATA_PATHS.get(slp_file)
-        h5_file_path = DATA_PATHS.get(h5_file)
-        ds_from_slp = load_poses.from_sleap_file(slp_file_path)
-        ds_from_h5 = load_poses.from_sleap_file(h5_file_path)
-        xr.testing.assert_allclose(ds_from_h5, ds_from_slp)
-
-    @pytest.mark.parametrize(
-        "file_name",
-        [
-            "DLC_single-wasp.predictions.h5",
-            "DLC_single-wasp.predictions.csv",
-            "DLC_two-mice.predictions.csv",
-        ],
+    # Check if the "individuals" coordinate matches
+    # the assigned default "individuals_0"
+    assert ds_from_trackless.individuals == ["individual_0"]
+    xr.testing.assert_allclose(
+        ds_from_trackless.drop_vars("individuals"),
+        ds_from_tracked.drop_vars("individuals"),
     )
-    def test_load_from_dlc_file(self, file_name):
-        """Test that loading pose tracks from valid DLC files
-        returns a proper Dataset.
-        """
-        file_path = DATA_PATHS.get(file_name)
-        ds = load_poses.from_dlc_file(file_path)
-        self.assert_dataset(ds, file_path, "DeepLabCut")
-
-    @pytest.mark.parametrize(
-        "source_software", ["DeepLabCut", "LightningPose", None]
+
+
+@pytest.mark.parametrize(
+    "slp_file, h5_file",
+    [
+        (
+            "SLEAP_single-mouse_EPM.analysis.h5",
+            "SLEAP_single-mouse_EPM.predictions.slp",
+        ),
+        (
+            "SLEAP_three-mice_Aeon_proofread.analysis.h5",
+            "SLEAP_three-mice_Aeon_proofread.predictions.slp",
+        ),
+        (
+            "SLEAP_three-mice_Aeon_mixed-labels.analysis.h5",
+            "SLEAP_three-mice_Aeon_mixed-labels.predictions.slp",
+        ),
+    ],
+)
+def test_load_from_sleap_slp_file_or_h5_file_returns_same(slp_file, h5_file):
+    """Test that loading pose tracks from SLEAP .slp and .h5 files
+    return the same Dataset.
+    """
+    slp_file_path = DATA_PATHS.get(slp_file)
+    h5_file_path = DATA_PATHS.get(h5_file)
+    ds_from_slp = load_poses.from_sleap_file(slp_file_path)
+    ds_from_h5 = load_poses.from_sleap_file(h5_file_path)
+    xr.testing.assert_allclose(ds_from_h5, ds_from_slp)
+
+
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "DLC_single-wasp.predictions.h5",
+        "DLC_single-wasp.predictions.csv",
+        "DLC_two-mice.predictions.csv",
+    ],
+)
+def test_load_from_dlc_file(file_name, movement_dataset_asserts):
+    """Test that loading pose tracks from valid DLC files
+    returns a proper Dataset.
+    """
+    file_path = DATA_PATHS.get(file_name)
+    ds = load_poses.from_dlc_file(file_path)
+    expected_values = {
+        **expected_values_poses,
+        "source_software": "DeepLabCut",
+        "file_path": file_path,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
+
+
+@pytest.mark.parametrize(
+    "source_software", ["DeepLabCut", "LightningPose", None]
+)
+def test_load_from_dlc_style_df(
+    dlc_style_df, source_software, movement_dataset_asserts
+):
+    """Test that loading pose tracks from a valid DLC-style DataFrame
+    returns a proper Dataset.
+    """
+    ds = load_poses.from_dlc_style_df(
+        dlc_style_df, source_software=source_software
     )
-    def test_load_from_dlc_style_df(self, dlc_style_df, source_software):
-        """Test that loading pose tracks from a valid DLC-style DataFrame
-        returns a proper Dataset.
-        """
-        ds = load_poses.from_dlc_style_df(
-            dlc_style_df, source_software=source_software
-        )
-        self.assert_dataset(ds, expected_source_software=source_software)
-
-    def test_load_from_dlc_file_csv_or_h5_file_returns_same(self):
-        """Test that loading pose tracks from DLC .csv and .h5 files
-        return the same Dataset.
-        """
-        csv_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.csv")
-        h5_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.h5")
-        ds_from_csv = load_poses.from_dlc_file(csv_file_path)
-        ds_from_h5 = load_poses.from_dlc_file(h5_file_path)
-        xr.testing.assert_allclose(ds_from_h5, ds_from_csv)
-
-    @pytest.mark.parametrize(
-        "fps, expected_fps, expected_time_unit",
-        [
-            (None, None, "frames"),
-            (-5, None, "frames"),
-            (0, None, "frames"),
-            (30, 30, "seconds"),
-            (60.0, 60, "seconds"),
-        ],
+    expected_values = {
+        **expected_values_poses,
+        "source_software": source_software,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
+
+
+def test_load_from_dlc_file_csv_or_h5_file_returns_same():
+    """Test that loading pose tracks from DLC .csv and .h5 files
+    return the same Dataset.
+    """
+    csv_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.csv")
+    h5_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.h5")
+    ds_from_csv = load_poses.from_dlc_file(csv_file_path)
+    ds_from_h5 = load_poses.from_dlc_file(h5_file_path)
+    xr.testing.assert_allclose(ds_from_h5, ds_from_csv)
+
+
+@pytest.mark.parametrize(
+    "fps, expected_fps, expected_time_unit",
+    [
+        (None, None, "frames"),
+        (-5, None, "frames"),
+        (0, None, "frames"),
+        (30, 30, "seconds"),
+        (60.0, 60, "seconds"),
+    ],
+)
+def test_fps_and_time_coords(fps, expected_fps, expected_time_unit):
+    """Test that time coordinates are set according to the provided fps."""
+    ds = load_poses.from_sleap_file(
+        DATA_PATHS.get("SLEAP_three-mice_Aeon_proofread.analysis.h5"),
+        fps=fps,
     )
-    def test_fps_and_time_coords(self, fps, expected_fps, expected_time_unit):
-        """Test that time coordinates are set according to the provided fps."""
-        ds = load_poses.from_sleap_file(
-            DATA_PATHS.get("SLEAP_three-mice_Aeon_proofread.analysis.h5"),
-            fps=fps,
+    assert ds.time_unit == expected_time_unit
+    if expected_fps is None:
+        assert ds.fps is expected_fps
+    else:
+        assert ds.fps == expected_fps
+        np.testing.assert_allclose(
+            ds.coords["time"].data,
+            np.arange(ds.sizes["time"], dtype=int) / ds.attrs["fps"],
         )
-        assert ds.time_unit == expected_time_unit
-        if expected_fps is None:
-            assert ds.fps is expected_fps
-        else:
-            assert ds.fps == expected_fps
-            np.testing.assert_allclose(
-                ds.coords["time"].data,
-                np.arange(ds.sizes["time"], dtype=int) / ds.attrs["fps"],
-            )
 
-    @pytest.mark.parametrize(
-        "file_name",
-        [
-            "LP_mouse-face_AIND.predictions.csv",
-            "LP_mouse-twoview_AIND.predictions.csv",
-        ],
-    )
-    def test_load_from_lp_file(self, file_name):
-        """Test that loading pose tracks from valid LightningPose (LP) files
-        returns a proper Dataset.
-        """
-        file_path = DATA_PATHS.get(file_name)
-        ds = load_poses.from_lp_file(file_path)
-        self.assert_dataset(ds, file_path, "LightningPose")
-
-    def test_load_from_lp_or_dlc_file_returns_same(self):
-        """Test that loading a single-animal DeepLabCut-style .csv file
-        using either the `from_lp_file` or `from_dlc_file` function
-        returns the same Dataset (except for the source_software).
-        """
-        file_path = DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv")
-        ds_drom_lp = load_poses.from_lp_file(file_path)
-        ds_from_dlc = load_poses.from_dlc_file(file_path)
-        xr.testing.assert_allclose(ds_from_dlc, ds_drom_lp)
-        assert ds_drom_lp.source_software == "LightningPose"
-        assert ds_from_dlc.source_software == "DeepLabCut"
-
-    def test_load_multi_individual_from_lp_file_raises(self):
-        """Test that loading a multi-individual .csv file using the
-        `from_lp_file` function raises a ValueError.
-        """
-        file_path = DATA_PATHS.get("DLC_two-mice.predictions.csv")
-        with pytest.raises(ValueError):
-            load_poses.from_lp_file(file_path)
-
-    @pytest.mark.parametrize(
-        "source_software", ["SLEAP", "DeepLabCut", "LightningPose", "Unknown"]
+
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "LP_mouse-face_AIND.predictions.csv",
+        "LP_mouse-twoview_AIND.predictions.csv",
+    ],
+)
+def test_load_from_lp_file(file_name, movement_dataset_asserts):
+    """Test that loading pose tracks from valid LightningPose (LP) files
+    returns a proper Dataset.
+    """
+    file_path = DATA_PATHS.get(file_name)
+    ds = load_poses.from_lp_file(file_path)
+    expected_values = {
+        **expected_values_poses,
+        "source_software": "LightningPose",
+        "file_path": file_path,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
+
+
+def test_load_from_lp_or_dlc_file_returns_same():
+    """Test that loading a single-animal DeepLabCut-style .csv file
+    using either the `from_lp_file` or `from_dlc_file` function
+    returns the same Dataset (except for the source_software).
+    """
+    file_path = DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv")
+    ds_drom_lp = load_poses.from_lp_file(file_path)
+    ds_from_dlc = load_poses.from_dlc_file(file_path)
+    xr.testing.assert_allclose(ds_from_dlc, ds_drom_lp)
+    assert ds_drom_lp.source_software == "LightningPose"
+    assert ds_from_dlc.source_software == "DeepLabCut"
+
+
+def test_load_multi_individual_from_lp_file_raises():
+    """Test that loading a multi-individual .csv file using the
+    `from_lp_file` function raises a ValueError.
+    """
+    file_path = DATA_PATHS.get("DLC_two-mice.predictions.csv")
+    with pytest.raises(ValueError):
+        load_poses.from_lp_file(file_path)
+
+
+@pytest.mark.parametrize(
+    "source_software", ["SLEAP", "DeepLabCut", "LightningPose", "Unknown"]
+)
+@pytest.mark.parametrize("fps", [None, 30, 60.0])
+def test_from_file_delegates_correctly(source_software, fps):
+    """Test that the from_file() function delegates to the correct
+    loader function according to the source_software.
+    """
+    software_to_loader = {
+        "SLEAP": "movement.io.load_poses.from_sleap_file",
+        "DeepLabCut": "movement.io.load_poses.from_dlc_file",
+        "LightningPose": "movement.io.load_poses.from_lp_file",
+    }
+    if source_software == "Unknown":
+        with pytest.raises(ValueError, match="Unsupported source"):
+            load_poses.from_file("some_file", source_software)
+    else:
+        with patch(software_to_loader[source_software]) as mock_loader:
+            load_poses.from_file("some_file", source_software, fps)
+            mock_loader.assert_called_with("some_file", fps)
+
+
+@pytest.mark.parametrize("source_software", [None, "SLEAP"])
+def test_from_numpy_valid(
+    valid_position_array, source_software, movement_dataset_asserts
+):
+    """Test that loading pose tracks from a multi-animal numpy array
+    with valid parameters returns a proper Dataset.
+    """
+    valid_position = valid_position_array("multi_individual_array")
+    rng = np.random.default_rng(seed=42)
+    valid_confidence = rng.random(valid_position.shape[:-1])
+    ds = load_poses.from_numpy(
+        valid_position,
+        valid_confidence,
+        individual_names=["mouse1", "mouse2"],
+        keypoint_names=["snout", "tail"],
+        fps=None,
+        source_software=source_software,
     )
-    @pytest.mark.parametrize("fps", [None, 30, 60.0])
-    def test_from_file_delegates_correctly(self, source_software, fps):
-        """Test that the from_file() function delegates to the correct
-        loader function according to the source_software.
-        """
-        software_to_loader = {
-            "SLEAP": "movement.io.load_poses.from_sleap_file",
-            "DeepLabCut": "movement.io.load_poses.from_dlc_file",
-            "LightningPose": "movement.io.load_poses.from_lp_file",
-        }
-
-        if source_software == "Unknown":
-            with pytest.raises(ValueError, match="Unsupported source"):
-                load_poses.from_file("some_file", source_software)
-        else:
-            with patch(software_to_loader[source_software]) as mock_loader:
-                load_poses.from_file("some_file", source_software, fps)
-                mock_loader.assert_called_with("some_file", fps)
-
-    @pytest.mark.parametrize("source_software", [None, "SLEAP"])
-    def test_from_numpy_valid(
-        self,
-        valid_position_array,
-        source_software,
-    ):
-        """Test that loading pose tracks from a multi-animal numpy array
-        with valid parameters returns a proper Dataset.
-        """
-        valid_position = valid_position_array("multi_individual_array")
-        rng = np.random.default_rng(seed=42)
-        valid_confidence = rng.random(valid_position.shape[:-1])
-
-        ds = load_poses.from_numpy(
-            valid_position,
-            valid_confidence,
-            individual_names=["mouse1", "mouse2"],
-            keypoint_names=["snout", "tail"],
-            fps=None,
-            source_software=source_software,
-        )
-        self.assert_dataset(ds, expected_source_software=source_software)
-
-    def test_from_multiview_files(self):
-        """Test that the from_file() function delegates to the correct
-        loader function according to the source_software.
-        """
-        view_names = ["view_0", "view_1"]
-        file_path_dict = {
-            view: DATA_PATHS.get("DLC_single-wasp.predictions.h5")
-            for view in view_names
-        }
-        multi_view_ds = load_poses.from_multiview_files(
-            file_path_dict, source_software="DeepLabCut"
-        )
+    expected_values = {
+        **expected_values_poses,
+        "source_software": source_software,
+    }
+    movement_dataset_asserts.valid_dataset(ds, expected_values)
 
-        assert isinstance(multi_view_ds, xr.Dataset)
-        assert "view" in multi_view_ds.dims
-        assert multi_view_ds.view.values.tolist() == view_names
+
+def test_from_multiview_files():
+    """Test that the from_file() function delegates to the correct
+    loader function according to the source_software.
+    """
+    view_names = ["view_0", "view_1"]
+    file_path_dict = {
+        view: DATA_PATHS.get("DLC_single-wasp.predictions.h5")
+        for view in view_names
+    }
+    multi_view_ds = load_poses.from_multiview_files(
+        file_path_dict, source_software="DeepLabCut"
+    )
+    assert isinstance(multi_view_ds, xr.Dataset)
+    assert "view" in multi_view_ds.dims
+    assert multi_view_ds.view.values.tolist() == view_names
diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py
index e5be81c..1704833 100644
--- a/tests/test_unit/test_validators/test_datasets_validators.py
+++ b/tests/test_unit/test_validators/test_datasets_validators.py
@@ -75,10 +75,10 @@ invalid_bboxes_arrays_and_expected_log = {
             f"Expected a numpy array, but got {type(list())}.",
         ),  # not an ndarray
         (
-            np.zeros((10, 2, 3)),
+            np.zeros((10, 3, 2)),
             f"Expected '{key}_array' to have 2 spatial "
             "coordinates, but got 3.",
-        ),  # last dim not 2
+        ),  # `space` dim (at idx 1) not 2
     ]
     for key in ["position", "shape"]
 }
@@ -101,10 +101,10 @@ invalid_bboxes_arrays_and_expected_log = {
             "Expected 'position_array' to have 4 dimensions, but got 3.",
         ),  # not 4d
         (
-            np.zeros((10, 2, 3, 4)),
+            np.zeros((10, 4, 3, 2)),
             "Expected 'position_array' to have 2 or 3 "
             "spatial dimensions, but got 4.",
-        ),  # last dim not 2 or 3
+        ),  # `space` dim (at idx 1) not 2 or 3
     ],
 )
 def test_poses_dataset_validator_with_invalid_position_array(