Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
--index-strategy unsafe-first-match \
geo-track-analyzer[cli]==$VERSION
geo-track-analyzer[cli,visualization]==$VERSION
env:
VERSION: ${{needs.build.outputs.version}}
- name: Run API and Worker script
Expand Down Expand Up @@ -150,7 +150,7 @@ jobs:
- name: Build the documentation
run: |
uv run python docs/dump_github_releases.py
uv run python docs/generate_visualization_examples.py
uv run --all-extras docs/generate_visualization_examples.py
uv run --group doc mkdocs build
- uses: actions/upload-artifact@master
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ jobs:
with:
python-version: 3.12
- name: Install the project
run: uv sync --no-group dev --no-group doc --no-cache
run: uv sync --no-group dev --no-group doc --no-cache --extra visualization
- name: pytest
run: |
uv run --no-group dev --no-group doc pytest
uv run --no-group dev --no-group doc --extra visualization pytest
test-all:
name: ${{ matrix.os }} / ${{ matrix.python-version }}
runs-on: ${{ matrix.os }}-latest
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.11.5"
rev: "v0.14.10"
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix, --output-format, concise]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-yaml
- id: check-toml
Expand Down
3 changes: 2 additions & 1 deletion docs/api_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
::: geo_track_analyzer.model.Position2D
::: geo_track_analyzer.model.Position3D
::: geo_track_analyzer.model.ElevationMetrics
::: geo_track_analyzer.model.SegmentOverviewMetric
::: geo_track_analyzer.model.SegmentOverview
::: geo_track_analyzer.model.SegmentOverlap
::: geo_track_analyzer.model.PointDistance

## Other model objects

::: geo_track_analyzer.model.ZoneInterval
::: geo_track_analyzer.model.Zones
::: geo_track_analyzer.model.Zones
1 change: 1 addition & 0 deletions docs/api_tracks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
::: geo_track_analyzer.track.ByteTrack
::: geo_track_analyzer.track.PyTrack
::: geo_track_analyzer.track.SegmentTrack
::: geo_track_analyzer.track.GeoJsonTrack

14 changes: 14 additions & 0 deletions docs/generate_comparison_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from geo_track_analyzer.track import GPXFileTrack
from geo_track_analyzer.utils.base import init_logging

if __name__ == "__main__":
init_logging(10)

track = GPXFileTrack(
"../tests/resources/Freiburger_Münster_nach_Schau_Ins_Land.gpx"
)
track_sub = GPXFileTrack("../tests/resources/Teilstueck_Schau_ins_land.gpx")

overlap_info = track.find_overlap_with_segment(0, track_sub, 0)

print(overlap_info)
135 changes: 135 additions & 0 deletions docs/geojson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# GeoJSON support

The track enhancer support loading specific configurations of valid GeoJSON file via the [`GeoJsonTrack`][geo_track_analyzer.track.GeoJsonTrack].

???+ warning "Warning"

The order in the cooordinates array must be [longitude, latitude, elevation] and not [latitude, longitude, elevation]. This is a common mistake when working with GeoJSON data and can lead to incorrect results if not handled properly.

The following example for all three configurations that are supported.

The use does not need to specify in advance which configuration is used. On initialization, it is automatically determined based on the input structure. If no valid configuration is found, a `UnsupportedGeoJsonTypeError` is raised.

Additionally, it is possible to load GeoJSON files that do not contain any geometry. While this seems like a silly feature in a "track analyzer", it provides the option to visualize files that contain for example heart rate as a function of time. By default, a `GeoJsonWithoutGeometryError` is raised. But the `allow_empty_spatial` flag can be passed to enable this feature. In this case the internal gpx track (or the one you would get by exporting the [`GeoJsonTrack`][geo_track_analyzer.track.GeoJsonTrack] to xml) with the coordinates set with `fallback_coordinates` defaulting to (0,0). Also, this disables the moving/stopped logic in the segment data (every point is considered moving in that case).

## LineString + Arrays

Here the coordinates are stored as a LineString geometry and the time, heart rate, cadence and power values are stored as arrays in the properties. The arrays must be of the same length as the number of coordinates in the LineString.

```json
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[13.404954, 52.520008, 30.5],
[13.405000, 52.520100, 31.0],
[13.405100, 52.520200, 31.2]
]
},
"properties": {
"name": "Morning Ride",
"coordTimes": [
"2023-10-01T08:00:00Z",
"2023-10-01T08:00:05Z",
"2023-10-01T08:00:10Z"
],
"heartRates": [140, 142, 145]
"cadences": [85, 87, 88],
"powers": [200.5, 205.3, 210.0],
}
}
```

### With multiple segments

Multiple segments can be represented as multiple features in a FeatureCollection and is *only* available in the __LineString + Arrays__ configuration. Each feature must have a `segment_index` property that indicates the index of the segment in the track. The `coordTimes`, `heartRates`, `cadences`, and `powers` properties must be arrays of the same length as the number of coordinates in the LineString geometry of the feature.

```json
{
"type": "FeatureCollection",
"properties": {
"name": "Morning Ride with Pause"
},
"features": [
{
"type": "Feature",
"properties": {
"segment_index": 0,
"coordTimes": [
"2023-10-01T08:00:00Z",
"2023-10-01T08:00:05Z"
],
"heartRates": [140, 142]
},
"geometry": {
"type": "LineString",
"coordinates": [
[13.404954, 52.520008, 30.5],
[13.405000, 52.520100, 31.0]
]
}
},
{
"type": "Feature",
"properties": {
"segment_index": 1,
"coordTimes": [
"2023-10-01T08:15:00Z",
"2023-10-01T08:15:05Z"
],
"heartRates": [135,138]
},
"geometry": {
"type": "LineString",
"coordinates": [
[13.405100,52.520200, 31.2],
[13.405200, 52.520300, 31.5]
]
}
}
]
}
```





## Collection of Points

In this case the data is fully represented by a FeatureCollection and each feature represents a single point in the track. The time, heart rate, cadence and power values are stored as properties of each feature. The geometry of each feature must be a Point geometry with the coordinates representing the latitude, longitude and elevation of the point.

```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [13.404954, 52.520008, 30.5]
},
"properties": {
"time": "2023-10-01T08:00:00Z",
"heartRate": 140,
"cadence": 85
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [13.405000, 52.520100, 31.0]
},
"properties": {
"time": "2023-10-01T08:00:05Z",
"heartRate": 142,
"cadence": 87
}
}
]
}
```


6 changes: 6 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ Furthermore an summary of the segments and tracks can be generated in the form o

### Visualizing the track

???+ warning "Warning"

Starting from version 2.0.0, the visualizations requires the installation of the `visualization` extra (or you have plotly in your dependencies anyways).



Visualizations of a track can be generated via the [`Track.plot`][geo_track_analyzer.GPXFileTrack.plot] method via the `kind` parameter. Additionally the
track data can be extracted with the [`Track.get_track_data`][geo_track_analyzer.GPXFileTrack.get_track_data] or [`Track.get_segment_data`][geo_track_analyzer.GPXFileTrack.get_segment_data]
methods and using the functions described in the [Profiles and Maps](vis_profiles_and_maps.md) and [Summaries](vis_summaries.md).
Expand Down
4 changes: 4 additions & 0 deletions docs/vis_profiles_and_maps.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Track visualization - Profiles and Maps

???+ note "Extra"

Not all dependencies for the visualizations are installed by default. Please use the `visualization` extra during installation.

All visualizations are implemented using the [Plotly Graphing Library](https://plotly.com/python/). All methods and functions return a [Figure](https://plotly.com/python-api-reference/generated/plotly.graph_objects.Figure.html#id0>) objects to enable additonal customization of the plot outside of the package e.g. using the `update_layout` method.

## Elevations profiles
Expand Down
4 changes: 4 additions & 0 deletions docs/vis_summaries.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Track visualization - Summaries

???+ warning "Extra"

Starting from 2.0.0: Not all dependencies for the visualizations are installed by default. Please use the `visualization` extra during installation.


## Segment summaries

Expand Down
11 changes: 10 additions & 1 deletion geo_track_analyzer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
OpenTopoElevationEnhancer,
get_enhancer,
)
from .track import ByteTrack, FITTrack, GPXFileTrack, PyTrack, SegmentTrack, Track
from .track import (
ByteTrack,
FITTrack,
GeoJsonTrack,
GPXFileTrack,
PyTrack,
SegmentTrack,
Track,
)

__all__ = [
"ByteTrack",
Expand All @@ -18,6 +26,7 @@
"EnhancerType",
"FITTrack",
"GPXFileTrack",
"GeoJsonTrack",
"OpenElevationEnhancer",
"OpenTopoElevationEnhancer",
"PyTrack",
Expand Down
12 changes: 12 additions & 0 deletions geo_track_analyzer/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,15 @@ class TrackAnalysisError(Exception):

class VisualizationSetupWarning(Warning):
pass


class GeoJsonWithoutGeometryError(Exception):
pass


class UnsupportedGeoJsonTypeError(Exception):
pass


class EmptyGeoJsonError(Exception):
pass
53 changes: 34 additions & 19 deletions geo_track_analyzer/model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Generic, TypeVar

import numpy as np
from gpxpy.gpx import GPXTrackPoint
Expand All @@ -14,6 +14,8 @@

from geo_track_analyzer.utils.internal import GPXTrackPointAfterValidator

T = TypeVar("T")


class Model(BaseModel):
model_config = ConfigDict(extra="forbid")
Expand Down Expand Up @@ -49,6 +51,16 @@ class ElevationMetrics(Model):
"""Slopes between points in a uphill/downhill section"""


class SegmentOverviewMetric(Model, Generic[T]):
"""Collection max/average metric in a segement"""

max: T
""" Maximum value of the metric """

avg: T
""" Average value of the metric"""


class SegmentOverview(Model):
"""Collection of metrics for a Segment"""

Expand All @@ -64,13 +76,8 @@ class SegmentOverview(Model):
total_distance: float
"""Total distance of the segment in m"""

max_velocity: None | float
"""Maximum velocity in the segment in m/s (only considering velocities below the XX
percentile)"""

avg_velocity: None | float
"""Average velocity in the segment in m/s (only considering velocities below the XX
percentile)"""
velocity: SegmentOverviewMetric[float] | None
"""Velocity in the segment in m/s (only considering velocities below the XX percentile). Saved as [`SegmentOverviewMetric`][geo_track_analyzer.model.SegmentOverviewMetric]""" # noqa: E501

max_elevation: None | float
"""Maximum elevation in the segment in m"""
Expand All @@ -84,28 +91,36 @@ class SegmentOverview(Model):
downhill_elevation: None | float
"""Elevation traveled downhill in m"""

heartrate: SegmentOverviewMetric[int] | None = Field(default=None)
""" Heartrate metrics in bpm. Saved as [`SegmentOverviewMetric`][geo_track_analyzer.model.SegmentOverviewMetric] """ # noqa: E501

power: SegmentOverviewMetric[int] | None = Field(default=None)
""" Power metrics in watts. Saved as [`SegmentOverviewMetric`][geo_track_analyzer.model.SegmentOverviewMetric] """ # noqa: E501

cadence: SegmentOverviewMetric[int] | None = Field(default=None)
""" Cadence metrics in rpm. Saved as [`SegmentOverviewMetric`][geo_track_analyzer.model.SegmentOverviewMetric] """ # noqa: E501

# Attributes that will be calculated from primary attributes
moving_distance_km: float = Field(default=-1)
"""moving_distance converted the km"""

total_distance_km: float = Field(default=-1)
"""total_distance converted the km"""

max_velocity_kmh: None | float = Field(default=None)
"""max_velocity converted the km/h"""

avg_velocity_kmh: None | float = Field(default=None)
"""avg_speed converted the km/h"""
velocity_kmh: SegmentOverviewMetric[None | float] | None = Field(default=None)
""" Velocity in the segment in km/h """

@model_validator(mode="after") # type: ignore
def set_km_attr(self) -> "SegmentOverview":
self.moving_distance_km = self.moving_distance / 1000
self.total_distance_km = self.total_distance / 1000
self.max_velocity_kmh = (
None if self.max_velocity is None else 3.6 * self.max_velocity
)
self.avg_velocity_kmh = (
None if self.avg_velocity is None else 3.6 * self.avg_velocity
self.velocity_kmh = (
SegmentOverviewMetric(
max=3.6 * self.velocity.max,
avg=3.6 * self.velocity.avg,
)
if self.velocity is not None
else None
)
return self

Expand Down Expand Up @@ -135,7 +150,7 @@ class SegmentOverlap(Model):
"""Index of the last point in match segment"""

def __repr__(self) -> str:
ret_str = f"Overlap {self.overlap*100:.2f}%, Inverse: {self.inverse},"
ret_str = f"Overlap {self.overlap * 100:.2f}%, Inverse: {self.inverse},"
ret_str += f" Plate: {self.plate.shape}, Points: "
point_strs = []
for point, idx in zip(
Expand Down
Loading