├── .github └── workflows │ ├── publish.yml │ └── test-and-lint.yml ├── .gitignore ├── .readthedocs.yaml ├── .ruff.toml ├── CHANGELOG.md ├── CITATION.cff ├── LICENSE ├── README.md ├── _tasks.py ├── docs ├── .gitignore ├── Makefile ├── _static │ └── videos │ │ └── gui_guide.webm ├── conf.py ├── guides │ ├── cal_store.rst │ ├── ferraris_calibration_instructions.png │ ├── ferraris_guide.rst │ └── index.rst ├── index.rst ├── make.bat ├── modules │ └── index.rst └── templates │ ├── class.rst │ ├── class_with_private.rst │ ├── function.rst │ └── numpydoc_docstring.rst ├── example_data ├── __init__.py ├── annotated_session.csv ├── example_ferraris_session.csv ├── example_ferraris_session_list.json └── legacy_calibration_pre_2.0.json ├── examples ├── README.rst ├── basic_ferraris.py └── custom_calibration_info.py ├── imucal ├── __init__.py ├── calibration_gui.py ├── calibration_info.py ├── ferraris_calibration.py ├── ferraris_calibration_info.py ├── legacy.py └── management.py ├── paper ├── img │ └── imucal_ferraris_gui.png ├── imucal.bib └── paper.md ├── poetry.lock ├── pyproject.toml ├── pytest.ini └── tests ├── __init__.py ├── _test_gui.py ├── conftest.py ├── ferraris_example.csv ├── snapshots └── example_cal.json ├── test_calibration_calculation.py ├── test_calibration_info_basic.py ├── test_calibration_info_math.py ├── test_examples.py ├── test_legacy.py └── test_management.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install poetry 25 | - name: Build and publish 26 | run: | 27 | poetry publish -u "${{ secrets.PYPI_USERNAME }}" -p "${{ secrets.PYPI_PASSWORD }}" --build 28 | -------------------------------------------------------------------------------- /.github/workflows/test-and-lint.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test_lint: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.9", "3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install poetry 27 | poetry config virtualenvs.in-project true 28 | poetry install --all-extras 29 | - name: Testing 30 | run: | 31 | poetry run poe test 32 | - name: Linting 33 | if: ${{ matrix.python-version == '3.9' }} 34 | run: | 35 | poetry run poe ci_check 36 | - name: Upload coverage reports to Codecov 37 | if: ${{ matrix.python-version == '3.9' }} 38 | uses: codecov/codecov-action@v3 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.gitignore.io/api/windows,visualstudiocode,python,pycharm+all,linux,jupyternotebooks,macos 4 | # Edit at https://www.gitignore.io/?templates=windows,visualstudiocode,python,pycharm+all,linux,jupyternotebooks,macos 5 | 6 | ### JupyterNotebooks ### 7 | # gitignore template for Jupyter Notebooks 8 | # website: http://jupyter.org/ 9 | 10 | .ipynb_checkpoints 11 | */.ipynb_checkpoints/* 12 | 13 | # IPython 14 | profile_default/ 15 | ipython_config.py 16 | 17 | # Remove previous ipynb_checkpoints 18 | # git rm -r .ipynb_checkpoints/ 19 | 20 | ### Linux ### 21 | *~ 22 | 23 | # temporary files which can be created if a process still has a handle open of a deleted file 24 | .fuse_hidden* 25 | 26 | # KDE directory preferences 27 | .directory 28 | 29 | # Linux trash folder which might appear on any partition or disk 30 | .Trash-* 31 | 32 | # .nfs files are created when an open file is removed but is still being accessed 33 | .nfs* 34 | 35 | ### macOS ### 36 | # General 37 | .DS_Store 38 | .AppleDouble 39 | .LSOverride 40 | 41 | # Icon must end with two \r 42 | Icon 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | 63 | ### PyCharm+all ### 64 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 65 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 66 | 67 | # User-specific stuff 68 | .idea/**/workspace.xml 69 | .idea/**/tasks.xml 70 | .idea/**/usage.statistics.xml 71 | .idea/**/dictionaries 72 | .idea/**/shelf 73 | 74 | # Generated files 75 | .idea/**/contentModel.xml 76 | 77 | # Sensitive or high-churn files 78 | .idea/**/dataSources/ 79 | .idea/**/dataSources.ids 80 | .idea/**/dataSources.local.xml 81 | .idea/**/sqlDataSources.xml 82 | .idea/**/dynamic.xml 83 | .idea/**/uiDesigner.xml 84 | .idea/**/dbnavigator.xml 85 | 86 | # Gradle 87 | .idea/**/gradle.xml 88 | .idea/**/libraries 89 | 90 | # Gradle and Maven with auto-import 91 | # When using Gradle or Maven with auto-import, you should exclude module files, 92 | # since they will be recreated, and may cause churn. Uncomment if using 93 | # auto-import. 94 | # .idea/modules.xml 95 | # .idea/*.iml 96 | # .idea/modules 97 | # *.iml 98 | # *.ipr 99 | 100 | # CMake 101 | cmake-build-*/ 102 | 103 | # Mongo Explorer plugin 104 | .idea/**/mongoSettings.xml 105 | 106 | # File-based project format 107 | *.iws 108 | 109 | # IntelliJ 110 | out/ 111 | 112 | # mpeltonen/sbt-idea plugin 113 | .idea_modules/ 114 | 115 | # JIRA plugin 116 | atlassian-ide-plugin.xml 117 | 118 | # Cursive Clojure plugin 119 | .idea/replstate.xml 120 | 121 | # Crashlytics plugin (for Android Studio and IntelliJ) 122 | com_crashlytics_export_strings.xml 123 | crashlytics.properties 124 | crashlytics-build.properties 125 | fabric.properties 126 | 127 | # Editor-based Rest Client 128 | .idea/httpRequests 129 | 130 | # Android studio 3.1+ serialized cache file 131 | .idea/caches/build_file_checksums.ser 132 | 133 | ### PyCharm+all Patch ### 134 | # Ignores the whole .idea folder and all .iml files 135 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 136 | 137 | .idea/ 138 | 139 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 140 | 141 | *.iml 142 | modules.xml 143 | .idea/misc.xml 144 | *.ipr 145 | 146 | # Sonarlint plugin 147 | .idea/sonarlint 148 | 149 | ### Python ### 150 | # Byte-compiled / optimized / DLL files 151 | __pycache__/ 152 | *.py[cod] 153 | *$py.class 154 | 155 | # C extensions 156 | *.so 157 | 158 | # Distribution / packaging 159 | .Python 160 | build/ 161 | develop-eggs/ 162 | dist/ 163 | downloads/ 164 | eggs/ 165 | .eggs/ 166 | lib/ 167 | lib64/ 168 | parts/ 169 | sdist/ 170 | var/ 171 | wheels/ 172 | pip-wheel-metadata/ 173 | share/python-wheels/ 174 | *.egg-info/ 175 | .installed.cfg 176 | *.egg 177 | MANIFEST 178 | 179 | # PyInstaller 180 | # Usually these files are written by a python script from a template 181 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 182 | *.manifest 183 | *.spec 184 | 185 | # Installer logs 186 | pip-log.txt 187 | pip-delete-this-directory.txt 188 | 189 | # Unit test / coverage reports 190 | htmlcov/ 191 | .tox/ 192 | .nox/ 193 | .coverage 194 | .coverage.* 195 | .cache 196 | nosetests.xml 197 | coverage.xml 198 | *.cover 199 | .hypothesis/ 200 | .pytest_cache/ 201 | 202 | # Translations 203 | *.mo 204 | *.pot 205 | 206 | # Scrapy stuff: 207 | .scrapy 208 | 209 | # Sphinx documentation 210 | docs/_build/ 211 | 212 | # PyBuilder 213 | target/ 214 | 215 | # pyenv 216 | .python-version 217 | 218 | # pipenv 219 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 220 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 221 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 222 | # install all needed dependencies. 223 | #Pipfile.lock 224 | 225 | # celery beat schedule file 226 | celerybeat-schedule 227 | 228 | # SageMath parsed files 229 | *.sage.py 230 | 231 | # Spyder project settings 232 | .spyderproject 233 | .spyproject 234 | 235 | # Rope project settings 236 | .ropeproject 237 | 238 | # Mr Developer 239 | .mr.developer.cfg 240 | .project 241 | .pydevproject 242 | 243 | # mkdocs documentation 244 | /site 245 | 246 | # mypy 247 | .mypy_cache/ 248 | .dmypy.json 249 | dmypy.json 250 | 251 | # Pyre type checker 252 | .pyre/ 253 | 254 | ### VisualStudioCode ### 255 | .vscode/* 256 | !.vscode/settings.json 257 | !.vscode/tasks.json 258 | !.vscode/launch.json 259 | !.vscode/extensions.json 260 | 261 | ### VisualStudioCode Patch ### 262 | # Ignore all local history of files 263 | .history 264 | 265 | ### Windows ### 266 | # Windows thumbnail cache files 267 | Thumbs.db 268 | Thumbs.db:encryptable 269 | ehthumbs.db 270 | ehthumbs_vista.db 271 | 272 | # Dump file 273 | *.stackdump 274 | 275 | # Folder config file 276 | [Dd]esktop.ini 277 | 278 | # Recycle Bin used on file shares 279 | $RECYCLE.BIN/ 280 | 281 | # Windows Installer files 282 | *.cab 283 | *.msi 284 | *.msix 285 | *.msm 286 | *.msp 287 | 288 | # Windows shortcuts 289 | *.lnk 290 | 291 | # End of https://www.gitignore.io/api/windows,visualstudiocode,python,pycharm+all,linux,jupyternotebooks,macos 292 | 293 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 294 | 295 | # Virtual env 296 | .venv 297 | 298 | # doit 299 | .doit.* 300 | 301 | # vscode (full) 302 | .vscode 303 | 304 | # jupyter notebooks (we do not use them) 305 | *.ipynb 306 | .virtual_documents 307 | 308 | # Just a folder for local tests 309 | _debug_test 310 | 311 | # Compiled doc examples 312 | docs/examples 313 | 314 | # Joss paper 315 | paper.jats 316 | paper.pdf 317 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | build: 13 | os: "ubuntu-20.04" 14 | tools: 15 | python: "3.10" 16 | jobs: 17 | post_create_environment: 18 | - pip install poetry 19 | post_install: 20 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --all-extras 21 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | target-version = "py39" 3 | 4 | [lint] 5 | select = [ 6 | # pyflakes 7 | "F", 8 | # pycodestyle 9 | "E", 10 | "W", 11 | # mccabe 12 | "C90", 13 | # isort 14 | "I", 15 | # pydocstyle 16 | "D", 17 | # pyupgrade 18 | "UP", 19 | # pep8-naming 20 | "N", 21 | # flake8-blind-except 22 | "BLE", 23 | # flake8-2020 24 | "YTT", 25 | # flake8-builtins 26 | "A", 27 | # flake8-comprehensions 28 | "C4", 29 | # flake8-debugger 30 | "T10", 31 | # flake8-errmsg 32 | "EM", 33 | # flake8-implicit-str-concat 34 | "ISC", 35 | # flake8-pytest-style 36 | "PT", 37 | # flake8-return 38 | "RET", 39 | # flake8-simplify 40 | "SIM", 41 | # flake8-unused-arguments 42 | "ARG", 43 | # pandas-vet 44 | "PD", 45 | # pygrep-hooks 46 | "PGH", 47 | # flake8-bugbear 48 | "B", 49 | # flake8-quotes 50 | "Q", 51 | # pylint 52 | "PL", 53 | # flake8-pie 54 | "PIE", 55 | # flake8-type-checking 56 | "TCH", 57 | # tryceratops 58 | "TRY", 59 | # flake8-use-pathlib 60 | "PTH", 61 | "RUF", 62 | # Numpy rules 63 | "NPY", 64 | # Implicit namespace packages 65 | "INP", 66 | # No relative imports 67 | "TID252", 68 | # f-strings over string concatenation 69 | "FLY", 70 | ] 71 | 72 | ignore = [ 73 | # controversial 74 | "B006", 75 | # controversial 76 | "B008", 77 | "B010", 78 | # Magic constants 79 | "PLR2004", 80 | # Strings in error messages 81 | "EM101", 82 | "EM102", 83 | "EM103", 84 | # Exception strings 85 | "TRY003", 86 | # Varaibles before return 87 | "RET504", 88 | # Abstract raise into inner function 89 | "TRY301", 90 | # df as varaible name 91 | "PD901", 92 | # melt over stack 93 | "PD013", 94 | # No Any annotations 95 | "ANN401", 96 | # Self annotation 97 | "ANN101", 98 | # To many arguments 99 | "PLR0913", 100 | # Class attribute shadows builtin 101 | "A003", 102 | # No typing for `cls` 103 | "ANN102", 104 | # Ignore because of formatting 105 | "ISC001", 106 | ] 107 | 108 | 109 | exclude = [ 110 | "doc/sphinxext/*.py", 111 | "doc/build/*.py", 112 | "doc/temp/*.py", 113 | ".eggs/*.py", 114 | "example_data", 115 | ] 116 | 117 | [lint.pydocstyle] 118 | convention = "numpy" 119 | 120 | [format] 121 | docstring-code-format = true 122 | docstring-code-line-length = 80 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) (+ the Migration Guide section), and 5 | this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | # [2.6.0] - 31.10.2024 8 | 9 | - Added the option to control the initial figure size of the interactive plot. This should help resolve [#24](https://github.com/mad-lab-fau/imucal/issues/24) 10 | 11 | # [2.5.0] - 31.10.2024 12 | 13 | - Tooling overhaul to make the development process easier and fix doc building issues 14 | - A bunch of quality of life improvements for the GUI. 15 | You can now use shortcuts (o, p, h, b, and f) to activate the matplib tools for zooming, panning, home, back, and forward. 16 | Once zoomed in, you can use the scrollwheel to scroll along the x-axis. 17 | The first section is also activated by default now, so you can start labeling right away. 18 | - Updated minimal versions of dependencies. Notable: pandas >=2.0, python >=3.9 19 | 20 | # [2.4.0] - 26.05.2023 21 | 22 | - Tooling overhaul to make the development process easier and fix doc building issues 23 | - Changed some ValueError to TypeError 24 | 25 | # [2.3.1] - 17.10.2022 26 | 27 | - Fixed import so that no tkinter or matplotlib are required when the GUI is not required 28 | 29 | # [2.3.0] - 17.10.2022 30 | 31 | - Removed upper version bounds to reduce the chance of version conflicts 32 | 33 | # [2.2.1] - 02.05.2022 34 | 35 | - Some minor updates to README.md 36 | - Improved the doc building process: No additional requirements.txt file! 37 | - JOSS paper accepted! :) 38 | 39 | # [2.2.0] - 26.04.2022 40 | 41 | - Dropped support for `h5py` < 3.0.0. This should only affect users using the hdf5 export feature. 42 | Dropping old versions allows for proper support of Python 3.10. 43 | 44 | # [2.1.1] - 04.04.2022 45 | 46 | - Looser version requirements for typing-extensions 47 | 48 | # [2.1.0] - 08.03.2022 49 | 50 | - switched from `distutils` to `packaging` for version handling. 51 | - Added a paper describing the basics of imucal for submission in JOSS 52 | - Fixed small issues in README and docs 53 | - manual gui test is working again 54 | - dependency updates 55 | 56 | # [2.0.2] - 02.11.2021 57 | 58 | ### Changed 59 | 60 | - Minor typos 61 | - Zenodo Release 62 | - Coverage badge 63 | 64 | # [2.0.1] - 02.03.2021 65 | 66 | ### Changed 67 | 68 | - Made sure that tkinter is only imported, if the GUI is really used. 69 | This is important if the library is used in a context were no graphical output is possible (e.g. a Docker container). 70 | Previous versions of imucal would result in an import error in these situations. 71 | 72 | # [2.0.0] - 09.01.2021 73 | 74 | 2.0 is a rewrite of a lot of the API and requires multiple changes to legacy code using this library. 75 | Please refer to the migration guide for more information. 76 | During this refactoring multiple bugs were fixed as well. 77 | Therefore, it is highly suggested upgrading to the new version, even if it takes some work! 78 | 79 | ### Added 80 | 81 | - A new `calibrate_df` method for the `CalibrationInfo` that can calibrate df directly. 82 | - It is now possible to define which `CalibrationInfo` subclass should be used by the `FerrarisCalibration` 83 | - A set of "management" functions to save, find, and load IMU calibrations of multiple sensors 84 | - The ability to add a custom comment to a `CalibrationInfo` 85 | - The user is now forced to provide the units of the input data to avoid applying calibrations that were meant for unit 86 | conversion. 87 | - Applying a calibration now checks if the units of your data match with the input unit of the calibration. 88 | - The export format of calibration-info objects is now versioned. 89 | This helps to make changes to the format in the future while still supporting old exports. 90 | See the migration guide for more information. 91 | - Helper functions to load "legacy" calibration info objects. (`imucal.legacy`) 92 | 93 | ### Changed 94 | 95 | - `FerrarisCalibration` has a new interface. 96 | Instead of providing all calibration and data related parameters in the `__init__`, the `__init__` is now only used 97 | to configure the calibration. 98 | The data and all data related parameter are now passed to the `compute` method (replaces `compute_calibration_matrix`) 99 | - Using `from_...` constructors on a subclass of `CalibrationInfo` does not search all subclasses of `CalibrationInfo` 100 | anymore, but only the subclasses (and the class itself), it is called on. 101 | For example, `FerrarisCalibrationInfo.from_json` will only consider subclasses of `FerrarisCalibrationInfo`, but not 102 | other subclasses of `CalibrationInfo`. 103 | - The short hand "gyro" is not replaced with "gyr" in all parameter and variable names. 104 | This might cause an issue when loading old calibration files. 105 | 106 | ### Deprecated 107 | 108 | ### Removed 109 | 110 | - `FerrarisCalibration` does not have any `from_...` constructors anymore. 111 | The functionality of these constructors can now be accessed via the `ferraris_regions_from_...` helper functions. 112 | - It is not possible anymore to calibrate the acc and gyro separately. 113 | No one was using this feature, and hence, was removed to simplify the API. 114 | 115 | ### Fixed 116 | 117 | ### Migration Guide 118 | 119 | - The main change is how `FerrarisCalibration` and `TurntableCalibration` are used. 120 | Before you would do: 121 | ```python 122 | from imucal import FerrarisCalibration 123 | 124 | cal, section_list = FerrarisCalibration.from_interactive_plot(data, sampling_rate=sampling_rate) 125 | cal_info = cal.compute_calibration_matrix() 126 | ``` 127 | 128 | Now you need to first create your Ferraris sections and then provide them as arguments for the `compute` method: 129 | 130 | ```python 131 | from imucal import FerrarisCalibration, ferraris_regions_from_interactive_plot 132 | 133 | sections, section_list = ferraris_regions_from_interactive_plot(data) 134 | cal = FerrarisCalibration() 135 | cal_info = cal.compute(sections, sampling_rate_hz=sampling_rate, from_acc_unit="m/s^2", from_gyr_unit="deg/s") 136 | ``` 137 | 138 | Note, that you are also forced to provide the units of the input data. 139 | We always recommend to first turn your data into the same units you would expect after the calibration and then using 140 | the calibrations as refinement. 141 | - `CalibrationInfot.calibrate` now requires you to specify the units of your data and validates that they match the 142 | units expected by the calibration. 143 | You need to add the parameters `acc_unit` and `gyr_unit` to all calls to calibrate. 144 | Note, that older calibrations will not have `from` units and hence, can not perform this check. 145 | In this case you can set the units to `None` to avoid an error. 146 | However, it is recommended to recreate the calibrations with proper `from` units. 147 | - If you were using `from_json_file` before, double check, if this still works for you, as the way the correct baseclass 148 | is selected have been chosen. 149 | In any case, you should consider to use `imucal.management.load_calibration_info` instead, as it is more flexible. 150 | - If you were using any parameters or package variables, that contained the short hand `gyro`, replace it with `gyr`. 151 | Note that this also effects the exported calibration files. 152 | You will not be able to load them unless you replace `gyro_unit` with `gyr_unit` in all files. 153 | - The `CalibrationInfo` objects now have more fields by default. 154 | To avoid issues with missing values, we highly recommend recreating all calibration files you have using the original 155 | session data and the most current version of `imucal`. 156 | Alternatively you can use the functions provided in `imucal.legacy` to load the old 157 | calibration. 158 | Then you can modify the loaded calibration info object and save it again to replace the old calibration. 159 | 160 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Küderle" 5 | given-names: "Arne" 6 | orcid: "https://orcid.org/0000-0002-5686-281X" 7 | - family-names: "Roth" 8 | given-names: "Nils" 9 | orcid: "https://orcid.org/0000-0002-9166-3920" 10 | - family-names: "Richer" 11 | given-names: "Robert" 12 | orcid: "https://orcid.org/0000-0003-0272-5403" 13 | - family-names: "Eskofier" 14 | given-names: "Bjoern M." 15 | orcid: "https://orcid.org/0000-0002-0417-0336" 16 | title: "imucal - A Python library to calibrate 6 DOF IMUs" 17 | version: 2.0.2 18 | doi: 10.17605/OSF.IO/37TD9 19 | url: "https://github.com/mad-lab-fau/imucal" 20 | 21 | preferred-citation: 22 | type: article 23 | authors: 24 | - family-names: "Küderle" 25 | given-names: "Arne" 26 | orcid: "https://orcid.org/0000-0002-5686-281X" 27 | - family-names: "Roth" 28 | given-names: "Nils" 29 | orcid: "https://orcid.org/0000-0002-9166-3920" 30 | - family-names: "Richer" 31 | given-names: "Robert" 32 | orcid: "https://orcid.org/0000-0003-0272-5403" 33 | - family-names: "Eskofier" 34 | given-names: "Bjoern M." 35 | orcid: "https://orcid.org/0000-0002-0417-0336" 36 | doi: 10.21105/joss.04338 37 | journal: "Journal of Open Source Software" 38 | start: 4338 39 | title: "imucal - A Python library to calibrate 6 DOF IMUs" 40 | issue: 73 41 | volume: 7 42 | year: 2022 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 MaD-Lab Erlangen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imucal - A Python library to calibrate 6 DOF IMUs 2 | ![Test and Lint](https://github.com/mad-lab-fau/imucal/workflows/Test%20and%20Lint/badge.svg) 3 | [![codecov](https://codecov.io/gh/mad-lab-fau/imucal/branch/master/graph/badge.svg?token=0OPHTQDYIB)](https://codecov.io/gh/mad-lab-fau/imucal) 4 | [![Documentation Status](https://readthedocs.org/projects/imucal/badge/?version=latest)](https://imucal.readthedocs.io/en/latest/?badge=latest) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![PyPI](https://img.shields.io/pypi/v/imucal)](https://pypi.org/project/imucal/) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/imucal) 8 | [![status](https://joss.theoj.org/papers/3dd1a7dd5ba06ce024326eee2e9be148/status.svg)](https://joss.theoj.org/papers/3dd1a7dd5ba06ce024326eee2e9be148) 9 | 10 | This package provides methods to calculate and apply calibrations for 6 DOF IMUs based on multiple different methods. 11 | 12 | So far supported are: 13 | 14 | - Ferraris Calibration ([Ferraris1994](https://www.sciencedirect.com/science/article/pii/0924424794800316) / [Ferraris1995](https://www.researchgate.net/publication/245080041_Calibration_of_three-axial_rate_gyros_without_angular_velocity_standards)) 15 | - Ferraris Calibration using a Turntable 16 | 17 | For more information check the quickstart guide below and the [documentation](https://imucal.readthedocs.io/en/latest/). 18 | 19 | ## Installation 20 | 21 | ``` 22 | pip install imucal 23 | ``` 24 | 25 | To use the included calibration GUI you also need [matplotlib](https://pypi.org/project/matplotlib/) (version >2.2). 26 | You can install it using: 27 | 28 | ``` 29 | pip install imucal[calplot] 30 | ``` 31 | 32 | ### Supported Python versions and Platforms 33 | 34 | `imucal` is officially tested on Python 3.7, 3.8, 3.9 and 3.10. 35 | It should further work with all major operating systems. 36 | 37 | ## Quickstart 38 | This package implements the IMU-infield calibration based on [Ferraris1995](https://www.researchgate.net/publication/245080041_Calibration_of_three-axial_rate_gyros_without_angular_velocity_standards). 39 | This calibration method requires the IMU data from 6 static positions (3 axes parallel and antiparallel to the gravitation 40 | vector) for calibrating the accelerometer and 3 rotations around the 3 main axes for calibrating the gyroscope. 41 | In this implementation, these parts are referred to as `{acc,gyr}_{x,y,z}_{p,a}` for the static regions and 42 | `{acc,gyr}_{x,y,z}_rot` for the rotations. 43 | As example, `acc_y_a` would be the 3D-acceleration data measured during a static phase, where the **y-axis** was 44 | oriented **antiparallel** to the gravitation vector. 45 | For more information on how to perform the calibration check [our guide](https://imucal.readthedocs.io/en/latest/guides/ferraris_guide.html). 46 | 47 | For the calibration, you need to separate your data into these individual sections. 48 | If you already recorded them separately or know where each section starts and ends in a continuous recording, you can 49 | use [`ferraris_regions_from_df`](https://imucal.readthedocs.io/en/latest/modules/generated/imucal.ferraris_regions_from_df.html) 50 | and [`ferraris_regions_from_section_list`](https://imucal.readthedocs.io/en/latest/modules/generated/imucal.ferraris_regions_from_section_list.html), 51 | respectively to convert the data into the correct format for the calibration (`section_data` in the snippet below). 52 | 53 | If you don't have that information yet, we recommend to use the included GUI to annotate the data. 54 | To annotate a Ferraris calibration session that was recorded in a single go, you can use the following code snippet. 55 | **Note**: This will open an interactive Tkinter plot. Therefore, this will only work on your local PC and not on a server or remote hosted Jupyter instance. 56 | 57 | ```python 58 | from imucal import ferraris_regions_from_interactive_plot 59 | 60 | # Your data as a 6 column dataframe 61 | data = ... 62 | 63 | section_data, section_list = ferraris_regions_from_interactive_plot( 64 | data, acc_cols=["acc_x", "acc_y", "acc_z"], gyr_cols=["gyr_x", "gyr_y", "gyr_z"] 65 | ) 66 | # Save the section list as reference for the future 67 | section_list.to_csv('./calibration_sections.csv') # This is optional, but recommended 68 | ``` 69 | 70 | Independent of how you obtained the `section_data` in the correct format, you can now calculate the calibration 71 | parameters: 72 | 73 | ```python 74 | from imucal import FerrarisCalibration 75 | 76 | sampling_rate = 100 #Hz 77 | cal = FerrarisCalibration() 78 | cal_mat = cal.compute(section_data, sampling_rate, from_acc_unit="m/s^2", from_gyr_unit="deg/s") 79 | # `cal_mat` is your final calibration matrix object you can use to calibrate data 80 | cal_mat.to_json_file('./calibration.json') 81 | ``` 82 | 83 | Applying a calibration: 84 | 85 | ```python 86 | from imucal.management import load_calibration_info 87 | 88 | cal_mat = load_calibration_info('./calibration.json') 89 | new_data = pd.DataFrame(...) 90 | calibrated_data = cal_mat.calibrate_df(new_data, acc_unit="m/s^2", gyr_unit="deg/s") 91 | ``` 92 | 93 | For further information on how to perform a calibration check the 94 | [User Guides](https://imucal.readthedocs.io/en/latest/guides/index.html) or the 95 | [Examples](https://imucal.readthedocs.io/en/latest/auto_examples/index.html). 96 | 97 | ## Further Calibration Methods 98 | 99 | At the moment, this package only implements calibration methods based on Ferraris1994/95, because this is what we use to 100 | calibrate our IMUs. 101 | We are aware that various other methods exist and would love to add them to this package as well. 102 | Unfortunately, at the moment we can not justify the time investment. 103 | 104 | Still, we think that this package provides a suitable framework to implement other calibration methods with relative 105 | ease. 106 | If you would like to contribute such a method, let us know via [GitHub Issue](https://github.com/mad-lab-fau/imucal/issues), and we will try to help you as good 107 | as possible. 108 | 109 | ## Citation 110 | 111 | If you are using `imucal` in your scientific work, we would appreciate if you would cite our [JOSS paper](https://joss.theoj.org/papers/3dd1a7dd5ba06ce024326eee2e9be148) or link the project. 112 | 113 | ``` 114 | Küderle, Arne, Nils Roth, Robert Richer, and Bjoern M. Eskofier. 115 | “Imucal - A Python Library to Calibrate 6 DOF IMUs.” 116 | Journal of Open Source Software 7, no. 73 (May 26, 2022): 4338. https://doi.org/10.21105/joss.04338. 117 | ``` 118 | 119 | ## Contributing 120 | 121 | All project management and development happens through [this GitHub project](https://github.com/mad-lab-fau/imucal). 122 | If you have any issues, ideas, or any comments at all, just open a new issue. 123 | We are always happy when people are interested to use our work and would like to support you in this process. 124 | In particular, we want to welcome contributions of new calibration algorithms, to make this package even more useful for a wider audience. 125 | 126 | ## Dev Setup 127 | 128 | We use [poetry](https://python-poetry.org) to manage our dependencies. 129 | Therefore, you need to first install Poetry locally on you machine. 130 | 131 | Then you can run the following command to install a local development version of this library in a dedicated venv. 132 | 133 | ```bash 134 | git clone https://github.com/mad-lab-fau/imucal 135 | cd imucal 136 | poetry install --all-extras 137 | ``` 138 | 139 | To run tests/the linter/... we use [poethepoet](https://github.com/nat-n/poethepoet). 140 | You can see all available commands by running: 141 | 142 | ``` 143 | poetry run poe list 144 | ``` 145 | 146 | This should show you all configured commands: 147 | 148 | ``` 149 | CONFIGURED TASKS 150 | format 151 | lint Lint all files with ruff. 152 | ci_check Check all potential format and linting issues. 153 | test Run Pytest with coverage. 154 | docs Build the html docs using Sphinx. 155 | bump_version 156 | ``` 157 | 158 | You execute any command by running 159 | 160 | ``` 161 | poetry run doit 162 | ``` 163 | 164 | ### Updating dependencies 165 | 166 | If you update or add dependencies using (`poetry add` or `poetry update`) you will see that the `pyproject.toml` and the `poetry.lock` files are both updated. 167 | Make sure you commit the changes to **both** files. 168 | Otherwise, wrong versions of dependencies will be used in the CI and by other developers. 169 | 170 | In case you update dependencies by directly editing the `pyproject.toml` file, you need to be very careful and make sure, you run `poetry lock [--no-update]` afterwards. 171 | Otherwise, the lock file will be out of date. 172 | 173 | In general, it is a good idea to just run `poetry update` from time to time. 174 | This will install the latest version of all dependencies that are still allowed by the version constrains in the `pyproject.toml`. 175 | This allows to check, if everything still works well with the newest versions of all libraries. 176 | -------------------------------------------------------------------------------- /_tasks.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import re 3 | import shutil 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | 8 | HERE = Path(__file__).parent 9 | 10 | 11 | def task_docs() -> None: 12 | """Build the html docs using Sphinx.""" 13 | # Delete Autogenerated files from previous run 14 | shutil.rmtree(str(HERE / "docs/modules/generated"), ignore_errors=True) 15 | 16 | if platform.system() == "Windows": 17 | subprocess.run([HERE / "docs/make.bat", "html"], shell=False, check=True) 18 | else: 19 | subprocess.run(["make", "-C", HERE / "docs", "html"], shell=False, check=True) 20 | 21 | 22 | def update_version_strings(file_path, new_version): 23 | # taken from: 24 | # https://stackoverflow.com/questions/57108712/replace-updated-version-strings-in-files-via-python 25 | version_regex = re.compile(r"(^_*?version_*?\s*=\s*\")(\d+\.\d+\.\d+-?\S*)\"", re.M) 26 | with open(file_path, "r+") as f: 27 | content = f.read() 28 | f.seek(0) 29 | f.write( 30 | re.sub( 31 | version_regex, 32 | lambda match: f'{match.group(1)}{new_version}"', 33 | content, 34 | ) 35 | ) 36 | f.truncate() 37 | 38 | 39 | def update_version(version): 40 | subprocess.run(["poetry", "version", version], shell=False, check=True) 41 | new_version = ( 42 | subprocess.run(["poetry", "version"], shell=False, check=True, capture_output=True) 43 | .stdout.decode() 44 | .strip() 45 | .split(" ", 1)[1] 46 | ) 47 | update_version_strings(HERE.joinpath("imucal/__init__.py"), new_version) 48 | 49 | 50 | def task_update_version(): 51 | update_version(sys.argv[1]) 52 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | modules/generated 2 | auto_examples 3 | README.md -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/videos/gui_guide.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mad-lab-fau/imucal/8716e4c50bbb7044918b747f38b26cb0a6420910/docs/_static/videos/gui_guide.webm -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import re 15 | import sys 16 | from datetime import datetime 17 | from importlib import import_module 18 | from inspect import getsourcefile, getsourcelines 19 | from pathlib import Path 20 | from shutil import copy 21 | 22 | import toml 23 | 24 | sys.path.insert(0, os.path.abspath("..")) 25 | 26 | import contextlib 27 | from typing import Optional 28 | 29 | import imucal 30 | 31 | URL = "https://github.com/mad-lab-fau/imucal" 32 | 33 | # -- Copy README file -------------------------------------------------------- 34 | copy(Path("../README.md"), Path("./README.md")) 35 | 36 | # -- Project information ----------------------------------------------------- 37 | 38 | # Info from poetry config: 39 | info = toml.load("../pyproject.toml")["tool"]["poetry"] 40 | 41 | project = info["name"] 42 | author = ", ".join(info["authors"]) 43 | release = info["version"] 44 | copyright = f"2018 - {datetime.now().year}, MaD-Lab FAU, Digital Health - Gait Analytics Group" 45 | 46 | # -- General configuration --------------------------------------------------- 47 | 48 | # Add any Sphinx extension module names here, as strings. They can be 49 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 50 | # ones. 51 | extensions = [ 52 | "sphinx.ext.autodoc", 53 | "sphinx.ext.autosummary", 54 | "numpydoc", 55 | "sphinx.ext.linkcode", 56 | "sphinx.ext.doctest", 57 | "sphinx.ext.intersphinx", 58 | "sphinx_gallery.gen_gallery", 59 | "recommonmark", 60 | ] 61 | 62 | # this is needed for some reason... 63 | # see https://github.com/numpy/numpydoc/issues/69 64 | numpydoc_class_members_toctree = False 65 | 66 | # Taken from sklearn config 67 | # For maths, use mathjax by default and svg if NO_MATHJAX env variable is set 68 | # (useful for viewing the doc offline) 69 | if os.environ.get("NO_MATHJAX"): 70 | extensions.append("sphinx.ext.imgmath") 71 | imgmath_image_format = "svg" 72 | mathjax_path = "" 73 | else: 74 | extensions.append("sphinx.ext.mathjax") 75 | mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/" "tex-chtml.js" 76 | 77 | autodoc_default_options = {"members": True, "inherited-members": True, "special_members": True} 78 | # autodoc_typehints = 'description' # Does not work as expected. Maybe try at future date again 79 | 80 | # Add any paths that contain templates here, relative to this directory. 81 | templates_path = ["templates"] 82 | 83 | # generate autosummary even if no references 84 | autosummary_generate = True 85 | autosummary_generate_overwrite = True 86 | 87 | # List of patterns, relative to source directory, that match files and 88 | # directories to ignore when looking for source files. 89 | # This pattern also affects html_static_path and html_extra_path. 90 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "templates"] 91 | 92 | # The reST default role (used for this markup: `text`) to use for all 93 | # documents. 94 | default_role = "literal" 95 | 96 | # If true, '()' will be appended to :func: etc. cross-reference text. 97 | add_function_parentheses = False 98 | 99 | 100 | # -- Options for HTML output ------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | # 105 | # Activate the theme. 106 | html_theme = "pydata_sphinx_theme" 107 | html_theme_options = {"show_prev_next": False, "github_url": URL} 108 | 109 | # Add any paths that contain custom static files (such as style sheets) here, 110 | # relative to this directory. They are copied after the builtin static files, 111 | # so a file named "default.css" will overwrite the builtin "default.css". 112 | html_static_path = ["_static"] 113 | 114 | # -- Options for extensions -------------------------------------------------- 115 | # Intersphinx 116 | 117 | # intersphinx configuration 118 | intersphinx_module_mapping = { 119 | "numpy": ("https://numpy.org/doc/stable/", None), 120 | "scipy": ("https://docs.scipy.org/doc/scipy/", None), 121 | "matplotlib": ("https://matplotlib.org/stable/", None), 122 | "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), 123 | } 124 | 125 | intersphinx_mapping = { 126 | "python": (f"https://docs.python.org/{sys.version_info.major}", None), 127 | **intersphinx_module_mapping, 128 | } 129 | 130 | # Sphinx Gallery 131 | sphinx_gallery_conf = { 132 | "examples_dirs": ["../examples"], 133 | "gallery_dirs": ["./auto_examples"], 134 | "reference_url": {"imucal": None}, 135 | "backreferences_dir": "modules/generated/backreferences", 136 | "doc_module": ("imucal",), 137 | "filename_pattern": re.escape(os.sep), 138 | "remove_config_comments": True, 139 | "show_memory": True, 140 | } 141 | 142 | # Linkcode 143 | 144 | 145 | def get_nested_attr(obj, attr): 146 | attrs = attr.split(".", 1) 147 | new_obj = getattr(obj, attrs[0]) 148 | if len(attrs) == 1: 149 | return new_obj 150 | else: 151 | return get_nested_attr(new_obj, attrs[1]) 152 | 153 | 154 | def linkcode_resolve(domain, info) -> Optional[str]: 155 | if domain != "py": 156 | return None 157 | if not info["module"]: 158 | return None 159 | module = import_module(info["module"]) 160 | obj = get_nested_attr(module, info["fullname"]) 161 | code_line = None 162 | filename = "" 163 | with contextlib.suppress(Exception): 164 | filename = str(Path(getsourcefile(obj)).relative_to(Path(getsourcefile(imucal)).parent.parent)) 165 | 166 | with contextlib.suppress(Exception): 167 | code_line = getsourcelines(obj)[-1] 168 | 169 | if filename: 170 | if code_line: 171 | return f"{URL}/tree/master/{filename}#L{code_line}" 172 | return f"{URL}/tree/master/{filename}" 173 | return None 174 | 175 | 176 | def skip_properties(app, what, name, obj, skip, options) -> Optional[bool]: 177 | """This removes all properties from the documentation as they are expected to be documented in the docstring.""" 178 | if isinstance(obj, property): 179 | return True 180 | return None 181 | 182 | 183 | def setup(app) -> None: 184 | app.connect("autodoc-skip-member", skip_properties) 185 | -------------------------------------------------------------------------------- /docs/guides/cal_store.rst: -------------------------------------------------------------------------------- 1 | .. _cal_store_guide: 2 | 3 | ===================================================== 4 | Storing and Managing Calibrations of Multiple Sensors 5 | ===================================================== 6 | 7 | When you build or use multiple sensors it is advisable to store all calibrations at a central location and with a 8 | certain structure to ensure that the correct calibration is used every time. 9 | Keep in mind that it is usually required to store multiple calibration files per sensor. 10 | Over time and when measuring in vastly different environmental conditions, the calibration coefficients of the sensor 11 | might change. 12 | This management system should fulfill the following requirements: 13 | 14 | * It should be easy to find calibrations belonging to a specific sensor 15 | * It should be easy to add new calibrations 16 | * It should be possible to find calibrations with certain requirements 17 | * All users should have easy access to the calibrations and all updates 18 | 19 | 20 | Storing Meta-information for Calibrations 21 | ========================================= 22 | 23 | To store the actual calibration information, you can simply use the :meth:`~imucal.CalibrationInfo.to_json_file` and 24 | :meth:`~imucal.CalibrationInfo.to_hdf5` methods of the :class:`~imucal.CalibrationInfo` objects. 25 | However, `imucal` does explicitly not include meta information of a calibration (besides the generic `comments` field), 26 | as we can simply not anticipate what you might want to do with your sensors or calibrations. 27 | To store such meta-information, you basically have three options: 28 | 29 | 1. Store meta-information separately and not in the actual calibration json/hdf5 file. 30 | You could put that information into a second file or encode it in the file or folder name. 31 | 2. Create a custom writer that extracts the information from the calibration-info objects and store them in your own 32 | file format that includes the meta-information you require. 33 | You could use the :meth:`~imucal.CalibrationInfo.to_json` method to get a json string that could be embedded in a 34 | larger json file. 35 | For many sensors it might also be feasible to ditch files entirely and write a custom interface to store all the 36 | information in a database. 37 | 3. Create a subclass of :class:`~imucal.CalibrationInfo` and include the meta-information you need as additional fields. 38 | This will ensure that this information is included in all json and hdf5 exports. 39 | This is covered in this example (TODO!). 40 | 41 | 42 | In the following, we will first discuss which meta-information you might typically require and then explain an 43 | implementation of option 1 (that we use internally as well) and provide further information, when the other cases might 44 | be useful. 45 | 46 | Which Meta-information Should I Save? 47 | ------------------------------------- 48 | 49 | Primarily, the meta-information is there to ensure that you apply the correct calibration to the correct recordings. 50 | This means you should at least store the following: 51 | 52 | * Unique identifier for your sensor. 53 | * Sensor settings that might change calibration-relevant parameters. 54 | This will depend on your sensor, but some units have a different bias when certain settings are chosen. 55 | * The datetime at which the calibration was performed. 56 | * Special circumstances that might have effected the outcome of the calibration. 57 | 58 | Based on this meta information, you should then be able to select the correct calibration for any recording. 59 | 60 | Where to Store Meta-information? 61 | -------------------------------- 62 | 63 | For a small team of tech-savvy users with a medium amount of sensors (<100) we suggest to encode all information in 64 | file and folder names and to store the final calibration files as json (and the raw calibration-session recordings) 65 | at a shared network location everyone can access. 66 | This way, the calibration files can be easily searched with a simple file browser or a small script that performs 67 | regex checks against the filenames. 68 | 69 | The exact names and the way how you encode the information relevant to you in the filenames, is of course up to you. 70 | For us, the two important pieces of information are the sensor ID and the datetime. 71 | Therefore, we store the files using the following format: 72 | 73 | `base_folder/{sensor_id}/{cal_info.CAL_TYPE}/{sensor_id}_%Y-%m-%d_%H-%M.json` 74 | 75 | Note that we include the calibration type in the folder structure as well. 76 | This is because we usually have turntable and manual calibration for many sensors. 77 | As these two methods might have different accuracy, we decided that this is an important piece of information that you 78 | should get immediately when looking through the folder structure manually. 79 | If you want to use the same structure as we, you can simply use the function 80 | :func:`~imucal.management.save_calibration_info` from `imucal.management` . 81 | 82 | Besides storing a json export of the actual calibration matrices, we recommend storing the raw data of the calibration 83 | session and the session-list produced by :func:`~imucal.ferraris_regions_from_interactive_plot` (if used). 84 | We store these files in a separate file tree with an identical naming scheme. 85 | Further, we maintain a small script that can recreate all calibration files based on these raw data. 86 | 87 | To give you some further inside into how we manage these files: We actually keep all calibration files under version 88 | control. 89 | We have two repositories. The first stores the raw calibration session and session list (using `git-lfs` for the raw 90 | sessions). 91 | The second repository contains just the final calibrations and is installable as a python package. 92 | This makes it easy to install and keep it as a fixed version dependency for a data analysis project. 93 | When we want to update the calibrations, we push a new raw session and its annotation to the first repository. 94 | Through continuous integration this triggers the calculation of the actual calibration object, which is then 95 | automatically exported as json and pushed to the second repository. 96 | 97 | Note that our solution works for our specific use case and our specific work environment, which consists of tech-savvy 98 | users that are part of our team, and use Python and git as art of their workflow anyway. 99 | If you want to provide calibration files to end users, we would recommend the second option listed above and create a 100 | database to store all calibrations. 101 | Whatever type of end user interface you deploy for your customers can then access this database. 102 | 103 | The third option, extending the :class:`~imucal.CalibrationInfo` class, is only recommended if you have pieces of 104 | meta-information that fundamentally change how a calibration should be applied (i.e., similar to the expected units 105 | of the input data) or are actually required as part of the calibration procedure. 106 | If you do that, make sure that you provide a new `CAL_TYPE` value for the subclass and use it when calculating the 107 | calibration. 108 | Otherwise, loading the stored files with :func:`~imucal.management.load_calibration_info` is not possible. 109 | 110 | Finding Calibration Files 111 | ========================= 112 | 113 | If you followed our example and stored meta information encoded in file and folder names, you can use simple regex 114 | searches to find calibrations that fulfill your specifications. 115 | To make that even easier for you, we provide the functions :func:`~imucal.management.find_calibration_info_for_sensor` 116 | and :func:`~imucal.management.find_closest_calibration_info_to_date` to simplify the two most typical queries, namely, 117 | finding all calibrations for a sensor and finding the calibration that was performed closest (timewise) 118 | to the actual recording. 119 | Both functions further allow you to filter based on the calibration type and provide a custom validation to check 120 | parameters inside the calibration file (e.g. the expected units of the input data). 121 | Note that these functions expect you to store the calibrations using :func:`~imucal.management.save_calibration_info`. 122 | 123 | In general, it is the best to use a calibration that was recorded as close as possible before the actual recording. 124 | However, it will depend on your application and tolerances which criteria you should use. 125 | 126 | 127 | Further Notes 128 | ============= 129 | 130 | Our unique identifier for the sensors is based on the internal mac-addresses of the bluetooth chip. 131 | While this sounds like a good choice initially, there are situations where we expect the calibration information to 132 | change. 133 | To name a few: A new IMU-chip is soldered onto the board, the sensor board is transferred into a new enclosure 134 | (this does not change scaling factors, but might change the expected directions relative to the casing), the board was 135 | damaged and resoldered in a reflow oven, etc. 136 | 137 | With these things in mind, we would advise to maintain an additional version number that really uniquely identifies 138 | a sensor unit/configuration and not just a sensor board. 139 | -------------------------------------------------------------------------------- /docs/guides/ferraris_calibration_instructions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mad-lab-fau/imucal/8716e4c50bbb7044918b747f38b26cb0a6420910/docs/guides/ferraris_calibration_instructions.png -------------------------------------------------------------------------------- /docs/guides/ferraris_guide.rst: -------------------------------------------------------------------------------- 1 | .. _ferraris_guide: 2 | 3 | ======================================== 4 | Ferraris Calibration – A Practical Guide 5 | ======================================== 6 | 7 | This guide will provide you a brief overview on how to perform a Ferraris Calibration and provide you with some 8 | experience-based best practices. 9 | 10 | The Ferraris Calibration is a relatively simple calibration for 6 DOF IMUs as it only consists of placing the sensor 11 | units on each side and rotating them around each axis. 12 | These motions can either be performed entirely by hand or assisted by a turntable. 13 | The method was first described by Ferraris et al. in 1994 (full text of the paper is hard to find online, but the 14 | authors of this package might be able to help you with finding a copy). 15 | Compared to directly calibrating the Gyroscope using a defined rate of rotation, this method uses a rotation of a fixed 16 | angle. 17 | This makes it feasible to perform the calibration without the use of expensive equipment. 18 | 19 | What You Need 20 | ============= 21 | 22 | 1. A 6 DOF sensor unit that can record or stream the raw IMU data. 23 | 2. (Optional) A calibration adapter for the IMU unit if the sensor does not have flat perpendicular sides. 24 | 3. A flat and level surface 25 | 4. A fixed flat object (like a box) that can be used as rotation guide 26 | 27 | Step by Step 28 | ============ 29 | 30 | Before you start, you need to decide which directions the sensor axis (x, y, z) should have after the calibration. 31 | It is not important in which direction the actual "hardware" axes of the IMU point. 32 | The calibration procedure will transform the "hardware" axis into the newly calibrated coordinate system, independent of 33 | the orientation. 34 | Note, that the calibrated coordinate system must be **right-handed**. 35 | 36 | 1. Check that your surface is level. During the procedure, avoid moving/bumping the surface 37 | 2. (Optional) Place the sensor unit in its calibration adapter. 38 | Make sure that it does not wobble and use the same orientation when calibrating multiple sensor units. 39 | 3. Place the sensor on the flat surface in a way that the calibrated x-axis points upwards. 40 | In this configuration, mark the top left corner using a marker or a sticker. 41 | We will use it as guide for all further calibration positions (see image). 42 | 4. Prepare the sensor unit to record the calibration. 43 | You can either record each calibration position/motion as a separate recording or you can make a single large 44 | recording and use :func:`~imucal.ferraris_regions_from_interactive_plot` to mark the different sections after the 45 | recording. 46 | While the first option sounds like less manual work, it is more error prone and still requires some manual cleaning 47 | for the best possible results. 48 | Therefore, we suggest to use the second method. 49 | 5. Go through the calibration positions/motions shown below and record them with the IMU unit. 50 | Keep the sensor at least 3 seconds in each static position. 51 | To perform the rotations, press the sensor against a box to define a start position. 52 | Slowly move it forward a little, perform the rotation and press it against the box to mark the end position. 53 | This ensures that you really performed a rotation of 360 deg. 54 | Avoid, wobbling/tilting the sensor during the rotation. 55 | If this is difficult, consider designing a calibration adapter with larger flat surfaces. 56 | **Note:** This is the most sensitive part of the calibration! 57 | Therefore, go slow and consistent to avoid additional movements that should not be part of the rotation. 58 | 6. End your recording and load the data onto a PC. 59 | Then follow :ref:`the code part of this tutorial `. 60 | 61 | .. image:: ./ferraris_calibration_instructions.png 62 | 63 | General Tips and Gotchas 64 | ======================== 65 | 66 | - Over time or due to changes in operating condition (e.g. change of temperature) a recalibration might be required. 67 | For the best results, calibrate close to your actual measurement under the same conditions. 68 | However, for many applications you will also get great results without such a rigorous calibration protocol. 69 | - It is always advisable to perform sanity checks of the calculated results! 70 | - For the rotations, make sure the final signal used for the calibration contains only the rotation and little to no 71 | resting or other movements before and after. 72 | Because the calibration uses an integration, such additional movements would introduce calibration errors. 73 | - If you have multiple sensors to calibrate, make sure you think of a way to store and retrieve the calibration 74 | information. 75 | For further information on this, read :ref:`our dedicated guide on this topic ` 76 | - Be aware of the signal units before and after calibration! 77 | Some sensor units provide data in a raw format (voltage or bits) with no real unit (a.u.), while others will provide 78 | the data in physical units. 79 | Independent of the input, the Ferraris Calibration will find a transformation to convert these values to m/s^2 and 80 | deg/s (with the default settings). 81 | However, if the input was in some raw format, the calibration will depend on the configured range of the sensor, 82 | as these raw outputs are usually scaled accordingly. 83 | If you want to get a calibration that can be used independent of the sensor range, it is advisable to first convert 84 | the raw output into physical units using the conversion equations provided by the manufacturer and then refine the 85 | results using a calibration. 86 | In any case, you should record the used input units (from-units) with each calibration to avoid applying it to the 87 | wrong data. 88 | 89 | -------------------------------------------------------------------------------- /docs/guides/index.rst: -------------------------------------------------------------------------------- 1 | .. _guides: 2 | 3 | =========== 4 | User Guides 5 | =========== 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | Performing a Ferraris Calibration 11 | Storing Calibration Files 12 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | imucal Docu Overview 3 | ======================== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | Readme 9 | guides/index.rst 10 | modules/index.rst 11 | auto_examples/index.rst 12 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | Ferraris(based) Calibrations 6 | ============================ 7 | 8 | Calibration Classes 9 | ------------------- 10 | Classes to actually calculate calibrations based on the Ferraris method. 11 | 12 | .. currentmodule:: imucal 13 | .. autosummary:: 14 | :toctree: generated 15 | :template: class_with_private.rst 16 | 17 | FerrarisCalibration 18 | TurntableCalibration 19 | 20 | Calibration Info Classes 21 | ------------------------ 22 | 23 | Class objects representing the calibration results and have methods to apply these calibrations to data, save them to 24 | disk and load them again. 25 | 26 | .. currentmodule:: imucal 27 | .. autosummary:: 28 | :toctree: generated 29 | :template: class.rst 30 | 31 | FerrarisCalibrationInfo 32 | TurntableCalibrationInfo 33 | 34 | Data Preparation Helper 35 | ----------------------- 36 | 37 | Helper Functions to generate valid input data for Ferraris-like calibrations. 38 | 39 | .. currentmodule:: imucal 40 | .. autosummary:: 41 | :toctree: generated 42 | :template: function.rst 43 | 44 | ferraris_regions_from_interactive_plot 45 | ferraris_regions_from_df 46 | ferraris_regions_from_section_list 47 | 48 | .. autosummary:: 49 | :toctree: generated 50 | :template: class.rst 51 | 52 | FerrarisSignalRegions 53 | 54 | 55 | Calibration File Management 56 | =========================== 57 | 58 | .. automodule:: imucal.management 59 | :no-members: 60 | :no-inherited-members: 61 | 62 | .. currentmodule:: imucal.management 63 | .. autosummary:: 64 | :toctree: generated 65 | :template: function.rst 66 | 67 | load_calibration_info 68 | save_calibration_info 69 | find_calibration_info_for_sensor 70 | find_closest_calibration_info_to_date 71 | 72 | .. autosummary:: 73 | :toctree: generated 74 | :template: class.rst 75 | 76 | CalibrationWarning 77 | 78 | 79 | Legacy Support 80 | ============== 81 | 82 | .. automodule:: imucal.legacy 83 | :no-members: 84 | :no-inherited-members: 85 | 86 | .. currentmodule:: imucal.legacy 87 | .. autosummary:: 88 | :toctree: generated 89 | :template: function.rst 90 | 91 | load_v1_json_files 92 | load_v1_json 93 | 94 | 95 | Label GUI 96 | ========= 97 | The gui label class. 98 | Normally, you do not need to interact with these directly. 99 | 100 | .. currentmodule:: imucal.calibration_gui 101 | 102 | Classes 103 | ------- 104 | .. autosummary:: 105 | :toctree: generated 106 | :template: class_with_private.rst 107 | 108 | CalibrationGui 109 | 110 | Constants 111 | ========= 112 | 113 | 114 | 115 | Base Classes 116 | ============ 117 | This is only interesting for developers! 118 | 119 | .. currentmodule:: imucal 120 | 121 | .. autosummary:: 122 | :toctree: generated 123 | :template: class_with_private.rst 124 | 125 | CalibrationInfo 126 | -------------------------------------------------------------------------------- /docs/templates/class.rst: -------------------------------------------------------------------------------- 1 | {{module}}.{{objname}} 2 | {{ underline }}============== 3 | 4 | .. currentmodule:: {{ module }} 5 | 6 | .. autoclass:: {{ objname }} 7 | 8 | {% block methods %} 9 | .. automethod:: __init__ 10 | {% endblock %} 11 | 12 | .. include:: /modules/generated/backreferences/{{module}}.{{objname}}.examples 13 | -------------------------------------------------------------------------------- /docs/templates/class_with_private.rst: -------------------------------------------------------------------------------- 1 | :mod:`{{module}}`.{{objname}} 2 | {{ underline }}============== 3 | 4 | .. currentmodule:: {{ module }} 5 | 6 | .. autoclass:: {{ objname }} 7 | :private-members: 8 | 9 | {% block methods %} 10 | .. automethod:: __init__ 11 | {% endblock %} 12 | 13 | .. include:: /modules/generated/backreferences/{{module}}.{{objname}}.examples 14 | -------------------------------------------------------------------------------- /docs/templates/function.rst: -------------------------------------------------------------------------------- 1 | {{module}}.{{objname}} 2 | {{ underline }}==================== 3 | 4 | .. currentmodule:: {{ module }} 5 | 6 | .. autofunction:: {{ objname }} 7 | 8 | .. include:: /modules/generated/backreferences/{{module}}.{{objname}}.examples 9 | -------------------------------------------------------------------------------- /docs/templates/numpydoc_docstring.rst: -------------------------------------------------------------------------------- 1 | {{index}} 2 | {{summary}} 3 | {{extended_summary}} 4 | {{parameters}} 5 | {{returns}} 6 | {{yields}} 7 | {{other_parameters}} 8 | {{attributes}} 9 | {{raises}} 10 | {{warns}} 11 | {{warnings}} 12 | {{see_also}} 13 | {{notes}} 14 | {{references}} 15 | {{examples}} 16 | {{methods}} 17 | -------------------------------------------------------------------------------- /example_data/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | EXAMPLE_PATH = Path(__file__).parent 4 | -------------------------------------------------------------------------------- /example_data/example_ferraris_session_list.json: -------------------------------------------------------------------------------- 1 | {"x_p":{"start":540,"end":1271},"x_a":{"start":1620,"end":2361},"y_p":{"start":2814,"end":3298},"y_a":{"start":3740,"end":4152},"z_p":{"start":4522,"end":4975},"z_a":{"start":5376,"end":5983},"x_rot":{"start":6770,"end":7093},"y_rot":{"start":8081,"end":8405},"z_rot":{"start":9205,"end":9512}} -------------------------------------------------------------------------------- /example_data/legacy_calibration_pre_2.0.json: -------------------------------------------------------------------------------- 1 | {"K_a": [[208.0872101632679, 0.0, 0.0], [0.0, 209.27545798890046, 0.0], [0.0, 0.0, 213.65073106754224]], "R_a": [[0.9998620291746435, -0.01483057100492679, -0.007481763056406507], [0.008576740442555634, 0.9999615197419686, 0.001843518026355982], [0.013331187730679013, 0.002003617949970859, 0.999909128345571]], "b_a": [112.13215955810813, -128.64258204284693, 83.27016485374816], "K_g": [[16.84151216420952, 0.0, 0.0], [0.0, 16.09609664670747, 0.0], [0.0, 0.0, 16.356319026301364]], "R_g": [[0.9999793425280371, -0.00045421361799804144, -0.006411568231274508], [-0.00022220540636464127, 0.9999961009525418, -0.002783649487862619], [0.00971400449849657, 0.00764109329294033, 0.9999236229882219]], "K_ga": [[0.006383388123673677, -0.007506418795761887, -0.0004889775217182036], [0.007078089693795729, 0.0079808097411764, 0.010123020998329641], [0.0016329203640659267, -0.0014926956472095258, 0.003860846398736716]], "b_g": [-9.824970828471413, -6.05950991831972, 0.9629521586931156], "cal_type": "Ferraris", "acc_unit": "m/s^2", "gyro_unit": "deg/s"} -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | .. _examples-index: 2 | 3 | Gallery of Examples 4 | =================== -------------------------------------------------------------------------------- /examples/basic_ferraris.py: -------------------------------------------------------------------------------- 1 | r""" 2 | .. _basic_ferraris: 3 | 4 | Annotate a session and perform a Ferraris Calibration 5 | ===================================================== 6 | 7 | The following example demonstrates the main workflow we will use when working with `imucal`. 8 | 9 | We assume that you recorded a Ferraris session with you IMU unit following our :ref:`tutorial `. 10 | We further assume that you recorded all calibration steps in a single recording and still need to annotate the various 11 | sections. 12 | If you have already separated them, you can create a instance of :class:`~imucal.FerrarisSignalRegions` or use 13 | :func:`~imucal.ferraris_regions_from_df`. 14 | 15 | But let's start with the tutorial 16 | """ 17 | 18 | # %% 19 | # Loading the session 20 | # ------------------- 21 | # First we need to load the recorded session. 22 | # This will depend on you sensor unit. 23 | # In this example we have exported the sensor data as csv first, so that we can easily import it. 24 | # Note, that the sensor data already has the units `m/s^2` and `deg/s` and not some raw bit values, which you might 25 | # encounter when just streaming raw sensor values from a custom IMU board. 26 | # We highly recommend to perform this conversion using the equations provided in the IMU chip documentation before 27 | # applying a Ferraris calibration. 28 | # This will ensure that you calibration is independent of the selected sensor ranges and you do not need to record a 29 | # new calibration, when you change these settings. 30 | from pathlib import Path 31 | 32 | import pandas as pd 33 | 34 | from example_data import EXAMPLE_PATH 35 | 36 | data = pd.read_csv(EXAMPLE_PATH / "example_ferraris_session.csv", header=0, index_col=0) 37 | 38 | data.head() 39 | 40 | # %% 41 | # Annotating the data 42 | # ------------------- 43 | # Now we need to annotate the different sections of the ferraris calibration in the interactive GUI. 44 | # Note, that this will only work, if your Python process runs on your machine and not some kind of sever. 45 | # Otherwise, the GUI will not open. 46 | # 47 | # To start the annotation, run: 48 | # 49 | # >>> from imucal import ferraris_regions_from_interactive_plot 50 | # >>> regions, section_list = ferraris_regions_from_interactive_plot(data) 51 | # 52 | # You can see in the gif below, how you can annotate the session. 53 | # 54 | # Some Notes: 55 | # 56 | # * If your data has other column names than `acc_x, ..., gyr_z`, you can provide them in the function call 57 | # * If you have recorded the section in the correct order, you can just jump to the next section by pressing Enter 58 | # * If you need to annotate/correct a specific section, click on it in the sidebar 59 | # * If you use the zoom or pan tool of matplotlib, you need to deselect it again, before you can make annotations 60 | # * Once you annotated all sections, simply close the plot 61 | # * In the video you can see that the gyro rotations are split up in 4 sections of 90 deg instead of one 360 deg 62 | # rotation. 63 | # This was simply how the operator performed the rotation in this case. 64 | # But usually you would expect a single large spike there. 65 | # 66 | # .. raw:: html 67 | # 68 | # 72 | # 73 | # Instead of performing the annotation in this example, we will load the section list from a previous annotation of the 74 | # data. 75 | # In general it is advisable to save the annotated sections, so that you can rerun the calibration in the future. 76 | from imucal import ferraris_regions_from_section_list 77 | 78 | section_list = pd.read_json(EXAMPLE_PATH / "example_ferraris_session_list.json").T 79 | 80 | section_list 81 | 82 | # %% 83 | # This section list can then be used to recreate the regions 84 | regions = ferraris_regions_from_section_list(data, section_list) 85 | regions 86 | 87 | # %% 88 | # Now we can calculate the actual calibration parameters. 89 | # For this we will create a instance of `FerrarisCalibration` with the desired settings and then call `compute` with 90 | # the regions we have extracted. 91 | # 92 | # Note that we need to specify the units of the input data. 93 | # This information is stored with the calibration as documentation to check in the future that data is suitable for a 94 | # given calibration. 95 | # We can further add a comment if we want. 96 | from imucal import FerrarisCalibration 97 | 98 | cal = FerrarisCalibration() 99 | cal_info = cal.compute( 100 | regions, sampling_rate_hz=102.4, from_acc_unit="m/s^2", from_gyr_unit="deg/s", comment="My comment" 101 | ) 102 | 103 | 104 | print(cal_info.to_json()) 105 | 106 | # %% 107 | # The final `cal_info` now contains all information to calibrate future data recordings from the same sensor. 108 | # For now we will save it to disk and then see how to load it again. 109 | # 110 | # Note, that we will use a temporary folder here. 111 | # In reality you would chose some folder where you can keep the calibration files save until eternity. 112 | import tempfile 113 | 114 | d = tempfile.TemporaryDirectory() 115 | d.name 116 | 117 | # %% 118 | # You can either use the `to_json_file` or `to_hdf5` methods of :class:`~imucal.FerrarisCalibration` directly ... 119 | 120 | cal_info.to_json_file(Path(d.name) / "my_sensor_cal.json") 121 | 122 | # %% 123 | # ... or use the provided management tools to save the file in a predefined folder structure. 124 | # Read more about this in the our :ref:`guide on that topic `. 125 | from datetime import datetime 126 | 127 | from imucal.management import save_calibration_info 128 | 129 | file_path = save_calibration_info( 130 | cal_info, sensor_id="imu1", cal_time=datetime(2020, 8, 12, 13, 21), folder=Path(d.name) 131 | ) 132 | file_path 133 | 134 | # %% 135 | # In the latter case, we can use the helper functions :func:`~imucal.management.find_calibration_info_for_sensor` 136 | # and :func:`~imucal.management.find_closest_calibration_info_to_date` to find the calibration again. 137 | from imucal.management import find_calibration_info_for_sensor 138 | 139 | cals = find_calibration_info_for_sensor("imu1", Path(d.name)) 140 | cals 141 | 142 | # %% 143 | # In any case, we can use :func:`~imucal.management.load_calibration_info` to load the calibration if we know the file 144 | # path. 145 | from imucal.management import load_calibration_info 146 | 147 | loaded_cal_info = load_calibration_info(cals[0]) 148 | print(loaded_cal_info.to_json()) 149 | 150 | # %% 151 | # After loading the calibration file, we will apply it to a "new" recording (we will just use the calibration session 152 | # as example here). 153 | 154 | calibrated_data = loaded_cal_info.calibrate_df(data, "m/s^2", "deg/s") 155 | 156 | # %% 157 | # We can see the effect of the calibration, when we plot the acc norm in the beginning of the recording. 158 | # The calibrated values are now much closer to 9.81 m/s^2 compared to before the calibration. 159 | import matplotlib.pyplot as plt 160 | from numpy.linalg import norm 161 | 162 | plt.figure() 163 | plt.plot(norm(data.filter(like="acc"), axis=1)[500:1000], label="before cal") 164 | plt.plot(norm(calibrated_data.filter(like="acc")[500:1000], axis=1), label="after cal") 165 | plt.legend() 166 | plt.ylabel("acc norm [m/s^2]") 167 | plt.show() 168 | 169 | # %% 170 | # Finally, remove temp directory. 171 | d.cleanup() 172 | -------------------------------------------------------------------------------- /examples/custom_calibration_info.py: -------------------------------------------------------------------------------- 1 | r""" 2 | .. _custom_info_class: 3 | 4 | Custom Calibration Info Subclass 5 | ================================ 6 | 7 | Whenever you want to implement your own version of a calibration or simply want to add some meta information to your 8 | calibration info objects, you need to create a new subclass of :class:`~imucal.CalibrationInfo`. 9 | 10 | When creating a entirely new calibration, you should subclass from :class:`~imucal.CalibrationInfo` directly. 11 | If you just want to extend an existing object, subclass from the respective class. 12 | """ 13 | 14 | # %% 15 | # In the following, we will see how to extend the :class:`~imucal.FerrarisCalibrationInfo`. 16 | # 17 | # Here are the important things to keep in mind: 18 | # 19 | # 1. Each subclass needs to overwrite `CAL_TYPE`. Otherwise it is not recognised as a separate class when loading 20 | # objects from file. 21 | # 2. The new class needs to inherit from `dataclass`, if you add fields that should be serialized when using the export 22 | # methods. 23 | # 3. All new attributes need Type annotation and a default value (if in doubt use None) 24 | # 4. Note, that all new attributes need to be json serializable by default, if you want ot use json export. 25 | from dataclasses import dataclass 26 | from typing import Optional 27 | 28 | from example_data import EXAMPLE_PATH 29 | from imucal import FerrarisCalibrationInfo, ferraris_regions_from_df 30 | 31 | 32 | @dataclass 33 | class ExtendedFerrarisCalibrationInfo(FerrarisCalibrationInfo): 34 | CAL_TYPE = "ExtendedFerraris" 35 | new_meta_info: Optional[str] = None 36 | 37 | 38 | # %% 39 | # With that we have a new subclass of the ferraris calibration info. 40 | # As it is marked as dataclass, the `__init__` is created automatically and saving and loading from file is also taken 41 | # care of. 42 | # To use the new class, we need to provide it when initializing our `FerrarisCalibration`. 43 | from imucal import FerrarisCalibration 44 | 45 | cal = FerrarisCalibration(calibration_info_class=ExtendedFerrarisCalibrationInfo) 46 | 47 | # %% 48 | # Now, we can calculate the calibration as normal (here we just use some dummy data). 49 | # To provide a value for our `new_meta_info` field, we can pass it directly to the `calculate` method. 50 | import pandas as pd 51 | 52 | cal_data = ferraris_regions_from_df(pd.read_csv(EXAMPLE_PATH / "annotated_session.csv", header=0, index_col=[0, 1])) 53 | 54 | cal_info = cal.compute( 55 | cal_data, sampling_rate_hz=204.8, from_acc_unit="a.u.", from_gyr_unit="a.u.", new_meta_info="my value" 56 | ) 57 | 58 | cal_info.new_meta_info 59 | 60 | # %% 61 | # And of course we can simply export and reimport the new calibration info to json or hdf5 (we will use a tempfile 62 | # here to not clutter the example folder). 63 | import tempfile 64 | from pathlib import Path 65 | 66 | from imucal.management import load_calibration_info 67 | 68 | with tempfile.TemporaryDirectory() as d: 69 | file_name = Path(d) / "my_cal_info.json" 70 | cal_info.to_json_file(file_name) 71 | 72 | # An load it again 73 | loaded_cal_info = load_calibration_info(file_name) 74 | 75 | loaded_cal_info.new_meta_info 76 | -------------------------------------------------------------------------------- /imucal/__init__.py: -------------------------------------------------------------------------------- 1 | """A library to calibrate 6 DOF IMUs.""" 2 | 3 | from imucal.calibration_info import CalibrationInfo 4 | from imucal.ferraris_calibration import ( 5 | FerrarisCalibration, 6 | FerrarisSignalRegions, 7 | TurntableCalibration, 8 | ferraris_regions_from_df, 9 | ferraris_regions_from_interactive_plot, 10 | ferraris_regions_from_section_list, 11 | ) 12 | from imucal.ferraris_calibration_info import FerrarisCalibrationInfo, TurntableCalibrationInfo 13 | 14 | __version__ = "2.6.0" 15 | 16 | __all__ = [ 17 | "CalibrationInfo", 18 | "FerrarisCalibration", 19 | "TurntableCalibration", 20 | "FerrarisSignalRegions", 21 | "FerrarisCalibrationInfo", 22 | "TurntableCalibrationInfo", 23 | "ferraris_regions_from_df", 24 | "ferraris_regions_from_interactive_plot", 25 | "ferraris_regions_from_section_list", 26 | ] 27 | -------------------------------------------------------------------------------- /imucal/calibration_gui.py: -------------------------------------------------------------------------------- 1 | """Helper providing a small GUI to label timeseries data.""" 2 | 3 | import tkinter as tk 4 | from collections import OrderedDict 5 | from collections.abc import Sequence 6 | from itertools import chain 7 | from tkinter.messagebox import showinfo 8 | from typing import Optional 9 | 10 | import numpy as np 11 | 12 | 13 | class CalibrationGui: 14 | """A Gui that can be used to label the different required sections of a calibration. 15 | 16 | For details see the `manual` string. 17 | 18 | Examples 19 | -------- 20 | >>> # This will launch the GUI and block execution until closed 21 | >>> gui = CalibrationGui(acc, gyro, ["label1", "label2"]) 22 | >>> # While the GUI is open the sections are labeled. Once closed the next line is executed 23 | >>> labels = gui.section_list 24 | >>> labels["label1"] # First value is start, second value is end of region 25 | (12, 355) 26 | >>> labels["label2"] # First value is start, second value is end of region 27 | (500, 758) 28 | 29 | Notes 30 | ----- 31 | If the toolbar is not visible at the bottom of the GUI, try reducing the `initial_figsize` parameter, when 32 | creating the GUI. 33 | 34 | """ 35 | 36 | section_list = None 37 | acc_list_markers = None 38 | gyro_list_markers = None 39 | expected_labels = None 40 | 41 | manual = """ 42 | Mark the start and end point of each section listed in the sidebar in the plots. 43 | The section that is currently labeled is marked in blue in the sidebar. 44 | You can use either () or to advance to the next section, which has missing labels 45 | or click a section label manual. 46 | 47 | To create a mark click either with the left or the right mouse button on the plot. 48 | For each section you can place one RightClick and one LeftClick label. 49 | It does not matter, which you place first. 50 | If both labels are placed, the region in between them is colored. 51 | Now you can either press Enter (or click on any other label in the sidebar) to continue with labeling the next 52 | section or you can adjust the labels by repeated left and right clicks until you're satisfied. 53 | 54 | Tip: You can use the matplotlib toolbar to zoom in and out or to pan the plots. 55 | For faster operations, you can toggle the matplotlib tools using there respective shortcuts: 56 | 57 | Zoom: 'o' 58 | Pan: 'p' 59 | Home: 'h' 60 | Back: 'b' 61 | Forward: 'f' 62 | 63 | After zooming in, you can use the mouse wheel to scroll along the x-axis. 64 | """ 65 | 66 | def __init__( 67 | self, 68 | acc: np.ndarray, 69 | gyro: np.ndarray, 70 | expected_labels: Sequence[str], 71 | title: Optional[str] = None, 72 | master: Optional[tk.Frame] = None, 73 | initial_figsize=(20, 10), 74 | ) -> None: 75 | """Launch new GUI instance. 76 | 77 | Parameters 78 | ---------- 79 | acc : 80 | 3D array containing all acceleration data 81 | gyro : 82 | 3D array containing all gyroscope data 83 | expected_labels : 84 | List of all label names that should be labeled 85 | title : 86 | Title displayed in the titlebar of the GUI 87 | master : 88 | Parent window if GUI should be embedded in larger application 89 | 90 | """ 91 | import matplotlib 92 | from matplotlib.backends.backend_tkagg import ( 93 | FigureCanvasTkAgg, 94 | NavigationToolbar2Tk, 95 | ) 96 | 97 | class CustomToolbar(NavigationToolbar2Tk): 98 | def __init__(self, canvas, window) -> None: 99 | # List of buttons to remove 100 | buttons_to_remove = ["Subplots", "Save"] 101 | print(self.toolitems) 102 | self.toolitems = [item for item in self.toolitems if item[0] not in buttons_to_remove] 103 | super().__init__(canvas, window) 104 | 105 | self.expected_labels = expected_labels 106 | cmap = matplotlib.cm.get_cmap("Set3") 107 | self.colors = {k: matplotlib.colors.to_hex(cmap(i / 12)) for i, k in enumerate(expected_labels)} 108 | 109 | self.text_label = f"Labels set: {{}}/{len(expected_labels)}" 110 | 111 | if not master: 112 | master = tk.Tk() 113 | 114 | master.title(title or "Calibration Gui") 115 | master.bind("", lambda _: self._select_next(self.labels.curselection()[0])) 116 | master.bind("", lambda _: self._select_next(self.labels.curselection()[0])) 117 | 118 | # reset variables 119 | self.section_list = OrderedDict((k, [None, None]) for k in expected_labels) 120 | self.acc_list_markers = {k: [] for k in expected_labels} 121 | self.gyro_list_markers = {k: [] for k in expected_labels} 122 | 123 | self.main_area = tk.Frame(master=master, relief=tk.RAISED) 124 | self.main_area.pack(fill=tk.BOTH, expand=1) 125 | 126 | self.side_bar = tk.Frame(master=self.main_area) 127 | self.side_bar.pack(fill=tk.Y, side=tk.RIGHT) 128 | self._create_sidebar() 129 | 130 | # Create a container 131 | self.fig, self.axs = _create_figure(acc, gyro, figsize=initial_figsize) 132 | 133 | self.canvas = FigureCanvasTkAgg(self.fig, master=self.main_area) 134 | self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1) 135 | 136 | self.canvas.mpl_connect("button_press_event", self._onclick) 137 | 138 | toolbar = CustomToolbar(self.canvas, self.main_area) 139 | toolbar.update() 140 | 141 | # To make the shortcut works, we need to manually forward the key event to the toolbar 142 | # Bind keypress events to the canvas widget 143 | self.canvas.get_tk_widget().bind("

", lambda _: toolbar.pan()) 144 | self.canvas.get_tk_widget().bind("", lambda _: toolbar.zoom()) 145 | self.canvas.get_tk_widget().bind("", lambda _: toolbar.home()) 146 | self.canvas.get_tk_widget().bind("", lambda _: toolbar.back()) 147 | self.canvas.get_tk_widget().bind("", lambda _: toolbar.forward()) 148 | 149 | # Bind mouse wheel event to scroll along the x-axis 150 | self.canvas.get_tk_widget().bind("", self._scroll_x) 151 | self.canvas.get_tk_widget().bind("", self._scroll_x) # For Linux 152 | self.canvas.get_tk_widget().bind("", self._scroll_x) # For Linux 153 | 154 | self.label_text = tk.Text(self.main_area, height=1, width=80, state=tk.DISABLED) 155 | self.label_text.pack(side=tk.TOP, fill=tk.X, expand=0) 156 | self._update_text_label(self.text_label.format(str(0))) 157 | 158 | self.labels.selection_anchor(0) 159 | self.labels.selection_set(0) 160 | 161 | toolbar.pack(side=tk.TOP, fill=tk.X, expand=0) 162 | 163 | # This way, we can start labeling right away 164 | self.labels.selection_set(0) 165 | 166 | master.mainloop() 167 | 168 | def _scroll_x(self, event) -> None: 169 | ax = self.canvas.figure.gca() 170 | x_min, x_max = ax.get_xlim() 171 | data_min, data_max = ax.dataLim.intervalx 172 | delta = x_max - x_min 173 | scroll_in_units = delta * 0.1 # Adjust the scroll speed as needed 174 | 175 | if data_min >= x_min and data_max <= x_max: 176 | # No scrolling, when we see all the data 177 | return 178 | 179 | # But we allow scrolling past the ends a little 180 | data_min -= delta * 0.1 181 | data_max += delta * 0.1 182 | 183 | if event.num == 5 or event.delta < 0: # Scroll down -> right 184 | new_x_max = min(x_max + scroll_in_units, data_max) 185 | new_x_min = new_x_max - delta 186 | elif event.num == 4 or event.delta > 0: # Scroll up -> left 187 | new_x_min = max(x_min - scroll_in_units, data_min) 188 | new_x_max = new_x_min + delta 189 | 190 | ax.set_xlim(new_x_min, new_x_max) 191 | self.canvas.draw_idle() 192 | 193 | def _create_sidebar(self) -> None: 194 | self.labels = tk.Listbox(master=self.side_bar, width=30) 195 | self.labels.pack(side=tk.TOP, fill=tk.BOTH, expand=1) 196 | help_button = tk.Button(master=self.side_bar, height=2, text="Help", command=self._show_help) 197 | help_button.pack(side=tk.BOTTOM, fill=tk.BOTH) 198 | 199 | for key in self.section_list: 200 | self.labels.insert(tk.END, key) 201 | 202 | self._update_list_box() 203 | 204 | def _show_help(self) -> None: 205 | showinfo("Window", self.manual) 206 | 207 | def _select_next(self, current) -> None: 208 | next_val = (current + 1) % self.labels.size() 209 | 210 | if all(list(self.section_list.values())[next_val]): 211 | if self._n_labels() == len(self.section_list): 212 | return 213 | 214 | self._select_next(next_val) 215 | 216 | self.labels.selection_clear(0, tk.END) 217 | self.labels.selection_anchor(next_val) 218 | self.labels.selection_set(next_val) 219 | 220 | def _update_list_box(self) -> None: 221 | for i, (key, val) in enumerate(self.section_list.items()): 222 | self.labels.itemconfig(i, {"bg": self.colors[key]}) 223 | if all(val): 224 | self.labels.itemconfig(i, {"fg": "#a5a9af"}) 225 | 226 | def _update_text_label(self, new_text) -> None: 227 | self.label_text.config(state=tk.NORMAL) 228 | self.label_text.delete("1.0", tk.END) 229 | self.label_text.insert(tk.END, new_text) 230 | self.label_text.config(state=tk.DISABLED) 231 | 232 | def _onclick(self, event) -> None: 233 | # Only listen to left and right mouse clicks and only if we are not in zoom or drag mode. 234 | if event.button not in [1, 3] or str(self.canvas.toolbar.mode): 235 | return 236 | 237 | selected_index = self.labels.curselection() 238 | 239 | if not selected_index: 240 | return 241 | 242 | selected_key = self.labels.get(selected_index) 243 | 244 | if event.button == 1: 245 | x = int(event.xdata) 246 | self.section_list[selected_key][0] = x 247 | elif event.button == 3: 248 | x = int(event.xdata) 249 | self.section_list[selected_key][1] = x 250 | 251 | if all(self.section_list[selected_key]): 252 | self.section_list[selected_key].sort() 253 | 254 | self._update_marker(selected_key) 255 | 256 | self._update_text_label(self.text_label.format(str(self._n_labels()))) 257 | 258 | self._update_list_box() 259 | 260 | def _update_marker(self, key) -> None: 261 | for line in chain(self.acc_list_markers[key], self.gyro_list_markers[key]): 262 | line.remove() 263 | self.acc_list_markers[key] = [] 264 | self.gyro_list_markers[key] = [] 265 | 266 | for val in self.section_list[key]: 267 | if val: 268 | marker_acc = self.axs[0].axvline(val, c=self.colors[key]) 269 | marker_gyro = self.axs[1].axvline(val, c=self.colors[key]) 270 | self.acc_list_markers[key].append(marker_acc) 271 | self.gyro_list_markers[key].append(marker_gyro) 272 | 273 | if all(self.section_list[key]): 274 | a_acc = self.axs[0].axvspan( 275 | self.section_list[key][0], self.section_list[key][1], alpha=0.5, color=self.colors[key] 276 | ) 277 | a_gyr = self.axs[1].axvspan( 278 | self.section_list[key][0], self.section_list[key][1], alpha=0.5, color=self.colors[key] 279 | ) 280 | self.acc_list_markers[key].append(a_acc) 281 | self.gyro_list_markers[key].append(a_gyr) 282 | 283 | self.fig.canvas.draw() 284 | self.fig.canvas.flush_events() 285 | 286 | def _n_labels(self): 287 | return sum(all(v) for v in self.section_list.values()) 288 | 289 | 290 | def _create_figure(acc, gyro, figsize=(20, 10)): 291 | from matplotlib.figure import Figure 292 | 293 | fig = Figure(figsize=figsize) 294 | ax1 = fig.add_subplot(211) 295 | lines = ax1.plot(acc) 296 | ax1.grid(True) 297 | ax1.set_title("Use this plot to find the static regions for acc calibration") 298 | ax1.set_xlabel("time [s]") 299 | ax1.set_ylabel("acceleration [m/s^2]") 300 | ax1.legend(lines, list("xyz")) 301 | ax2 = fig.add_subplot(212, sharex=ax1) 302 | lines = ax2.plot(gyro) 303 | ax2.grid(True) 304 | ax2.set_title("Use this plot to find the single axis rotations for gyro calibration") 305 | ax2.set_xlabel("time [s]") 306 | ax2.set_ylabel("angular velocity [°/s]") 307 | ax2.legend(lines, list("xyz")) 308 | fig.tight_layout() 309 | return fig, (ax1, ax2) 310 | -------------------------------------------------------------------------------- /imucal/calibration_info.py: -------------------------------------------------------------------------------- 1 | """Base Class for all CalibrationInfo objects.""" 2 | 3 | import json 4 | from collections.abc import Iterable 5 | from dataclasses import asdict, dataclass, fields 6 | from pathlib import Path 7 | from typing import ClassVar, Optional, TypeVar, Union 8 | 9 | import numpy as np 10 | import pandas as pd 11 | from packaging.version import Version 12 | 13 | CalInfo = TypeVar("CalInfo", bound="CalibrationInfo") 14 | 15 | _CAL_FORMAT_VERSION = Version("2.0.0") 16 | 17 | 18 | @dataclass(eq=False) 19 | class CalibrationInfo: 20 | """Abstract BaseClass for all Calibration Info objects. 21 | 22 | .. note :: 23 | All `CalibrationInfo` subclasses implement a `CAL_TYPE` attribute. 24 | If the calibration is exported into any format, this information is stored as well. 25 | If imported, all constructor methods intelligently infer the correct CalibrationInfo subclass based on 26 | this parameter. 27 | 28 | >>> json_string = "{cal_type: 'Ferraris', ...}" 29 | >>> CalibrationInfo.from_json(json_string) 30 | 31 | 32 | """ 33 | 34 | CAL_FORMAT_VERSION: ClassVar[Version] = _CAL_FORMAT_VERSION 35 | CAL_TYPE: ClassVar[str] = None 36 | acc_unit: Optional[str] = None 37 | gyr_unit: Optional[str] = None 38 | from_acc_unit: Optional[str] = None 39 | from_gyr_unit: Optional[str] = None 40 | comment: Optional[str] = None 41 | 42 | _cal_paras: ClassVar[tuple[str, ...]] 43 | 44 | def calibrate( 45 | self, acc: np.ndarray, gyr: np.ndarray, acc_unit: Optional[str], gyr_unit: Optional[str] 46 | ) -> tuple[np.ndarray, np.ndarray]: 47 | """Abstract method to perform a calibration on both acc and gyro. 48 | 49 | This absolutely needs to implement by any daughter class. 50 | It is further recommended to call `self._validate_units` in the overwritten calibrate method, to check if the 51 | input units are as expected. 52 | 53 | Parameters 54 | ---------- 55 | acc : 56 | 3D acceleration 57 | gyr : 58 | 3D gyroscope values 59 | acc_unit 60 | The unit of the acceleration data 61 | gyr_unit 62 | The unit of the gyroscope data 63 | 64 | """ 65 | raise NotImplementedError("This method needs to be implemented by a subclass") 66 | 67 | def calibrate_df( 68 | self, 69 | df: pd.DataFrame, 70 | acc_unit: Optional[str], 71 | gyr_unit: Optional[str], 72 | acc_cols: Iterable[str] = ("acc_x", "acc_y", "acc_z"), 73 | gyr_cols: Iterable[str] = ("gyr_x", "gyr_y", "gyr_z"), 74 | ) -> pd.DataFrame: 75 | """Apply the calibration to data stored in a dataframe. 76 | 77 | This calls `calibrate` for the respective columns and returns a copy of the df with the respective columns 78 | replaced by their calibrated counter-part. 79 | 80 | See the `calibrate` method for more information. 81 | 82 | Parameters 83 | ---------- 84 | df : 85 | 6 column dataframe (3 acc, 3 gyro) 86 | acc_cols : 87 | The name of the 3 acceleration columns in order x,y,z. 88 | gyr_cols : 89 | The name of the 3 acceleration columns in order x,y,z. 90 | acc_unit 91 | The unit of the acceleration data 92 | gyr_unit 93 | The unit of the gyroscope data 94 | 95 | Returns 96 | ------- 97 | cal_df 98 | A copy of `df` with the calibrated data. 99 | 100 | """ 101 | acc_cols = list(acc_cols) 102 | gyr_cols = list(gyr_cols) 103 | acc = df[acc_cols].to_numpy() 104 | gyr = df[gyr_cols].to_numpy() 105 | cal_acc, cal_gyr = self.calibrate(acc=acc, gyr=gyr, acc_unit=acc_unit, gyr_unit=gyr_unit) 106 | 107 | cal_df = df.copy() 108 | cal_df[acc_cols] = cal_acc 109 | cal_df[gyr_cols] = cal_gyr 110 | 111 | return cal_df 112 | 113 | def _validate_units(self, other_acc, other_gyr) -> None: 114 | check_pairs = {"acc": (other_acc, self.from_acc_unit), "gyr": (other_gyr, self.from_gyr_unit)} 115 | for name, (other, this) in check_pairs.items(): 116 | if other != this: 117 | if this is None: 118 | raise ValueError( 119 | "This calibration does not provide any information about the expected input " 120 | f"units for {name}. " 121 | f"Set `{name}_unit` explicitly to `None` to ignore this error. " 122 | "However, we recommend to recreate your calibration with proper information " 123 | "about the input unit." 124 | ) 125 | raise ValueError( 126 | f"The provided {name} data has a unit of {other}. " 127 | f"However, the calibration is created to calibrate data with a unit of {this}." 128 | ) 129 | 130 | def __eq__(self, other): 131 | """Check if two calibrations are identical. 132 | 133 | Note, we use a custom function for that, as we need to compare the numpy arrays. 134 | """ 135 | # Check type: 136 | if not isinstance(other, self.__class__): 137 | raise TypeError(f"Comparison is only defined between two {self.__class__.__name__} object!") 138 | 139 | # Test keys equal: 140 | if fields(self) != fields(other): 141 | return False 142 | 143 | # Test method equal 144 | if not self.CAL_TYPE == other.CAL_TYPE: 145 | return False 146 | 147 | # Test all values 148 | for f in fields(self): 149 | a1 = getattr(self, f.name) 150 | a2 = getattr(other, f.name) 151 | equal = np.array_equal(a1, a2) if isinstance(a1, np.ndarray) else a1 == a2 152 | if not equal: 153 | return False 154 | return True 155 | 156 | def _to_list_dict(self): 157 | d = asdict(self) 158 | d["cal_type"] = self.CAL_TYPE 159 | d["_format_version"] = str(self.CAL_FORMAT_VERSION) 160 | return d 161 | 162 | @classmethod 163 | def _from_list_dict(cls, list_dict): 164 | for k in cls._cal_paras: 165 | list_dict[k] = np.array(list_dict[k]) 166 | return cls(**list_dict) 167 | 168 | @classmethod 169 | def _get_subclasses(cls): 170 | for subclass in cls.__subclasses__(): 171 | yield from subclass._get_subclasses() 172 | yield subclass 173 | 174 | @classmethod 175 | def find_subclass_from_cal_type(cls, cal_type): 176 | """Get a SensorCalibration subclass that handles the specified calibration type.""" 177 | if cal_type == cls.CAL_TYPE: 178 | return cls 179 | try: 180 | out_cls = next(x for x in cls._get_subclasses() if cal_type == x.CAL_TYPE) 181 | except StopIteration as e: 182 | raise ValueError( 183 | f"No suitable calibration info class could be found for caltype `{cal_type}`. " 184 | f"The following classes were checked: {(cls.__name__, *(x.__name__ for x in cls._get_subclasses()))}. " 185 | "If your CalibrationInfo class is missing, make sure it is imported before loading a " 186 | "file." 187 | ) from e 188 | return out_cls 189 | 190 | def to_json(self) -> str: 191 | """Convert all calibration matrices into a json string.""" 192 | data_dict = self._to_list_dict() 193 | return json.dumps(data_dict, indent=4, cls=NumpyEncoder) 194 | 195 | @classmethod 196 | def from_json(cls: type[CalInfo], json_str: str) -> CalInfo: 197 | """Create a calibration object from a json string (created by `CalibrationInfo.to_json`). 198 | 199 | Parameters 200 | ---------- 201 | json_str : 202 | valid json string object 203 | 204 | Returns 205 | ------- 206 | cal_info 207 | A CalibrationInfo object. 208 | The exact child class is determined by the `cal_type` key in the json string. 209 | 210 | """ 211 | raw_json = json.loads(json_str) 212 | check_cal_format_version(Version(raw_json.pop("_format_version", None)), cls.CAL_FORMAT_VERSION) 213 | subclass = cls.find_subclass_from_cal_type(raw_json.pop("cal_type")) 214 | return subclass._from_list_dict(raw_json) 215 | 216 | def to_json_file(self, path: Union[str, Path]): 217 | """Dump acc calibration matrices into a file in json format. 218 | 219 | Parameters 220 | ---------- 221 | path : 222 | path to the json file 223 | 224 | """ 225 | data_dict = self._to_list_dict() 226 | with Path(path).open("w", encoding="utf8") as f: 227 | json.dump(data_dict, f, cls=NumpyEncoder, indent=4) 228 | 229 | @classmethod 230 | def from_json_file(cls: type[CalInfo], path: Union[str, Path]) -> CalInfo: 231 | """Create a calibration object from a valid json file (created by `CalibrationInfo.to_json_file`). 232 | 233 | Parameters 234 | ---------- 235 | path : 236 | Path to the json file 237 | 238 | Returns 239 | ------- 240 | cal_info 241 | A CalibrationInfo object. 242 | The exact child class is determined by the `cal_type` key in the json string. 243 | 244 | """ 245 | with Path(path).open(encoding="utf8") as f: 246 | raw_json = json.load(f) 247 | check_cal_format_version(raw_json.pop("_format_version", None), cls.CAL_FORMAT_VERSION) 248 | subclass = cls.find_subclass_from_cal_type(raw_json.pop("cal_type")) 249 | return subclass._from_list_dict(raw_json) 250 | 251 | def to_hdf5(self, path: Union[str, Path]) -> None: 252 | """Save calibration matrices to hdf5 file format. 253 | 254 | Parameters 255 | ---------- 256 | path : 257 | Path to the hdf5 file 258 | 259 | """ 260 | import h5py 261 | 262 | with h5py.File(path, "w") as hdf: 263 | for k, v in self._to_list_dict().items(): 264 | if k in self._cal_paras: 265 | hdf.create_dataset(k, data=v.tolist()) 266 | elif v: 267 | hdf[k] = v 268 | 269 | @classmethod 270 | def from_hdf5(cls: type[CalInfo], path: Union[str, Path]): 271 | """Read calibration data stored in hdf5 fileformat (created by `CalibrationInfo.save_to_hdf5`). 272 | 273 | Parameters 274 | ---------- 275 | path : 276 | Path to the hdf5 file 277 | 278 | Returns 279 | ------- 280 | cal_info 281 | A CalibrationInfo object. 282 | The exact child class is determined by the `cal_type` key in the json string. 283 | 284 | """ 285 | import h5py 286 | 287 | with h5py.File(path, "r") as hdf: 288 | format_version = hdf.get("_format_version") 289 | if format_version: 290 | format_version = format_version.asstr()[()] 291 | check_cal_format_version(format_version, cls.CAL_FORMAT_VERSION) 292 | subcls = cls.find_subclass_from_cal_type(hdf["cal_type"][()].decode("utf-8")) 293 | data = {} 294 | for k in fields(subcls): 295 | dp = hdf.get(k.name) 296 | if h5py.check_string_dtype(dp.dtype): 297 | # String data 298 | data[k.name] = dp.asstr()[()] 299 | else: 300 | # No string data 301 | data[k.name] = dp[()] 302 | return subcls._from_list_dict(data) 303 | 304 | 305 | class NumpyEncoder(json.JSONEncoder): 306 | """Custom encoder for numpy array.""" 307 | 308 | def default(self, obj): 309 | """Allow encoding of numpy objects by converting them to lists.""" 310 | if isinstance(obj, np.ndarray): 311 | return obj.tolist() 312 | return json.JSONEncoder.default(self, obj) 313 | 314 | 315 | def check_cal_format_version(version: Optional[Version] = None, current_version: Version = _CAL_FORMAT_VERSION) -> None: 316 | """Check if a calibration can be loaded with the current loader.""" 317 | # No version means, the old 1.0 format is used that does not provide a version string 318 | if not version: 319 | version = Version("1.0.0") 320 | if isinstance(version, str): 321 | version = Version(version) 322 | 323 | if version == current_version: 324 | return 325 | if version > current_version: 326 | raise ValueError("The provided version, is larger than the currently supported version.") 327 | if version < current_version: 328 | raise ValueError( 329 | "The provided calibration format is no longer supported. " 330 | "Check `imucal.legacy` if conversion helper exist." 331 | ) 332 | -------------------------------------------------------------------------------- /imucal/ferraris_calibration.py: -------------------------------------------------------------------------------- 1 | """Calculate a Ferraris calibration from sensor data.""" 2 | 3 | from collections.abc import Iterable 4 | from typing import ClassVar, NamedTuple, Optional, TypeVar 5 | 6 | import numpy as np 7 | import pandas as pd 8 | from numpy.linalg import inv 9 | 10 | from imucal.calibration_info import CalibrationInfo 11 | from imucal.ferraris_calibration_info import FerrarisCalibrationInfo, TurntableCalibrationInfo 12 | 13 | T = TypeVar("T", bound="FerrarisCalibration") 14 | 15 | 16 | def _convert_data_from_section_list_to_df(data: pd.DataFrame, section_list: pd.DataFrame) -> pd.DataFrame: 17 | out = {} 18 | 19 | for label, row in section_list.iterrows(): 20 | out[label] = data.iloc[row.start : row.end] 21 | 22 | return pd.concat(out) 23 | 24 | 25 | class FerrarisSignalRegions(NamedTuple): 26 | """NamedTuple containing all signal regions required for a Ferraris Calibration.""" 27 | 28 | acc_x_p: np.ndarray 29 | acc_x_a: np.ndarray 30 | acc_y_p: np.ndarray 31 | acc_y_a: np.ndarray 32 | acc_z_p: np.ndarray 33 | acc_z_a: np.ndarray 34 | gyr_x_p: np.ndarray 35 | gyr_x_a: np.ndarray 36 | gyr_y_p: np.ndarray 37 | gyr_y_a: np.ndarray 38 | gyr_z_p: np.ndarray 39 | gyr_z_a: np.ndarray 40 | 41 | acc_x_rot: np.ndarray 42 | acc_y_rot: np.ndarray 43 | acc_z_rot: np.ndarray 44 | gyr_x_rot: np.ndarray 45 | gyr_y_rot: np.ndarray 46 | gyr_z_rot: np.ndarray 47 | 48 | def validate(self) -> None: 49 | """Validate that all fields are populated with numpy arrays.""" 50 | for k in self._fields: 51 | if not isinstance(getattr(self, k), np.ndarray) or len(getattr(self, k)) == 0: 52 | raise ValueError("The the signal region {} is no valid numpy array.") 53 | 54 | 55 | class FerrarisCalibration: 56 | """Calculate a Ferraris calibration matrices based on a set of calibration movements. 57 | 58 | The Ferraris calibration is derived based on a well defined series of data recordings: 59 | 60 | ===== ====================================================================== 61 | Static Holds 62 | ----------------------------------------------------------------------------- 63 | Name Explanation 64 | ===== ====================================================================== 65 | `x_p` positive x-axis of sensor is aligned with gravity (x-acc measures +1g) 66 | `x_a` negative x-axis of sensor is aligned with gravity (x-acc measures -1g) 67 | `y_p` positive y-axis of sensor is aligned with gravity (y-acc measures +1g) 68 | `y_a` negative y-axis of sensor is aligned with gravity (y-acc measures -1g) 69 | `z_p` positive z-axis of sensor is aligned with gravity (z-acc measures +1g) 70 | `z_a` negative z-axis of sensor is aligned with gravity (z-acc measures -1g) 71 | ===== ====================================================================== 72 | 73 | ======= ======================================================================================================== 74 | Rotations 75 | ----------------------------------------------------------------------------------------------------------------- 76 | Name Explanation 77 | ======= ======================================================================================================== 78 | `x_rot` sensor is rotated clockwise in the `x_p` position around the x-axis (x-gyro shows negative values) for a 79 | well known angle (typically 360 deg) 80 | `y_rot` sensor is rotated clockwise in the `y_p` position around the y-axis (y-gyro shows negative values) for a 81 | well known angle (typically 360 deg) 82 | `z_rot` sensor is rotated clockwise in the `z_p` position around the z-axis (z-gyro shows negative values) for a 83 | well known angle (typically 360 deg) 84 | ======= ======================================================================================================== 85 | 86 | All sections need to be recorded for a sensor and then annotated. 87 | This class then takes the data of each section (represented as a 88 | :class:`~imucal.FerrarisSignalRegions` object) and calculates the calibration matrizes for 89 | the gyroscope and accelerometer. 90 | 91 | As it is quite tedious to obtain the data of each section in a seperate array, you should make use of the available 92 | helper functions to turn a continous recording into annotated sections (See the See also section) 93 | 94 | Parameters 95 | ---------- 96 | sampling_rate : 97 | Sampling rate of the data 98 | expected_angle : 99 | expected rotation angle for the gyroscope rotation. 100 | grav : 101 | The expected value of the gravitational acceleration. 102 | calibration_info_class : 103 | The calibration Info class to use to store the final calibration information. 104 | This should be a FerrarisCalibrationInfo or a custom subclass. 105 | 106 | Notes 107 | ----- 108 | Depending on how the axis of your respective sensor coordinate system are defined and how you perform the 109 | calibration, you might need to change the `grav` and `expected_angle` parameter. 110 | 111 | Typical situations are: 112 | 113 | - If you define the positive axis direction as the direction, where the acc measures -g, change `grav` to 114 | -9.81 m/s^2 115 | - If you perform a counter-clockwise rotation during the calibration, set `expected_angle` to +360 116 | - For combinations of both, both parameter might need to be adapted 117 | 118 | 119 | Examples 120 | -------- 121 | >>> from imucal import FerrarisCalibration, ferraris_regions_from_interactive_plot 122 | >>> sampling_rate = 100 # Hz 123 | >>> data = ... # my data as 6 col pandas dataframe 124 | >>> # This will open an interactive plot, where you can select the start and the stop sample of each region 125 | >>> section_data, section_list = ferraris_regions_from_interactive_plot( 126 | ... data, sampling_rate=sampling_rate 127 | ... ) 128 | >>> section_list.to_csv( 129 | ... "./calibration_sections.csv" 130 | ... ) # Store the annotated section list as reference for the 131 | ... # future 132 | >>> cal = FerrarisCalibration() # Create new calibration object 133 | >>> calibration_info = cal.compute( # Calculate the actual matrizes. 134 | ... section_data, 135 | ... sampling_rate_hz=sampling_rate, 136 | ... from_acc_unit="a.u.", 137 | ... from_gyr_unit="a.u.", 138 | ... comment="my custom comment." 139 | ...) 140 | >>> calibration_info_class 141 | < FerrarisCalibration object at ... > 142 | 143 | See Also 144 | -------- 145 | imucal.ferraris_regions_from_df: Generate valid sections from preannotated dataframe. 146 | imucal.ferraris_regions_from_interactive_plot: Generate valid sections via manual annotation in an interactive 147 | GUI. 148 | imucal.ferraris_regions_from_section_list: Generate valid sections based on raw data and start-end labels for the 149 | individual sections. 150 | 151 | """ 152 | 153 | grav: float 154 | expected_angle: float 155 | calibration_info_class: type[FerrarisCalibrationInfo] 156 | 157 | OUT_ACC_UNIT: ClassVar[str] = "m/s^2" 158 | OUT_GYR_UNIT: ClassVar[str] = "deg/s" 159 | 160 | FERRARIS_SECTIONS: ClassVar[tuple[str, ...]] = ( 161 | "x_p", 162 | "x_a", 163 | "y_p", 164 | "y_a", 165 | "z_p", 166 | "z_a", 167 | "x_rot", 168 | "y_rot", 169 | "z_rot", 170 | ) 171 | 172 | def __init__( 173 | self, 174 | grav: float = 9.81, 175 | expected_angle: float = -360, 176 | calibration_info_class: type[FerrarisCalibrationInfo] = FerrarisCalibrationInfo, 177 | ) -> None: 178 | self.grav = grav 179 | self.expected_angle = expected_angle 180 | self.calibration_info_class = calibration_info_class 181 | 182 | def compute( 183 | self, 184 | signal_regions: FerrarisSignalRegions, 185 | sampling_rate_hz: float, 186 | from_acc_unit: str, 187 | from_gyr_unit: str, 188 | **kwargs, 189 | ) -> CalibrationInfo: 190 | """Compute the calibration Information. 191 | 192 | This actually performs the Ferraris calibration following the original publication equation by equation. 193 | """ 194 | signal_regions.validate() 195 | 196 | # Initialize the cal info with all the meta data 197 | cal_mat = self.calibration_info_class( 198 | from_acc_unit=from_acc_unit, 199 | from_gyr_unit=from_gyr_unit, 200 | acc_unit=self.OUT_ACC_UNIT, 201 | gyr_unit=self.OUT_GYR_UNIT, 202 | **kwargs, 203 | ) 204 | 205 | ############################################################################################################### 206 | # Compute Acceleration Matrix 207 | 208 | # Calculate means from all static phases and stack them into 3x3 matrices 209 | # Note: Each measurement should be a column 210 | U_a_p = np.vstack( # noqa: N806 211 | ( 212 | np.mean(signal_regions.acc_x_p, axis=0), 213 | np.mean(signal_regions.acc_y_p, axis=0), 214 | np.mean(signal_regions.acc_z_p, axis=0), 215 | ) 216 | ).T 217 | U_a_n = np.vstack( # noqa: N806 218 | ( 219 | np.mean(signal_regions.acc_x_a, axis=0), 220 | np.mean(signal_regions.acc_y_a, axis=0), 221 | np.mean(signal_regions.acc_z_a, axis=0), 222 | ) 223 | ).T 224 | 225 | # Eq. 19 226 | U_a_s = U_a_p + U_a_n # noqa: N806 227 | 228 | # Bias Matrix 229 | # Eq. 20 230 | B_a = U_a_s / 2 # noqa: N806 231 | 232 | # Bias Vector 233 | b_a = np.diag(B_a) 234 | cal_mat.b_a = b_a 235 | 236 | # Compute Scaling and Rotation 237 | # No need for bias correction, since it cancels out! 238 | # Eq. 21 239 | U_a_d = U_a_p - U_a_n # noqa: N806 240 | 241 | # Calculate Scaling matrix 242 | # Eq. 23 243 | k_a_sq = 1 / (4 * self.grav**2) * np.diag(U_a_d @ U_a_d.T) 244 | K_a = np.diag(np.sqrt(k_a_sq)) # noqa: N806 245 | cal_mat.K_a = K_a 246 | 247 | # Calculate Rotation matrix 248 | # Eq. 22 249 | R_a = inv(K_a) @ U_a_d / (2 * self.grav) # noqa: N806 250 | cal_mat.R_a = R_a 251 | 252 | ############################################################################################################### 253 | # Calculate Gyroscope Matrix 254 | 255 | # Gyro Bias from the static phases of the acc calibration 256 | # One static phase would be sufficient, but why not use all of them if you have them. 257 | # Note that this calibration ignores any influences due to the earth rotation. 258 | 259 | b_g = np.mean( 260 | np.vstack( 261 | ( 262 | signal_regions.gyr_x_p, 263 | signal_regions.gyr_x_a, 264 | signal_regions.gyr_y_p, 265 | signal_regions.gyr_y_a, 266 | signal_regions.gyr_z_p, 267 | signal_regions.gyr_z_a, 268 | ) 269 | ), 270 | axis=0, 271 | ) 272 | 273 | cal_mat.b_g = b_g 274 | 275 | # Acceleration sensitivity 276 | 277 | # Note: Each measurement should be a column 278 | U_g_p = np.vstack( # noqa: N806 279 | ( 280 | np.mean(signal_regions.gyr_x_p, axis=0), 281 | np.mean(signal_regions.gyr_y_p, axis=0), 282 | np.mean(signal_regions.gyr_z_p, axis=0), 283 | ) 284 | ).T 285 | U_g_a = np.vstack( # noqa: N806 286 | ( 287 | np.mean(signal_regions.gyr_x_a, axis=0), 288 | np.mean(signal_regions.gyr_y_a, axis=0), 289 | np.mean(signal_regions.gyr_z_a, axis=0), 290 | ) 291 | ).T 292 | 293 | # Eq. 9 294 | K_ga = (U_g_p - U_g_a) / (2 * self.grav) # noqa: N806 295 | cal_mat.K_ga = K_ga 296 | 297 | # Gyroscope Scaling and Rotation 298 | 299 | # First apply partial calibration to remove offset and acc influence 300 | acc_x_rot_cor = cal_mat._calibrate_acc(signal_regions.acc_x_rot) 301 | acc_y_rot_cor = cal_mat._calibrate_acc(signal_regions.acc_y_rot) 302 | acc_z_rot_cor = cal_mat._calibrate_acc(signal_regions.acc_z_rot) 303 | gyr_x_rot_cor = cal_mat._calibrate_gyr_offsets(signal_regions.gyr_x_rot, acc_x_rot_cor) 304 | gyr_y_rot_cor = cal_mat._calibrate_gyr_offsets(signal_regions.gyr_y_rot, acc_y_rot_cor) 305 | gyr_z_rot_cor = cal_mat._calibrate_gyr_offsets(signal_regions.gyr_z_rot, acc_z_rot_cor) 306 | 307 | # Integrate gyro readings 308 | # Eg. 13/14 309 | W_s = np.zeros((3, 3)) # noqa: N806 310 | W_s[:, 0] = np.sum(gyr_x_rot_cor, axis=0) / sampling_rate_hz 311 | W_s[:, 1] = np.sum(gyr_y_rot_cor, axis=0) / sampling_rate_hz 312 | W_s[:, 2] = np.sum(gyr_z_rot_cor, axis=0) / sampling_rate_hz 313 | 314 | # Eq.15 315 | expected_angles = self.expected_angle * np.identity(3) 316 | multiplied = W_s @ inv(expected_angles) 317 | 318 | # Eq. 12 319 | k_g_sq = np.diag(multiplied @ multiplied.T) 320 | K_g = np.diag(np.sqrt(k_g_sq)) # noqa: N806 321 | cal_mat.K_g = K_g 322 | 323 | R_g = inv(K_g) @ multiplied # noqa: N806 324 | cal_mat.R_g = R_g 325 | 326 | return cal_mat 327 | 328 | 329 | class TurntableCalibration(FerrarisCalibration): 330 | """Calculate a Ferraris calibration matrices based on a turntable measurement. 331 | 332 | This calibration is basically identical to the FerrarisCalibration. 333 | However, the calibrate method will return a :class:`~imucal.TurntableCalibrationInfo` to indicate the expected 334 | higher precision of this calibration method. 335 | 336 | Further this Calibration expects rotations of 720 deg by default, as this is common for many turntables. 337 | For further information on the sign of the expected rotation angle see the :class:`~imucal.FerrarisCalibration`. 338 | 339 | See Also 340 | -------- 341 | imucal.FerrarisCalibration 342 | 343 | """ 344 | 345 | def __init__( 346 | self, 347 | grav: float = 9.81, 348 | expected_angle: float = -720, 349 | calibration_info_class: type[TurntableCalibrationInfo] = TurntableCalibrationInfo, 350 | ) -> None: 351 | super().__init__(grav=grav, expected_angle=expected_angle, calibration_info_class=calibration_info_class) 352 | 353 | 354 | def ferraris_regions_from_df( 355 | df: pd.DataFrame, 356 | acc_cols: Optional[Iterable[str]] = ("acc_x", "acc_y", "acc_z"), 357 | gyr_cols: Optional[Iterable[str]] = ("gyr_x", "gyr_y", "gyr_z"), 358 | ) -> FerrarisSignalRegions: 359 | """Create a Calibration object based on a dataframe which has all required sections labeled. 360 | 361 | The expected Dataframe has the section label as index and has at least the 6 required data columns. 362 | The index must contain all the sections listed in :class:`~imucal.FerrarisCalibration``.FERRARIS_SECTIONS`. 363 | 364 | Examples 365 | -------- 366 | >>> import pandas as pd 367 | >>> sampling_rate = 100 # Hz 368 | >>> df = ... # A valid DataFrame with all sections in the index 369 | >>> print(df) 370 | acc_x acc_y acc_z gyr_x gyr_y gyr_z 371 | part 372 | x_a -2052.0 -28.0 -73.0 1.0 0.0 -5.0 373 | x_a -2059.0 -29.0 -77.0 2.0 -3.0 -5.0 374 | x_a -2054.0 -25.0 -71.0 3.0 -2.0 -3.0 375 | ... ... ... ... ... ... ... 376 | z_rot -36.0 35.0 2079.0 2.0 -5.0 -2.0 377 | z_rot -28.0 36.0 2092.0 6.0 -5.0 -4.0 378 | z_rot -36.0 21.0 2085.0 5.0 -4.0 -4.0 379 | >>> regions = ferraris_regions_from_df(df) 380 | >>> regions 381 | FerrarisSignalRegions(x_a=array([...]), ..., z_rot=array([...])) 382 | 383 | Parameters 384 | ---------- 385 | df : 386 | 6 column dataframe (3 acc, 3 gyro) 387 | acc_cols : 388 | The name of the 3 acceleration columns in order x,y,z. 389 | gyr_cols : 390 | The name of the 3 acceleration columns in order x,y,z. 391 | 392 | Returns 393 | ------- 394 | ferraris_cal_obj : FerrarisSignalRegions 395 | 396 | See Also 397 | -------- 398 | ferraris_regions_from_interactive_plot 399 | ferraris_regions_from_section_list 400 | 401 | """ 402 | acc_df = df[list(acc_cols)] 403 | gyro_df = df[list(gyr_cols)] 404 | acc_dict = acc_df.groupby(level=0).apply(lambda x: x.to_numpy()).to_dict() 405 | gyro_dict = gyro_df.groupby(level=0).apply(lambda x: x.to_numpy()).to_dict() 406 | acc_dict = {"acc_" + k: v for k, v in acc_dict.items()} 407 | gyro_dict = {"gyr_" + k: v for k, v in gyro_dict.items()} 408 | 409 | return FerrarisSignalRegions(**acc_dict, **gyro_dict) 410 | 411 | 412 | def ferraris_regions_from_section_list( 413 | data: pd.DataFrame, 414 | section_list: pd.DataFrame, 415 | acc_cols: Optional[Iterable[str]] = ("acc_x", "acc_y", "acc_z"), 416 | gyr_cols: Optional[Iterable[str]] = ("gyr_x", "gyr_y", "gyr_z"), 417 | ) -> FerrarisSignalRegions: 418 | """Create a Calibration object based on a valid section list. 419 | 420 | A section list marks the start and the endpoints of each required section in the data object. 421 | A valid section list is usually created using `FerrarisCalibration.from_interactive_plot()`. 422 | This section list can be stored on disk and this method can be used to turn it back into a valid calibration 423 | object. 424 | 425 | Parameters 426 | ---------- 427 | data : 428 | 6 column dataframe (3 acc, 3 gyro) 429 | section_list : 430 | A pandas dataframe representing a section list 431 | acc_cols : 432 | The name of the 3 acceleration columns in order x,y,z. 433 | Defaults to `FerrarisCalibration.ACC_COLS` 434 | gyr_cols : 435 | The name of the 3 acceleration columns in order x,y,z. 436 | Defaults to `FerrarisCalibration.GYRO_COLS` 437 | 438 | Returns 439 | ------- 440 | ferraris_cal_obj : FerrarisSignalRegions 441 | 442 | 443 | Examples 444 | -------- 445 | >>> import pandas as pd 446 | >>> # Load a valid section list from disk. Note the `index_col=0` to preserve correct format! 447 | >>> section_list = pd.read_csv("./calibration_sections.csv", index_col=0) 448 | >>> sampling_rate = 100 # Hz 449 | >>> df = ... # my data as 6 col pandas dataframe 450 | >>> regions = ferraris_regions_from_section_list(df) 451 | >>> regions 452 | FerrarisSignalRegions(x_a=array([...]), ..., z_rot=array([...])) 453 | 454 | See Also 455 | -------- 456 | ferraris_regions_from_interactive_plot 457 | ferraris_regions_from_df 458 | 459 | """ 460 | df = _convert_data_from_section_list_to_df(data, section_list) 461 | return ferraris_regions_from_df(df, acc_cols=acc_cols, gyr_cols=gyr_cols) 462 | 463 | 464 | def ferraris_regions_from_interactive_plot( 465 | data: pd.DataFrame, 466 | acc_cols: Iterable[str] = ("acc_x", "acc_y", "acc_z"), 467 | gyr_cols: Iterable[str] = ("gyr_x", "gyr_y", "gyr_z"), 468 | title: Optional[str] = None, 469 | figsize=(20, 10), 470 | ) -> tuple[FerrarisSignalRegions, pd.DataFrame]: 471 | """Create a Calibration object by selecting the individual signal sections manually in an interactive GUI. 472 | 473 | This will open a Tkinter Window that allows you to label the start and the end all required sections for a 474 | Ferraris Calibration. 475 | See the class docstring for more detailed explanations of these sections. 476 | 477 | Examples 478 | -------- 479 | >>> sampling_rate = 100 # Hz 480 | >>> data = ... # my data as 6 col pandas dataframe 481 | >>> # This will open an interactive plot, where you can select the start and the stop sample of each region 482 | >>> regions, section_list = ferraris_regions_from_interactive_plot( 483 | ... data, sampling_rate=sampling_rate 484 | ... ) 485 | >>> section_list.to_csv( 486 | ... "./calibration_sections.csv" 487 | ... ) # This is optional, but recommended 488 | >>> regions 489 | FerrarisSignalRegions(x_a=array([...]), ..., z_rot=array([...])) 490 | 491 | Parameters 492 | ---------- 493 | data : 494 | 6 column dataframe (3 acc, 3 gyro) 495 | acc_cols : 496 | The name of the 3 acceleration columns in order x,y,z. 497 | gyr_cols : 498 | The name of the 3 acceleration columns in order x,y,z. 499 | title : 500 | Optional title of the plot window 501 | figsize : 502 | The initial size of the plot window. 503 | If you can not see the toolbar at the bottom, it might help to reduce the figure size. 504 | 505 | Returns 506 | ------- 507 | ferraris_cal_obj : FerrarisSignalRegions 508 | section_list : pd.DataFrame 509 | Section list representing the start and stop of each section. 510 | It is advised to save this to disk to avoid repeated manual labeling. 511 | :py:func:`~imucal.ferraris_regions_from_section_list` can be used to recreate the regions object 512 | 513 | See Also 514 | -------- 515 | ferraris_regions_from_section_list 516 | ferraris_regions_from_df 517 | 518 | """ 519 | acc = data[list(acc_cols)].to_numpy() 520 | gyr = data[list(gyr_cols)].to_numpy() 521 | 522 | section_list = _find_ferraris_regions_interactive(acc, gyr, title=title, figsize=figsize) 523 | return ( 524 | ferraris_regions_from_section_list(data, section_list, gyr_cols=gyr_cols, acc_cols=acc_cols), 525 | section_list, 526 | ) 527 | 528 | 529 | def _find_ferraris_regions_interactive( 530 | acc: np.ndarray, gyro: np.ndarray, title: Optional[str] = None, figsize=(20, 10) 531 | ): 532 | """Prepare the calibration data for the later calculation of calibration matrices. 533 | 534 | Parameters 535 | ---------- 536 | acc : (n, 3) array 537 | Acceleration data 538 | gyro : (n, 3) array 539 | Gyroscope data 540 | title : 541 | Optional title for the Calibration GUI 542 | 543 | 544 | """ 545 | from imucal.calibration_gui import CalibrationGui 546 | 547 | plot = CalibrationGui(acc, gyro, FerrarisCalibration.FERRARIS_SECTIONS, title=title, initial_figsize=figsize) 548 | 549 | section_list = plot.section_list 550 | 551 | check_all = (all(v) for v in section_list.values()) 552 | if not all(check_all): 553 | raise ValueError("Some regions are missing in the section list. Label all regions before closing the plot") 554 | 555 | section_list = pd.DataFrame(section_list, index=("start", "end")).T 556 | 557 | return section_list 558 | -------------------------------------------------------------------------------- /imucal/ferraris_calibration_info.py: -------------------------------------------------------------------------------- 1 | """Wrapper object to hold calibration matrices for a Ferraris Calibration.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import ClassVar, Optional 5 | 6 | import numpy as np 7 | 8 | from imucal.calibration_info import CalibrationInfo 9 | 10 | 11 | @dataclass(eq=False) 12 | class FerrarisCalibrationInfo(CalibrationInfo): 13 | """Calibration object that represents all the required information to apply a Ferraris calibration to a dataset. 14 | 15 | Parameters 16 | ---------- 17 | K_a : 18 | Scaling matrix for the acceleration 19 | R_a : 20 | Rotation matrix for the acceleration 21 | b_a : 22 | Acceleration bias 23 | K_g : 24 | Scaling matrix for the gyroscope 25 | R_g : 26 | Rotation matrix for the gyroscope 27 | K_ga : 28 | Influence of acceleration on gyroscope 29 | b_g : 30 | Gyroscope bias 31 | 32 | """ 33 | 34 | CAL_TYPE: ClassVar[str] = "Ferraris" 35 | 36 | acc_unit: str = "m/s^2" 37 | gyr_unit: str = "deg/s" 38 | K_a: Optional[np.ndarray] = None 39 | R_a: Optional[np.ndarray] = None 40 | b_a: Optional[np.ndarray] = None 41 | K_g: Optional[np.ndarray] = None 42 | R_g: Optional[np.ndarray] = None 43 | K_ga: Optional[np.ndarray] = None 44 | b_g: Optional[np.ndarray] = None 45 | 46 | _cal_paras: ClassVar[tuple[str, ...]] = ("K_a", "R_a", "b_a", "K_g", "R_g", "K_ga", "b_g") 47 | 48 | def calibrate( 49 | self, acc: np.ndarray, gyr: np.ndarray, acc_unit: Optional[str], gyr_unit: Optional[str] 50 | ) -> tuple[np.ndarray, np.ndarray]: 51 | """Calibrate the accelerometer and the gyroscope. 52 | 53 | This corrects: 54 | acc: scaling, rotation, non-orthogonalities, and bias 55 | gyro: scaling, rotation, non-orthogonalities, bias, and acc influence on gyro 56 | 57 | Parameters 58 | ---------- 59 | acc : 60 | 3D acceleration 61 | gyr : 62 | 3D gyroscope values 63 | acc_unit 64 | The unit of the acceleration data 65 | gyr_unit 66 | The unit of the gyroscope data 67 | 68 | Returns 69 | ------- 70 | Corrected acceleration and gyroscope values 71 | 72 | """ 73 | # Check if all required paras are initialized to throw appropriate error messages: 74 | for v in self._cal_paras: 75 | if getattr(self, v, None) is None: 76 | raise ValueError( 77 | f"{self._cal_paras} need to initialised before an acc calibration can be performed. {v} is missing." 78 | ) 79 | self._validate_units(acc_unit, gyr_unit) 80 | acc_out = self._calibrate_acc(acc) 81 | gyro_out = self._calibrate_gyr(gyr, acc_out) 82 | 83 | return acc_out, gyro_out 84 | 85 | def _calibrate_acc(self, acc: np.ndarray) -> np.ndarray: 86 | """Calibrate the accelerometer. 87 | 88 | This corrects scaling, rotation, non-orthogonalities and bias. 89 | 90 | Parameters 91 | ---------- 92 | acc : 93 | 3D acceleration 94 | 95 | Returns 96 | ------- 97 | Calibrated acceleration 98 | 99 | """ 100 | # Check if all required paras are initialized to throw appropriate error messages: 101 | paras = ("K_a", "R_a", "b_a") 102 | for v in paras: 103 | if getattr(self, v, None) is None: 104 | raise ValueError( 105 | f"{paras} need to initialised before an acc calibration can be performed. {v} is missing" 106 | ) 107 | 108 | # Combine Scaling and rotation matrix to one matrix 109 | acc_mat = np.linalg.inv(self.R_a) @ np.linalg.inv(self.K_a) 110 | acc_out = acc_mat @ (acc - self.b_a).T 111 | 112 | return acc_out.T 113 | 114 | def _calibrate_gyr(self, gyr, calibrated_acc=None): 115 | # Check if all required paras are initialized to throw appropriate error messages: 116 | required = ["K_g", "R_g", "b_g"] 117 | if calibrated_acc is not None: 118 | required += ["K_ga"] 119 | for v in required: 120 | if getattr(self, v, None) is None: 121 | raise ValueError( 122 | f"{required} need to initialised before an gyro calibration can be performed. {v} is missing" 123 | ) 124 | # Combine Scaling and rotation matrix to one matrix 125 | gyro_mat = np.matmul(np.linalg.inv(self.R_g), np.linalg.inv(self.K_g)) 126 | tmp = self._calibrate_gyr_offsets(gyr, calibrated_acc) 127 | 128 | gyro_out = gyro_mat @ tmp.T 129 | return gyro_out.T 130 | 131 | def _calibrate_gyr_offsets(self, gyr, calibrated_acc=None): 132 | d_ga = np.array(0) if calibrated_acc is None else self.K_ga @ calibrated_acc.T 133 | offsets = d_ga.T + self.b_g 134 | return gyr - offsets 135 | 136 | 137 | class TurntableCalibrationInfo(FerrarisCalibrationInfo): 138 | """Calibration object that represents all the required information to apply a Turntable calibration to a dataset. 139 | 140 | A Turntable calibration is identical to a Ferraris calibration. 141 | However, because the parameters are calculated using a calibration table instead of hand rotations, 142 | higher precision is expected. 143 | 144 | Parameters 145 | ---------- 146 | K_a : 147 | Scaling matrix for the acceleration 148 | R_a : 149 | Rotation matrix for the acceleration 150 | b_a : 151 | Acceleration bias 152 | K_g : 153 | Scaling matrix for the gyroscope 154 | R_g : 155 | Rotation matrix for the gyroscope 156 | K_ga : 157 | Influence of acceleration on gyroscope 158 | b_g : 159 | Gyroscope bias 160 | 161 | """ 162 | 163 | CAL_TYPE = "Turntable" 164 | -------------------------------------------------------------------------------- /imucal/legacy.py: -------------------------------------------------------------------------------- 1 | """Helper functions to import calibration info export from older imucal versions.""" 2 | 3 | import json 4 | import warnings 5 | from pathlib import Path 6 | from typing import Union 7 | 8 | from packaging.version import Version 9 | 10 | from imucal import CalibrationInfo 11 | 12 | 13 | def load_v1_json_files( 14 | path: Union[Path, str], 15 | base_class: type[CalibrationInfo] = CalibrationInfo, 16 | ): 17 | """Load a exported json file that was created using imucal <= 2.0.""" 18 | with Path(path).open(encoding="utf8") as f: 19 | json_str = f.read() 20 | return load_v1_json(json_str, base_class=base_class) 21 | 22 | 23 | def load_v1_json( 24 | json_str: str, 25 | base_class: type[CalibrationInfo] = CalibrationInfo, 26 | ): 27 | """Load a json string that was created using imucal <= 2.0.""" 28 | warnings.warn( 29 | "Importing a legacy calibration file, will use default values for all parameters that were newly" 30 | "introduced. " 31 | "These default values might not be correct for your calibration. " 32 | "Double check the resulting calibration info object and adapt the parameters manually. " 33 | "\n" 34 | "If you made any changes, make sure to save the modified calibration and load it with the normal " 35 | "loading function in the future.", 36 | stacklevel=2, 37 | ) 38 | json_dict = json.loads(json_str) 39 | 40 | # Check that the provided json is indeed a v1 calibration 41 | if "_format_version" in json_dict: 42 | raise ValueError( 43 | "The provided json does not seem to be a v1 json export, but has the format version {}.".format( 44 | json_dict["format_version"] 45 | ) 46 | ) 47 | 48 | # Apply the required modifications: 49 | json_dict["gyr_unit"] = json_dict.pop("gyro_unit") 50 | json_dict["_format_version"] = str(Version("2.0.0")) 51 | 52 | json_str = json.dumps(json_dict) 53 | 54 | return base_class.from_json(json_str) 55 | -------------------------------------------------------------------------------- /imucal/management.py: -------------------------------------------------------------------------------- 1 | """A set of highly opinionated helper functions to store and load calibration files for a medium number of sensors.""" 2 | 3 | import datetime 4 | import re 5 | import warnings 6 | from pathlib import Path 7 | from typing import Callable, Literal, Optional, TypeVar, Union 8 | 9 | import numpy as np 10 | 11 | from imucal import CalibrationInfo 12 | 13 | path_t = TypeVar("path_t", str, Path) 14 | 15 | 16 | class CalibrationWarning(Warning): 17 | """Indicate potential issues with a calibration.""" 18 | 19 | 20 | def save_calibration_info( 21 | cal_info: CalibrationInfo, 22 | sensor_id: str, 23 | cal_time: datetime.datetime, 24 | folder: path_t, 25 | folder_structure="{sensor_id}/{cal_info.CAL_TYPE}", 26 | **kwargs, 27 | ) -> Path: 28 | """Save a calibration info object in the correct format and file name for NilsPods. 29 | 30 | By default the files will be saved in the format: 31 | `folder/{sensor_id}/{cal_info.CAL_TYPE}/{sensor_id}_%Y-%m-%d_%H-%M.json` 32 | 33 | The naming schema and format is of course just a suggestion, and any structure can be used as long as it can be 34 | converted back into a CalibrationInfo object. 35 | However, following the naming convention will allow to use other calibration utils to search for suitable 36 | calibration files. 37 | 38 | .. note:: If the folder does not exist it will be created. 39 | 40 | Parameters 41 | ---------- 42 | cal_info : 43 | The CalibrationInfo object ot be saved 44 | sensor_id : 45 | A unique id to identify the calibrated sensor. 46 | Note that this will converted to all lower-case! 47 | cal_time : 48 | The date and time (min precision) when the calibration was performed. 49 | folder : 50 | Basepath of the folder, where the file will be stored. 51 | folder_structure : 52 | A valid formatted Python string using the `{}` syntax. 53 | `sensor_id`, `cal_info` and kwargs will be passed to the `str.format` as keyword arguments and can be used 54 | in the string. 55 | 56 | Returns 57 | ------- 58 | output_file_name 59 | The name under which the calibration file was saved 60 | 61 | Notes 62 | ----- 63 | Yes, this way of storing files doubles information at various places, which is usually discouraged. 64 | However, in this case it ensures that you still have all information just from the file name and the file content, 65 | and also easy "categories" if you search through the file tree manually. 66 | 67 | """ 68 | if not sensor_id.isalnum(): 69 | raise ValueError( 70 | "Sensor ids must be alphanumerical characters to not interfere with pattern matching in the file name." 71 | ) 72 | folder = Path(folder) / folder_structure.format(sensor_id=sensor_id, cal_info=cal_info, **kwargs) 73 | folder.mkdir(parents=True, exist_ok=True) 74 | f_name = folder / "{}_{}.json".format(sensor_id.lower(), cal_time.strftime("%Y-%m-%d_%H-%M")) 75 | cal_info.to_json_file(f_name) 76 | return f_name 77 | 78 | 79 | def find_calibration_info_for_sensor( 80 | sensor_id: str, 81 | folder: path_t, 82 | recursive: bool = True, 83 | filter_cal_type: Optional[str] = None, 84 | custom_validator: Optional[Callable[[CalibrationInfo], bool]] = None, 85 | ignore_file_not_found: Optional[bool] = False, 86 | ) -> list[Path]: 87 | """Find possible calibration files based on the filename. 88 | 89 | As this only checks the filenames, this might return false positives depending on your folder structure and naming. 90 | 91 | Parameters 92 | ---------- 93 | sensor_id : 94 | A unique id to identify the calibrated sensor 95 | folder : 96 | Basepath of the folder to search. 97 | recursive : 98 | If the folder should be searched recursive or not. 99 | filter_cal_type : 100 | Whether only files obtain with a certain calibration type should be found. 101 | This will look for the `CalType` inside the json file and hence cause performance problems. 102 | If None, all found files (over all potential subfolders) will be returned. 103 | custom_validator : 104 | A custom function that will be called with the CalibrationInfo object of each potential match. 105 | This needs load the json file of each match and could cause performance issues with many calibration files. 106 | ignore_file_not_found : 107 | If True this function will not raise an error, but rather return an empty list, if no calibration files were 108 | found for the specific sensor_type. 109 | 110 | Returns 111 | ------- 112 | list_of_cals 113 | List of paths pointing to available calibration objects. 114 | 115 | """ 116 | method = "glob" 117 | if recursive is True: 118 | method = "rglob" 119 | 120 | r = sensor_id.lower() + r"_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}" 121 | 122 | matches = [f for f in getattr(Path(folder), method)(f"{sensor_id}_*.json") if re.fullmatch(r, f.stem)] 123 | 124 | final_matches = [] 125 | for m in matches: 126 | cal = load_calibration_info(m, file_type="json") 127 | if (filter_cal_type is None or cal.CAL_TYPE.lower() == filter_cal_type.lower()) and ( 128 | custom_validator is None or custom_validator(cal) 129 | ): 130 | final_matches.append(m) 131 | 132 | if not final_matches and ignore_file_not_found is not True: 133 | raise ValueError(f"No Calibration for the sensor_type with the id {sensor_id} could be found") 134 | return final_matches 135 | 136 | 137 | def find_closest_calibration_info_to_date( 138 | sensor_id: str, 139 | cal_time: datetime.datetime, 140 | folder: Optional[path_t] = None, 141 | recursive: bool = True, 142 | filter_cal_type: Optional[str] = None, 143 | custom_validator: Optional[Callable[[CalibrationInfo], bool]] = None, 144 | before_after: Optional[str] = None, 145 | warn_thres: datetime.timedelta = datetime.timedelta(days=30), # E252 146 | ignore_file_not_found: Optional[bool] = False, 147 | ) -> Optional[Path]: 148 | """Find the calibration file for a sensor_type, that is closes to a given date. 149 | 150 | As this only checks the filenames, this might return a false positive depending on your folder structure and naming. 151 | 152 | Parameters 153 | ---------- 154 | sensor_id : 155 | A unique id to identify the calibrated sensor 156 | cal_time : 157 | time and date to look for 158 | folder : 159 | Basepath of the folder to search. If None, tries to find a default calibration 160 | recursive : 161 | If the folder should be searched recursive or not. 162 | filter_cal_type : 163 | Whether only files obtain with a certain calibration type should be found. 164 | This will look for the `CalType` inside the json file and hence cause performance problems. 165 | If None, all found files (over all potential subfolders) will be returned. 166 | custom_validator : 167 | A custom function that will be called with the CalibrationInfo object of each potential match. 168 | This needs load the json file of each match and could cause performance issues with many calibration files. 169 | before_after : 170 | Can either be 'before' or 'after', if the search should be limited to calibrations that were 171 | either before or after the specified date. 172 | If None the closest value will be returned, ignoring if it was before or after the measurement. 173 | warn_thres : 174 | If the distance to the closest calibration is larger than this threshold, a warning is emitted 175 | ignore_file_not_found : 176 | If True this function will not raise an error, but rather return `None`, if no calibration files were found for 177 | the specific sensor_type. 178 | 179 | Notes 180 | ----- 181 | If there are multiple calibrations that have the same date/hour/minute distance form the measurement, 182 | the calibration before the measurement will be chosen. This can be overwritten using the `before_after` para. 183 | 184 | See Also 185 | -------- 186 | nilspodlib.calibration_utils.find_calibrations_for_sensor 187 | 188 | Returns 189 | ------- 190 | cal_file_path or None 191 | The path to a suitable calibration file, or `None`, if no suitable file could be found. 192 | 193 | """ 194 | if before_after not in ("before", "after", None): 195 | raise ValueError('Invalid value for `before_after`. Only "before", "after" or None are allowed') 196 | 197 | potential_list = find_calibration_info_for_sensor( 198 | sensor_id=sensor_id, 199 | folder=folder, 200 | recursive=recursive, 201 | filter_cal_type=filter_cal_type, 202 | custom_validator=custom_validator, 203 | ignore_file_not_found=ignore_file_not_found, 204 | ) 205 | if not potential_list: 206 | if ignore_file_not_found is True: 207 | return None 208 | raise ValueError(f"No Calibration for the sensor with the id {sensor_id} could be found") 209 | 210 | dates = [datetime.datetime.strptime("_".join(d.stem.split("_")[1:]), "%Y-%m-%d_%H-%M") for d in potential_list] 211 | 212 | dates = np.array(dates, dtype="datetime64[s]") 213 | potential_list, _ = zip(*sorted(zip(potential_list, dates), key=lambda x: x[1])) 214 | dates.sort() 215 | 216 | diffs = (dates - np.datetime64(cal_time, "s")).astype(float) 217 | 218 | if before_after == "after": 219 | diffs[diffs < 0] = np.nan 220 | elif before_after == "before": 221 | diffs[diffs > 0] = np.nan 222 | 223 | if np.all(diffs) == np.nan: 224 | raise ValueError(f"No calibrations between {before_after} and {cal_time} were found for sensor {sensor_id}.") 225 | 226 | min_dist = float(np.nanmin(np.abs(diffs))) 227 | if warn_thres < datetime.timedelta(seconds=min_dist): 228 | warnings.warn( 229 | f"For the sensor {sensor_id} no calibration could be located that was in {warn_thres} of the {cal_time}." 230 | f"The closest calibration is {datetime.timedelta(seconds=min_dist)} away.", 231 | CalibrationWarning, 232 | stacklevel=2, 233 | ) 234 | 235 | return potential_list[int(np.nanargmin(np.abs(diffs)))] 236 | 237 | 238 | def load_calibration_info( 239 | path: Union[Path, str], 240 | file_type: Optional[Literal["hdf", "json"]] = None, 241 | base_class: type[CalibrationInfo] = CalibrationInfo, 242 | ) -> CalibrationInfo: 243 | """Load any calibration info object from file. 244 | 245 | Parameters 246 | ---------- 247 | path 248 | Path name to the file (can be .json or .hdf) 249 | file_type 250 | Format of the file (either `hdf` or `json`). 251 | If None, we try to figure out the correct format based on the file suffix. 252 | base_class 253 | This method finds the correct calibration info type by inspecting all subclasses of `base_class`. 254 | Usually that should be kept at the default value. 255 | 256 | Notes 257 | ----- 258 | This function determines the correct calibration info class to use based on the `cal_type` parameter stored in the 259 | file. 260 | For this to work, the correct calibration info class must be accessible. 261 | This means, if you created a new calibration info class, you need to make sure that it is imported (or at least 262 | the file it is defined in), before using this function. 263 | 264 | """ 265 | format_options = {"json": "from_json_file", "hdf": "from_hdf5"} 266 | path = Path(path) 267 | if file_type is None: 268 | # Determine format from file ending: 269 | suffix = path.suffix 270 | if suffix[1:] == "json": 271 | file_type = "json" 272 | elif suffix[1:] in ["hdf", "h5"]: 273 | file_type = "hdf" 274 | else: 275 | raise ValueError( 276 | "The loader format could not be determined from the file suffix. Please specify `format` explicitly." 277 | ) 278 | if file_type not in format_options: 279 | raise ValueError(f"`format` must be one of {list(format_options.keys())}") 280 | 281 | return getattr(base_class, format_options[file_type])(path) 282 | -------------------------------------------------------------------------------- /paper/img/imucal_ferraris_gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mad-lab-fau/imucal/8716e4c50bbb7044918b747f38b26cb0a6420910/paper/img/imucal_ferraris_gui.png -------------------------------------------------------------------------------- /paper/imucal.bib: -------------------------------------------------------------------------------- 1 | 2 | @article{Ferraris1994, 3 | title = {Calibration of Three-Axial Rate Gyros without Angular Velocity Standards}, 4 | author = {Ferraris, F. and Gorini, I. and Grimaldi, U. and Parvis, M.}, 5 | year = {1994}, 6 | month = apr, 7 | journal = {Sensors and Actuators A: Physical}, 8 | volume = {42}, 9 | number = {1-3}, 10 | pages = {446--449}, 11 | issn = {09244247}, 12 | doi = {10.1016/0924-4247(94)80031-6}, 13 | url = {https://linkinghub.elsevier.com/retrieve/pii/0924424794800316}, 14 | urldate = {2021-03-03}, 15 | langid = {english} 16 | } 17 | 18 | @article{Ferraris1995, 19 | title = {Procedure for Effortless In-Field Calibration of Three-Axial Rate Gyro and Accelerometers}, 20 | author = {Ferraris, Franco and Grimaldi, Ugo and Parvis, Marco}, 21 | year = {1995}, 22 | journal = {Sensors and Materials}, 23 | volume = {7}, 24 | number = {5}, 25 | pages = {311--330}, 26 | url = {https://myukk.org/SM2017/article.php?ss=10210} 27 | } 28 | 29 | @inproceedings{Kozlov2014, 30 | title = {{{IMU}} Calibration on a Low Grade Turntable: {{Embedded}} Estimation of the Instrument Displacement from the Axis of Rotation}, 31 | shorttitle = {{{IMU}} Calibration on a Low Grade Turntable}, 32 | booktitle = {2014 {{International Symposium}} on {{Inertial Sensors}} and {{Systems}} ({{ISISS}})}, 33 | author = {Kozlov, Alexander and Sazonov, Igor and Vavilova, Nina}, 34 | year = {2014}, 35 | month = feb, 36 | pages = {1--4}, 37 | doi = {10.1109/ISISS.2014.6782525}, 38 | abstract = {The paper concerns one simple method of calibration of an assembled inertial measurement units (IMU) of different grades on a low grade single axis turntable. The main feature of the method is that it has the weakest possible requirements to the testbench. The method was presented at ICINS 2010, ICINS 2013, and is now being used in industry for 3-4 years. It appears to work well in practice. One of special points of this method is the situation, when the IMU is displaced from the axis of rotation. In some cases this fact can be neglected, but in some cases not. It was shown earlier that parameters of this displacement need not to be measured prior to the experiment, but can be estimated automatically during the data processing, similar to the rest of IMU parameters. So the device can be placed arbitrarily onto the stand, and no care of displacement should be taken while conducting the experiment. This work concentrates primarily on aspects of observability and estimation accuracy of IMU displacement (which influence the accuracy of the calibration itself), and shows more variety of experimental results than in previous publications.}, 39 | keywords = {Accelerometers,Accuracy,Calibration,Estimation,Force,Instruments,Sensors} 40 | } 41 | 42 | @article{Qureshi2017, 43 | title = {An {{Algorithm}} for the {{In-Field Calibration}} of a {{MEMS IMU}}}, 44 | author = {Qureshi, Umar and Golnaraghi, Farid}, 45 | year = {2017}, 46 | month = nov, 47 | journal = {IEEE Sensors Journal}, 48 | volume = {17}, 49 | number = {22}, 50 | pages = {7479--7486}, 51 | issn = {1558-1748}, 52 | doi = {10.1109/JSEN.2017.2751572}, 53 | abstract = {Recently, micro electro-mechanical systems (MEMS) inertial sensors have found their way in various applications. These sensors are fairly low cost and easily available but their measurements are noisy and imprecise, which poses the necessity of calibration. In this paper, we present an approach to calibrate an inertial measurement unit (IMU) comprised of a low-cost tri-axial MEMS accelerometer and a gyroscope. As opposed to existing methods, our method is truly infield as it requires no external equipment and utilizes gravity signal as a stable reference. It only requires the sensor to be placed in approximate orientations, along with the application of simple rotations. This also offers easier and quicker calibration comparatively. We analyzed the method by performing experiments on two different IMUs: an in-house built IMU and a commercially calibrated IMU. We also calibrated the in-house built IMU using an aviation grade rate table for comparison. The results validate the calibration method as a useful low-cost IMU calibration scheme.}, 54 | keywords = {Accelerometer and gyroscope calibration,Accelerometers,Calibration,Gravity,gravity based in-field calibration,Gyroscopes,inertial measurement unit (IMU),low cost IMU calibration,micro electro-mechanical systems (MEMS),Micromechanical devices,multi-position calibration,Sensors} 55 | } 56 | 57 | @article{Scapellato2005, 58 | title = {In-Use Calibration of Body-Mounted Gyroscopes for Applications in Gait Analysis}, 59 | author = {Scapellato, Sergio and Cavallo, Filippo and Martelloni, Chiara and Sabatini, Angelo M.}, 60 | year = {2005}, 61 | month = sep, 62 | journal = {Sensors and Actuators A: Physical}, 63 | series = {Eurosensors {{XVIII}} 2004}, 64 | volume = {123--124}, 65 | pages = {418--422}, 66 | issn = {0924-4247}, 67 | doi = {10.1016/j.sna.2005.03.052}, 68 | url = {https://www.sciencedirect.com/science/article/pii/S0924424705001755}, 69 | urldate = {2022-03-06}, 70 | abstract = {In this paper, we propose an in-use calibration procedure for gyroscopes. The case report is a simple inertial measurement unit (IMU), which is used in our current research on inertial motion-sensing for advanced footware. The IMU contains two biaxial accelerometers and one gyroscope; it is developed for being mounted on one subject's foot instep, with the aim to reconstruct the trajectory in the sagittal plane of the sensed anatomical point. Since the IMU sagittal displacements can be estimated by performing strapdown integration, they can also be compared with their true values. One movement, which corresponds to known (vertical) displacements, consists of foot placements from the ground level on to top of steps of known height (step climbing). Provided that the IMU accelerometers are calibrated separately by any standard calibration procedure, motion tracking during the stepping movement allows to estimate the gyroscope sensitivity. The experimental results we present in this paper demonstrate the proposed in-use calibration procedure.}, 71 | langid = {english}, 72 | keywords = {Gait analysis,Inertial measurement unit,Inertial motion-sensing,Sensor calibration} 73 | } 74 | 75 | @inproceedings{Skog2006, 76 | title = {Calibration of a {{MEMS}} Inertial Measurement Unit}, 77 | booktitle = {In {{Proc}}. {{XVII IMEKO WORLD CONGRESS}}, ({{Rio}} de {{Janeiro}}}, 78 | author = {Skog, Isaac and H{\"a}ndel, Peter}, 79 | year = {2006}, 80 | abstract = {Abstract: An approach for calibrating a low-cost IMU is studied, requiring no mechanical platform for the accelerometer calibration and only a simple rotating table for the gyro calibration. The proposed calibration methods utilize the fact that ideally the norm of the measured output of the accelerometer and gyro cluster are equal to the magnitude of applied force and rotational velocity, respectively. This fact, together with model of the sensors is used to construct a cost function, which is minimized with respect to the unknown model parameters using Newton's method. The performance of the calibration algorithm is compared with the Cram\'er-Rao bound for the case when a mechanical platform is used to rotate the IMU into different precisely controlled orientations. Simulation results shows that the mean square error of the estimated sensor model parameters reaches the Cram\'er-Rao bound within 8 dB, and thus the proposed method may be acceptable for a wide range of low-cost applications. Keyword: Inertial measurement unit, MEMS sensors, Calibration. 1.} 81 | } 82 | 83 | @inproceedings{Tedaldi2014a, 84 | title = {A Robust and Easy to Implement Method for {{IMU}} Calibration without External Equipments}, 85 | booktitle = {2014 {{IEEE International Conference}} on {{Robotics}} and {{Automation}} ({{ICRA}})}, 86 | author = {Tedaldi, David and Pretto, Alberto and Menegatti, Emanuele}, 87 | year = {2014}, 88 | month = may, 89 | pages = {3042--3049}, 90 | issn = {1050-4729}, 91 | doi = {10.1109/ICRA.2014.6907297}, 92 | abstract = {Motion sensors as inertial measurement units (IMU) are widely used in robotics, for instance in the navigation and mapping tasks. Nowadays, many low cost micro electro mechanical systems (MEMS) based IMU are available off the shelf, while smartphones and similar devices are almost always equipped with low-cost embedded IMU sensors. Nevertheless, low cost IMUs are affected by systematic error given by imprecise scaling factors and axes misalignments that decrease accuracy in the position and attitudes estimation. In this paper, we propose a robust and easy to implement method to calibrate an IMU without any external equipment. The procedure is based on a multi-position scheme, providing scale and misalignments factors for both the accelerometers and gyroscopes triads, while estimating the sensor biases. Our method only requires the sensor to be moved by hand and placed in a set of different, static positions (attitudes). We describe a robust and quick calibration protocol that exploits an effective parameterless static filter to reliably detect the static intervals in the sensor measurements, where we assume local stability of the gravity's magnitude and stable temperature. We first calibrate the accelerometers triad taking measurement samples in the static intervals. We then exploit these results to calibrate the gyroscopes, employing a robust numerical integration technique. The performances of the proposed calibration technique has been successfully evaluated via extensive simulations and real experiments with a commercial IMU provided with a calibration certificate as reference data.}, 93 | keywords = {Accelerometers,Accuracy,Calibration,Gravity,Gyroscopes,Sensors,Vectors} 94 | } 95 | 96 | @article{Zhang2009, 97 | title = {Improved Multi-Position Calibration for Inertial Measurement Units}, 98 | author = {Zhang, Hongliang and Wu, Yuanxin and Wu, Wenqi and Wu, Meiping and Hu, Xiaoping}, 99 | year = {2009}, 100 | month = nov, 101 | journal = {Measurement Science and Technology}, 102 | volume = {21}, 103 | number = {1}, 104 | pages = {015107}, 105 | publisher = {{IOP Publishing}}, 106 | issn = {0957-0233}, 107 | doi = {10.1088/0957-0233/21/1/015107}, 108 | url = {https://doi.org/10.1088/0957-0233/21/1/015107}, 109 | urldate = {2022-03-06}, 110 | abstract = {Calibration of inertial measurement units (IMU) is carried out to estimate the coefficients which transform the raw outputs of inertial sensors to meaningful quantities of interest. Based on the fact that the norms of the measured outputs of the accelerometer and gyroscope cluster are equal to the magnitudes of specific force and rotational velocity inputs, respectively, an improved multi-position calibration approach is proposed. Specifically, two open but important issues are addressed for the multi-position calibration: (1) calibration of inter-triad misalignment between the gyroscope and accelerometer triads and (2) the optimal calibration scheme design. A new approach to calibrate the inter-triad misalignment is devised using the rotational axis direction measurements separately derived from the gyroscope and accelerometer triads. By maximizing the sensitivity of the norm of the IMU measurement with respect to the calibration parameters, we propose an approximately optimal calibration scheme. Simulations and real tests show that the improved multi-position approach outperforms the traditional laboratory calibration method, meanwhile relaxing the requirement of precise orientation control.}, 111 | langid = {english} 112 | } 113 | 114 | 115 | -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'imucal - A Python library to calibrate 6 DOF IMUs' 3 | tags: 4 | - Python 5 | - Machine Learning 6 | - Data Analysis 7 | authors: 8 | - name: Arne Küderle^[corresponding author] 9 | orcid: 0000-0002-5686-281X 10 | affiliation: 1 11 | - name: Nils Roth 12 | orcid: 0000-0002-9166-3920 13 | affiliation: 1 14 | - name: Robert Richer 15 | orcid: 0000-0003-0272-5403 16 | affiliation: 1 17 | - name: Bjoern M. Eskofier 18 | orcid: 0000-0002-0417-0336 19 | affiliation: 1 20 | affiliations: 21 | - name: Machine Learning and Data Analytics Lab (MaD Lab), Department Artificial Intelligence in Biomedical Engineering (AIBE), Friedrich-Alexander-Universität Erlangen-Nürnberg (FAU) 22 | index: 1 23 | date: 06 March 2022 24 | bibliography: imucal.bib 25 | --- 26 | 27 | # Summary 28 | 29 | Inertial measurement units (IMUs) have wide application areas from human movement analysis to commercial drone navigation. 30 | However, to use modern micro-electromechanical systems (MEMS)-based IMUs a calibration is required to transform the raw output of the sensor into physically meaningful units. 31 | To obtain such calibrations one needs to perform sets of predefined motions with the sensor unit and then apply a calibration algorithm to obtain the required transformation and correction factors for this unit. 32 | The `imucal` library implements the calibration algorithm described by Ferraris et al. [@Ferraris1994; @Ferraris1995] and provides functionality to calculate calibration parameters and apply them to new measurements. 33 | As typically multiple calibrations are recorded per sensor over time, `imucal` further provides a set of opinionated tools to save, organize, and retrieve recorded calibrations. 34 | This helps to make sure that always the best possible calibration is applied for each recording even when dealing with multiple sensors and dozens of measurements. 35 | 36 | # Statement of Need 37 | 38 | When working with MEMS-based IMUs, calibrations are required to correct sensor errors like bias, scaling, or non-orthogonality of the included gyroscope and accelerometer as well as to transform the raw sensor output into physical units. 39 | While out-of-the-box factory calibrations and self-calibration procedures have become better over the past years, high precision applications still benefit from manual calibrations temporally close to the measurement itself. 40 | This is because the parameters of the sensor can change because of the soldering process, change in humidity, or temperature. Also, it could simply change over time as the silicon ages. 41 | Various algorithms and protocols exist to tackle this issue. To calibrate the accelerometer, most of them require the sensor unit to be placed in multiple well-defined orientations relative to gravity. To calibrate the gyroscope, the sensor is required to be rotated either with known angular rate or by a known degree. 42 | From the data recorded in the different phases of the calibrations, the correction and transformation parameters for a specific sensor can be calculated. 43 | 44 | While multiple of these procedures have been published in literature, for example [@Skog2006; @Tedaldi2014a; @Ferraris1994; @Kozlov2014; @Qureshi2017; @Scapellato2005; @Zhang2009], no high-quality code implementations are available for most of them. 45 | Existing implementations that can be found on the internet are usually "one-off" scripts that would require adaptation and tinkering to make them usable for custom use cases. 46 | Further, many practical aspects of calibrating IMUs, like which information needs to be stored to make sure that a calibration can be correctly applied to a new recording, are usually not discussed in research papers or are not easily available online. 47 | 48 | Hence, well-maintained reference implementations of algorithms, clear guidelines, and informal guides are needed to make the procedure of creating and using calibrations easier. 49 | 50 | # Provided Functionality 51 | 52 | With `imucal` and its documentation, we address all the above needs and hope to even further expand on that in the future based on community feedback. 53 | The library provides a sensor-agnostic object-oriented implementation of the calibration algorithm by Ferraris et al. [@Ferraris1994,@Ferraris1995] and functionality to apply it to new data. 54 | Further, we provide a simple GUI interface to annotate recorded calibration sessions (\autoref{fig:ferraris_gui}). 55 | 56 | ![Screenshot of the GUI to annotate recorded Ferraris sessions. 57 | Each region corresponds to one of the required static positions or rotations. 58 | The annotation is performed using the mouse with support for keyboard shortcuts to speed up some interactions.\label{fig:ferraris_gui}](img/imucal_ferraris_gui.png) 59 | 60 | When working with sensors and multiple calibrations, storing and managing them can become complicated. 61 | Therefore, `imucal` also implements a set of opinionated helpers to store the calibrations and required metadata as _.json_ files and functions to retrieve them based on sensor ID, date, type of calibration, or custom metadata. 62 | 63 | While `imucal` itself only implements a single calibration algorithm so far, all tools in the library have been designed with the idea of having multiple algorithms in mind. 64 | Therefore, the provided structure and base classes should provide a solid basis to implement further algorithms, either as part of the library itself or as part of custom software packages. 65 | 66 | To ensure that all the provided tools are usable, the documentation contains full guides on how to practically perform the calibration and then use `imucal` to process the recorded sessions. 67 | As much as possible, these guides include informal tips to avoid common pitfalls. 68 | 69 | The `imucal` library has been used extensively in the background of all movement research at the Machine Learning and Data Analytics Lab (MaD Lab) to calibrate our over 100 custom and commercial IMU sensors. 70 | Therefore, we hope this library can bring similar value to research groups working on IMU-related topics. 71 | 72 | # Availability 73 | 74 | The software is available as a pip installable package (`pip install imucal`) and via [GitHub](https://github.com/mad-lab-fau/imucal). 75 | Documentation can be found on [Read the Docs](https://imucal.readthedocs.io/). 76 | 77 | # Acknowledgments 78 | 79 | `imucal` was developed to solve the chaos of random calibration scripts, old calibrations in unknown formats on shared folders, and general uncertainty when it came to calibrating or finding calibrations for one of the hundreds of self-build or off-the-shelf IMU units at the MaD Lab. 80 | Therefore, we would like to thank all members of the team and our students for their feedback and suggestions when working with the library. 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "imucal" 3 | version = "2.6.0" 4 | description = "A Python library to calibrate 6 DOF IMUs" 5 | authors = [ 6 | "Arne Küderle ", 7 | "Nils Roth ", 8 | "Robert Richer =3.9.2", optional=true} 28 | h5py = {version = ">=3", optional = true} 29 | 30 | [tool.poetry.extras] 31 | calplot = ["matplotlib"] 32 | h5py = ["h5py"] 33 | 34 | [tool.poetry.group.dev.dependencies] 35 | poethepoet = "^0.22.0" 36 | pytest = "^7.4.0" 37 | pytest-cov = "^4.1.0" 38 | ruff = "^0.5.0" 39 | sphinx = "^7.2.6" 40 | sphinx-gallery = "^0.14.0" 41 | memory-profiler = "^0.61.0" 42 | matplotlib = "^3.7.2" 43 | toml = "^0.10.2" 44 | myst-parser = "^2.0.0" 45 | numpydoc = "^1.6.0" 46 | recommonmark = "^0.7.1" 47 | pydata-sphinx-theme = "^0.16.0" 48 | 49 | [tool.poe.tasks] 50 | _format = "ruff format ." 51 | _auto_fix = "ruff check . --fix-only --show-fixes --exit-zero" 52 | _auto_fix_unsafe = "ruff check . --fix-only --show-fixes --exit-zero --unsafe-fixes" 53 | format = ["_auto_fix", "_format"] 54 | format_unsafe = ["_auto_fix_unsafe", "_format"] 55 | lint = { cmd = "ruff check imucal --fix", help = "Lint all files with ruff." } 56 | _lint_ci = "ruff check imucal --output-format=github" 57 | _check_format = "ruff format . --check" 58 | ci_check = { sequence = ["_check_format", "_lint_ci"], help = "Check all potential format and linting issues." } 59 | test = { cmd = "pytest tests --cov=imucal --cov-report=term-missing --cov-report=xml", help = "Run Pytest with coverage." } 60 | docs = { "script" = "_tasks:task_docs()", help = "Build the html docs using Sphinx." } 61 | docs_preview = { cmd = "python -m http.server --directory docs/_build/html", help = "Preview the built html docs." } 62 | version = { script = "_tasks:task_update_version()", help="Bump the version number in all relevant files."} 63 | 64 | [build-system] 65 | requires = ["poetry-core>=1.0.0"] 66 | build-backend = "poetry.core.masonry.api" 67 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore:Matplotlib is currently using agg:UserWarning 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mad-lab-fau/imucal/8716e4c50bbb7044918b747f38b26cb0a6420910/tests/__init__.py -------------------------------------------------------------------------------- /tests/_test_gui.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | from imucal import ferraris_regions_from_interactive_plot 6 | 7 | data = pd.read_csv(Path(__file__).parent.parent / "example_data/example_ferraris_session.csv").sort_values("n_samples") 8 | 9 | section_data, section_list = ferraris_regions_from_interactive_plot(data) 10 | 11 | print(section_data, section_list) 12 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from imucal import FerrarisCalibrationInfo, TurntableCalibrationInfo 7 | 8 | 9 | @pytest.fixture() 10 | def sample_cal_dict(): 11 | sample_data = { 12 | "K_a": np.array([[208.54567264, 0.0, 0.0], [0.0, 208.00113412, 0.0], [0.0, 0.0, 214.78455365]]), 13 | "R_a": np.array( 14 | [ 15 | [0.99991252, 0.00712206, -0.01114566], 16 | [-0.00794738, 0.99968874, 0.0236489], 17 | [0.0213429, -0.01078188, 0.99971407], 18 | ] 19 | ), 20 | "b_a": np.array([-6.01886802, -48.28787402, -28.96636637]), 21 | "K_g": np.array([[16.67747318, 0.0, 0.0], [0.0, 16.18769383, 0.0], [0.0, 0.0, 16.25326253]]), 22 | "R_g": np.array( 23 | [ 24 | [9.99918368e-01, 3.38399869e-04, -1.27727091e-02], 25 | [-5.19256254e-03, 9.99269158e-01, 3.78706515e-02], 26 | [1.28516088e-02, -3.63520887e-02, 9.99256404e-01], 27 | ] 28 | ), 29 | "K_ga": np.array( 30 | [ 31 | [0.00229265, 0.01387371, -0.00925911], 32 | [-0.01613463, 0.00544361, 0.00850631], 33 | [0.01846544, -0.00881248, -0.00393538], 34 | ] 35 | ), 36 | "b_g": np.array([1.9693536, -4.46624421, -3.65097072]), 37 | "acc_unit": "custom_acc_unit", 38 | "gyr_unit": "custom_gyro_unit", 39 | "from_acc_unit": "custom_from_acc_unit", 40 | "from_gyr_unit": "custom_from_gyr_unit", 41 | "comment": "my custom comment", 42 | } 43 | return sample_data 44 | 45 | 46 | @dataclass(eq=False) 47 | class CustomFerraris(FerrarisCalibrationInfo): 48 | CAL_TYPE = "Custom Ferraris" 49 | custom_field: str = "default_custom_value" 50 | 51 | 52 | @pytest.fixture(params=(FerrarisCalibrationInfo, TurntableCalibrationInfo, CustomFerraris)) 53 | def sample_cal(sample_cal_dict, request): 54 | info_class = request.param 55 | if info_class == CustomFerraris: 56 | sample_cal_dict["custom_field"] = "custom_value" 57 | return info_class(**sample_cal_dict) 58 | 59 | 60 | @pytest.fixture(params=[FerrarisCalibrationInfo, TurntableCalibrationInfo]) 61 | def dummy_cal(request): 62 | sample_data = { 63 | "K_a": np.identity(3), 64 | "R_a": np.identity(3), 65 | "b_a": np.zeros(3), 66 | "K_g": np.identity(3), 67 | "R_g": np.identity(3), 68 | "K_ga": np.zeros((3, 3)), 69 | "b_g": np.zeros(3), 70 | } 71 | return request.param(**sample_data) 72 | 73 | 74 | @pytest.fixture() 75 | def dummy_data(): 76 | sample_acc = np.repeat(np.array([[0, 0, 1.0]]), 100, axis=0) 77 | sample_gyro = np.repeat(np.array([[1, 1, 1.0]]), 100, axis=0) 78 | return sample_acc, sample_gyro 79 | -------------------------------------------------------------------------------- /tests/ferraris_example.csv: -------------------------------------------------------------------------------- 1 | n_samples,gyr_x,gyr_y,gyr_z,acc_x,acc_y,acc_z 2 | 0,-0.014105584278515125,0.06153544603248425,0.000950045631146391,9.827509503062053,-0.047742624123060955,-0.015169203751548592 3 | 1,0.04564229282537987,-0.00041418756618863384,0.061989605556476875,9.83252813884066,-0.02865391571213295,-0.02463628747426653 4 | 2,0.045267265651009796,-0.000577805744934139,0.0008511577156752319,9.789420432920876,-0.028319755286719935,-0.005338381293921712 5 | 3,-0.07386674941442922,-0.0007586123750648612,-0.05912805989376649,9.77497079505487,-0.023391692327397177,-0.019198492773494936 6 | 4,-0.014095395558432844,-0.0005859992848789485,0.0014371576898285856,9.789635835624473,0.0004017374906584591,-0.0334845751933666 7 | 5,-0.014101999840140806,-0.0006075534697115101,0.0014354021531202477,9.823484974876678,0.01444802006499719,-0.03396401137431489 8 | 6,0.04525896875709126,-0.0005955373860626222,0.0008576469247760289,9.847299871214465,0.004704416825328834,-0.043623920160352325 9 | 7,0.04566097022451606,-0.00039219695573145273,0.06199937542822627,9.798929293195055,-0.013987633715479899,-0.04762253082582894 10 | 8,-0.014123776950564822,-0.06267982095214278,0.0019225948433844236,9.789216135240835,-0.004286967701985171,-0.0802792009130686 11 | 9,0.045257524679834696,-0.06269017766287592,0.0013382944091309367,9.779955549478064,0.0005017879592004586,-0.04271763754665539 12 | 10,0.04528269913268302,0.061553970021952745,0.0003816022480780779,9.818078500974654,-0.02372585275281019,-0.03849639895383981 13 | 11,0.0456455418313794,-0.00039824918294438733,0.061994825822315115,9.822778070888244,-0.028536008830817708,-0.043230378561423755 14 | 12,-0.01411174922277754,-0.0005772252045145079,0.0014315422517843731,9.79401533012546,-0.033103126745567736,-0.02411390400230829 15 | 13,-0.014467617170142113,0.06135007295045265,-0.06018691936560537,9.789737479690695,-0.014012578136350476,0.008671607957960945 16 | 14,-0.014504821491383659,-0.0007673184666563647,-0.05971017672934178,9.808677342052274,-0.02369722793536109,-0.010285346371655046 17 | 15,-0.01406298862486927,0.1236678530905097,0.0004811224737847216,9.803696625819462,-0.02840733354482663,-0.02425238833252783 18 | 16,-0.07388014467444405,-0.0008017014917478238,-0.05913482306285945,9.833021659220687,-0.004799692255130458,-0.005966819754729502 19 | 17,0.04486400778557074,-0.0007574471867201119,-0.06028837360437633,9.81821705503907,-0.02855740924763964,-0.010402794802594683 20 | 18,0.10502492070425917,-0.00037452656453004177,0.06141165127939954,9.779350289555858,-0.05689655656362338,-0.009827821582436702 21 | 19,0.04564904564318832,-0.0003941217880084466,0.06199244330510141,9.803520152209254,-0.03795437985145966,-0.02891186680018223 22 | 20,-0.014489574706309228,0.061364476553212616,-0.06018298429261523,9.827687995767453,-0.028603890477861927,-0.029252818650910974 23 | 21,-0.07348522715163402,-0.0006095541586290769,0.0020073307074979915,9.813554395123672,-0.014164494037452732,-0.019731394195093083 24 | 22,-0.014069081354028028,0.06155075513951815,0.0009593615388186834,9.765431082068071,-0.01853151101511864,-0.0190810443425553 25 | 23,0.045295836663314513,0.061562433398145856,0.00038247042900934453,9.784511517589264,-0.023456029970392735,-0.02868748788794295 26 | 24,0.0456521854559049,-0.00041429755190642615,0.06199285079469414,9.823095117658063,-0.014228831680448249,-0.02922038930954104 27 | 25,0.04530669272554591,0.06154896625852096,0.0003889513831197675,9.799354041316672,0.014680289823578926,-0.047685638523668625 28 | 26,-0.01407756494454525,0.061533042751371766,0.0009625986522431735,9.813663106023068,0.004992096020519516,-0.04317603782832388 29 | 27,-0.014507252678203374,-0.0007486888683571279,-0.05969974571998939,9.822885772240044,-0.014175262442128567,-0.057303475511146146 30 | 28,-0.014109199448569833,-0.0005840823204697034,0.0014352182168365446,9.808609579341457,-0.01408768418402187,-0.03838946847254008 31 | 29,0.045244238613102714,-0.06269715263241667,0.0013363440162834734,9.808681380242657,-0.004513853258228998,-0.047771533105688366 32 | 30,0.045647425246081626,-0.00039472595829722685,0.061997430495306524,9.822779080435842,-0.023740165161534717,-0.0526019252449321 33 | 31,0.04524633867311621,-0.06270690954974807,0.0013291760224628279,9.794368277345876,-0.014009034132301788,-0.01479494706699974 34 | 32,-0.014141537290279097,-0.06273136791284396,0.0018986961575196248,9.813516475577881,-0.0285430968389151,0.0037027314884976953 35 | 33,0.045242830755414125,-0.06271840196601737,0.001323961216552307,9.79924230177449,-0.018863831242242418,0.003873645160087141 36 | 34,0.10467053401200607,0.061530522502580086,-0.000203585541952473,9.809100071078698,-0.004620991734868361,0.008394639297521947 37 | 35,0.045640630161881794,-0.00042362591148102744,0.06198482553979765,9.83259691109907,-0.03346761579418928,-0.00590371205688982 38 | 36,0.04528332322579713,0.06153475550634683,0.0003729063332545118,9.813481584674879,-0.028534168632528487,-0.0009777828784364702 39 | 37,-0.014108169089971792,-0.0005701595022380827,0.0014334995020908812,9.784369934882058,-0.03301200448341238,-0.028666451988663046 40 | 38,0.04486751570327272,-0.0007459547704508281,-0.06028315879846581,9.813343030610461,-0.023702612137699038,-0.029071387029681647 41 | 39,0.04487208921649721,-0.0007655071110779235,-0.0602909905947545,9.80401165349409,-0.023691843733023164,0.008500694286371471 42 | 40,0.046073711331746765,0.061865190663506396,0.12264587506666043,9.804081435300096,-0.02370970014579639,0.01786172302023991 43 | 41,0.04565609671551686,-0.0004161045305978789,0.061991545029468915,9.813554395123672,-0.014164494037452732,-0.019731394195093083 44 | 42,-0.0738613187392075,-0.0007811992879430738,-0.05912397097919386,9.804263966195881,0.014612408176534721,-0.03370807861315579 45 | 43,-0.014495663329574922,-0.0007452563764456557,-0.059697147904457036,9.803806346266454,-0.004454899817571392,-0.057068578649266954 46 | 44,0.045291043581205004,0.06154882915195331,0.00038657712089643163,9.823133037203855,0.00014977112101423155,-0.05265451499313182 47 | 45,0.045269781370612154,-0.0005787669932262056,0.0008602611200915325,9.813592314669464,0.00021410876400974877,-0.04316551987868392 48 | 46,0.045261585458190645,-0.0005751145298790922,0.000856368459189368,9.813378931061061,-0.01891569667480266,-0.03376241934625575 49 | 47,-0.014493635484665179,-0.0007505865797842124,-0.05970105834267366,9.794264614184463,-0.00918640584385884,-0.038208036851310696 50 | 48,-0.014493822181263973,-0.0007505673268021085,-0.059704310438349964,9.78461719984587,-0.018686970920269583,-0.024017491470648612 51 | 49,0.045220427610945656,-0.06269953314330039,0.001324649653793858,9.82266935998885,-0.04769259888878996,-0.019785734928192984 52 | -------------------------------------------------------------------------------- /tests/snapshots/example_cal.json: -------------------------------------------------------------------------------- 1 | { 2 | "acc_unit": "m/s^2", 3 | "gyr_unit": "deg/s", 4 | "from_acc_unit": "a.u.", 5 | "from_gyr_unit": "a.u.", 6 | "comment": null, 7 | "K_a": [ 8 | [ 9 | 208.54567263762615, 10 | 0.0, 11 | 0.0 12 | ], 13 | [ 14 | 0.0, 15 | 208.0011341167055, 16 | 0.0 17 | ], 18 | [ 19 | 0.0, 20 | 0.0, 21 | 214.78455364603022 22 | ] 23 | ], 24 | "R_a": [ 25 | [ 26 | 0.9999125214309234, 27 | 0.007122056140390757, 28 | -0.011145662922687352 29 | ], 30 | [ 31 | -0.007947378455060604, 32 | 0.9996887358297629, 33 | 0.023648903373787954 34 | ], 35 | [ 36 | 0.021342900724016442, 37 | -0.010781879507682177, 38 | 0.9997140749549175 39 | ] 40 | ], 41 | "b_a": [ 42 | -6.018868019671572, 43 | -48.287874016760156, 44 | -28.96636637224333 45 | ], 46 | "K_g": [ 47 | [ 48 | 16.67769558952488, 49 | 0.0, 50 | 0.0 51 | ], 52 | [ 53 | 0.0, 54 | 16.187895200550262, 55 | 0.0 56 | ], 57 | [ 58 | 0.0, 59 | 0.0, 60 | 16.253362639451936 61 | ] 62 | ], 63 | "R_g": [ 64 | [ 65 | -0.9999143756542296, 66 | -0.0006142300632876558, 67 | 0.013071498821540703 68 | ], 69 | [ 70 | 0.0055057030733027396, 71 | -0.9992594967426224, 72 | -0.03808077472097593 73 | ], 74 | [ 75 | -0.01314650914177706, 76 | 0.0365074524847183, 77 | -0.9992469040284595 78 | ] 79 | ], 80 | "K_ga": [ 81 | [ 82 | 0.0022926499308660976, 83 | -0.016134632407810497, 84 | 0.018465435717563698 85 | ], 86 | [ 87 | 0.013873705024757028, 88 | 0.005443610335094903, 89 | -0.008812480865044459 90 | ], 91 | [ 92 | -0.00925910567448679, 93 | 0.008506306471459087, 94 | -0.003935382156556474 95 | ] 96 | ], 97 | "b_g": [ 98 | 1.960686204431737, 99 | -4.472837741243746, 100 | -3.6511794138670477 101 | ], 102 | "cal_type": "Ferraris", 103 | "_format_version": "2.0" 104 | } -------------------------------------------------------------------------------- /tests/test_calibration_calculation.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import pytest 6 | from numpy.testing import assert_array_almost_equal 7 | 8 | from example_data import EXAMPLE_PATH 9 | from imucal import FerrarisCalibrationInfo 10 | from imucal.ferraris_calibration import ( 11 | FerrarisCalibration, 12 | FerrarisSignalRegions, 13 | TurntableCalibration, 14 | ferraris_regions_from_df, 15 | ) 16 | 17 | 18 | @pytest.fixture() 19 | def example_calibration_data(): 20 | calib = FerrarisCalibrationInfo.from_json_file(Path(__file__).parent / "snapshots/example_cal.json") 21 | data = pd.read_csv(EXAMPLE_PATH / "annotated_session.csv", index_col=[0, 1]) 22 | sampling_rate = 204.8 23 | return data, sampling_rate, calib 24 | 25 | 26 | def test_example_calibration(example_calibration_data) -> None: 27 | data, sampling_rate, calib = example_calibration_data 28 | 29 | cal = FerrarisCalibration() 30 | regions = ferraris_regions_from_df(data) 31 | cal_mat = cal.compute(regions, sampling_rate, from_acc_unit="a.u.", from_gyr_unit="a.u.") 32 | 33 | # # Uncomment if you want to save the new cal matrix to update the regression test 34 | # cal_mat.to_json_file(Path(__file__).parent / "snapshots/example_cal.json") 35 | 36 | assert cal_mat == calib 37 | 38 | 39 | @pytest.fixture() 40 | def default_expected(): 41 | expected = {} 42 | expected["K_a"] = np.identity(3) 43 | expected["R_a"] = np.identity(3) 44 | expected["b_a"] = np.zeros(3) 45 | expected["K_g"] = np.identity(3) 46 | expected["R_g"] = np.identity(3) 47 | expected["b_g"] = np.zeros(3) 48 | expected["K_ga"] = np.zeros((3, 3)) 49 | 50 | return expected 51 | 52 | 53 | @pytest.fixture() 54 | def default_data(): 55 | data = {} 56 | 57 | data["acc_x_p"] = np.repeat(np.array([[9.81, 0, 0]]), 100, axis=0) 58 | data["acc_x_a"] = -data["acc_x_p"] 59 | data["acc_y_p"] = np.repeat(np.array([[0, 9.81, 0]]), 100, axis=0) 60 | data["acc_y_a"] = -data["acc_y_p"] 61 | data["acc_z_p"] = np.repeat(np.array([[0, 0, 9.81]]), 100, axis=0) 62 | data["acc_z_a"] = -data["acc_z_p"] 63 | 64 | data["gyr_x_p"] = np.zeros((100, 3)) 65 | data["gyr_x_a"] = np.zeros((100, 3)) 66 | data["gyr_y_p"] = np.zeros((100, 3)) 67 | data["gyr_y_a"] = np.zeros((100, 3)) 68 | data["gyr_z_p"] = np.zeros((100, 3)) 69 | data["gyr_z_a"] = np.zeros((100, 3)) 70 | 71 | data["acc_x_rot"] = np.repeat(np.array([[9.81, 0, 0]]), 100, axis=0) 72 | data["acc_y_rot"] = np.repeat(np.array([[0, 9.81, 0]]), 100, axis=0) 73 | data["acc_z_rot"] = np.repeat(np.array([[0, 0, 9.81]]), 100, axis=0) 74 | 75 | data["gyr_x_rot"] = -np.repeat(np.array([[360.0, 0, 0]]), 100, axis=0) 76 | data["gyr_y_rot"] = -np.repeat(np.array([[0, 360.0, 0]]), 100, axis=0) 77 | data["gyr_z_rot"] = -np.repeat(np.array([[0, 0, 360.0]]), 100, axis=0) 78 | 79 | out = {} 80 | out["signal_regions"] = FerrarisSignalRegions(**data) 81 | out["sampling_rate_hz"] = 100 82 | out["from_acc_unit"] = "a.u." 83 | out["from_gyr_unit"] = "a.u." 84 | return out 85 | 86 | 87 | @pytest.fixture() 88 | def k_ga_data(default_data, default_expected): 89 | # "Simulate" a large influence of acc on gyro. 90 | # Every gyro axis depends on acc_x to produce predictable off axis elements 91 | cal_regions = default_data["signal_regions"]._asdict() 92 | cal_regions["gyr_x_p"] += cal_regions["acc_x_p"] 93 | cal_regions["gyr_x_a"] += cal_regions["acc_x_a"] 94 | cal_regions["gyr_y_p"] += cal_regions["acc_x_p"] 95 | cal_regions["gyr_y_a"] += cal_regions["acc_x_a"] 96 | cal_regions["gyr_z_p"] += cal_regions["acc_x_p"] 97 | cal_regions["gyr_z_a"] += cal_regions["acc_x_a"] 98 | 99 | # add the influence artifact to the rotation as well 100 | cal_regions["gyr_x_rot"] += cal_regions["acc_x_p"] 101 | cal_regions["gyr_y_rot"] += cal_regions["acc_x_p"] 102 | cal_regions["gyr_z_rot"] += cal_regions["acc_x_p"] 103 | 104 | default_data["signal_regions"] = FerrarisSignalRegions(**cal_regions) 105 | 106 | # Only influence in K_ga expected 107 | # acc_x couples to 100% into all axis -> Therefore first row 1 108 | expected = np.zeros((3, 3)) 109 | expected[0, :] = 1 110 | default_expected["K_ga"] = expected 111 | 112 | return default_data, default_expected 113 | 114 | 115 | @pytest.fixture() 116 | def bias_data(default_data, default_expected): 117 | cal_regions = default_data["signal_regions"]._asdict() 118 | # Add bias to acc 119 | acc_bias = np.array([1, 3, 5]) 120 | cal_regions["acc_x_p"] += acc_bias 121 | cal_regions["acc_x_a"] += acc_bias 122 | cal_regions["acc_y_p"] += acc_bias 123 | cal_regions["acc_y_a"] += acc_bias 124 | cal_regions["acc_z_p"] += acc_bias 125 | cal_regions["acc_z_a"] += acc_bias 126 | 127 | default_expected["b_a"] = acc_bias 128 | 129 | # Add bias to gyro 130 | gyro_bias = np.array([2, 4, 6]) 131 | cal_regions["gyr_x_p"] += gyro_bias 132 | cal_regions["gyr_x_a"] += gyro_bias 133 | cal_regions["gyr_y_p"] += gyro_bias 134 | cal_regions["gyr_y_a"] += gyro_bias 135 | cal_regions["gyr_z_p"] += gyro_bias 136 | cal_regions["gyr_z_a"] += gyro_bias 137 | 138 | cal_regions["gyr_x_rot"] += gyro_bias 139 | cal_regions["gyr_y_rot"] += gyro_bias 140 | cal_regions["gyr_z_rot"] += gyro_bias 141 | 142 | default_data["signal_regions"] = FerrarisSignalRegions(**cal_regions) 143 | 144 | default_expected["b_g"] = gyro_bias 145 | 146 | return default_data, default_expected 147 | 148 | 149 | @pytest.fixture() 150 | def scaling_data(default_data, default_expected): 151 | cal_regions = default_data["signal_regions"]._asdict() 152 | # Add scaling to acc 153 | acc_scaling = np.array([1, 3, 5]) 154 | cal_regions["acc_x_p"] *= acc_scaling[0] 155 | cal_regions["acc_x_a"] *= acc_scaling[0] 156 | cal_regions["acc_y_p"] *= acc_scaling[1] 157 | cal_regions["acc_y_a"] *= acc_scaling[1] 158 | cal_regions["acc_z_p"] *= acc_scaling[2] 159 | cal_regions["acc_z_a"] *= acc_scaling[2] 160 | 161 | default_expected["K_a"] = np.diag(acc_scaling) 162 | 163 | # Add scaling to gyro 164 | gyro_scaling = np.array([2, 4, 6]) 165 | cal_regions["gyr_x_rot"] *= gyro_scaling[0] 166 | cal_regions["gyr_y_rot"] *= gyro_scaling[1] 167 | cal_regions["gyr_z_rot"] *= gyro_scaling[2] 168 | 169 | default_data["signal_regions"] = FerrarisSignalRegions(**cal_regions) 170 | default_expected["K_g"] = np.diag(gyro_scaling) 171 | 172 | return default_data, default_expected 173 | 174 | 175 | # TODO: Test for rotation is missing 176 | 177 | 178 | @pytest.mark.parametrize("test_data", ["k_ga_data", "bias_data", "scaling_data"]) 179 | def test_simulations(test_data, request) -> None: 180 | test_data = request.getfixturevalue(test_data) 181 | cal = FerrarisCalibration() 182 | cal_mat = cal.compute(**test_data[0]) 183 | 184 | for para, val in test_data[1].items(): 185 | assert_array_almost_equal(getattr(cal_mat, para), val, err_msg=para) 186 | 187 | 188 | def test_turntable_calibration(default_data, default_expected) -> None: 189 | cal = TurntableCalibration() 190 | cal_mat = cal.compute(**default_data) 191 | 192 | keys = set(default_expected.keys()) - {"K_g"} 193 | for para in keys: 194 | assert_array_almost_equal(getattr(cal_mat, para), default_expected[para], err_msg=para) 195 | 196 | assert_array_almost_equal(cal_mat.K_g, default_expected["K_g"] / 2, err_msg="K_g") 197 | 198 | assert cal.expected_angle == -720 199 | -------------------------------------------------------------------------------- /tests/test_calibration_info_basic.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from copy import deepcopy 3 | 4 | import pytest 5 | 6 | from imucal import CalibrationInfo 7 | from imucal.management import load_calibration_info 8 | 9 | 10 | def test_equal(sample_cal) -> None: 11 | assert sample_cal == deepcopy(sample_cal) 12 | 13 | 14 | def test_equal_wrong_type(sample_cal) -> None: 15 | with pytest.raises(TypeError): 16 | assert sample_cal == 3 17 | 18 | 19 | def test_equal_data(sample_cal, sample_cal_dict) -> None: 20 | not_equal = sample_cal_dict 21 | not_equal["K_a"] = not_equal["K_a"] - 1 22 | assert sample_cal != sample_cal.__class__(**not_equal) 23 | 24 | 25 | def test_json_roundtrip(sample_cal) -> None: 26 | out = CalibrationInfo.from_json(sample_cal.to_json()) 27 | assert sample_cal == out 28 | 29 | 30 | def test_json_file_roundtrip(sample_cal) -> None: 31 | with tempfile.NamedTemporaryFile(mode="w+") as f: 32 | sample_cal.to_json_file(f.name) 33 | out = load_calibration_info(f.name, file_type="json") 34 | assert sample_cal == out 35 | 36 | 37 | def test_hdf5_file_roundtrip(sample_cal) -> None: 38 | with tempfile.NamedTemporaryFile(mode="w+") as f: 39 | sample_cal.to_hdf5(f.name) 40 | out = load_calibration_info(f.name, file_type="hdf") 41 | assert sample_cal == out 42 | 43 | 44 | @pytest.mark.parametrize("unit", ["acc", "gyr"]) 45 | @pytest.mark.parametrize("is_none", ["some_value", None]) 46 | def test_error_on_wrong_calibration(unit, is_none, dummy_cal, dummy_data) -> None: 47 | # Test without error first: 48 | setattr(dummy_cal, f"from_{unit}_unit", is_none) 49 | units = {"acc_unit": dummy_cal.from_acc_unit, "gyr_unit": dummy_cal.from_gyr_unit} 50 | units[f"{unit}_unit"] = is_none 51 | dummy_cal.calibrate(*dummy_data, **units) 52 | 53 | # Now with error 54 | units[f"{unit}_unit"] = "wrong_value" 55 | with pytest.raises(ValueError) as e: 56 | dummy_cal.calibrate(*dummy_data, **units) 57 | 58 | if is_none is None: 59 | assert "explicitly to `None` to ignore this error" in str(e.value) 60 | else: 61 | assert is_none in str(e.value) 62 | -------------------------------------------------------------------------------- /tests/test_calibration_info_math.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | 5 | def test_dummy_cal(dummy_cal, dummy_data) -> None: 6 | acc, gyro = dummy_cal.calibrate(*dummy_data, None, None) 7 | assert np.array_equal(acc, dummy_data[0]) 8 | assert np.array_equal(gyro, dummy_data[1]) 9 | 10 | 11 | def test_dummy_cal_df(dummy_cal, dummy_data) -> None: 12 | dummy_df = pd.DataFrame(np.column_stack(dummy_data)) 13 | dummy_df.columns = ["acc_x", "acc_y", "acc_z", "gyr_x", "gyr_y", "gyr_z"] 14 | 15 | result_df = dummy_cal.calibrate_df(dummy_df, None, None) 16 | assert np.array_equal(dummy_data[0], result_df.filter(like="acc")) 17 | assert np.array_equal(dummy_data[1], result_df.filter(like="gyr")) 18 | 19 | 20 | def test_ka_ba_ra(dummy_cal, dummy_data) -> None: 21 | dummy_cal.K_a *= 2 22 | dummy_cal.b_a += 2 23 | dummy_cal.R_a = np.flip(dummy_cal.R_a, axis=1) 24 | acc, _ = dummy_cal.calibrate(*dummy_data, None, None) 25 | assert np.all(acc == [-0.5, -1, -1]) 26 | 27 | 28 | def test_kg_rg_bg(dummy_cal, dummy_data) -> None: 29 | dummy_cal.K_g *= 2 30 | dummy_cal.b_g += 2 31 | dummy_cal.R_g = np.array([[0, 0, 1], [0, 1, 0], [0.5, 0, 0]]) 32 | _, gyro = dummy_cal.calibrate(*dummy_data, None, None) 33 | assert np.all(gyro == [-1, -0.5, -0.5]) 34 | 35 | 36 | def test_kga(dummy_cal, dummy_data) -> None: 37 | dummy_cal.K_g *= 2 38 | dummy_cal.b_g += 2 39 | dummy_cal.R_g = np.array([[0, 0, 1], [0, 1, 0], [0.5, 0, 0]]) 40 | dummy_cal.K_ga = np.identity(3) 41 | _, gyro = dummy_cal.calibrate(*dummy_data, None, None) 42 | assert np.all(gyro == [-2, -0.5, -0.5]) 43 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import matplotlib 4 | import pandas as pd 5 | 6 | # This is needed to avoid plots to open 7 | matplotlib.use("Agg") 8 | 9 | 10 | def test_custom_calibration_info() -> None: 11 | from examples.custom_calibration_info import cal_info, loaded_cal_info 12 | 13 | assert cal_info.new_meta_info == "my value" 14 | assert loaded_cal_info.new_meta_info == "my value" 15 | 16 | 17 | def test_basic_ferraris_calibration() -> None: 18 | from examples.basic_ferraris import calibrated_data 19 | 20 | # Uncomment the following lines to update the snapshot 21 | # calibrated_data.head(50).to_csv(Path(__file__).parent / "ferraris_example.csv") 22 | 23 | expected = pd.read_csv(Path(__file__).parent / "ferraris_example.csv", header=0, index_col=0) 24 | 25 | pd.testing.assert_frame_equal(expected, calibrated_data.head(50)) 26 | -------------------------------------------------------------------------------- /tests/test_legacy.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from imucal import FerrarisCalibrationInfo 6 | from imucal.legacy import load_v1_json, load_v1_json_files 7 | from imucal.management import load_calibration_info 8 | 9 | 10 | @pytest.fixture() 11 | def legacy_pre_2_0_cal(): 12 | return Path(__file__).parent.parent / "example_data/legacy_calibration_pre_2.0.json" 13 | 14 | 15 | def test_legacy_json_import(legacy_pre_2_0_cal) -> None: 16 | cal_info = load_v1_json_files(legacy_pre_2_0_cal) 17 | 18 | # Just check a couple of parameters to confirm that the file is loaded. 19 | assert isinstance(cal_info, FerrarisCalibrationInfo) 20 | assert cal_info.b_g[0] == -9.824970828471413 21 | 22 | 23 | def test_legacy_json_str(legacy_pre_2_0_cal) -> None: 24 | with open(legacy_pre_2_0_cal) as f: 25 | json_str = f.read() 26 | cal_info = load_v1_json(json_str) 27 | 28 | # Just check a couple of parameters to confirm that the file is loaded. 29 | assert isinstance(cal_info, FerrarisCalibrationInfo) 30 | assert cal_info.b_g[0] == -9.824970828471413 31 | 32 | 33 | def test_error_raised(legacy_pre_2_0_cal) -> None: 34 | with pytest.raises(ValueError) as e: 35 | load_calibration_info(legacy_pre_2_0_cal) 36 | 37 | assert "`imucal.legacy`" in str(e) 38 | -------------------------------------------------------------------------------- /tests/test_management.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import tempfile 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from imucal import FerrarisCalibrationInfo, TurntableCalibrationInfo 8 | from imucal.management import ( 9 | CalibrationWarning, 10 | find_calibration_info_for_sensor, 11 | find_closest_calibration_info_to_date, 12 | load_calibration_info, 13 | save_calibration_info, 14 | ) 15 | from tests.conftest import CustomFerraris 16 | 17 | 18 | @pytest.fixture() 19 | def sample_cal_folder(sample_cal): 20 | with tempfile.TemporaryDirectory() as f: 21 | for sid in ["test1", "test2", "test3"]: 22 | for min in range(10, 30, 2): 23 | date = datetime.datetime(2000, 10, 3, 13, min) 24 | save_calibration_info(sample_cal, sid, date, f, folder_structure="") 25 | yield f 26 | 27 | 28 | @pytest.fixture() 29 | def sample_cal_folder_recursive(sample_cal_dict): 30 | with tempfile.TemporaryDirectory() as f: 31 | for s in [FerrarisCalibrationInfo, TurntableCalibrationInfo, CustomFerraris]: 32 | for sid in ["test1", "test2", "test3"]: 33 | new_cal = s(**sample_cal_dict) 34 | for min in range(10, 30, 2): 35 | date = datetime.datetime(2000, 10, 3, 13, min) 36 | save_calibration_info(new_cal, sid, date, Path(f)) 37 | yield f 38 | 39 | 40 | class TestSaveCalibration: 41 | temp_folder = Path 42 | 43 | @pytest.fixture(autouse=True) 44 | def temp_folder(self): 45 | with tempfile.TemporaryDirectory() as f: 46 | self.temp_folder = Path(f) 47 | yield 48 | 49 | def test_default_filename(self, sample_cal) -> None: 50 | out = save_calibration_info(sample_cal, "test", datetime.datetime(2000, 10, 3, 13, 22), self.temp_folder) 51 | 52 | expected_out = next((self.temp_folder / "test" / sample_cal.CAL_TYPE).glob("*")) 53 | 54 | assert out == expected_out 55 | assert expected_out.name == "test_2000-10-03_13-22.json" 56 | 57 | @pytest.mark.parametrize("sensor_id", ["a_b", "tes*", ""]) 58 | def test_valid_s_id(self, sample_cal, sensor_id) -> None: 59 | with pytest.raises(ValueError): 60 | save_calibration_info(sample_cal, sensor_id, datetime.datetime(2000, 10, 3, 13, 22), self.temp_folder) 61 | 62 | @pytest.mark.parametrize( 63 | ("str_in", "folder_path"), 64 | [ 65 | ("simple", ("simple",)), 66 | ("{sensor_id}/{cal_info.from_gyr_unit}_custom", ("test", "custom_from_gyr_unit_custom")), 67 | ], 68 | ) 69 | def test_custom_folder_path(self, sample_cal, str_in, folder_path) -> None: 70 | out = save_calibration_info( 71 | sample_cal, "test", datetime.datetime(2000, 10, 3, 13, 22), self.temp_folder, folder_structure=str_in 72 | ) 73 | 74 | match = out.parts[-len(folder_path) - 1 : -1] 75 | for e, o in zip(match, folder_path): 76 | assert e == o 77 | 78 | def test_empty_folder_structure(self, sample_cal) -> None: 79 | out = save_calibration_info( 80 | sample_cal, "test", datetime.datetime(2000, 10, 3, 13, 22), self.temp_folder, folder_structure="" 81 | ) 82 | 83 | assert out.parent == self.temp_folder 84 | 85 | def test_kwargs(self, sample_cal) -> None: 86 | out = save_calibration_info( 87 | sample_cal, 88 | "test", 89 | datetime.datetime(2000, 10, 3, 13, 22), 90 | self.temp_folder, 91 | folder_structure="{sensor_id}/{my_custom}", 92 | my_custom="my_custom_val", 93 | ) 94 | 95 | assert out.parts[-2] == "my_custom_val" 96 | assert out.parts[-3] == "test" 97 | 98 | 99 | class TestFindCalibration: 100 | def test_simple(self, sample_cal_folder) -> None: 101 | cals = find_calibration_info_for_sensor("test1", sample_cal_folder) 102 | 103 | assert len(cals) == 10 104 | assert all("test1" in str(x) for x in cals) 105 | 106 | def test_find_calibration_non_existent(self, sample_cal_folder) -> None: 107 | with pytest.raises(ValueError): 108 | find_calibration_info_for_sensor("wrong_sensor", sample_cal_folder) 109 | 110 | cals = find_calibration_info_for_sensor("wrong_sensor", sample_cal_folder, ignore_file_not_found=True) 111 | 112 | assert len(cals) == 0 113 | 114 | def test_find_calibration_recursive(self, sample_cal_folder_recursive) -> None: 115 | with pytest.raises(ValueError): 116 | find_calibration_info_for_sensor("test1", sample_cal_folder_recursive, recursive=False) 117 | 118 | cals = find_calibration_info_for_sensor("test1", sample_cal_folder_recursive, recursive=True) 119 | 120 | assert len(cals) == 30 121 | assert all("test1" in str(x) for x in cals) 122 | 123 | def test_find_calibration_type_filter(self, sample_cal_folder_recursive) -> None: 124 | cals = find_calibration_info_for_sensor( 125 | "test1", sample_cal_folder_recursive, recursive=True, filter_cal_type="ferraris" 126 | ) 127 | 128 | assert len(cals) == 10 129 | assert all("test1" in str(x) for x in cals) 130 | assert all(load_calibration_info(c).CAL_TYPE.lower() == "ferraris" for c in cals) 131 | 132 | @pytest.mark.parametrize("string", ["Ferraris", "ferraris", "FERRARIS"]) 133 | def test_find_calibration_type_filter_case_sensitive(self, sample_cal_folder_recursive, string) -> None: 134 | cals = find_calibration_info_for_sensor( 135 | "test1", sample_cal_folder_recursive, recursive=True, filter_cal_type=string 136 | ) 137 | 138 | assert len(cals) == 10 139 | assert all("test1" in str(x) for x in cals) 140 | assert all(load_calibration_info(c).CAL_TYPE.lower() == "ferraris" for c in cals) 141 | 142 | def test_custom_validator(self, sample_cal_folder_recursive) -> None: 143 | # We simulate the caltype filter with a custom validator 144 | def validator(x): 145 | return x.CAL_TYPE.lower() == "ferraris" 146 | 147 | cals = find_calibration_info_for_sensor( 148 | "test1", sample_cal_folder_recursive, recursive=True, filter_cal_type=None, custom_validator=validator 149 | ) 150 | 151 | assert len(cals) == 10 152 | assert all("test1" in str(x) for x in cals) 153 | assert all(load_calibration_info(c).CAL_TYPE.lower() == "ferraris" for c in cals) 154 | 155 | 156 | class TestFindClosestCalibration: 157 | @pytest.mark.parametrize("relative", ["before", "after", None]) 158 | def test_find_closest(self, sample_cal_folder, relative) -> None: 159 | # Test that before and after still return the correct one if there is an exact match 160 | 161 | cal = find_closest_calibration_info_to_date( 162 | "test1", datetime.datetime(2000, 10, 3, 13, 14), sample_cal_folder, before_after=relative 163 | ) 164 | 165 | assert cal.name == "test1_2000-10-03_13-14.json" 166 | 167 | def test_find_closest_non_existend(self, sample_cal_folder) -> None: 168 | with pytest.raises(ValueError): 169 | find_closest_calibration_info_to_date( 170 | "wrong_sensor", datetime.datetime(2000, 10, 3, 13, 14), sample_cal_folder 171 | ) 172 | 173 | cal = find_closest_calibration_info_to_date( 174 | "wrong_sensor", datetime.datetime(2000, 10, 3, 13, 14), sample_cal_folder, ignore_file_not_found=True 175 | ) 176 | 177 | assert cal is None 178 | 179 | @pytest.mark.parametrize( 180 | ("relative", "expected"), 181 | [ 182 | ("before", "test1_2000-10-03_13-14.json"), 183 | ("after", "test1_2000-10-03_13-16.json"), 184 | (None, "test1_2000-10-03_13-14.json"), 185 | ], 186 | ) 187 | def test_find_closest_before_after(self, sample_cal_folder, relative, expected) -> None: 188 | # Default to earlier if same distance before and after. 189 | cal = find_closest_calibration_info_to_date( 190 | "test1", datetime.datetime(2000, 10, 3, 13, 15), sample_cal_folder, before_after=relative 191 | ) 192 | 193 | assert cal.name == expected 194 | 195 | @pytest.mark.parametrize(("warn_type", "day"), [(CalibrationWarning, 15), (None, 14)]) 196 | def test_find_closest_warning(self, sample_cal_folder, warn_type, day) -> None: 197 | with pytest.warns(warn_type) as rec: 198 | find_closest_calibration_info_to_date( 199 | "test1", 200 | datetime.datetime(2000, 10, 3, 13, day), 201 | sample_cal_folder, 202 | warn_thres=datetime.timedelta(seconds=30), 203 | ) 204 | expected_n = 1 205 | if warn_type is None: 206 | expected_n = 0 207 | assert len(rec) == expected_n 208 | 209 | 210 | class TestLoadCalFiles: 211 | def test_invalid_file(self) -> None: 212 | with pytest.raises(ValueError) as e: 213 | load_calibration_info("invalid_file.txt") 214 | 215 | assert "The loader format could not be determined" in str(e) 216 | 217 | def test_finds_subclass(self, sample_cal) -> None: 218 | """If the wrong subclass is used it can not find the correct calibration.""" 219 | with tempfile.NamedTemporaryFile(mode="w+") as f: 220 | sample_cal.to_json_file(f.name) 221 | if isinstance(sample_cal, CustomFerraris): 222 | assert load_calibration_info(f.name, file_type="json", base_class=CustomFerraris) == sample_cal 223 | else: 224 | with pytest.raises(ValueError) as e: 225 | load_calibration_info(f.name, file_type="json", base_class=CustomFerraris) 226 | assert sample_cal.CAL_TYPE in str(e) 227 | 228 | @pytest.mark.parametrize("file_type", ["json", "hdf"]) 229 | def test_fixed_loader(self, file_type, sample_cal) -> None: 230 | method = {"json": "to_json_file", "hdf": "to_hdf5"} 231 | with tempfile.NamedTemporaryFile(mode="w+") as f: 232 | getattr(sample_cal, method[file_type])(f.name) 233 | out = load_calibration_info(f.name, file_type=file_type) 234 | assert sample_cal == out 235 | 236 | @pytest.mark.parametrize("file_type", ["json", "hdf"]) 237 | def test_auto_loader(self, file_type, sample_cal) -> None: 238 | method = {"json": "to_json_file", "hdf": "to_hdf5"} 239 | with tempfile.NamedTemporaryFile(mode="w+", suffix="." + file_type) as f: 240 | getattr(sample_cal, method[file_type])(f.name) 241 | out = load_calibration_info(f.name) 242 | assert sample_cal == out 243 | 244 | def test_invalid_loader(self) -> None: 245 | with pytest.raises(ValueError) as e: 246 | load_calibration_info("invalid_file.txt", file_type="invalid") 247 | 248 | assert "`format` must be one of" in str(e) 249 | --------------------------------------------------------------------------------