Repository: neuroinformatics-unit/movement

Issue:
Pass data arrays to filtering functions instead of a dataset
**Is your feature request related to a problem? Please describe.**
Almost all filtering/smoothing operations are meant to operate on a single data array (in most cases the `position` data variable). However, they are currently designed such that they take in an entire `movement` dataset (so `position` + `confidence`) and return a different dataset in whuch only the `position` variable has been updated.

This made sense for our first filtering function - [`filter_by_confidence()`](https://movement.neuroinformatics.dev/api/movement.filtering.filter_by_confidence.html#movement.filtering.filter_by_confidence), which needs both variables, but it no longer makes sense for the other filters like [`median_filter()`](https://movement.neuroinformatics.dev/api/movement.filtering.median_filter.html) and [`savgol_filter()`](https://movement.neuroinformatics.dev/api/movement.filtering.savgol_filter.html)

**Describe the solution you'd like**
All filtering functions should explicitly accept a `DataArray` and return the modified `DataArray`. This is the transparent, no-magic option. For the special case of `filter_by_confidence()`, we will have to explicitly provide the `confidence` data array as a 2nd argument.

**Describe alternatives you've considered**
The alternative would be to leave the `filter_by_confidence()` as is, and only change the other functions. This would make the interface somewhat inconsistent though.

**Additional context**
Modifying `filter_by_confidence()` to accept both `position` and `confidence` data arrays as separate arguments would be a bit awkward. Under the hood, we would have to validate that those match (in shape and labels), and this is almost equivalent to re-building a `movement` dataset. It feels like we would be disassembling a dataset to pass it to this function, reassemble it internally to do the validation and thresholding, and then return only `position`.

We've already touched on this topic in #162 but we didn't agree on a solution. Me and @niksirbi  agree that the current status-quo feels counter-intuitive. Thoughts @lochhh and @b-peri ?


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=209) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/neuroinformatics-unit/movement/pull/209?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.68%. Comparing base [(`52ac099`)](https://app.codecov.io/gh/neuroinformatics-unit/movement/commit/52ac099f7948b4b8cea1ac26680a525107d31b80?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=neuroinformatics-unit) to head [(`444fd5c`)](https://app.codecov.io/gh/neuroinformatics-unit/movement/commit/444fd5ca355c133c34312094bdf54d8d75502bc1?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     #209      +/-   ##
==========================================
- Coverage   99.68%   99.68%   -0.01%     
==========================================
  Files          11       11              
  Lines         633      626       -7     
==========================================
- Hits          631      624       -7     
  Misses          2        2              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/neuroinformatics-unit/movement/pull/209?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).

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=209) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=neuroinformatics-unit_movement&pullRequest=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [C Reliability Rating on New Code](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209) (required ≥ A)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)

##   
![](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 '') [SonarLint](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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


lochhh: Thanks for the thorough review @niksirbi !

> ## Windows, gaps and time units
> * One way to achieve consistency would be to make `max_gap` in  `interpolate_over_time` also be in frames (observations). I think this could be done by  setting `use_coordinate=False` in xarray's `interpolate_na`. Alternatively, we could explicitly define an extra coordinate variable - called `frames` - along the `time` dimension, and set `use_coordinate="frames"`. This is trickier to do, but it's something we [planned to do anyway](https://github.com/neuroinformatics-unit/movement/issues/117), and it will pave the way for choosing to work with "real time" or with "frames". Maybe in this PR we can just try to solve it by `use_coordinate=False` (if it indeed works) and update the docstring of `interpolate_over_time` accordingly? At least this way we would consistently operate in "frames"/observations throughout, and @sfmig will get her wish.

I've decided to go with the simpler `use_coordinate=False`.

> ## `sphinx-gallery` examples
> * If we end up adopting the 2nd solution from above (everything works in frames), we could update the examples such that all window lengths and gaps are mentioned in numbers of frames, and hence avoid the "awkwardness" of having to convert each time. If we adopt some version of the 3rd solution, the "awkwardness" will naturally go away.
> * In the kinematics example we present the accessor `.move` syntax as the preferred way of computing the variables, and only briefly mention what gets called under the hood (aka the "functional" way of doing things). In the filtering-related examples, we present the "functional" way as the only way. When we update these examples, in this or in a future PR, we should present both ways of doing things, but maybe present the `.move` way as the preferred way for consistency? My point is that we shouldn't let this inconsistency in documentation persist for long.

All done. Wrt`inplace` and `ds.move.update`, I've chosen instead to document the use of xarray's own `update` in Getting started with modifying movement datasets, as you suggested. 

> ## `report_nan_values` and squeezing
> * Do you think we should move the whole machinery about counting and reporting NaNs to a module in `utils`?
> * To avoid the whole awkwardness with squeezing, should we already "harden" `report_nan_values` against missing dimensions? So, if the `individuals` dimension doesn't exist, assume 1 individual, and report per keypoint only. Similarly, if `keypoints` doesn't exist, assume 1 keypoint and report per individual only. If neither exists, assume 1 individual and 1 keypoint and report the 1 value for the only existing track.


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&resolved=false&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=209&resolutions=WONTFIX)

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=209&resolved=false&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)


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=209) **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=209&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=209&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=209&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=209&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=209&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=neuroinformatics-unit_movement&pullRequest=209)



Patch:
diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst
index d6d04ed..3960acf 100644
--- a/docs/source/api_index.rst
+++ b/docs/source/api_index.rst
@@ -19,6 +19,7 @@ Information on specific functions, classes, and methods.
     movement.analysis.kinematics
     movement.utils.vector
     movement.utils.logging
+    movement.utils.reports
     movement.sample_data
     movement.validators.files
     movement.validators.datasets
diff --git a/docs/source/conf.py b/docs/source/conf.py
index e33171c..dc731d0 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -47,6 +47,7 @@ extensions = [
     "sphinx_design",
     "sphinx_gallery.gen_gallery",
     "sphinx_sitemap",
+    "sphinx.ext.autosectionlabel",
 ]
 
 # Configure the myst parser to enable cool markdown features
@@ -76,6 +77,9 @@ templates_path = ["_templates"]
 autosummary_generate = True
 autodoc_default_flags = ["members", "inherited-members"]
 
+# Prefix section labels with the document name
+autosectionlabel_prefix_document = True
+
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 # This pattern also affects html_static_path and html_extra_path.
@@ -104,7 +108,7 @@ sphinx_gallery_conf = {
         "binderhub_url": "https://mybinder.org",
         "dependencies": ["environment.yml"],
     },
-    'remove_config_comments': True, 
+    "remove_config_comments": True,
     # do not render config params set as # sphinx_gallery_config [= value]
 }
 
diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md
index ffadc15..9c85adc 100644
--- a/docs/source/getting_started/movement_dataset.md
+++ b/docs/source/getting_started/movement_dataset.md
@@ -150,3 +150,10 @@ Custom **attributes** can also be added to the dataset:
 ds.attrs["my_custom_attribute"] = "my_custom_value"
 # henceforth accessible as ds.my_custom_attribute
 ```
+
+To update existing **data variables** in-place, e.g. `position`
+and `velocity`:
+
+```python
+ds.update({"position": position, "velocity": velocity_filtered})
+```
diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py
index 3ada578..8cea091 100644
--- a/examples/compute_kinematics.py
+++ b/examples/compute_kinematics.py
@@ -104,8 +104,9 @@ fig.tight_layout()
 
 # %%
 # We can also easily plot the components of the position vector against time
-# using ``xarray``'s built-in plotting methods. We use ``squeeze()`` to
-# remove the dimension of length 1 from the data (the keypoints dimension).
+# using ``xarray``'s built-in plotting methods. We use
+# :py:meth:`xarray.DataArray.squeeze` to
+# remove the dimension of length 1 from the data (the ``keypoints`` dimension).
 position.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5)
 plt.gcf().show()
 
@@ -130,7 +131,7 @@ displacement = ds.move.compute_displacement()
 
 # %%
 # Notice that we could also compute the displacement (and all the other
-# kinematic variables) using the kinematics module:
+# kinematic variables) using the :py:mod:`movement.analysis.kinematics` module:
 
 # %%
 import movement.analysis.kinematics as kin
@@ -282,8 +283,9 @@ velocity = ds.move.compute_velocity()
 
 # %%
 # We can plot the components of the velocity vector against time
-# using ``xarray``'s built-in plotting methods. We use ``squeeze()`` to
-# remove the dimension of length 1 from the data (the keypoints dimension).
+# using ``xarray``'s built-in plotting methods. We use
+# :py:meth:`xarray.DataArray.squeeze` to
+# remove the dimension of length 1 from the data (the ``keypoints`` dimension).
 
 velocity.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5)
 plt.gcf().show()
diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py
index d3e52ca..dbe3304 100644
--- a/examples/filter_and_interpolate.py
+++ b/examples/filter_and_interpolate.py
@@ -9,7 +9,6 @@ missing values.
 # Imports
 # -------
 from movement import sample_data
-from movement.filtering import filter_by_confidence, interpolate_over_time
 
 # %%
 # Load a sample dataset
@@ -19,16 +18,21 @@ ds = sample_data.fetch_dataset("DLC_single-wasp.predictions.h5")
 print(ds)
 
 # %%
-# We can see that this dataset contains the 2D pose tracks and confidence
-# scores for a single wasp, generated with DeepLabCut. There are 2 keypoints:
-# "head" and "stinger".
+# We see that the dataset contains the 2D pose tracks and confidence scores
+# for a single wasp, generated with DeepLabCut. The wasp is tracked at two
+# keypoints: "head" and "stinger" in a video that was recorded at 40 fps and
+# lasts for approximately 27 seconds.
 
 # %%
 # Visualise the pose tracks
 # -------------------------
+# Since the data contains only a single wasp, we use
+# :py:meth:`xarray.DataArray.squeeze` to remove
+# the dimension of length 1 from the data (the ``individuals`` dimension).
 
-position = ds.position.sel(individuals="individual_0")
-position.plot.line(x="time", row="keypoints", hue="space", aspect=2, size=2.5)
+ds.position.squeeze().plot.line(
+    x="time", row="keypoints", hue="space", aspect=2, size=2.5
+)
 
 # %%
 # We can see that the pose tracks contain some implausible "jumps", such
@@ -46,70 +50,113 @@ position.plot.line(x="time", row="keypoints", hue="space", aspect=2, size=2.5)
 # estimation frameworks, and their ranges can vary. Therefore,
 # it's always a good idea to inspect the actual confidence values in the data.
 #
-# Let's first look at a histogram of the confidence scores.
-ds.confidence.plot.hist(bins=20)
+# Let's first look at a histogram of the confidence scores. As before, we use
+# :py:meth:`xarray.DataArray.squeeze` to remove the ``individuals`` dimension
+# from the data.
+
+ds.confidence.squeeze().plot.hist(bins=20)
 
 # %%
 # Based on the above histogram, we can confirm that the confidence scores
 # indeed range between 0 and 1, with most values closer to 1. Now let's see how
 # they evolve over time.
 
-confidence = ds.confidence.sel(individuals="individual_0")
-confidence.plot.line(x="time", row="keypoints", aspect=2, size=2.5)
+ds.confidence.squeeze().plot.line(
+    x="time", row="keypoints", aspect=2, size=2.5
+)
 
 # %%
 # Encouragingly, some of the drops in confidence scores do seem to correspond
 # to the implausible jumps and spikes we had seen in the position.
 # We can use that to our advantage.
 
-
 # %%
 # Filter out points with low confidence
 # -------------------------------------
-# We can filter out points with confidence scores below a certain threshold.
-# Here, we use ``threshold=0.6``. Points in the ``position`` data variable
-# with confidence scores below this threshold will be converted to NaN.
-# The ``print_report`` argument, which is True by default, reports the number
-# of NaN values in the dataset before and after the filtering operation.
+# Using the
+# :py:meth:`filter_by_confidence()\
+# <movement.move_accessor.MovementDataset.filtering_wrapper>`
+# method of the ``move`` accessor,
+# we can filter out points with confidence scores below a certain threshold.
+# The default ``threshold=0.6`` will be used when ``threshold`` is not
+# provided.
+# This method will also report the number of NaN values in the dataset before
+# and after the filtering operation by default (``print_report=True``).
+# We will use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place
+# with the filtered ``position``.
+
+ds.update({"position": ds.move.filter_by_confidence()})
 
-ds_filtered = filter_by_confidence(ds, threshold=0.6, print_report=True)
+# %%
+# .. note::
+#    The ``move`` accessor :py:meth:`filter_by_confidence()\
+#    <movement.move_accessor.MovementDataset.filtering_wrapper>`
+#    method is a convenience method that applies
+#    :py:func:`movement.filtering.filter_by_confidence`,
+#    which takes ``position`` and ``confidence`` as arguments.
+#    The equivalent function call using the
+#    :py:mod:`movement.filtering` module would be:
+#
+#    .. code-block:: python
+#
+#       from movement.filtering import filter_by_confidence
+#
+#       ds.update({"position": filter_by_confidence(position, confidence)})
 
 # %%
 # We can see that the filtering operation has introduced NaN values in the
 # ``position`` data variable. Let's visualise the filtered data.
 
-position_filtered = ds_filtered.position.sel(individuals="individual_0")
-position_filtered.plot.line(
+ds.position.squeeze().plot.line(
     x="time", row="keypoints", hue="space", aspect=2, size=2.5
 )
 
 # %%
-# Here we can see that gaps have appeared in the pose tracks, some of which
-# are over the implausible jumps and spikes we had seen earlier. Moreover,
-# most gaps seem to be brief, lasting < 1 second.
+# Here we can see that gaps (consecutive NaNs) have appeared in the
+# pose tracks, some of which are over the implausible jumps and spikes we had
+# seen earlier. Moreover, most gaps seem to be brief,
+# lasting < 1 second (or 40 frames).
 
 # %%
 # Interpolate over missing values
 # -------------------------------
-# We can interpolate over the gaps we've introduced in the pose tracks.
-# Here we use the default linear interpolation method and ``max_gap=1``,
-# meaning that we will only interpolate over gaps of 1 second or shorter.
-# Setting ``max_gap=None`` would interpolate over all gaps, regardless of
-# their length, which should be used with caution as it can introduce
+# Using the
+# :py:meth:`interpolate_over_time()\
+# <movement.move_accessor.MovementDataset.filtering_wrapper>`
+# method of the ``move`` accessor,
+# we can interpolate over the gaps we've introduced in the pose tracks.
+# Here we use the default linear interpolation method (``method=linear``)
+# and interpolate over gaps of 40 frames or less (``max_gap=40``).
+# The default ``max_gap=None`` would interpolate over all gaps, regardless of
+# their length, but this should be used with caution as it can introduce
 # spurious data. The ``print_report`` argument acts as described above.
 
-ds_interpolated = interpolate_over_time(
-    ds_filtered, method="linear", max_gap=1, print_report=True
-)
+ds.update({"position": ds.move.interpolate_over_time(max_gap=40)})
+
+# %%
+# .. note::
+#    The ``move`` accessor :py:meth:`interpolate_over_time()\
+#    <movement.move_accessor.MovementDataset.filtering_wrapper>`
+#    is also a convenience method that applies
+#    :py:func:`movement.filtering.interpolate_over_time`
+#    to the ``position`` data variable.
+#    The equivalent function call using the
+#    :py:mod:`movement.filtering` module would be:
+#
+#    .. code-block:: python
+#
+#       from movement.filtering import interpolate_over_time
+#
+#       ds.update({"position": interpolate_over_time(
+#           position_filtered, max_gap=40
+#       )})
 
 # %%
 # We see that all NaN values have disappeared, meaning that all gaps were
-# indeed shorter than 1 second. Let's visualise the interpolated pose tracks
+# indeed shorter than 40 frames.
+# Let's visualise the interpolated pose tracks.
 
-position_interpolated = ds_interpolated.position.sel(
-    individuals="individual_0"
-)
-position_interpolated.plot.line(
+ds.position.squeeze().plot.line(
     x="time", row="keypoints", hue="space", aspect=2, size=2.5
 )
 
@@ -119,9 +166,37 @@ position_interpolated.plot.line(
 # So, far we've processed the pose tracks first by filtering out points with
 # low confidence scores, and then by interpolating over missing values.
 # The order of these operations and the parameters with which they were
-# performed are saved in the ``log`` attribute of the dataset.
+# performed are saved in the ``log`` attribute of the ``position`` data array.
 # This is useful for keeping track of the processing steps that have been
-# applied to the data.
+# applied to the data. Let's inspect the log entries.
 
-for log_entry in ds_interpolated.log:
+for log_entry in ds.position.log:
     print(log_entry)
+
+# %%
+# Filtering multiple data variables
+# ---------------------------------
+# All :py:mod:`movement.filtering` functions are available via the
+# ``move`` accessor. These ``move`` accessor methods operate on the
+# ``position`` data variable in the dataset ``ds`` by default.
+# There is also an additional argument ``data_vars`` that allows us to
+# specify which data variables in ``ds`` to filter.
+# When multiple data variable names are specified in ``data_vars``,
+# the method will return a dictionary with the data variable names as keys
+# and the filtered DataArrays as values, otherwise it will return a single
+# DataArray that is the filtered data.
+# This is useful when we want to apply the same filtering operation to
+# multiple data variables in ``ds`` at the same time.
+#
+# For instance, to filter both ``position`` and ``velocity`` data variables
+# in ``ds``, based on the confidence scores, we can specify
+# ``data_vars=["position", "velocity"]`` in the method call.
+# As the filtered data variables are returned as a dictionary, we can once
+# again use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place
+# with the filtered data variables.
+
+ds["velocity"] = ds.move.compute_velocity()
+filtered_data_dict = ds.move.filter_by_confidence(
+    data_vars=["position", "velocity"]
+)
+ds.update(filtered_data_dict)
diff --git a/examples/smooth.py b/examples/smooth.py
index 54a2135..f9b969b 100644
--- a/examples/smooth.py
+++ b/examples/smooth.py
@@ -12,11 +12,6 @@ from matplotlib import pyplot as plt
 from scipy.signal import welch
 
 from movement import sample_data
-from movement.filtering import (
-    interpolate_over_time,
-    median_filter,
-    savgol_filter,
-)
 
 # %%
 # Load a sample dataset
@@ -30,9 +25,10 @@ ds_wasp = sample_data.fetch_dataset("DLC_single-wasp.predictions.h5")
 print(ds_wasp)
 
 # %%
-# We see that the dataset contains a single individual (a wasp) with two
-# keypoints tracked in 2D space. The video was recorded at 40 fps and lasts for
-# ~27 seconds.
+# We see that the dataset contains the 2D pose tracks and confidence scores
+# for a single wasp, generated with DeepLabCut. The wasp is tracked at two
+# keypoints: "head" and "stinger" in a video that was recorded at 40 fps and
+# lasts for approximately 27 seconds.
 
 # %%
 # Define a plotting function
@@ -80,9 +76,10 @@ def plot_raw_and_smooth_timeseries_and_psd(
             label=f"{label} {space}",
         )
 
-        # generate interpolated dataset to avoid NaNs in the PSD calculation
-        ds_interp = interpolate_over_time(ds, max_gap=None, print_report=False)
-        pos_interp = ds_interp.position.sel(**selection)
+        # interpolate data to remove NaNs in the PSD calculation
+        pos_interp = ds.sel(**selection).move.interpolate_over_time(
+            print_report=False
+        )
         # compute and plot the PSD
         freq, psd = welch(pos_interp, fs=ds.fps, nperseg=256)
         ax[1].semilogy(
@@ -111,12 +108,36 @@ def plot_raw_and_smooth_timeseries_and_psd(
 # %%
 # Smoothing with a median filter
 # ------------------------------
-# Here we use the :py:func:`movement.filtering.median_filter` function to
-# apply a rolling window median filter to the wasp dataset.
-# The ``window_length`` parameter is defined in seconds (according to the
-# ``time_unit`` dataset attribute).
+# Using the
+# :py:meth:`median_filter()\
+# <movement.move_accessor.MovementDataset.filtering_wrapper>`
+# method of the ``move`` accessor,
+# we apply a rolling window median filter over a 0.1-second window
+# (4 frames) to the wasp dataset.
+# As the ``window`` parameter is defined in *number of observations*,
+# we can simply multiply the desired time window by the frame rate
+# of the video. We will also create a copy of the dataset to avoid
+# modifying the original data.
+
+window = int(0.1 * ds_wasp.fps)
+ds_wasp_smooth = ds_wasp.copy()
+ds_wasp_smooth.update({"position": ds_wasp_smooth.move.median_filter(window)})
 
-ds_wasp_medfilt = median_filter(ds_wasp, window_length=0.1)
+# %%
+# .. note::
+#    The ``move`` accessor :py:meth:`median_filter()\
+#    <movement.move_accessor.MovementDataset.filtering_wrapper>`
+#    method is a convenience method that applies
+#    :py:func:`movement.filtering.median_filter`
+#    to the ``position`` data variable.
+#    The equivalent function call using the
+#    :py:mod:`movement.filtering` module would be:
+#
+#    .. code-block:: python
+#
+#       from movement.filtering import median_filter
+#
+#       ds_wasp_smooth.update({"position": median_filter(position, window)})
 
 # %%
 # We see from the printed report that the dataset has no missing values
@@ -124,7 +145,7 @@ ds_wasp_medfilt = median_filter(ds_wasp, window_length=0.1)
 # median filter in the time and frequency domains.
 
 plot_raw_and_smooth_timeseries_and_psd(
-    ds_wasp, ds_wasp_medfilt, keypoint="stinger"
+    ds_wasp, ds_wasp_smooth, keypoint="stinger"
 )
 
 # %%
@@ -142,8 +163,8 @@ plot_raw_and_smooth_timeseries_and_psd(
 # %%
 # Choosing parameters for the median filter
 # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-# You can control the behaviour of :py:func:`movement.filtering.median_filter`
-# via two parameters: ``window_length`` and ``min_periods``.
+# We can control the behaviour of the median filter
+# via two parameters: ``window`` and ``min_periods``.
 # To better understand the effect of these parameters, let's use a
 # dataset that contains missing values.
 
@@ -154,10 +175,15 @@ print(ds_mouse)
 # The dataset contains a single mouse with six keypoints tracked in
 # 2D space. The video was recorded at 30 fps and lasts for ~616 seconds. We can
 # see that there are some missing values, indicated as "nan" in the
-# printed dataset. Let's apply the median filter to this dataset, with
-# the ``window_length`` set to 0.1 seconds.
-
-ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1)
+# printed dataset.
+# Let's apply the median filter over a 0.1-second window (3 frames)
+# to the dataset.
+
+window = int(0.1 * ds_mouse.fps)
+ds_mouse_smooth = ds_mouse.copy()
+ds_mouse_smooth.update(
+    {"position": ds_mouse_smooth.move.median_filter(window)}
+)
 
 # %%
 # The report informs us that the raw data contains NaN values, most of which
@@ -172,7 +198,9 @@ ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1)
 # For example, setting ``min_periods=2`` means that two non-NaN values in the
 # window are sufficient for the median to be calculated. Let's try this.
 
-ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2)
+ds_mouse_smooth.update(
+    {"position": ds_mouse.move.median_filter(window, min_periods=2)}
+)
 
 # %%
 # We see that this time the number of NaN values has decreased
@@ -183,32 +211,36 @@ ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2)
 # parts of the data.
 
 plot_raw_and_smooth_timeseries_and_psd(
-    ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80)
+    ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80)
 )
 
 # %%
 # The smoothing once again reduces the power of high-frequency components, but
 # the resulting time series stays quite close to the raw data.
 #
-# What happens if we increase the ``window_length`` to 2 seconds?
+# What happens if we increase the ``window`` to 2 seconds (60 frames)?
 
-ds_mouse_medfilt = median_filter(ds_mouse, window_length=2, min_periods=2)
+window = int(2 * ds_mouse.fps)
+ds_mouse_smooth.update(
+    {"position": ds_mouse.move.median_filter(window, min_periods=2)}
+)
 
 # %%
-# The number of NaN values has decreased even further. That's because the
-# chance of finding at least 2 valid values within a 2 second window is
-# quite high. Let's plot the results for the same keypoint and time range
+# The number of NaN values has decreased even further.
+# That's because the chance of finding at least 2 valid values within
+# a 2-second window (i.e. 60 frames) is quite high.
+# Let's plot the results for the same keypoint and time range
 # as before.
 
 plot_raw_and_smooth_timeseries_and_psd(
-    ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80)
+    ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80)
 )
 # %%
 # We see that the filtered time series is much smoother and it has even
 # "bridged" over some small gaps. That said, it often deviates from the raw
 # data, in ways that may not be desirable, depending on the application.
-# That means that our choice of ``window_length`` may be too large.
-# In general, you should choose a ``window_length`` that is small enough to
+# Here, our choice of ``window`` may be too large.
+# In general, you should choose a ``window`` that is small enough to
 # preserve the original data structure, but large enough to remove
 # "spikes" and high-frequency noise. Always inspect the results to ensure
 # that the filter is not removing important features.
@@ -216,18 +248,27 @@ plot_raw_and_smooth_timeseries_and_psd(
 # %%
 # Smoothing with a Savitzky-Golay filter
 # --------------------------------------
-# Here we use the :py:func:`movement.filtering.savgol_filter` function,
-# which is a wrapper around :py:func:`scipy.signal.savgol_filter`.
+# Here we use the
+# :py:meth:`savgol_filter()\
+# <movement.move_accessor.MovementDataset.filtering_wrapper>`
+# method of the ``move`` accessor, which is a convenience method that applies
+# :py:func:`movement.filtering.savgol_filter`
+# (a wrapper around :py:func:`scipy.signal.savgol_filter`),
+# to the ``position`` data variable.
 # The Savitzky-Golay filter is a polynomial smoothing filter that can be
-# applied to time series data on a rolling window basis. A polynomial of
-# degree ``polyorder`` is fitted to the data in each window of length
-# ``window_length``, and the value of the polynomial at the center of the
-# window is used as the output value.
+# applied to time series data on a rolling window basis.
+# A polynomial with a degree specified by ``polyorder`` is applied to each
+# data segment defined by the size ``window``.
+# The value of the polynomial at the midpoint of each ``window`` is then
+# used as the output value.
 #
-# Let's try it on the mouse dataset.
-
-ds_mouse_savgol = savgol_filter(ds_mouse, window_length=0.2, polyorder=2)
+# Let's try it on the mouse dataset, this time using a 0.2-second
+# window (i.e. 6 frames) and the default ``polyorder=2`` for smoothing.
+# As before, we first compute the corresponding number of observations
+# to be used as the ``window`` size.
 
+window = int(0.2 * ds_mouse.fps)
+ds_mouse_smooth.update({"position": ds_mouse.move.savgol_filter(window)})
 
 # %%
 # We see that the number of NaN values has increased after filtering. This is
@@ -238,22 +279,21 @@ ds_mouse_savgol = savgol_filter(ds_mouse, window_length=0.2, polyorder=2)
 # effects in the time and frequency domains.
 
 plot_raw_and_smooth_timeseries_and_psd(
-    ds_mouse, ds_mouse_savgol, keypoint="snout", time_range=slice(0, 80)
+    ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80)
 )
 # %%
 # Once again, the power of high-frequency components has been reduced, but more
 # missing values have been introduced.
 
 # %%
-# Now let's take a look at the wasp dataset.
+# Now let's apply the same Savitzky-Golay filter to the wasp dataset.
 
-ds_wasp_savgol = savgol_filter(ds_wasp, window_length=0.2, polyorder=2)
+window = int(0.2 * ds_wasp.fps)
+ds_wasp_smooth.update({"position": ds_wasp.move.savgol_filter(window)})
 
 # %%
 plot_raw_and_smooth_timeseries_and_psd(
-    ds_wasp,
-    ds_wasp_savgol,
-    keypoint="stinger",
+    ds_wasp, ds_wasp_smooth, keypoint="stinger"
 )
 # %%
 # This example shows two important limitations of the Savitzky-Golay filter.
@@ -261,7 +301,7 @@ plot_raw_and_smooth_timeseries_and_psd(
 # example, focus on what happens around the sudden drop in position
 # during the final second. Second, the PSD appears to have large periodic
 # drops at certain frequencies. Both of these effects vary with the
-# choice of ``window_length`` and ``polyorder``. You can read more about these
+# choice of ``window`` and ``polyorder``. You can read more about these
 # and other limitations of the Savitzky-Golay filter in
 # `this paper <https://pubs.acs.org/doi/10.1021/acsmeasuresciau.1c00054>`_.
 
@@ -269,33 +309,41 @@ plot_raw_and_smooth_timeseries_and_psd(
 # %%
 # Combining multiple smoothing filters
 # ------------------------------------
-# You can also combine multiple smoothing filters by applying them
+# We can also combine multiple smoothing filters by applying them
 # sequentially. For example, we can first apply the median filter with a small
-# ``window_length`` to remove "spikes" and then apply the Savitzky-Golay filter
-# with a larger ``window_length`` to further smooth the data.
+# ``window`` to remove "spikes" and then apply the Savitzky-Golay filter
+# with a larger ``window`` to further smooth the data.
 # Between the two filters, we can interpolate over small gaps to avoid the
 # excessive proliferation of NaN values. Let's try this on the mouse dataset.
-# First, let's apply the median filter.
+# First, we will apply the median filter.
 
-ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2)
+window = int(0.1 * ds_mouse.fps)
+ds_mouse_smooth.update(
+    {"position": ds_mouse.move.median_filter(window, min_periods=2)}
+)
 
 # %%
-# Next, let's linearly interpolate over gaps smaller than 1 second.
+# Next, let's linearly interpolate over gaps smaller than 1 second (30 frames).
 
-ds_mouse_medfilt_interp = interpolate_over_time(ds_mouse_medfilt, max_gap=1)
+ds_mouse_smooth.update(
+    {"position": ds_mouse_smooth.move.interpolate_over_time(max_gap=30)}
+)
 
 # %%
-# Finally, let's apply the Savitzky-Golay filter.
+# Finally, let's apply the Savitzky-Golay filter over a 0.4-second window
+# (12 frames).
 
-ds_mouse_medfilt_interp_savgol = savgol_filter(
-    ds_mouse_medfilt_interp, window_length=0.4, polyorder=2
+window = int(0.4 * ds_mouse.fps)
+ds_mouse_smooth.update(
+    {"position": ds_mouse_smooth.move.savgol_filter(window)}
 )
 
 # %%
-# A record of all applied operations is stored in the dataset's ``log``
-# attribute. Let's inspect it to summarise what we've done.
+# A record of all applied operations is stored in the ``log`` attribute of the
+# ``ds_mouse_smooth.position`` data array. Let's inspect it to summarise
+# what we've done.
 
-for entry in ds_mouse_medfilt_interp_savgol.log:
+for entry in ds_mouse_smooth.position.log:
     print(entry)
 
 # %%
@@ -304,7 +352,7 @@ for entry in ds_mouse_medfilt_interp_savgol.log:
 
 plot_raw_and_smooth_timeseries_and_psd(
     ds_mouse,
-    ds_mouse_medfilt_interp_savgol,
+    ds_mouse_smooth,
     keypoint="snout",
     time_range=slice(0, 80),
 )
@@ -312,3 +360,9 @@ plot_raw_and_smooth_timeseries_and_psd(
 # %%
 # Feel free to play around with the parameters of the applied filters and to
 # also look at other keypoints and time ranges.
+
+# %%
+# .. seealso::
+#   :ref:`examples/filter_and_interpolate:Filtering multiple data variables`
+#   in the
+#   :ref:`sphx_glr_examples_filter_and_interpolate.py` example.
diff --git a/movement/filtering.py b/movement/filtering.py
index 5779205..573e863 100644
--- a/movement/filtering.py
+++ b/movement/filtering.py
@@ -1,136 +1,30 @@
 """Filter and interpolate  pose tracks in ``movement`` datasets."""
 
-import logging
-from datetime import datetime
-from functools import wraps
-
 import xarray as xr
 from scipy import signal
 
-from movement.utils.logging import log_error
-
-
-def log_to_attrs(func):
-    """Log the operation performed by the wrapped function.
-
-    This decorator appends log entries to the xarray.Dataset's "log" attribute.
-    For the decorator to work, the wrapped function must accept an
-    xarray.Dataset as its first argument and return an xarray.Dataset.
-    """
-
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        result = func(*args, **kwargs)
-
-        log_entry = {
-            "operation": func.__name__,
-            "datetime": str(datetime.now()),
-            **{f"arg_{i}": arg for i, arg in enumerate(args[1:], start=1)},
-            **kwargs,
-        }
-
-        # Append the log entry to the result's attributes
-        if result is not None and hasattr(result, "attrs"):
-            if "log" not in result.attrs:
-                result.attrs["log"] = []
-            result.attrs["log"].append(log_entry)
-
-        return result
-
-    return wrapper
-
-
-def report_nan_values(ds: xr.Dataset, ds_label: str = "dataset"):
-    """Report the number and percentage of points that are NaN.
-
-    Numbers are reported for each individual and keypoint in the dataset.
-
-    Parameters
-    ----------
-    ds : xarray.Dataset
-        Dataset containing position, confidence scores, and metadata.
-    ds_label : str
-        Label to identify the dataset in the report. Default is "dataset".
-
-    """
-    # Compile the report
-    nan_report = f"\nMissing points (marked as NaN) in {ds_label}:"
-    for ind in ds.individuals.values:
-        nan_report += f"\n\tIndividual: {ind}"
-        for kp in ds.keypoints.values:
-            # Get the position track for the current individual and keypoint
-            position = ds.position.sel(individuals=ind, keypoints=kp)
-            # A point is considered NaN if any of its space coordinates are NaN
-            n_nans = position.isnull().any(["space"]).sum(["time"]).item()
-            n_points = position.time.size
-            percent_nans = round((n_nans / n_points) * 100, 1)
-            nan_report += f"\n\t\t{kp}: {n_nans}/{n_points} ({percent_nans}%)"
-
-    # Write nan report to logger
-    logger = logging.getLogger(__name__)
-    logger.info(nan_report)
-    # Also print the report to the console
-    print(nan_report)
-    return None
-
-
-@log_to_attrs
-def interpolate_over_time(
-    ds: xr.Dataset,
-    method: str = "linear",
-    max_gap: int | None = None,
-    print_report: bool = True,
-) -> xr.Dataset | None:
-    """Fill in NaN values by interpolating over the time dimension.
-
-    Parameters
-    ----------
-    ds : xarray.Dataset
-        Dataset containing position, confidence scores, and metadata.
-    method : str
-        String indicating which method to use for interpolation.
-        Default is ``linear``. See documentation for
-        ``xarray.DataArray.interpolate_na`` for complete list of options.
-    max_gap :
-        The largest time gap of consecutive NaNs (in seconds) that will be
-        interpolated over. The default value is ``None`` (no limit).
-    print_report : bool
-        Whether to print a report on the number of NaNs in the dataset
-        before and after interpolation. Default is ``True``.
-
-    Returns
-    -------
-    ds_interpolated : xr.Dataset
-        The provided dataset (ds), where NaN values have been
-        interpolated over using the parameters provided.
-
-    """
-    ds_interpolated = ds.copy()
-    position_interpolated = ds.position.interpolate_na(
-        dim="time", method=method, max_gap=max_gap, fill_value="extrapolate"
-    )
-    ds_interpolated.update({"position": position_interpolated})
-    if print_report:
-        report_nan_values(ds, "input dataset")
-        report_nan_values(ds_interpolated, "interpolated dataset")
-    return ds_interpolated
+from movement.utils.logging import log_error, log_to_attrs
+from movement.utils.reports import report_nan_values
 
 
 @log_to_attrs
 def filter_by_confidence(
-    ds: xr.Dataset,
+    data: xr.DataArray,
+    confidence: xr.DataArray,
     threshold: float = 0.6,
     print_report: bool = True,
-) -> xr.Dataset | None:
-    """Drop all points below a certain confidence threshold.
+) -> xr.DataArray:
+    """Drop data points below a certain confidence threshold.
 
-    Position points with an associated confidence value below the threshold are
+    Data points with an associated confidence value below the threshold are
     converted to NaN.
 
     Parameters
     ----------
-    ds : xarray.Dataset
-        Dataset containing position, confidence scores, and metadata.
+    data : xarray.DataArray
+        The input data to be filtered.
+    confidence : xarray.DataArray
+        The data array containing confidence scores to filter by.
     threshold : float
         The confidence threshold below which datapoints are filtered.
         A default value of ``0.6`` is used. See notes for more information.
@@ -140,10 +34,9 @@ def filter_by_confidence(
 
     Returns
     -------
-    ds_thresholded : xarray.Dataset
-        The provided dataset (ds), where points with a confidence
-        value below the user-defined threshold have been converted
-        to NaNs.
+    xarray.DataArray
+        The data where points with a confidence value below the
+        user-defined threshold have been converted to NaNs.
 
     Notes
     -----
@@ -157,57 +50,107 @@ def filter_by_confidence(
     in their dataset and adjust the threshold accordingly.
 
     """
-    ds_thresholded = ds.copy()
-    ds_thresholded.update(
-        {"position": ds.position.where(ds.confidence >= threshold)}
-    )
+    data_filtered = data.where(confidence >= threshold)
     if print_report:
-        report_nan_values(ds, "input dataset")
-        report_nan_values(ds_thresholded, "filtered dataset")
+        print(report_nan_values(data, "input"))
+        print(report_nan_values(data_filtered, "output"))
+    return data_filtered
 
-    return ds_thresholded
+
+@log_to_attrs
+def interpolate_over_time(
+    data: xr.DataArray,
+    method: str = "linear",
+    max_gap: int | None = None,
+    print_report: bool = True,
+) -> xr.DataArray:
+    """Fill in NaN values by interpolating over the ``time`` dimension.
+
+    This method uses :py:meth:`xarray.DataArray.interpolate_na` under the
+    hood and passes the ``method`` and ``max_gap`` parameters to it.
+    See the xarray documentation for more details on these parameters.
+
+    Parameters
+    ----------
+    data : xarray.DataArray
+        The input data to be interpolated.
+    method : str
+        String indicating which method to use for interpolation.
+        Default is ``linear``.
+    max_gap : int, optional
+        Maximum size of gap, a continuous sequence of missing observations
+        (represented as NaNs), to fill.
+        The default value is ``None`` (no limit).
+        Gap size is defined as the number of consecutive NaNs.
+    print_report : bool
+        Whether to print a report on the number of NaNs in the dataset
+        before and after interpolation. Default is ``True``.
+
+    Returns
+    -------
+    xr.DataArray
+        The data where NaN values have been interpolated over
+        using the parameters provided.
+
+    Notes
+    -----
+    The ``max_gap`` parameter differs slightly from that in
+    :py:meth:`xarray.DataArray.interpolate_na`, in which the gap size
+    is defined as the difference between the ``time`` coordinate values
+    at the first data point after a gap and the last value before a gap.
+
+    """
+    data_interpolated = data.interpolate_na(
+        dim="time",
+        method=method,
+        use_coordinate=False,
+        max_gap=max_gap + 1 if max_gap is not None else None,
+        fill_value="extrapolate",
+    )
+    if print_report:
+        print(report_nan_values(data, "input"))
+        print(report_nan_values(data_interpolated, "output"))
+    return data_interpolated
 
 
 @log_to_attrs
 def median_filter(
-    ds: xr.Dataset,
-    window_length: int,
+    data: xr.DataArray,
+    window: int,
     min_periods: int | None = None,
     print_report: bool = True,
-) -> xr.Dataset:
-    """Smooth pose tracks by applying a median filter over time.
+) -> xr.DataArray:
+    """Smooth data by applying a median filter over time.
 
     Parameters
     ----------
-    ds : xarray.Dataset
-        Dataset containing position, confidence scores, and metadata.
-    window_length : int
-        The size of the filter window. Window length is interpreted
-        as being in the input dataset's time unit, which can be inspected
-        with ``ds.time_unit``.
+    data : xarray.DataArray
+        The input data to be smoothed.
+    window : int
+        The size of the filter window, representing the fixed number
+        of observations used for each window.
     min_periods : int
-        Minimum number of observations in window required to have a value
-        (otherwise result is NaN). The default, None, is equivalent to
-        setting ``min_periods`` equal to the size of the window.
+        Minimum number of observations in the window required to have
+        a value (otherwise result is NaN). The default, None, is
+        equivalent to setting ``min_periods`` equal to the size of the window.
         This argument is directly  passed to the ``min_periods`` parameter of
-        ``xarray.DataArray.rolling``.
+        :py:meth:`xarray.DataArray.rolling`.
     print_report : bool
         Whether to print a report on the number of NaNs in the dataset
         before and after filtering. Default is ``True``.
 
     Returns
     -------
-    ds_smoothed : xarray.Dataset
-        The provided dataset (ds), where pose tracks have been smoothed
-        using a median filter with the provided parameters.
+    xarray.DataArray
+        The data smoothed using a median filter with the provided parameters.
 
     Notes
     -----
     By default, whenever one or more NaNs are present in the filter window,
     a NaN is returned to the output array. As a result, any
-    stretch of NaNs present in the input dataset will be propagated
-    proportionally to the size of the window in frames  (specifically, by
-    ``floor(window_length/2)``). To control this behaviour, the
+    stretch of NaNs present in the input data will be propagated
+    proportionally to the size of the window  (specifically, by
+    ``floor(window/2)``). To control this behaviour, the
     ``min_periods`` option can be used to specify the minimum number of
     non-NaN values required in the window to compute a result. For example,
     setting ``min_periods=1`` will result in the filter returning NaNs
@@ -215,85 +158,74 @@ def median_filter(
     is sufficient to compute the median.
 
     """
-    ds_smoothed = ds.copy()
-
-    # Express window length (and its half) in frames
-    if ds.time_unit == "seconds":
-        window_length = int(window_length * ds.fps)
-
-    half_window = window_length // 2
-
-    ds_smoothed.update(
-        {
-            "position": ds.position.pad(  # Pad the edges to avoid NaNs
-                time=half_window, mode="reflect"
-            )
-            .rolling(  # Take rolling windows across time
-                time=window_length, center=True, min_periods=min_periods
-            )
-            .median(  # Compute the median of each window
-                skipna=True
-            )
-            .isel(  # Remove the padded edges
-                time=slice(half_window, -half_window)
-            )
-        }
+    half_window = window // 2
+    data_smoothed = (
+        data.pad(  # Pad the edges to avoid NaNs
+            time=half_window, mode="reflect"
+        )
+        .rolling(  # Take rolling windows across time
+            time=window, center=True, min_periods=min_periods
+        )
+        .median(  # Compute the median of each window
+            skipna=True
+        )
+        .isel(  # Remove the padded edges
+            time=slice(half_window, -half_window)
+        )
     )
-
     if print_report:
-        report_nan_values(ds, "input dataset")
-        report_nan_values(ds_smoothed, "filtered dataset")
-
-    return ds_smoothed
+        print(report_nan_values(data, "input"))
+        print(report_nan_values(data_smoothed, "output"))
+    return data_smoothed
 
 
 @log_to_attrs
 def savgol_filter(
-    ds: xr.Dataset,
-    window_length: int,
+    data: xr.DataArray,
+    window: int,
     polyorder: int = 2,
     print_report: bool = True,
     **kwargs,
-) -> xr.Dataset:
-    """Smooth pose tracks by applying a Savitzky-Golay filter over time.
+) -> xr.DataArray:
+    """Smooth data by applying a Savitzky-Golay filter over time.
 
     Parameters
     ----------
-    ds : xarray.Dataset
-        Dataset containing position, confidence scores, and metadata.
-    window_length : int
-        The size of the filter window. Window length is interpreted
-        as being in the input dataset's time unit, which can be inspected
-        with ``ds.time_unit``.
+    data : xarray.DataArray
+        The input data to be smoothed.
+    window : int
+        The size of the filter window, representing the fixed number
+        of observations used for each window.
     polyorder : int
         The order of the polynomial used to fit the samples. Must be
-        less than ``window_length``. By default, a ``polyorder`` of
+        less than ``window``. By default, a ``polyorder`` of
         2 is used.
     print_report : bool
         Whether to print a report on the number of NaNs in the dataset
         before and after filtering. Default is ``True``.
     **kwargs : dict
-        Additional keyword arguments are passed to scipy.signal.savgol_filter.
+        Additional keyword arguments are passed to
+        :py:func:`scipy.signal.savgol_filter`.
         Note that the ``axis`` keyword argument may not be overridden.
 
 
     Returns
     -------
-    ds_smoothed : xarray.Dataset
-        The provided dataset (ds), where pose tracks have been smoothed
-        using a Savitzky-Golay filter with the provided parameters.
+    xarray.DataArray
+        The data smoothed using a Savitzky-Golay filter with the
+        provided parameters.
 
     Notes
     -----
-    Uses the ``scipy.signal.savgol_filter`` function to apply a Savitzky-Golay
-    filter to the input dataset's ``position`` variable.
-    See the scipy documentation for more information on that function.
+    Uses the :py:func:`scipy.signal.savgol_filter` function to apply a
+    Savitzky-Golay filter to the input data.
+    See the SciPy documentation for more information on that function.
     Whenever one or more NaNs are present in a filter window of the
-    input dataset, a NaN is returned to the output array. As a result, any
-    stretch of NaNs present in the input dataset will be propagated
-    proportionally to the size of the window in frames (specifically, by
-    ``floor(window_length/2)``). Note that, unlike
-    ``movement.filtering.median_filter()``, there is no ``min_periods``
+    input data, a NaN is returned to the output array. As a result, any
+    stretch of NaNs present in the input data will be propagated
+    proportionally to the size of the window (specifically, by
+    ``floor(window/2)``). Note that, unlike
+    :py:func:`movement.filtering.median_filter()`, there is no ``min_periods``
     option to control this behaviour.
 
     """
@@ -301,25 +233,15 @@ def savgol_filter(
         raise log_error(
             ValueError, "The 'axis' argument may not be overridden."
         )
-
-    ds_smoothed = ds.copy()
-
-    if ds.time_unit == "seconds":
-        window_length = int(window_length * ds.fps)
-
-    position_smoothed = signal.savgol_filter(
-        ds.position,
-        window_length,
+    data_smoothed = data.copy()
+    data_smoothed.values = signal.savgol_filter(
+        data,
+        window,
         polyorder,
         axis=0,
         **kwargs,
     )
-    position_smoothed_da = ds.position.copy(data=position_smoothed)
-
-    ds_smoothed.update({"position": position_smoothed_da})
-
     if print_report:
-        report_nan_values(ds, "input dataset")
-        report_nan_values(ds_smoothed, "filtered dataset")
-
-    return ds_smoothed
+        print(report_nan_values(data, "input"))
+        print(report_nan_values(data_smoothed, "output"))
+    return data_smoothed
diff --git a/movement/move_accessor.py b/movement/move_accessor.py
index 406550d..e625283 100644
--- a/movement/move_accessor.py
+++ b/movement/move_accessor.py
@@ -5,6 +5,7 @@ from typing import ClassVar
 
 import xarray as xr
 
+from movement import filtering
 from movement.analysis import kinematics
 from movement.utils.logging import log_error
 from movement.validators.datasets import ValidPosesDataset
@@ -61,7 +62,9 @@ class MovementDataset:
         """Forward requested but undefined attributes to relevant modules.
 
         This method currently only forwards kinematic property computation
-        to the respective functions in the ``kinematics``  module.
+        and filtering operations to the respective functions in
+        :py:mod:`movement.analysis.kinematics` and
+        :py:mod:`movement.filtering`.
 
         Parameters
         ----------
@@ -81,29 +84,157 @@ class MovementDataset:
         """
 
         def method(*args, **kwargs):
-            if not name.startswith("compute_") or not hasattr(
-                kinematics, name
-            ):
+            if hasattr(kinematics, name):
+                return self.kinematics_wrapper(name, *args, **kwargs)
+            elif hasattr(filtering, name):
+                return self.filtering_wrapper(name, *args, **kwargs)
+            else:
                 error_msg = (
                     f"'{self.__class__.__name__}' object has "
                     f"no attribute '{name}'"
                 )
                 raise log_error(AttributeError, error_msg)
-            if not hasattr(self._obj, "position"):
-                raise log_error(
-                    AttributeError,
-                    "Missing required data variables: 'position'",
-                )
-            try:
-                return getattr(kinematics, name)(
-                    self._obj.position, *args, **kwargs
-                )
-            except Exception as e:
-                error_msg = f"Failed to evoke '{name}'. "
-                raise log_error(AttributeError, error_msg) from e
 
         return method
 
+    def kinematics_wrapper(
+        self, fn_name: str, *args, **kwargs
+    ) -> xr.DataArray:
+        """Provide convenience method for computing kinematic properties.
+
+        This method forwards kinematic property computation
+        to the respective functions in :py:mod:`movement.analysis.kinematics`.
+
+        Parameters
+        ----------
+        fn_name : str
+            The name of the kinematics function to call.
+        args : tuple
+            Positional arguments to pass to the function.
+        kwargs : dict
+            Keyword arguments to pass to the function.
+
+        Returns
+        -------
+        xarray.DataArray
+            The computed kinematics attribute value.
+
+        Raises
+        ------
+        RuntimeError
+            If the requested function fails to execute.
+
+        Examples
+        --------
+        Compute ``displacement`` based on the ``position`` data variable
+        in the Dataset ``ds`` and store the result in ``ds``.
+
+        >>> ds["displacement"] = ds.move.compute_displacement()
+
+        Compute ``velocity`` based on the ``position`` data variable in
+        the Dataset ``ds`` and store the result in ``ds``.
+
+        >>> ds["velocity"] = ds.move.compute_velocity()
+
+        Compute ``acceleration`` based on the ``position`` data variable
+        in the Dataset ``ds`` and store the result in ``ds``.
+
+        >>> ds["acceleration"] = ds.move.compute_acceleration()
+
+        """
+        try:
+            return getattr(kinematics, fn_name)(
+                self._obj.position, *args, **kwargs
+            )
+        except Exception as e:
+            error_msg = (
+                f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}"
+            )
+            raise log_error(RuntimeError, error_msg) from e
+
+    def filtering_wrapper(
+        self, fn_name: str, *args, data_vars: list[str] | None = None, **kwargs
+    ) -> xr.DataArray | dict[str, xr.DataArray]:
+        """Provide convenience method for filtering data variables.
+
+        This method forwards filtering and/or smoothing to the respective
+        functions in :py:mod:`movement.filtering`. The data variables to
+        filter can be specified in ``data_vars``. If ``data_vars`` is not
+        specified, the ``position`` data variable is selected by default.
+
+        Parameters
+        ----------
+        fn_name : str
+            The name of the filtering function to call.
+        args : tuple
+            Positional arguments to pass to the function.
+        data_vars : list[str] | None
+            The data variables to apply filtering. If ``None``, the
+            ``position`` data variable will be passed by default.
+        kwargs : dict
+            Keyword arguments to pass to the function.
+
+        Returns
+        -------
+        xarray.DataArray | dict[str, xarray.DataArray]
+            The filtered data variable or a dictionary of filtered data
+            variables, if multiple data variables are specified.
+
+        Raises
+        ------
+        RuntimeError
+            If the requested function fails to execute.
+
+        Examples
+        --------
+        Filter the ``position`` data variable to drop points with
+        ``confidence`` below 0.7 and store the result back into the
+        Dataset ``ds``.
+        Since ``data_vars`` is not supplied, the filter will be applied to
+        the ``position`` data variable by default.
+
+        >>> ds["position"] = ds.move.filter_by_confidence(threshold=0.7)
+
+        Apply a median filter to the ``position`` data variable and
+        store this back into the Dataset ``ds``.
+
+        >>> ds["position"] = ds.move.median_filter(window=3)
+
+        Apply a Savitzky-Golay filter to both the ``position`` and
+        ``velocity`` data variables and store these back into the
+        Dataset ``ds``. ``filtered_data`` is a dictionary, where the keys
+        are the data variable names and the values are the filtered
+        DataArrays.
+
+        >>> filtered_data = ds.move.savgol_filter(
+        ...     window=3, data_vars=["position", "velocity"]
+        ... )
+        >>> ds.update(filtered_data)
+
+        """
+        ds = self._obj
+        if data_vars is None:  # Default to filter on position
+            data_vars = ["position"]
+        if fn_name == "filter_by_confidence":
+            # Add confidence to kwargs
+            kwargs["confidence"] = ds.confidence
+        try:
+            result = {
+                data_var: getattr(filtering, fn_name)(
+                    ds[data_var], *args, **kwargs
+                )
+                for data_var in data_vars
+            }
+            # Return DataArray if result only has one key
+            if len(result) == 1:
+                return result[list(result.keys())[0]]
+            return result
+        except Exception as e:
+            error_msg = (
+                f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}"
+            )
+            raise log_error(RuntimeError, error_msg) from e
+
     def validate(self) -> None:
         """Validate the dataset.
 
diff --git a/movement/utils/logging.py b/movement/utils/logging.py
index 9311711..14add0a 100644
--- a/movement/utils/logging.py
+++ b/movement/utils/logging.py
@@ -1,6 +1,8 @@
 """Logging utilities for the movement package."""
 
 import logging
+from datetime import datetime
+from functools import wraps
 from logging.handlers import RotatingFileHandler
 from pathlib import Path
 
@@ -105,3 +107,34 @@ def log_warning(message: str, logger_name: str = "movement"):
     """
     logger = logging.getLogger(logger_name)
     logger.warning(message)
+
+
+def log_to_attrs(func):
+    """Log the operation performed by the wrapped function.
+
+    This decorator appends log entries to the data's ``log``
+    attribute. The wrapped function must accept an :py:class:`xarray.Dataset`
+    or :py:class:`xarray.DataArray` as its first argument and return an
+    object of the same type.
+    """
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        result = func(*args, **kwargs)
+
+        log_entry = {
+            "operation": func.__name__,
+            "datetime": str(datetime.now()),
+            **{f"arg_{i}": arg for i, arg in enumerate(args[1:], start=1)},
+            **kwargs,
+        }
+
+        # Append the log entry to the result's attributes
+        if result is not None and hasattr(result, "attrs"):
+            if "log" not in result.attrs:
+                result.attrs["log"] = []
+            result.attrs["log"].append(log_entry)
+
+        return result
+
+    return wrapper
diff --git a/movement/utils/reports.py b/movement/utils/reports.py
new file mode 100644
index 0000000..416baec
--- /dev/null
+++ b/movement/utils/reports.py
@@ -0,0 +1,95 @@
+"""Utility functions for reporting missing data."""
+
+import logging
+
+import xarray as xr
+
+logger = logging.getLogger(__name__)
+
+
+def calculate_nan_stats(
+    data: xr.DataArray,
+    keypoint: str | None = None,
+    individual: str | None = None,
+) -> str:
+    """Calculate NaN stats for a given keypoint and individual.
+
+    This function calculates the number and percentage of NaN points
+    for a given keypoint and individual in the input data. A keypoint
+    is considered NaN if any of its ``space`` coordinates are NaN.
+
+    Parameters
+    ----------
+    data : xarray.DataArray
+        The input data containing ``keypoints`` and ``individuals``
+        dimensions.
+    keypoint : str, optional
+        The name of the keypoint for which to generate the report.
+        If ``None``, it is assumed that the input data contains only
+        one keypoint and this keypoint is used.
+        Default is ``None``.
+    individual : str, optional
+        The name of the individual for which to generate the report.
+        If ``None``, it is assumed that the input data contains only
+        one individual and this individual is used.
+        Default is ``None``.
+
+    Returns
+    -------
+    str
+        A string containing the report.
+
+    """
+    selection_criteria = {}
+    if individual is not None:
+        selection_criteria["individuals"] = individual
+    if keypoint is not None:
+        selection_criteria["keypoints"] = keypoint
+    selected_data = (
+        data.sel(**selection_criteria) if selection_criteria else data
+    )
+    n_nans = selected_data.isnull().any(["space"]).sum(["time"]).item()
+    n_points = selected_data.time.size
+    percent_nans = round((n_nans / n_points) * 100, 1)
+    return f"\n\t\t{keypoint}: {n_nans}/{n_points} ({percent_nans}%)"
+
+
+def report_nan_values(da: xr.DataArray, label: str | None = None) -> str:
+    """Report the number and percentage of keypoints that are NaN.
+
+    Numbers are reported for each individual and keypoint in the data.
+
+    Parameters
+    ----------
+    da : xarray.DataArray
+        The input data containing ``keypoints`` and ``individuals``
+        dimensions.
+    label : str, optional
+        Label to identify the data in the report. If not provided,
+        the name of the DataArray is used as the label.
+        Default is ``None``.
+
+    Returns
+    -------
+    str
+        A string containing the report.
+
+    """
+    # Compile the report
+    label = label or da.name
+    nan_report = f"\nMissing points (marked as NaN) in {label}"
+    # Check if the data has individuals and keypoints dimensions
+    has_individuals_dim = "individuals" in da.dims
+    has_keypoints_dim = "keypoints" in da.dims
+    # Default values for individuals and keypoints
+    individuals = da.individuals.values if has_individuals_dim else [None]
+    keypoints = da.keypoints.values if has_keypoints_dim else [None]
+
+    for ind in individuals:
+        ind_name = ind if ind is not None else da.individuals.item()
+        nan_report += f"\n\tIndividual: {ind_name}"
+        for kp in keypoints:
+            nan_report += calculate_nan_stats(da, keypoint=kp, individual=ind)
+    # Write nan report to logger
+    logger.info(nan_report)
+    return nan_report


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index bb3e77d..dcb8196 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -251,17 +251,20 @@ def valid_poses_dataset(valid_position_array, request):
     except AttributeError:
         array_format = "multi_individual_array"
     position_array = valid_position_array(array_format)
-    n_individuals, n_keypoints = position_array.shape[1:3]
+    n_frames, n_individuals, n_keypoints = position_array.shape[:3]
     return xr.Dataset(
         data_vars={
             "position": xr.DataArray(position_array, dims=dim_names),
             "confidence": xr.DataArray(
-                np.ones(position_array.shape[:-1]),
+                np.repeat(
+                    np.linspace(0.1, 1.0, n_frames),
+                    n_individuals * n_keypoints,
+                ).reshape(position_array.shape[:-1]),
                 dims=dim_names[:-1],
             ),
         },
         coords={
-            "time": np.arange(position_array.shape[0]),
+            "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"],
@@ -328,42 +331,17 @@ def kinematic_property(request):
 
 
 class Helpers:
-    """Generic helper methods for ``movement`` testing modules."""
+    """Generic helper methods for ``movement`` test modules."""
 
     @staticmethod
-    def count_nans(ds):
-        """Count NaNs in the x coordinate timeseries of the first keypoint
-        of the first individual in the dataset.
-        """
-        n_nans = np.count_nonzero(
-            np.isnan(
-                ds.position.isel(individuals=0, keypoints=0, space=0).values
-            )
-        )
-        return n_nans
+    def count_nans(da):
+        """Count number of NaNs in a DataArray."""
+        return da.isnull().sum().item()
 
     @staticmethod
-    def count_nan_repeats(ds):
-        """Count the number of continuous stretches of NaNs in the
-        x coordinate timeseries of the first keypoint of the first individual
-        in the dataset.
-        """
-        x = ds.position.isel(individuals=0, keypoints=0, space=0).values
-        repeats = []
-        running_count = 1
-        for i in range(len(x)):
-            if i != len(x) - 1:
-                if np.isnan(x[i]) and np.isnan(x[i + 1]):
-                    running_count += 1
-                elif np.isnan(x[i]):
-                    repeats.append(running_count)
-                    running_count = 1
-                else:
-                    running_count = 1
-            elif np.isnan(x[i]):
-                repeats.append(running_count)
-                running_count = 1
-        return len(repeats)
+    def count_consecutive_nans(da):
+        """Count occurrences of consecutive NaNs in a DataArray."""
+        return (da.isnull().astype(int).diff("time") == 1).sum().item()
 
 
 @pytest.fixture
diff --git a/tests/test_integration/test_filtering.py b/tests/test_integration/test_filtering.py
index 1f55f9c..90efce6 100644
--- a/tests/test_integration/test_filtering.py
+++ b/tests/test_integration/test_filtering.py
@@ -1,16 +1,13 @@
+from contextlib import nullcontext as does_not_raise
+
 import pytest
+import xarray as xr
 
-from movement.filtering import (
-    filter_by_confidence,
-    interpolate_over_time,
-    median_filter,
-    savgol_filter,
-)
 from movement.io import load_poses
 from movement.sample_data import fetch_dataset_paths
 
 
-@pytest.fixture(scope="module")
+@pytest.fixture
 def sample_dataset():
     """Return a single-animal sample dataset, with time unit in frames.
     This allows us to better control the expected number of NaNs in the tests.
@@ -18,55 +15,112 @@ def sample_dataset():
     ds_path = fetch_dataset_paths("DLC_single-mouse_EPM.predictions.h5")[
         "poses"
     ]
-    return load_poses.from_dlc_file(ds_path, fps=None)
+    ds = load_poses.from_dlc_file(ds_path)
+    ds["velocity"] = ds.move.compute_velocity()
+    return ds
 
 
-@pytest.mark.parametrize("window_length", [3, 5, 6, 13])
-def test_nan_propagation_through_filters(
-    sample_dataset, window_length, helpers
-):
-    """Tests how NaNs are propagated when passing a dataset through multiple
-    filters sequentially. For the ``median_filter`` and ``savgol_filter``,
-    we expect the number of NaNs to increase at most by the filter's window
-    length minus one (``window_length - 1``) multiplied by the number of
-    continuous stretches of NaNs present in the input dataset.
+@pytest.mark.parametrize("window", [3, 5, 6, 13])
+def test_nan_propagation_through_filters(sample_dataset, window, helpers):
+    """Test NaN propagation when passing a DataArray through
+    multiple filters sequentially. For the ``median_filter``
+    and ``savgol_filter``, the number of NaNs is expected to increase
+    at most by the filter's window length minus one (``window - 1``)
+    multiplied by the number of consecutive NaNs in the input data.
     """
     # Introduce nans via filter_by_confidence
-    ds_with_nans = filter_by_confidence(sample_dataset, threshold=0.6)
-    nans_after_confilt = helpers.count_nans(ds_with_nans)
-    nan_repeats_after_confilt = helpers.count_nan_repeats(ds_with_nans)
-    assert nans_after_confilt == 2555, (
-        f"Unexpected number of NaNs in filtered dataset: "
-        f"expected: 2555, got: {nans_after_confilt}"
+    sample_dataset.update(
+        {"position": sample_dataset.move.filter_by_confidence()}
+    )
+    expected_n_nans = 13136
+    n_nans_confilt = helpers.count_nans(sample_dataset.position)
+    assert n_nans_confilt == expected_n_nans, (
+        f"Expected {expected_n_nans} NaNs in filtered data, "
+        f"got: {n_nans_confilt}"
+    )
+    n_consecutive_nans = helpers.count_consecutive_nans(
+        sample_dataset.position
     )
-
     # Apply median filter and check that
     # it doesn't introduce too many or too few NaNs
-    ds_medfilt = median_filter(ds_with_nans, window_length)
-    nans_after_medfilt = helpers.count_nans(ds_medfilt)
-    nan_repeats_after_medfilt = helpers.count_nan_repeats(ds_medfilt)
-    max_nans_increase = (window_length - 1) * nan_repeats_after_confilt
+    sample_dataset.update(
+        {"position": sample_dataset.move.median_filter(window)}
+    )
+    n_nans_medfilt = helpers.count_nans(sample_dataset.position)
+    max_nans_increase = (window - 1) * n_consecutive_nans
     assert (
-        nans_after_medfilt <= nans_after_confilt + max_nans_increase
+        n_nans_medfilt <= n_nans_confilt + max_nans_increase
     ), "Median filter introduced more NaNs than expected."
     assert (
-        nans_after_medfilt >= nans_after_confilt
+        n_nans_medfilt >= n_nans_confilt
     ), "Median filter mysteriously removed NaNs."
+    n_consecutive_nans = helpers.count_consecutive_nans(
+        sample_dataset.position
+    )
 
     # Apply savgol filter and check that
     # it doesn't introduce too many or too few NaNs
-    ds_savgol = savgol_filter(
-        ds_medfilt, window_length, polyorder=2, print_report=True
+    sample_dataset.update(
+        {"position": sample_dataset.move.savgol_filter(window, polyorder=2)}
     )
-    nans_after_savgol = helpers.count_nans(ds_savgol)
-    max_nans_increase = (window_length - 1) * nan_repeats_after_medfilt
+    n_nans_savgol = helpers.count_nans(sample_dataset.position)
+    max_nans_increase = (window - 1) * n_consecutive_nans
     assert (
-        nans_after_savgol <= nans_after_medfilt + max_nans_increase
+        n_nans_savgol <= n_nans_medfilt + max_nans_increase
     ), "Savgol filter introduced more NaNs than expected."
     assert (
-        nans_after_savgol >= nans_after_medfilt
+        n_nans_savgol >= n_nans_medfilt
     ), "Savgol filter mysteriously removed NaNs."
 
-    # Apply interpolate_over_time (without max_gap) to eliminate all NaNs
-    ds_interpolated = interpolate_over_time(ds_savgol, print_report=True)
-    assert helpers.count_nans(ds_interpolated) == 0
+    # Interpolate data (without max_gap) to eliminate all NaNs
+    sample_dataset.update(
+        {"position": sample_dataset.move.interpolate_over_time()}
+    )
+    assert helpers.count_nans(sample_dataset.position) == 0
+
+
+@pytest.mark.parametrize(
+    "method",
+    [
+        "filter_by_confidence",
+        "interpolate_over_time",
+        "median_filter",
+        "savgol_filter",
+    ],
+)
+@pytest.mark.parametrize(
+    "data_vars, expected_exception",
+    [
+        (None, does_not_raise(xr.DataArray)),
+        (["position", "velocity"], does_not_raise(dict)),
+        (["vlocity"], pytest.raises(RuntimeError)),  # Does not exist
+    ],
+)
+def test_accessor_filter_method(
+    sample_dataset, method, data_vars, expected_exception
+):
+    """Test that filtering methods in the ``move`` accessor
+    return the expected data type and structure, and the
+    expected ``log`` attribute containing the filtering method
+    applied, if valid data variables are passed, otherwise
+    raise an exception.
+    """
+    with expected_exception as expected_type:
+        if method in ["median_filter", "savgol_filter"]:
+            # supply required "window" argument
+            result = getattr(sample_dataset.move, method)(
+                data_vars=data_vars, window=3
+            )
+        else:
+            result = getattr(sample_dataset.move, method)(data_vars=data_vars)
+        assert isinstance(result, expected_type)
+        if isinstance(result, xr.DataArray):
+            assert hasattr(result, "log")
+            assert result.log[0]["operation"] == method
+        elif isinstance(result, dict):
+            assert set(result.keys()) == set(data_vars)
+            assert all(hasattr(value, "log") for value in result.values())
+            assert all(
+                value.log[0]["operation"] == method
+                for value in result.values()
+            )
diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py
index dbd3994..78b4cf9 100644
--- a/tests/test_integration/test_kinematics_vector_transform.py
+++ b/tests/test_integration/test_kinematics_vector_transform.py
@@ -16,7 +16,7 @@ class TestKinematicsVectorTransform:
         [
             ("valid_poses_dataset", does_not_raise()),
             ("valid_poses_dataset_with_nan", does_not_raise()),
-            ("missing_dim_dataset", pytest.raises(AttributeError)),
+            ("missing_dim_dataset", pytest.raises(RuntimeError)),
         ],
     )
     def test_cart_and_pol_transform(
diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py
index a3500b2..0336f0c 100644
--- a/tests/test_unit/test_filtering.py
+++ b/tests/test_unit/test_filtering.py
@@ -1,125 +1,122 @@
+from contextlib import nullcontext as does_not_raise
+
 import pytest
 import xarray as xr
 
 from movement.filtering import (
     filter_by_confidence,
     interpolate_over_time,
-    log_to_attrs,
     median_filter,
     savgol_filter,
 )
-from movement.sample_data import fetch_dataset
-
-
-@pytest.fixture(scope="module")
-def sample_dataset():
-    """Return a single-animal sample dataset, with time unit in seconds."""
-    return fetch_dataset("DLC_single-mouse_EPM.predictions.h5")
 
 
-def test_log_to_attrs(sample_dataset):
-    """Test for the ``log_to_attrs()`` decorator. Decorates a mock function and
-    checks that ``attrs`` contains all expected values.
+@pytest.mark.parametrize(
+    "max_gap, expected_n_nans", [(None, 0), (1, 8), (2, 0)]
+)
+def test_interpolate_over_time(
+    valid_poses_dataset_with_nan, helpers, max_gap, expected_n_nans
+):
+    """Test that the number of NaNs decreases after interpolating
+    over time and that the resulting number of NaNs is as expected
+    for different values of ``max_gap``.
     """
-
-    @log_to_attrs
-    def fake_func(ds, arg, kwarg=None):
-        return ds
-
-    ds = fake_func(sample_dataset, "test1", kwarg="test2")
-
-    assert "log" in ds.attrs
-    assert ds.attrs["log"][0]["operation"] == "fake_func"
-    assert (
-        ds.attrs["log"][0]["arg_1"] == "test1"
-        and ds.attrs["log"][0]["kwarg"] == "test2"
+    # First dataset with time unit in frames
+    data_in_frames = valid_poses_dataset_with_nan.position
+    # Create second dataset with time unit in seconds
+    data_in_seconds = data_in_frames.copy()
+    data_in_seconds["time"] = data_in_seconds["time"] * 0.1
+    data_interp_frames = interpolate_over_time(data_in_frames, max_gap=max_gap)
+    data_interp_seconds = interpolate_over_time(
+        data_in_seconds, max_gap=max_gap
     )
-
-
-def test_interpolate_over_time(sample_dataset, helpers):
-    """Test the ``interpolate_over_time`` function.
-
-    Check that the number of nans is decreased after running this function
-    on a filtered dataset
+    n_nans_before = helpers.count_nans(data_in_frames)
+    n_nans_after_frames = helpers.count_nans(data_interp_frames)
+    n_nans_after_seconds = helpers.count_nans(data_interp_seconds)
+    # The number of NaNs should be the same for both datasets
+    # as max_gap is based on number of missing observations (NaNs)
+    assert n_nans_after_frames == n_nans_after_seconds
+    assert n_nans_after_frames < n_nans_before
+    assert n_nans_after_frames == expected_n_nans
+
+
+def test_filter_by_confidence(valid_poses_dataset, helpers):
+    """Test that points below the default 0.6 confidence threshold
+    are converted to NaN.
     """
-    ds_filtered = filter_by_confidence(sample_dataset)
-    ds_interpolated = interpolate_over_time(ds_filtered)
-
-    assert helpers.count_nans(ds_interpolated) < helpers.count_nans(
-        ds_filtered
+    data = valid_poses_dataset.position
+    confidence = valid_poses_dataset.confidence
+    data_filtered = filter_by_confidence(data, confidence)
+    n_nans = helpers.count_nans(data_filtered)
+    assert isinstance(data_filtered, xr.DataArray)
+    # 5 timepoints * 2 individuals * 2 keypoints * 2 space dimensions
+    # have confidence below 0.6
+    assert n_nans == 40
+
+
+@pytest.mark.parametrize("window_size", [2, 4])
+def test_median_filter(valid_poses_dataset_with_nan, window_size):
+    """Test that applying the median filter returns
+    a different xr.DataArray than the input data.
+    """
+    data = valid_poses_dataset_with_nan.position
+    data_smoothed = median_filter(data, window_size)
+    del data_smoothed.attrs["log"]
+    assert isinstance(data_smoothed, xr.DataArray) and not (
+        data_smoothed.equals(data)
     )
 
 
-def test_filter_by_confidence(sample_dataset, caplog, helpers):
-    """Tests for the ``filter_by_confidence()`` function.
-    Checks that the function filters the expected amount of values
-    from a known dataset, and tests that this value is logged
-    correctly.
+def test_median_filter_with_nans(valid_poses_dataset_with_nan, helpers):
+    """Test NaN behaviour of the median filter. The input data
+    contains NaNs in all keypoints of the first individual at timepoints
+    3, 7, and 8 (0-indexed, 10 total timepoints). The median filter
+    should propagate NaNs within the windows of the filter,
+    but it should not introduce any NaNs for the second individual.
     """
-    ds_filtered = filter_by_confidence(sample_dataset, threshold=0.6)
-
-    assert isinstance(ds_filtered, xr.Dataset)
-
-    n_nans = helpers.count_nans(ds_filtered)
-    assert n_nans == 2555
-
-    # Check that diagnostics are being logged correctly
-    assert f"snout: {n_nans}/{ds_filtered.time.values.shape[0]}" in caplog.text
+    data = valid_poses_dataset_with_nan.position
+    data_smoothed = median_filter(data, window=3)
+    # All points of the first individual are converted to NaNs except
+    # at timepoints 0, 1, and 5.
+    assert not (
+        data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any()
+    )
+    # 7 timepoints * 1 individual * 2 keypoints * 2 space dimensions
+    assert helpers.count_nans(data_smoothed) == 28
+    # No NaNs should be introduced for the second individual
+    assert not data_smoothed.isel(individuals=1).isnull().any()
 
 
-@pytest.mark.parametrize("window_size", [0.2, 1, 4, 12])
-def test_median_filter(sample_dataset, window_size):
-    """Tests for the ``median_filter()`` function. Checks that
-    the function successfully receives the input data and
-    returns a different xr.Dataset with the correct dimensions.
+@pytest.mark.parametrize("window, polyorder", [(2, 1), (4, 2)])
+def test_savgol_filter(valid_poses_dataset_with_nan, window, polyorder):
+    """Test that applying the Savitzky-Golay filter returns
+    a different xr.DataArray than the input data.
     """
-    ds_smoothed = median_filter(sample_dataset, window_size)
-
-    # Test whether filter received and returned correct data
-    assert isinstance(ds_smoothed, xr.Dataset) and ~(
-        ds_smoothed == sample_dataset
+    data = valid_poses_dataset_with_nan.position
+    data_smoothed = savgol_filter(data, window, polyorder=polyorder)
+    del data_smoothed.attrs["log"]
+    assert isinstance(data_smoothed, xr.DataArray) and not (
+        data_smoothed.equals(data)
     )
-    assert ds_smoothed.position.shape == sample_dataset.position.shape
 
 
-def test_median_filter_with_nans(valid_poses_dataset_with_nan, helpers):
-    """Test nan behavior of the ``median_filter()`` function. The
-    ``valid_poses_dataset_with_nan`` dataset (fixture defined in conftest.py)
+def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers):
+    """Test NaN behaviour of the Savitzky-Golay filter. The input data
     contains NaN values in all keypoints of the first individual at times
     3, 7, and 8 (0-indexed, 10 total timepoints).
-    The median filter should propagate NaNs within the windows of the filter,
-    but it should not introduce any NaNs for the second individual.
+    The Savitzky-Golay filter should propagate NaNs within the windows of
+    the filter, but it should not introduce any NaNs for the second individual.
     """
-    ds_smoothed = median_filter(valid_poses_dataset_with_nan, 3)
-    # There should be NaNs at 7 timepoints for the first individual
+    data = valid_poses_dataset_with_nan.position
+    data_smoothed = savgol_filter(data, window=3, polyorder=2)
+    # There should be 28 NaNs in total for the first individual, i.e.
+    # at 7 timepoints, 2 keypoints, 2 space dimensions
     # all except for timepoints 0, 1 and 5
-    assert helpers.count_nans(ds_smoothed) == 7
-    assert (
-        ~ds_smoothed.position.isel(individuals=0, time=[0, 1, 5])
-        .isnull()
-        .any()
-    )
-    # The second individual should not contain any NaNs
-    assert ~ds_smoothed.position.sel(individuals="ind2").isnull().any()
-
-
-@pytest.mark.parametrize("window_length", [0.2, 1, 4, 12])
-@pytest.mark.parametrize("polyorder", [1, 2, 3])
-def test_savgol_filter(sample_dataset, window_length, polyorder):
-    """Tests for the ``savgol_filter()`` function.
-    Checks that the function successfully receives the input
-    data and returns a different xr.Dataset with the correct
-    dimensions.
-    """
-    ds_smoothed = savgol_filter(
-        sample_dataset, window_length, polyorder=polyorder
-    )
-
-    # Test whether filter received and returned correct data
-    assert isinstance(ds_smoothed, xr.Dataset) and ~(
-        ds_smoothed == sample_dataset
+    assert helpers.count_nans(data_smoothed) == 28
+    assert not (
+        data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any()
     )
-    assert ds_smoothed.position.shape == sample_dataset.position.shape
+    assert not data_smoothed.isel(individuals=1).isnull().any()
 
 
 @pytest.mark.parametrize(
@@ -130,36 +127,20 @@ def test_savgol_filter(sample_dataset, window_length, polyorder):
         {"mode": "nearest", "axis": 1},
     ],
 )
-def test_savgol_filter_kwargs_override(sample_dataset, override_kwargs):
-    """Further tests for the ``savgol_filter()`` function.
-    Checks that the function raises a ValueError when the ``axis`` keyword
-    argument is overridden, as this is not allowed. Overriding other keyword
-    arguments (e.g. ``mode``) should not raise an error.
-    """
-    if "axis" in override_kwargs:
-        with pytest.raises(ValueError):
-            savgol_filter(sample_dataset, 5, **override_kwargs)
-    else:
-        ds_smoothed = savgol_filter(sample_dataset, 5, **override_kwargs)
-        assert isinstance(ds_smoothed, xr.Dataset)
-
-
-def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers):
-    """Test nan behavior of the ``savgol_filter()`` function. The
-    ``valid_poses_dataset_with_nan`` dataset (fixture defined in conftest.py)
-    contains NaN values in all keypoints of the first individual at times
-    3, 7, and 8 (0-indexed, 10 total timepoints).
-    The Savitzky-Golay filter should propagate NaNs within the windows of
-    the filter, but it should not introduce any NaNs for the second individual.
+def test_savgol_filter_kwargs_override(
+    valid_poses_dataset_with_nan, override_kwargs
+):
+    """Test that overriding keyword arguments in the Savitzky-Golay filter
+    works, except for the ``axis`` argument, which should raise a ValueError.
     """
-    ds_smoothed = savgol_filter(valid_poses_dataset_with_nan, 3, polyorder=2)
-    # There should be NaNs at 7 timepoints for the first individual
-    # all except for timepoints 0, 1 and 5
-    assert helpers.count_nans(ds_smoothed) == 7
-    assert (
-        ~ds_smoothed.position.isel(individuals=0, time=[0, 1, 5])
-        .isnull()
-        .any()
+    expected_exception = (
+        pytest.raises(ValueError)
+        if "axis" in override_kwargs
+        else does_not_raise()
     )
-    # The second individual should not contain any NaNs
-    assert ~ds_smoothed.position.sel(individuals="ind2").isnull().any()
+    with expected_exception:
+        savgol_filter(
+            valid_poses_dataset_with_nan.position,
+            window=3,
+            **override_kwargs,
+        )
diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py
index 680c4c9..d0a8c3b 100644
--- a/tests/test_unit/test_logging.py
+++ b/tests/test_unit/test_logging.py
@@ -2,7 +2,7 @@ import logging
 
 import pytest
 
-from movement.utils.logging import log_error, log_warning
+from movement.utils.logging import log_error, log_to_attrs, log_warning
 
 log_messages = {
     "DEBUG": "This is a debug message",
@@ -43,3 +43,29 @@ def test_log_warning(caplog):
     log_warning("This is a test warning")
     assert caplog.records[0].message == "This is a test warning"
     assert caplog.records[0].levelname == "WARNING"
+
+
+@pytest.mark.parametrize("input_data", ["dataset", "dataarray"])
+def test_log_to_attrs(input_data, valid_poses_dataset):
+    """Test that the ``log_to_attrs()`` decorator appends
+    log entries to the output data's ``log`` attribute and
+    checks that ``attrs`` contains all expected values.
+    """
+
+    @log_to_attrs
+    def fake_func(data, arg, kwarg=None):
+        return data
+
+    input_data = (
+        valid_poses_dataset
+        if input_data == "dataset"
+        else valid_poses_dataset.position
+    )
+    output_data = fake_func(input_data, "test1", kwarg="test2")
+
+    assert "log" in output_data.attrs
+    assert output_data.attrs["log"][0]["operation"] == "fake_func"
+    assert (
+        output_data.attrs["log"][0]["arg_1"] == "test1"
+        and output_data.attrs["log"][0]["kwarg"] == "test2"
+    )
diff --git a/tests/test_unit/test_move_accessor.py b/tests/test_unit/test_move_accessor.py
index 76422c9..a5ae835 100644
--- a/tests/test_unit/test_move_accessor.py
+++ b/tests/test_unit/test_move_accessor.py
@@ -21,9 +21,14 @@ class TestMovementDataset:
         self, invalid_poses_dataset, kinematic_property
     ):
         """Test that computing a kinematic property of an invalid
-        pose dataset via accessor methods raises the appropriate error.
+        poses dataset via accessor methods raises the appropriate error.
         """
-        with pytest.raises(AttributeError):
+        expected_exception = (
+            RuntimeError
+            if isinstance(invalid_poses_dataset, xr.Dataset)
+            else AttributeError
+        )
+        with pytest.raises(expected_exception):
             getattr(
                 invalid_poses_dataset.move, f"compute_{kinematic_property}"
             )()
@@ -31,7 +36,7 @@ class TestMovementDataset:
     @pytest.mark.parametrize(
         "method", ["compute_invalid_property", "do_something"]
     )
-    def test_invalid_compute(self, valid_poses_dataset, method):
+    def test_invalid_method_call(self, valid_poses_dataset, method):
         """Test that invalid accessor method calls raise an AttributeError."""
         with pytest.raises(AttributeError):
             getattr(valid_poses_dataset.move, method)()
diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py
new file mode 100644
index 0000000..51d441e
--- /dev/null
+++ b/tests/test_unit/test_reports.py
@@ -0,0 +1,31 @@
+import pytest
+
+from movement.utils.reports import report_nan_values
+
+
+@pytest.mark.parametrize(
+    "data_selection",
+    [
+        lambda ds: ds.position,  # Entire dataset
+        lambda ds: ds.position.sel(
+            individuals="ind1"
+        ),  # Missing "individuals" dim
+        lambda ds: ds.position.sel(
+            keypoints="key1"
+        ),  # Missing "keypoints" dim
+        lambda ds: ds.position.sel(
+            individuals="ind1", keypoints="key1"
+        ),  # Missing "individuals" and "keypoints" dims
+    ],
+)
+def test_report_nan_values(
+    capsys, valid_poses_dataset_with_nan, data_selection
+):
+    """Test that the nan-value reporting function handles data
+    with missing ``individuals`` and/or ``keypoint`` dims, and
+    that the dataset name is included in the report.
+    """
+    data = data_selection(valid_poses_dataset_with_nan)
+    assert data.name in report_nan_values(
+        data
+    ), "Dataset name should be in the output"