├── .github └── workflows │ ├── deploy.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── RELEASING.md ├── plots ├── calendar001.png ├── dumbbell001.png ├── elevations001.png ├── facets001.png ├── landscape001.png └── map001.png ├── pyproject.toml ├── src └── stravavis │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── plot_calendar.py │ ├── plot_dumbbell.py │ ├── plot_elevations.py │ ├── plot_facets.py │ ├── plot_landscape.py │ ├── plot_map.py │ ├── process_activities.py │ └── process_data.py ├── tests ├── csv │ └── activities.csv └── gpx │ ├── activity_7348460494.gpx │ ├── activity_7397878357.gpx │ ├── activity_7448910422.gpx │ ├── empty-trkseg.gpx │ └── invalid-lon-lat-missing.gpx └── tox.ini /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | pull_request: 8 | branches: [main] 9 | release: 10 | types: 11 | - published 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | env: 18 | FORCE_COLOR: 1 19 | 20 | jobs: 21 | # Always build & lint package. 22 | build-package: 23 | name: Build & verify package 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | persist-credentials: false 31 | 32 | - uses: hynek/build-and-inspect-python-package@v2 33 | 34 | # Upload to Test PyPI on every commit on main. 35 | release-test-pypi: 36 | name: Publish in-dev package to test.pypi.org 37 | if: | 38 | github.repository_owner == 'marcusvolz' 39 | && github.event_name == 'push' 40 | && github.ref == 'refs/heads/main' 41 | runs-on: ubuntu-latest 42 | needs: build-package 43 | 44 | permissions: 45 | id-token: write 46 | 47 | steps: 48 | - name: Download packages built by build-and-inspect-python-package 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: Packages 52 | path: dist 53 | 54 | - name: Upload package to Test PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | with: 57 | repository-url: https://test.pypi.org/legacy/ 58 | 59 | # Upload to real PyPI on GitHub Releases. 60 | release-pypi: 61 | name: Publish released package to pypi.org 62 | if: | 63 | github.repository_owner == 'marcusvolz' 64 | && github.event.action == 'published' 65 | runs-on: ubuntu-latest 66 | needs: build-package 67 | 68 | permissions: 69 | id-token: write 70 | 71 | steps: 72 | - name: Download packages built by build-and-inspect-python-package 73 | uses: actions/download-artifact@v4 74 | with: 75 | name: Packages 76 | path: dist 77 | 78 | - name: Upload package to PyPI 79 | uses: pypa/gh-action-pypi-publish@release/v1 80 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | PIP_DISABLE_PIP_VERSION_CHECK: 1 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | persist-credentials: false 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.x" 23 | cache: pip 24 | - uses: pre-commit/action@v3.0.1 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 18 | os: [windows-latest, macos-latest, ubuntu-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | persist-credentials: false 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: pip 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install -U pip 34 | python -m pip install -U tox 35 | 36 | - name: Tox tests 37 | run: | 38 | tox -e py 39 | 40 | - name: Upload output images 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: images-${{ matrix.os }}-${{ matrix.python-version }} 44 | path: "*.png" 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Generated files 132 | *.png 133 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.2 4 | hooks: 5 | - id: ruff 6 | args: [--exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 24.10.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: check-added-large-files 17 | - id: check-case-conflict 18 | - id: check-merge-conflict 19 | - id: check-toml 20 | - id: check-yaml 21 | - id: debug-statements 22 | - id: end-of-file-fixer 23 | - id: forbid-submodules 24 | - id: trailing-whitespace 25 | 26 | - repo: https://github.com/python-jsonschema/check-jsonschema 27 | rev: 0.30.0 28 | hooks: 29 | - id: check-github-workflows 30 | 31 | - repo: https://github.com/rhysd/actionlint 32 | rev: v1.7.4 33 | hooks: 34 | - id: actionlint 35 | 36 | - repo: https://github.com/woodruffw/zizmor-pre-commit 37 | rev: v0.8.0 38 | hooks: 39 | - id: zizmor 40 | 41 | - repo: https://github.com/tox-dev/pyproject-fmt 42 | rev: v2.5.0 43 | hooks: 44 | - id: pyproject-fmt 45 | 46 | - repo: https://github.com/abravalheri/validate-pyproject 47 | rev: v0.23 48 | hooks: 49 | - id: validate-pyproject 50 | 51 | - repo: https://github.com/tox-dev/tox-ini-fmt 52 | rev: 1.4.1 53 | hooks: 54 | - id: tox-ini-fmt 55 | 56 | - repo: https://github.com/rbubley/mirrors-prettier 57 | rev: v3.4.2 58 | hooks: 59 | - id: prettier 60 | args: [--prose-wrap=always, --print-width=88] 61 | 62 | - repo: meta 63 | hooks: 64 | - id: check-hooks-apply 65 | - id: check-useless-excludes 66 | 67 | ci: 68 | autoupdate_schedule: quarterly 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marcus Volz 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 | # strava_py 2 | 3 | Create artistic visualisations with your exercise data (Python version). 4 | 5 | This is a port of the [R strava package](https://github.com/marcusvolz/strava) to 6 | Python. 7 | 8 | ## Installation 9 | 10 | Install via pip: 11 | 12 | ```sh 13 | python3 -m pip install stravavis 14 | ``` 15 | 16 | For development: 17 | 18 | ```sh 19 | git clone https://github.com/marcusvolz/strava_py 20 | cd strava_py 21 | pip install -e . 22 | ``` 23 | 24 | Then run from the terminal: 25 | 26 | ```sh 27 | stravavis --help 28 | ``` 29 | 30 | ## Examples 31 | 32 | ### Facets 33 | 34 | A plot of activities as small multiples. The concept behind this plot was originally 35 | inspired by [Sisu](https://twitter.com/madewithsisu). 36 | 37 | ![facets](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/facets001.png "Facets, showing activity outlines") 38 | 39 | ### Map 40 | 41 | A map of activities viewed in plan. 42 | 43 | ![map](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/map001.png "A map of activities viewed in plan") 44 | 45 | ### Elevations 46 | 47 | A plot of activity elevation profiles as small multiples. 48 | 49 | ![map](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/elevations001.png "A plot of activity elevation profiles as small multiples") 50 | 51 | ### Landscape 52 | 53 | Elevation profiles superimposed. 54 | 55 | ![map](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/landscape001.png "Elevation profiles superimposed") 56 | 57 | ### Calendar 58 | 59 | Calendar heatmap showing daily activity distance, using the 60 | [calmap](https://pythonhosted.org/calmap/) package. Requires "activities.csv" from the 61 | bulk Strava export. 62 | 63 | ![map](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/calendar001.png "Calendar heatmap") 64 | 65 | ### Dumbbell plot 66 | 67 | Activities shown as horizontal lines by time of day and day of year, facetted by year. 68 | Requires "activities.csv" from the bulk Strava export. 69 | 70 | ![map](https://raw.githubusercontent.com/marcusvolz/strava_py/main/plots/dumbbell001.png "Dumbbell plot") 71 | 72 | ## How to use 73 | 74 | ### Bulk export from Strava 75 | 76 | The process for downloading data is described on the Strava website here: 77 | [https://support.strava.com/hc/en-us/articles/216918437-Exporting-your-Data-and-Bulk-Export#Bulk], 78 | but in essence, do the following: 79 | 80 | 1. Log in to [Strava](https://www.strava.com/) 81 | 2. Select "[Settings](https://www.strava.com/settings/profile)" from the main drop-down 82 | menu at top right of the screen 83 | 3. Select "[My Account](https://www.strava.com/account)" from the navigation menu to the 84 | left of the screen. 85 | 4. Under the 86 | "[Download or Delete Your Account](https://www.strava.com/athlete/delete_your_account)" 87 | heading, click the "Get Started" button. 88 | 5. Under the "Download Request", heading, click the "Request Your Archive" button. 89 | **_Don't click anything else on that page, i.e. particularly not the "Request Account 90 | Deletion" button._** 91 | 6. Wait for an email to be sent 92 | 7. Click the link in email to download zipped folder containing activities 93 | 8. Unzip files 94 | 95 | ### Process the data 96 | 97 | The main function for importing and processing activity files expects a path to a 98 | directory of unzipped GPX and / or FIT files. If required, the 99 | [fit2gpx](https://github.com/dodo-saba/fit2gpx) package provides useful tools for 100 | pre-processing bulk files exported from Strava, e.g. unzipping activity files (see Use 101 | Case 3: Strava Bulk Export Tools). 102 | 103 | ```python 104 | df = process_data("") 105 | ``` 106 | 107 | Some plots use the "activities.csv" file from the Strava bulk export zip. For those 108 | plots, create an "activities" dataframe using the following function: 109 | 110 | ```python 111 | activities = process_activities("") 112 | ``` 113 | 114 | ### Plot activities as small multiples 115 | 116 | ```python 117 | plot_facets(df, output_file = 'plot.png') 118 | ``` 119 | 120 | ### Plot activity map 121 | 122 | ```python 123 | plot_map(df, lon_min=None, lon_max= None, lat_min=None, lat_max=None, 124 | alpha=0.3, linewidth=0.3, output_file="map.png") 125 | ``` 126 | 127 | ### Plot elevations 128 | 129 | ```python 130 | plot_elevations(df, output_file = 'elevations.png') 131 | ``` 132 | 133 | ### Plot landscape 134 | 135 | ```python 136 | plot_landscape(df, output_file = 'landscape.png') 137 | ``` 138 | 139 | ### Plot calendar 140 | 141 | ```python 142 | plot_calendar(activities, year_min=2015, year_max=2017, max_dist=50, 143 | fig_height=9, fig_width=15, output_file="calendar.png") 144 | ``` 145 | 146 | ### Plot dumbbell 147 | 148 | ```python 149 | plot_dumbbell(activities, year_min=2012, year_max=2015, local_timezone='Australia/Melbourne', 150 | fig_height=34, fig_width=34, output_file="dumbbell.png") 151 | ``` 152 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | - [ ] Get `main` to the appropriate code release state. 4 | [GitHub Actions](https://github.com/marcusvolz/strava_py/actions) should be 5 | running cleanly for all merges to `main`. 6 | [![GitHub Actions status](https://github.com/marcusvolz/strava_py/workflows/Test/badge.svg)](https://github.com/marcusvolz/strava_py/actions) 7 | 8 | - [ ] Go to the [Releases page](https://github.com/marcusvolz/strava_py/releases) and 9 | 10 | - [ ] Click "Draft a new release" 11 | 12 | - [ ] Click "Choose a tag" 13 | 14 | - [ ] Type the next `vX.Y.Z` version and select "**Create new tag: vX.Y.Z** on 15 | publish" 16 | 17 | - [ ] Leave the "Release title" blank (it will be autofilled) 18 | 19 | - [ ] Click "Generate release notes" and amend as required 20 | 21 | - [ ] Click "Publish release" 22 | 23 | - [ ] Check the tagged 24 | [GitHub Actions build](https://github.com/marcusvolz/strava_py/actions/workflows/deploy.yml) 25 | has deployed to [PyPI](https://pypi.org/project/stravavis/#history) 26 | 27 | - [ ] Check installation: 28 | 29 | ```bash 30 | pip3 uninstall -y stravavis && pip3 install -U stravavis && stravavis --help 31 | ``` 32 | -------------------------------------------------------------------------------- /plots/calendar001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/calendar001.png -------------------------------------------------------------------------------- /plots/dumbbell001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/dumbbell001.png -------------------------------------------------------------------------------- /plots/elevations001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/elevations001.png -------------------------------------------------------------------------------- /plots/facets001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/facets001.png -------------------------------------------------------------------------------- /plots/landscape001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/landscape001.png -------------------------------------------------------------------------------- /plots/map001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/plots/map001.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "stravavis" 10 | description = "Create artistic visualisations with your exercise data" 11 | readme = "README.md" 12 | keywords = [ 13 | "artistic", 14 | "artistic visualisations", 15 | "exercise", 16 | "exercise data", 17 | "strava", 18 | "visualisation", 19 | ] 20 | license = { text = "MIT" } 21 | maintainers = [ { name = "Hugo van Kemenade" } ] 22 | authors = [ { name = "Marcus Volz" } ] 23 | requires-python = ">=3.9" 24 | classifiers = [ 25 | "Development Status :: 3 - Alpha", 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Topic :: Artistic Software", 34 | "Topic :: Multimedia :: Graphics", 35 | "Topic :: Scientific/Engineering :: Visualization", 36 | ] 37 | dynamic = [ "version" ] 38 | dependencies = [ 39 | "calmap>=0.0.11", 40 | "fit2gpx", 41 | "gpxpy", 42 | "matplotlib", 43 | "pandas", 44 | "plotnine", 45 | "rich", 46 | "seaborn", 47 | "setuptools; python_version>='3.12'", # TODO Remove when https://github.com/MarvinT/calmap/issues/22 is fixed 48 | ] 49 | urls.Homepage = "https://github.com/marcusvolz/strava_py" 50 | urls.Source = "https://github.com/marcusvolz/strava_py" 51 | scripts.stravavis = "stravavis.cli:main" 52 | 53 | [tool.hatch] 54 | version.source = "vcs" 55 | 56 | [tool.hatch.version.raw-options] 57 | local_scheme = "no-local-version" 58 | 59 | [tool.ruff] 60 | fix = true 61 | 62 | lint.select = [ 63 | "C4", # flake8-comprehensions 64 | "E", # pycodestyle errors 65 | "EM", # flake8-errmsg 66 | "F", # pyflakes errors 67 | "I", # isort 68 | "ICN", # flake8-import-conventions 69 | "ISC", # flake8-implicit-str-concat 70 | "LOG", # flake8-logging 71 | "PGH", # pygrep-hooks 72 | "RUF022", # unsorted-dunder-all 73 | "RUF100", # unused noqa (yesqa) 74 | "UP", # pyupgrade 75 | "W", # pycodestyle warnings 76 | "YTT", # flake8-2020 77 | ] 78 | lint.ignore = [ 79 | "E203", # Whitespace before ':' 80 | "E221", # Multiple spaces before operator 81 | "E226", # Missing whitespace around arithmetic operator 82 | "E241", # Multiple spaces after ',' 83 | "UP038", # Makes code slower and more verbose 84 | ] 85 | lint.isort.known-first-party = [ "stravavis" ] 86 | lint.isort.required-imports = [ "from __future__ import annotations" ] 87 | -------------------------------------------------------------------------------- /src/stravavis/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusvolz/strava_py/11174ce8ef9be80e1d1db6da1c82ca6ba5505313/src/stravavis/__init__.py -------------------------------------------------------------------------------- /src/stravavis/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import cli 4 | 5 | if __name__ == "__main__": 6 | cli.main() 7 | -------------------------------------------------------------------------------- /src/stravavis/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import glob 5 | import os.path 6 | import sys 7 | 8 | 9 | def main(): 10 | parser = argparse.ArgumentParser( 11 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 12 | ) 13 | parser.add_argument( 14 | "path", help="Input path specification to folder with GPX and / or FIT files" 15 | ) 16 | parser.add_argument( 17 | "-o", "--output_prefix", default="strava", help="Prefix for output PNG files" 18 | ) 19 | parser.add_argument( 20 | "--lon_min", 21 | type=float, 22 | help="Minimum longitude for plot_map " 23 | "(values less than this are removed from the data)", 24 | ) 25 | parser.add_argument( 26 | "--lon_max", 27 | type=float, 28 | help="Maximum longitude for plot_map " 29 | "(values greater than this are removed from the data)", 30 | ) 31 | parser.add_argument( 32 | "--lat_min", 33 | type=float, 34 | help="Minimum latitude for plot_map " 35 | "(values less than this are removed from the data)", 36 | ) 37 | parser.add_argument( 38 | "--lat_max", 39 | type=float, 40 | help="Maximum latitude for plot_map " 41 | "(values greater than this are removed from the data)", 42 | ) 43 | parser.add_argument( 44 | "--bbox", help="Shortcut for comma-separated LON_MIN,LAT_MIN,LON_MAX,LAT_MAX" 45 | ) 46 | parser.add_argument( 47 | "--alpha", 48 | default=0.4, 49 | help="Line transparency. 0 = Fully transparent, 1 = No transparency", 50 | ) 51 | parser.add_argument("--linewidth", default=0.4, help="Line width") 52 | parser.add_argument( 53 | "--activities_path", help="Path to activities.csv from Strava bulk export zip" 54 | ) 55 | parser.add_argument( 56 | "--year_min", 57 | type=int, 58 | help="The minimum year to use for the calendar heatmap and dumbbell.", 59 | ) 60 | parser.add_argument( 61 | "--year_max", 62 | type=int, 63 | help="The maximum year to use for the calendar heatmap and dumbbell.", 64 | ) 65 | parser.add_argument( 66 | "--max_dist", 67 | type=float, 68 | help="Maximum daily distance for the calendar heatmap; " 69 | "values above this will be capped.", 70 | ) 71 | parser.add_argument( 72 | "--fig_height", 73 | type=float, 74 | help="Figure height for the calendar heatmap and dumbbell.", 75 | ) 76 | parser.add_argument( 77 | "--fig_width", 78 | type=float, 79 | help="Figure width for the calendar heatmap and dumbbell.", 80 | ) 81 | parser.add_argument( 82 | "--local_timezone", 83 | help="Timezone for determining local times for activities. " 84 | "See pytz.all_timezones for a list of all timezones.", 85 | ) 86 | args = parser.parse_args() 87 | 88 | # Expand "~" or "~user" 89 | args.path = os.path.expanduser(args.path) 90 | 91 | if os.path.isdir(args.path): 92 | args.path = os.path.join(args.path, "*") 93 | 94 | filenames = sorted(glob.glob(args.path)) 95 | if not filenames: 96 | sys.exit(f"No files found matching {args.path}") 97 | 98 | if args.bbox: 99 | # Convert comma-separated string into floats 100 | args.lon_min, args.lat_min, args.lon_max, args.lat_max = ( 101 | float(x) for x in args.bbox.split(",") 102 | ) 103 | 104 | if args.activities_path and os.path.isdir(args.activities_path): 105 | args.activities_path = os.path.join(args.activities_path, "activities.csv") 106 | 107 | # Normally imports go at the top, but scientific libraries can be slow to import 108 | # so let's validate arguments first 109 | from .plot_calendar import plot_calendar 110 | from .plot_dumbbell import plot_dumbbell 111 | from .plot_elevations import plot_elevations 112 | from .plot_facets import plot_facets 113 | from .plot_landscape import plot_landscape 114 | from .plot_map import plot_map 115 | from .process_activities import process_activities 116 | from .process_data import process_data 117 | 118 | print("Processing data...") 119 | df = process_data(filenames) 120 | if df.empty: 121 | sys.exit("No data to plot") 122 | 123 | activities = None 124 | if args.activities_path: 125 | print("Processing activities...") 126 | activities = process_activities(args.activities_path) 127 | 128 | print("Plotting facets...") 129 | outfile = f"{args.output_prefix}-facets.png" 130 | plot_facets(df, output_file=outfile) 131 | print(f"Saved to {outfile}") 132 | 133 | print("Plotting map...") 134 | outfile = f"{args.output_prefix}-map.png" 135 | plot_map( 136 | df, 137 | args.lon_min, 138 | args.lon_max, 139 | args.lat_min, 140 | args.lat_max, 141 | args.alpha, 142 | args.linewidth, 143 | outfile, 144 | ) 145 | print(f"Saved to {outfile}") 146 | 147 | print("Plotting elevations...") 148 | outfile = f"{args.output_prefix}-elevations.png" 149 | plot_elevations(df, output_file=outfile) 150 | print(f"Saved to {outfile}") 151 | 152 | print("Plotting landscape...") 153 | outfile = f"{args.output_prefix}-landscape.png" 154 | plot_landscape(df, output_file=outfile) 155 | print(f"Saved to {outfile}") 156 | 157 | if activities is not None: 158 | print("Plotting calendar...") 159 | outfile = f"{args.output_prefix}-calendar.png" 160 | fig_height = args.fig_height or 15 161 | fig_width = args.fig_width or 9 162 | plot_calendar( 163 | activities, 164 | args.year_min, 165 | args.year_max, 166 | args.max_dist, 167 | fig_height, 168 | fig_width, 169 | outfile, 170 | ) 171 | print(f"Saved to {outfile}") 172 | 173 | print("Plotting dumbbell...") 174 | outfile = f"{args.output_prefix}-dumbbell.png" 175 | fig_height = args.fig_height or 34 176 | fig_width = args.fig_width or 34 177 | plot_dumbbell( 178 | activities, 179 | args.year_min, 180 | args.year_max, 181 | args.local_timezone, 182 | fig_height, 183 | fig_width, 184 | outfile, 185 | ) 186 | print(f"Saved to {outfile}") 187 | 188 | 189 | if __name__ == "__main__": 190 | main() 191 | -------------------------------------------------------------------------------- /src/stravavis/plot_calendar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import calmap 4 | import matplotlib.pyplot as plt 5 | import pandas as pd 6 | 7 | ACTIVITY_FORMAT = "%b %d, %Y, %H:%M:%S %p" 8 | 9 | 10 | def plot_calendar( 11 | activities, 12 | year_min=None, 13 | year_max=None, 14 | max_dist=None, 15 | fig_height=15, 16 | fig_width=9, 17 | output_file="calendar.png", 18 | ): 19 | # Create a new figure 20 | plt.figure() 21 | 22 | # Process data 23 | activities["Activity Date"] = pd.to_datetime( 24 | activities["Activity Date"], format=ACTIVITY_FORMAT 25 | ) 26 | activities["date"] = activities["Activity Date"].dt.date 27 | activities = activities.groupby(["date"])["Distance"].sum() 28 | activities.index = pd.to_datetime(activities.index) 29 | activities.clip(0, max_dist, inplace=True) 30 | 31 | if year_min: 32 | activities = activities[activities.index.year >= year_min] 33 | 34 | if year_max: 35 | activities = activities[activities.index.year <= year_max] 36 | 37 | # Create heatmap 38 | fig, ax = calmap.calendarplot(data=activities) 39 | 40 | # Save plot 41 | fig.set_figheight(fig_height) 42 | fig.set_figwidth(fig_width) 43 | fig.savefig(output_file, dpi=600) 44 | -------------------------------------------------------------------------------- /src/stravavis/plot_dumbbell.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pandas as pd 4 | from plotnine import ( 5 | aes, 6 | element_blank, 7 | element_rect, 8 | facet_grid, 9 | geom_point, 10 | geom_segment, 11 | ggplot, 12 | scale_x_continuous, 13 | scale_y_continuous, 14 | theme, 15 | theme_bw, 16 | xlab, 17 | ylab, 18 | ) 19 | 20 | 21 | def plot_dumbbell( 22 | activities, 23 | year_min=None, 24 | year_max=None, 25 | local_timezone=None, 26 | fig_height=34, 27 | fig_width=34, 28 | output_file="dumbbell.png", 29 | ): 30 | # Convert activity start date to datetime 31 | activities["Activity Date"] = pd.to_datetime(activities["Activity Date"]) 32 | 33 | # Convert to local timezone (if given) 34 | if local_timezone: 35 | activities["Activity Date"] = ( 36 | pd.to_datetime(activities["Activity Date"]) 37 | .dt.tz_localize(tz="UTC", nonexistent="NaT", ambiguous="NaT") 38 | .dt.tz_convert(local_timezone) 39 | ) 40 | 41 | # Get activity start and end times 42 | activities["start"] = activities["Activity Date"] 43 | activities["duration"] = pd.to_timedelta(activities["Elapsed Time"], unit="s") 44 | activities["end"] = pd.to_datetime(activities["start"] + activities["duration"]) 45 | 46 | # Remove activities outside the year_min -> year_max window 47 | activities["year"] = activities["Activity Date"].dt.year 48 | 49 | if year_min: 50 | activities = activities[activities["year"] >= year_min] 51 | 52 | if year_max: 53 | activities = activities[activities["year"] <= year_max] 54 | 55 | # Get day of year and time of day data 56 | activities["dayofyear"] = activities["Activity Date"].dt.dayofyear 57 | activities["start_time"] = activities["start"].dt.time 58 | activities["end_time"] = activities["end"].dt.time 59 | activities["x"] = ( 60 | activities["start"].dt.hour 61 | + activities["start"].dt.minute / 60 62 | + activities["start"].dt.second / 60 / 60 63 | ) 64 | activities["xend"] = ( 65 | activities["end"].dt.hour 66 | + activities["end"].dt.minute / 60 67 | + activities["end"].dt.second / 60 / 60 68 | ) 69 | 70 | # Create plotnine / ggplot 71 | p = ( 72 | ggplot(activities) 73 | + geom_segment( 74 | aes(x="x", y="dayofyear", xend="xend", yend="dayofyear"), size=0.1 75 | ) 76 | + geom_point(aes("x", "dayofyear"), size=0.05) 77 | + geom_point(aes("xend", "dayofyear"), size=0.05) 78 | + facet_grid(".~year") 79 | + scale_x_continuous( 80 | breaks=[0, 6, 12, 18, 24], labels=["12am", "6am", "12pm", "6pm", ""] 81 | ) 82 | + scale_y_continuous(breaks=[1, 100, 200, 300, 365]) 83 | + xlab("Time of Day") 84 | + ylab("Day of Year") 85 | + theme_bw() 86 | + theme( 87 | plot_background=element_rect(fill="white"), 88 | panel_grid_major_y=element_blank(), 89 | ) 90 | ) 91 | 92 | # Save plot 93 | p.save(output_file, width=fig_width, height=fig_height, units="cm", dpi=600) 94 | -------------------------------------------------------------------------------- /src/stravavis/plot_elevations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | import matplotlib.pyplot as plt 6 | import seaborn as sns 7 | 8 | 9 | def plot_elevations(df, output_file="elevations.png"): 10 | # Create a new figure 11 | plt.figure() 12 | 13 | # Compute activity start times (for facet ordering) 14 | start_times = ( 15 | df.groupby("name").agg({"time": "min"}).reset_index().sort_values("time") 16 | ) 17 | ncol = math.ceil(math.sqrt(len(start_times))) 18 | 19 | # Create facets 20 | p = sns.FacetGrid( 21 | data=df, 22 | col="name", 23 | col_wrap=ncol, 24 | col_order=start_times["name"], 25 | sharex=False, 26 | sharey=True, 27 | ) 28 | 29 | # Add activities 30 | p = p.map(plt.plot, "dist", "ele", color="black", linewidth=4) 31 | 32 | # Update plot aesthetics 33 | p.set(xlabel=None) 34 | p.set(ylabel=None) 35 | p.set(xticks=[]) 36 | p.set(yticks=[]) 37 | p.set(xticklabels=[]) 38 | p.set(yticklabels=[]) 39 | p.set_titles(col_template="", row_template="") 40 | sns.despine(left=True, bottom=True) 41 | plt.subplots_adjust(left=0.05, bottom=0.05, right=0.95, top=0.95) 42 | plt.savefig(output_file) 43 | -------------------------------------------------------------------------------- /src/stravavis/plot_facets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | import matplotlib.pyplot as plt 6 | import seaborn as sns 7 | 8 | 9 | def plot_facets(df, output_file="plot.png"): 10 | # Create a new figure 11 | plt.figure() 12 | 13 | # Compute activity start times (for facet ordering) 14 | start_times = ( 15 | df.groupby("name").agg({"time": "min"}).reset_index().sort_values("time") 16 | ) 17 | ncol = math.ceil(math.sqrt(len(start_times))) 18 | 19 | # Create facets 20 | p = sns.FacetGrid( 21 | data=df, 22 | col="name", 23 | col_wrap=ncol, 24 | col_order=start_times["name"], 25 | sharex=False, 26 | sharey=False, 27 | ) 28 | 29 | # Add activities 30 | p = p.map(plt.plot, "lon", "lat", color="black", linewidth=4) 31 | 32 | # Update plot aesthetics 33 | p.set(xlabel=None) 34 | p.set(ylabel=None) 35 | p.set(xticks=[]) 36 | p.set(yticks=[]) 37 | p.set(xticklabels=[]) 38 | p.set(yticklabels=[]) 39 | p.set_titles(col_template="", row_template="") 40 | sns.despine(left=True, bottom=True) 41 | plt.subplots_adjust(left=0.05, bottom=0.05, right=0.95, top=0.95) 42 | plt.savefig(output_file) 43 | -------------------------------------------------------------------------------- /src/stravavis/plot_landscape.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import matplotlib.pyplot as plt 4 | import pandas as pd 5 | from rich.progress import track 6 | 7 | 8 | def plot_landscape(df, output_file="landscape.png"): 9 | # Create a new figure 10 | plt.figure() 11 | 12 | # Convert ele to numeric 13 | df["ele"] = pd.to_numeric(df["ele"]) 14 | 15 | # Create a list of activity names 16 | activities = df["name"].unique() 17 | 18 | # Normalize dist 19 | processed = [] 20 | 21 | for activity in track(activities, "Processing tracks"): 22 | df_i = df[df["name"] == activity].copy() 23 | df_i.loc[:, "dist_norm"] = (df_i["dist"] - df_i["dist"].min()) / ( 24 | df_i["dist"].max() - df_i["dist"].min() 25 | ) 26 | processed.append(df_i) 27 | 28 | df = pd.concat(processed) 29 | 30 | # Plot activities one by one 31 | for activity in track(activities, "Plotting activities"): 32 | x = df[df["name"] == activity]["dist_norm"] 33 | y = df[df["name"] == activity]["ele"] 34 | plt.fill_between(x, y, color="black", alpha=0.03, linewidth=0) 35 | plt.plot(x, y, color="black", alpha=0.125, linewidth=0.25) 36 | 37 | # Update plot aesthetics 38 | plt.axis("off") 39 | plt.margins(0) 40 | plt.subplots_adjust(left=0.05, right=0.95, bottom=0.05, top=0.95) 41 | plt.savefig(output_file, dpi=600) 42 | -------------------------------------------------------------------------------- /src/stravavis/plot_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import log, pi, tan 4 | 5 | import matplotlib.pyplot as plt 6 | from rich.progress import track 7 | 8 | # Dummy units 9 | MAP_WIDTH = 1 10 | MAP_HEIGHT = 1 11 | 12 | 13 | def convert_x(lon): 14 | # Get x value 15 | x = (lon + 180) * (MAP_WIDTH / 360) 16 | return x 17 | 18 | 19 | def convert_y(lat): 20 | # Convert from degrees to radians 21 | lat_rad = lat * pi / 180 22 | 23 | # Get y value 24 | mercator_n = log(tan((pi / 4) + (lat_rad / 2))) 25 | y = (MAP_HEIGHT / 2) + (MAP_WIDTH * mercator_n / (2 * pi)) 26 | return y 27 | 28 | 29 | def plot_map( 30 | df, 31 | lon_min=None, 32 | lon_max=None, 33 | lat_min=None, 34 | lat_max=None, 35 | alpha=0.3, 36 | linewidth=0.3, 37 | output_file="map.png", 38 | ): 39 | # Create a new figure 40 | plt.figure() 41 | 42 | # Remove data outside the input ranges for lon / lat 43 | if lon_min is not None: 44 | df = df[df["lon"] >= lon_min] 45 | 46 | if lon_max is not None: 47 | df = df[df["lon"] <= lon_max] 48 | 49 | if lat_min is not None: 50 | df = df[df["lat"] >= lat_min] 51 | 52 | if lat_max is not None: 53 | df = df[df["lat"] <= lat_max] 54 | 55 | # Create a list of activity names 56 | activities = df["name"].unique() 57 | 58 | # Plot activities one by one 59 | for activity in track(activities, "Plotting activities"): 60 | x = df[df["name"] == activity]["lon"] 61 | y = df[df["name"] == activity]["lat"] 62 | 63 | # Transform to Mercator projection so maps aren't squashed away from equator 64 | x = x.transform(convert_x) 65 | y = y.transform(convert_y) 66 | 67 | plt.plot(x, y, color="black", alpha=alpha, linewidth=linewidth) 68 | 69 | # Update plot aesthetics 70 | plt.axis("off") 71 | plt.axis("equal") 72 | plt.margins(0) 73 | plt.subplots_adjust(left=0.05, right=0.95, bottom=0.05, top=0.95) 74 | plt.savefig(output_file, dpi=600) 75 | -------------------------------------------------------------------------------- /src/stravavis/process_activities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pandas as pd 4 | 5 | 6 | def process_activities(activities_path): 7 | # Import activities.csv from Strava bulk export zip 8 | activities = pd.read_csv(activities_path) 9 | 10 | # Further processing (to come) 11 | 12 | return activities 13 | -------------------------------------------------------------------------------- /src/stravavis/process_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import math 5 | import tempfile 6 | from multiprocessing import Pool 7 | from pathlib import Path 8 | 9 | import fit2gpx 10 | import gpxpy 11 | import pandas as pd 12 | from rich.progress import track 13 | 14 | 15 | def process_file(fpath: str) -> pd.DataFrame | None: 16 | if fpath.endswith(".gpx"): 17 | return process_gpx(fpath) 18 | elif fpath.endswith(".fit"): 19 | return process_fit(fpath) 20 | 21 | 22 | # Function for processing an individual GPX file 23 | # Ref: https://pypi.org/project/gpxpy/ 24 | def process_gpx(gpxfile: str) -> pd.DataFrame | None: 25 | with open(gpxfile, encoding="utf-8") as f: 26 | try: 27 | activity = gpxpy.parse(f) 28 | except gpxpy.mod_gpx.GPXException as e: 29 | print(f"\nSkipping {gpxfile}: {type(e).__name__}: {e}") 30 | return None 31 | 32 | lon = [] 33 | lat = [] 34 | ele = [] 35 | time = [] 36 | name = [] 37 | dist = [] 38 | 39 | for activity_track in activity.tracks: 40 | for segment in activity_track.segments: 41 | if not segment.points: 42 | continue 43 | 44 | x0 = segment.points[0].longitude 45 | y0 = segment.points[0].latitude 46 | d0 = 0 47 | for point in segment.points: 48 | x = point.longitude 49 | y = point.latitude 50 | z = point.elevation 51 | t = point.time 52 | lon.append(x) 53 | lat.append(y) 54 | ele.append(z) 55 | time.append(t) 56 | name.append(gpxfile) 57 | d = d0 + math.sqrt(math.pow(x - x0, 2) + math.pow(y - y0, 2)) 58 | dist.append(d) 59 | x0 = x 60 | y0 = y 61 | d0 = d 62 | 63 | df = pd.DataFrame( 64 | list(zip(lon, lat, ele, time, name, dist)), 65 | columns=["lon", "lat", "ele", "time", "name", "dist"], 66 | ) 67 | 68 | return df 69 | 70 | 71 | # Function for processing an individual FIT file 72 | # Ref: https://github.com/dodo-saba/fit2gpx 73 | def process_fit(fitfile: str) -> pd.DataFrame: 74 | conv = fit2gpx.Converter() 75 | df_lap, df = conv.fit_to_dataframes(fname=fitfile) 76 | 77 | df["name"] = fitfile 78 | 79 | dist = [] 80 | 81 | for i in range(len(df.index)): 82 | if i < 1: 83 | x0 = df["longitude"][0] 84 | y0 = df["latitude"][0] 85 | d0 = 0 86 | dist.append(d0) 87 | else: 88 | x = df["longitude"][i] 89 | y = df["latitude"][i] 90 | d = d0 + math.sqrt(math.pow(x - x0, 2) + math.pow(y - y0, 2)) 91 | dist.append(d) 92 | x0 = x 93 | y0 = y 94 | d0 = d 95 | 96 | df = df.join(pd.DataFrame({"dist": dist})) 97 | df = df[["longitude", "latitude", "altitude", "timestamp", "name", "dist"]] 98 | df = df.rename( 99 | columns={ 100 | "longitude": "lon", 101 | "latitude": "lat", 102 | "altitude": "ele", 103 | "timestamp": "time", 104 | } 105 | ) 106 | 107 | return df 108 | 109 | 110 | def load_cache(filenames: list[str]) -> tuple[Path, pd.DataFrame | None]: 111 | # Create a cache key from the filenames 112 | key = hashlib.md5("".join(filenames).encode("utf-8")).hexdigest() 113 | 114 | # Create a cache directory 115 | dir_name = Path(tempfile.gettempdir()) / "stravavis" 116 | dir_name.mkdir(parents=True, exist_ok=True) 117 | cache_filename = dir_name / f"cached_activities_{key}.pkl" 118 | print(f"Cache filename: {cache_filename}") 119 | 120 | # Load cache if it exists 121 | try: 122 | df = pd.read_pickle(cache_filename) 123 | print("Loaded cached activities") 124 | return cache_filename, df 125 | except FileNotFoundError: 126 | print("Cache not found") 127 | return cache_filename, None 128 | 129 | 130 | # Function for processing (unzipped) GPX and FIT files in a directory (path) 131 | def process_data(filenames: list[str]) -> pd.DataFrame: 132 | # Process all files (GPX or FIT) 133 | cache_filename, df = load_cache(filenames) 134 | if df is not None: 135 | return df 136 | 137 | with Pool() as pool: 138 | try: 139 | it = pool.imap_unordered(process_file, filenames) 140 | it = track(it, total=len(filenames), description="Processing") 141 | processed = list(it) 142 | finally: 143 | pool.close() 144 | pool.join() 145 | 146 | df = pd.concat(processed) 147 | 148 | df["time"] = pd.to_datetime(df["time"], utc=True) 149 | 150 | # Save cache 151 | df.to_pickle(cache_filename) 152 | 153 | return df 154 | -------------------------------------------------------------------------------- /tests/csv/activities.csv: -------------------------------------------------------------------------------- 1 | Activity ID,Activity Date,Activity Name,Activity Type,Activity Description,Elapsed Time,Distance,Max Heart Rate,Relative Effort,Commute,Activity Private Note,Activity Gear,Filename,Athlete Weight,Bike Weight,Elapsed Time,Moving Time,Distance,Max Speed,Average Speed,Elevation Gain,Elevation Loss,Elevation Low,Elevation High,Max Grade,Average Grade,Average Positive Grade,Average Negative Grade,Max Cadence,Average Cadence,Max Heart Rate,Average Heart Rate,Max Watts,Average Watts,Calories,Max Temperature,Average Temperature,Relative Effort,Total Work,Number of Runs,Uphill Time,Downhill Time,Other Time,Perceived Exertion,"Type","Start Time",Weighted Average Power,Power Count,Prefer Perceived Exertion,Perceived Relative Effort,Commute,Total Weight Lifted,From Upload,Grade Adjusted Distance,Weather Observation Time,Weather Condition,Weather Temperature,Apparent Temperature,Dewpoint,Humidity,Weather Pressure,Wind Speed,Wind Gust,Wind Bearing,Precipitation Intensity,Sunrise Time,Sunset Time,Moon Phase,Bike,Gear,Precipitation Probability,Precipitation Type,Cloud Cover,Weather Visibility,UV Index,Weather Ozone,"Jump Count","Total Grit","Avg Flow","Flagged","Avg Elapsed Speed","Dirt Distance","Newly Explored Distance","Newly Explored Dirt Distance","Sport Type","Total Steps",Media 2 | 9125662739,"May 23, 2023, 12:40:57 PM",Evening Ride,Ride,,6124,33.91,,,false,,TCR ADV 3,,,,6124,5103,33912.48046875,28.0181102752686,6.64559698104858,124,126,-20,58,44.4619865417481,0.002948766807094,,,,0,,0,,87.1724853515625,683,,21,,,,,,,6,,,,,0,134,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,5.53763580322266,1318.80004882813,,,,, 3 | 9148958024,"May 27, 2023, 1:24:16 AM",Morning Ride,Ride,,31872,122.67,,,false,,TCR ADV 3,,,,31872,19143,122675.9921875,12.3882808685303,6.40839958190918,373,357,-7,108,46.9055366516113,0.005706088151783,,,,0,,0,,117.04443359375,2562,,33,,,,,,,8,,,,,0,824,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,3.84902095794678,0,,,,, 4 | 9189096048,"Jun 2, 2023, 11:48:52 AM",Evening Ride,Ride,,14194,51.83,,,false,,TCR ADV 3,,,,14194,8405,51837.44921875,12.9382810592651,6.16745376586914,393,399,10,190,49.4113121032715,0.023149287328124,,,,0,,0,,126.669448852539,1079,,26,,,,,,,9,,,,,0,420,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,3.6520676612854,0,,,,, 5 | 9233077380,"Jun 9, 2023, 1:02:03 PM",Night Ride,Ride,,5280,32.84,,,false,,TCR ADV 3,,,,5280,4862,32848.51171875,10.8902339935303,6.75617265701294,132,134,12,79,36.616626739502,0.015221389941871,,,,0,,0,,129.770172119141,649,,29,,,,,,,7,,,,,0,155,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,6.22130918502808,0,,,,, 6 | 9233439372,"Jun 9, 2023, 1:02:03 PM",Evening Ride,Ride,,5280,32.84,,,false,,TCR ADV 3,,,8,5280,4862,32848.51171875,10.8902339935303,6.75617265701294,132,134,12,79,36.616626739502,0.015221389941871,,,,0,,0,,129.728652954102,649,,29,,,,,,,,,,,,,,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,6.22130918502808,0,,,,, 7 | 9239384321,"Jun 10, 2023, 10:36:58 AM",Evening Ride,Ride,,14142,67.2,,,false,,TCR ADV 3,,,8,14142,10121,67206.78125,10.5140628814697,6.64033031463623,219,238,-20,60,48.9795913696289,-0.002975890180096,,,,0,,0,,121.037376403809,1350,,31,,,,,,,8,,,,,0,435,0,,1,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,4.75228261947632,0,,,,, 8 | 9251030970,"May 20, 2023, 1:30:00 AM",Morning Ride,Ride,,14599,88.21,,,false,,TCR ADV 3,,,8,14599,14599,88210,,6.04219484329224,160,,,,,0,,,,,,,,,,,,,,,,,,8,,,,,0,628,0,,0,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,6.04219484329224,0,,,,, 9 | 9251043755,"May 13, 2023, 12:38:00 AM",Morning Ride,Ride,,16303,67.16,,,false,,TCR ADV 3,,,8,16303,16303,67160,,4.11948728561401,366,,,,,0,,,,,,,,,,,,,,,,,,9,,,,,0,815,0,,0,,,,,,,,,,,,,,,,12622314,,,,,,,,,,,0,4.11948728561401,0,,,,, 10 | -------------------------------------------------------------------------------- /tests/gpx/empty-trkseg.gpx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/gpx/invalid-lon-lat-missing.gpx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GOTOES STRAVA TOOLS 6 | 7 | 8 | 9 | 10 | Other 11 | 12 | 13 | 31.2 14 | 15 | 16 | 0 17 | 18 | 19 | 20 | 31.2 21 | 22 | 23 | 29 24 | 25 | 26 | 27 | 31.2 28 | 29 | 30 | 29 31 | 32 | 33 | 34 | 31.2 35 | 36 | 37 | 29 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | lint 6 | py{313, 312, 311, 310, 39} 7 | 8 | [testenv] 9 | pass_env = 10 | FORCE_COLOR 11 | commands = 12 | stravavis --help 13 | stravavis tests/gpx --activities_path tests/csv 14 | 15 | [testenv:lint] 16 | skip_install = true 17 | deps = 18 | pre-commit 19 | pass_env = 20 | PRE_COMMIT_COLOR 21 | commands = 22 | pre-commit run --all-files --show-diff-on-failure 23 | --------------------------------------------------------------------------------