geo-activity-playground 0.36.2__tar.gz → 0.38.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/PKG-INFO +1 -1
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/activities.py +12 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/config.py +6 -2
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/enrichment.py +9 -0
- geo_activity_playground-0.38.0/geo_activity_playground/core/meta_search.py +157 -0
- geo_activity_playground-0.38.0/geo_activity_playground/core/summary_stats.py +30 -0
- geo_activity_playground-0.38.0/geo_activity_playground/core/test_meta_search.py +100 -0
- geo_activity_playground-0.38.0/geo_activity_playground/core/test_summary_stats.py +108 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/controller.py +20 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/templates/activity/day.html.j2 +3 -10
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/templates/activity/name.html.j2 +2 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/templates/activity/show.html.j2 +17 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/app.py +27 -10
- geo_activity_playground-0.38.0/geo_activity_playground/webui/eddington_blueprint.py +190 -0
- geo_activity_playground-0.36.2/geo_activity_playground/webui/equipment/controller.py → geo_activity_playground-0.38.0/geo_activity_playground/webui/equipment_blueprint.py +29 -42
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/explorer/blueprint.py +4 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/heatmap/blueprint.py +10 -29
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/heatmap/heatmap_controller.py +45 -103
- geo_activity_playground-0.38.0/geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +42 -0
- geo_activity_playground-0.38.0/geo_activity_playground/webui/search_blueprint.py +70 -0
- geo_activity_playground-0.38.0/geo_activity_playground/webui/search_util.py +64 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/summary_blueprint.py +40 -39
- geo_activity_playground-0.38.0/geo_activity_playground/webui/templates/eddington/index.html.j2 +128 -0
- {geo_activity_playground-0.36.2/geo_activity_playground/webui/equipment → geo_activity_playground-0.38.0/geo_activity_playground/webui}/templates/equipment/index.html.j2 +3 -5
- geo_activity_playground-0.38.0/geo_activity_playground/webui/templates/search/index.html.j2 +42 -0
- geo_activity_playground-0.38.0/geo_activity_playground/webui/templates/search_form.html.j2 +116 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/summary/index.html.j2 +5 -1
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/pyproject.toml +4 -4
- geo_activity_playground-0.36.2/geo_activity_playground/webui/eddington_blueprint.py +0 -81
- geo_activity_playground-0.36.2/geo_activity_playground/webui/equipment/blueprint.py +0 -16
- geo_activity_playground-0.36.2/geo_activity_playground/webui/heatmap/__init__.py +0 -0
- geo_activity_playground-0.36.2/geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +0 -74
- geo_activity_playground-0.36.2/geo_activity_playground/webui/search_blueprint.py +0 -101
- geo_activity_playground-0.36.2/geo_activity_playground/webui/templates/eddington/index.html.j2 +0 -56
- geo_activity_playground-0.36.2/geo_activity_playground/webui/templates/search/index.html.j2 +0 -95
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/LICENSE +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/__main__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/coordinates.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/heart_rate.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/paths.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/privacy_zones.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/raster_map.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/similarity.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/tasks.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/test_tiles.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/test_time_conversion.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/tiles.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/core/time_conversion.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/explorer/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/explorer/grid_file.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/explorer/tile_visits.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/explorer/video.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/heatmap_video.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/activity_parsers.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/csv_parser.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/directory.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/strava_api.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/strava_checkout.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/test_csv_parser.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/test_directory.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/importers/test_strava_api.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/templates/activity/edit.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/activity/templates/activity/lines.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/auth_blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/authenticator.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/calendar/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/calendar/blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/calendar/controller.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/calendar/templates/calendar/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/calendar/templates/calendar/month.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/entry_controller.py +0 -0
- {geo_activity_playground-0.36.2/geo_activity_playground/webui/equipment → geo_activity_playground-0.38.0/geo_activity_playground/webui/explorer}/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/explorer/controller.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2/geo_activity_playground/webui/explorer → geo_activity_playground-0.38.0/geo_activity_playground/webui/heatmap}/__init__.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/plot_util.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/controller.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/admin-password.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/color-schemes.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/heart-rate.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/kind-renames.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/metadata-extraction.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/privacy-zones.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/segmentation.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/sharepic.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/settings/templates/settings/strava.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/square_planner_blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/Leaflet.fullscreen.min.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/MarkerCluster.Default.css +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/MarkerCluster.css +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/android-chrome-512x512.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/bootstrap-dark-mode.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/bootstrap.bundle.min.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/bootstrap.min.css +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/favicon-48x48.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/favicon.svg +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/fullscreen.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/fullscreen@2x.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/leaflet.css +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/leaflet.fullscreen.css +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/leaflet.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/leaflet.markercluster.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/table-sort.min.js +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/vega-embed@6 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/vega-lite@4 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/vega@5 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/web-app-manifest-192x192.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/static/web-app-manifest-512x512.png +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/auth/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/home.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/square_planner/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/upload/index.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/templates/upload/reload.html.j2 +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/tile_blueprint.py +0 -0
- {geo_activity_playground-0.36.2 → geo_activity_playground-0.38.0}/geo_activity_playground/webui/upload_blueprint.py +0 -0
@@ -24,11 +24,14 @@ logger = logging.getLogger(__name__)
|
|
24
24
|
|
25
25
|
|
26
26
|
class ActivityMeta(TypedDict):
|
27
|
+
average_speed_elapsed_kmh: float
|
28
|
+
average_speed_moving_kmh: float
|
27
29
|
calories: float
|
28
30
|
commute: bool
|
29
31
|
consider_for_achievements: bool
|
30
32
|
distance_km: float
|
31
33
|
elapsed_time: datetime.timedelta
|
34
|
+
elevation_gain: float
|
32
35
|
end_latitude: float
|
33
36
|
end_longitude: float
|
34
37
|
equipment: str
|
@@ -110,6 +113,15 @@ def build_activity_meta() -> None:
|
|
110
113
|
|
111
114
|
meta.sort_values("start", inplace=True)
|
112
115
|
|
116
|
+
meta.loc[meta["kind"] == "", "kind"] = "Unknown"
|
117
|
+
meta.loc[meta["equipment"] == "", "equipment"] = "Unknown"
|
118
|
+
meta["average_speed_moving_kmh"] = meta["distance_km"] / (
|
119
|
+
meta["moving_time"].dt.total_seconds() / 3_600
|
120
|
+
)
|
121
|
+
meta["average_speed_elapsed_kmh"] = meta["distance_km"] / (
|
122
|
+
meta["elapsed_time"].dt.total_seconds() / 3_600
|
123
|
+
)
|
124
|
+
|
113
125
|
meta.to_parquet(activities_file())
|
114
126
|
|
115
127
|
|
@@ -45,7 +45,12 @@ class Config:
|
|
45
45
|
time_diff_threshold_seconds: Optional[int] = 30
|
46
46
|
upload_password: Optional[str] = None
|
47
47
|
map_tile_url: str = "https://tile.openstreetmap.org/{zoom}/{x}/{y}.png"
|
48
|
-
map_tile_attribution: str =
|
48
|
+
map_tile_attribution: str = (
|
49
|
+
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> | <a href="https://www.openstreetmap.org/fixthemap">Correct Map</a>'
|
50
|
+
)
|
51
|
+
search_queries_favorites: list[dict] = dataclasses.field(default_factory=list)
|
52
|
+
search_queries_last: list[dict] = dataclasses.field(default_factory=list)
|
53
|
+
search_queries_num_keep: int = 10
|
49
54
|
|
50
55
|
|
51
56
|
class ConfigAccessor:
|
@@ -60,7 +65,6 @@ class ConfigAccessor:
|
|
60
65
|
return self._config
|
61
66
|
|
62
67
|
def save(self) -> None:
|
63
|
-
print(self._config)
|
64
68
|
with open(new_config_file(), "w") as f:
|
65
69
|
json.dump(
|
66
70
|
dataclasses.asdict(self._config),
|
@@ -120,6 +120,8 @@ def _get_metadata_from_timeseries(timeseries: pd.DataFrame) -> ActivityMeta:
|
|
120
120
|
metadata["start_longitude"] = timeseries["longitude"].iloc[0]
|
121
121
|
metadata["end_longitude"] = timeseries["longitude"].iloc[-1]
|
122
122
|
|
123
|
+
metadata["elevation_gain"] = timeseries["elevation_gain_cum"].iloc[-1]
|
124
|
+
|
123
125
|
return metadata
|
124
126
|
|
125
127
|
|
@@ -191,4 +193,11 @@ def _embellish_single_time_series(
|
|
191
193
|
timeseries["x"] = x
|
192
194
|
timeseries["y"] = y
|
193
195
|
|
196
|
+
if "altitude" in timeseries.columns:
|
197
|
+
altitude_diff = timeseries["altitude"].diff()
|
198
|
+
altitude_diff = altitude_diff.ewm(span=5, min_periods=5).mean()
|
199
|
+
altitude_diff.loc[altitude_diff.abs() > 30] = 0
|
200
|
+
altitude_diff.loc[altitude_diff < 0] = 0
|
201
|
+
timeseries["elevation_gain_cum"] = altitude_diff.cumsum()
|
202
|
+
|
194
203
|
return timeseries
|
@@ -0,0 +1,157 @@
|
|
1
|
+
import dataclasses
|
2
|
+
import datetime
|
3
|
+
import re
|
4
|
+
import urllib.parse
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
import dateutil.parser
|
8
|
+
import numpy as np
|
9
|
+
import pandas as pd
|
10
|
+
|
11
|
+
|
12
|
+
@dataclasses.dataclass
|
13
|
+
class SearchQuery:
|
14
|
+
equipment: list[str] = dataclasses.field(default_factory=list)
|
15
|
+
kind: list[str] = dataclasses.field(default_factory=list)
|
16
|
+
name: Optional[str] = None
|
17
|
+
name_case_sensitive: bool = False
|
18
|
+
start_begin: Optional[datetime.date] = None
|
19
|
+
start_end: Optional[datetime.date] = None
|
20
|
+
|
21
|
+
def __str__(self) -> str:
|
22
|
+
bits = []
|
23
|
+
if self.name:
|
24
|
+
bits.append(f"name is “{self.name}”")
|
25
|
+
if self.equipment:
|
26
|
+
bits.append(
|
27
|
+
"equipment is "
|
28
|
+
+ (" or ".join(f"“{equipment}”" for equipment in self.equipment))
|
29
|
+
)
|
30
|
+
if self.kind:
|
31
|
+
bits.append("kind is " + (" or ".join(f"“{kind}”" for kind in self.kind)))
|
32
|
+
if self.start_begin:
|
33
|
+
bits.append(f"after “{self.start_begin.isoformat()}”")
|
34
|
+
if self.start_end:
|
35
|
+
bits.append(f"until “{self.start_end.isoformat()}”")
|
36
|
+
return " and ".join(bits)
|
37
|
+
|
38
|
+
@property
|
39
|
+
def active(self) -> bool:
|
40
|
+
return (
|
41
|
+
self.equipment
|
42
|
+
or self.kind
|
43
|
+
or self.name
|
44
|
+
or self.start_begin
|
45
|
+
or self.start_end
|
46
|
+
)
|
47
|
+
|
48
|
+
def to_primitives(self) -> dict:
|
49
|
+
return {
|
50
|
+
"equipment": self.equipment,
|
51
|
+
"kind": self.kind,
|
52
|
+
"name": self.name or "",
|
53
|
+
"name_case_sensitive": self.name_case_sensitive,
|
54
|
+
"start_begin": _format_optional_date(self.start_begin),
|
55
|
+
"start_end": _format_optional_date(self.start_end),
|
56
|
+
}
|
57
|
+
|
58
|
+
@classmethod
|
59
|
+
def from_primitives(cls, d: dict) -> "SearchQuery":
|
60
|
+
return cls(
|
61
|
+
equipment=d.get("equipment", []),
|
62
|
+
kind=d.get("kind", []),
|
63
|
+
name=d.get("name", None),
|
64
|
+
name_case_sensitive=d.get("name_case_sensitive", False),
|
65
|
+
start_begin=_parse_date_or_none(d.get("start_begin", None)),
|
66
|
+
start_end=_parse_date_or_none(d.get("start_end", None)),
|
67
|
+
)
|
68
|
+
|
69
|
+
def to_jinja(self) -> dict:
|
70
|
+
result = self.to_primitives()
|
71
|
+
result["active"] = self.active
|
72
|
+
return result
|
73
|
+
|
74
|
+
def to_url_str(self) -> str:
|
75
|
+
variables = []
|
76
|
+
for equipment in self.equipment:
|
77
|
+
variables.append(("equipment", equipment))
|
78
|
+
for kind in self.kind:
|
79
|
+
variables.append(("kind", kind))
|
80
|
+
if self.name:
|
81
|
+
variables.append(("name", self.name))
|
82
|
+
if self.name_case_sensitive:
|
83
|
+
variables.append(("name_case_sensitive", "true"))
|
84
|
+
if self.start_begin:
|
85
|
+
variables.append(("start_begin", self.start_begin.isoformat()))
|
86
|
+
if self.start_end:
|
87
|
+
variables.append(("start_end", self.start_end.isoformat()))
|
88
|
+
|
89
|
+
return "&".join(
|
90
|
+
f"{key}={urllib.parse.quote_plus(value)}" for key, value in variables
|
91
|
+
)
|
92
|
+
|
93
|
+
|
94
|
+
def apply_search_query(
|
95
|
+
activity_meta: pd.DataFrame, search_query: SearchQuery
|
96
|
+
) -> pd.DataFrame:
|
97
|
+
mask = _make_mask(activity_meta.index, True)
|
98
|
+
|
99
|
+
if search_query.equipment:
|
100
|
+
mask &= _filter_column(activity_meta["equipment"], search_query.equipment)
|
101
|
+
if search_query.kind:
|
102
|
+
mask &= _filter_column(activity_meta["kind"], search_query.kind)
|
103
|
+
if search_query.name:
|
104
|
+
mask &= pd.Series(
|
105
|
+
[
|
106
|
+
bool(
|
107
|
+
re.search(
|
108
|
+
search_query.name,
|
109
|
+
activity_name,
|
110
|
+
0 if search_query.name_case_sensitive else re.IGNORECASE,
|
111
|
+
)
|
112
|
+
)
|
113
|
+
for activity_name in activity_meta["name"]
|
114
|
+
],
|
115
|
+
index=activity_meta.index,
|
116
|
+
)
|
117
|
+
if search_query.start_begin is not None:
|
118
|
+
start_begin = datetime.datetime.combine(
|
119
|
+
search_query.start_begin, datetime.time.min
|
120
|
+
)
|
121
|
+
mask &= start_begin <= activity_meta["start"]
|
122
|
+
if search_query.start_end is not None:
|
123
|
+
start_end = datetime.datetime.combine(search_query.start_end, datetime.time.max)
|
124
|
+
mask &= activity_meta["start"] <= start_end
|
125
|
+
|
126
|
+
return activity_meta.loc[mask]
|
127
|
+
|
128
|
+
|
129
|
+
def _format_optional_date(date: Optional[datetime.date]) -> str:
|
130
|
+
if date is None:
|
131
|
+
return ""
|
132
|
+
else:
|
133
|
+
return date.isoformat()
|
134
|
+
|
135
|
+
|
136
|
+
def _make_mask(
|
137
|
+
index: pd.Index,
|
138
|
+
default: bool,
|
139
|
+
) -> pd.Series:
|
140
|
+
if default:
|
141
|
+
return pd.Series(np.ones((len(index),), dtype=np.bool), index=index)
|
142
|
+
else:
|
143
|
+
return pd.Series(np.zeros((len(index),), dtype=np.bool), index=index)
|
144
|
+
|
145
|
+
|
146
|
+
def _filter_column(column: pd.Series, values: list):
|
147
|
+
sub_mask = _make_mask(column.index, False)
|
148
|
+
for equipment in values:
|
149
|
+
sub_mask |= column == equipment
|
150
|
+
return sub_mask
|
151
|
+
|
152
|
+
|
153
|
+
def _parse_date_or_none(s: Optional[str]) -> Optional[datetime.date]:
|
154
|
+
if not s:
|
155
|
+
return None
|
156
|
+
else:
|
157
|
+
return dateutil.parser.parse(s).date()
|
@@ -0,0 +1,30 @@
|
|
1
|
+
import pandas as pd
|
2
|
+
|
3
|
+
|
4
|
+
def get_equipment_use_table(
|
5
|
+
activity_meta: pd.DataFrame, offsets: dict[str, float]
|
6
|
+
) -> pd.DataFrame:
|
7
|
+
result = (
|
8
|
+
activity_meta.groupby("equipment")
|
9
|
+
.apply(
|
10
|
+
lambda group: pd.Series(
|
11
|
+
{
|
12
|
+
"total_distance_km": group["distance_km"].sum(),
|
13
|
+
"first_use": group["start"].min(skipna=True),
|
14
|
+
"last_use": group["start"].max(skipna=True),
|
15
|
+
},
|
16
|
+
),
|
17
|
+
include_groups=False,
|
18
|
+
)
|
19
|
+
.sort_values("last_use", ascending=False)
|
20
|
+
)
|
21
|
+
for equipment, offset in offsets.items():
|
22
|
+
result.loc[equipment, "total_distance_km"] += offset
|
23
|
+
|
24
|
+
result["total_distance_km"] = [
|
25
|
+
int(round(elem)) for elem in result["total_distance_km"]
|
26
|
+
]
|
27
|
+
result["first_use"] = [date.date().isoformat() for date in result["first_use"]]
|
28
|
+
result["last_use"] = [date.date().isoformat() for date in result["last_use"]]
|
29
|
+
|
30
|
+
return result.reset_index()
|
@@ -0,0 +1,100 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import pandas as pd
|
4
|
+
|
5
|
+
from geo_activity_playground.core.meta_search import _make_mask
|
6
|
+
from geo_activity_playground.core.meta_search import apply_search_query
|
7
|
+
from geo_activity_playground.core.meta_search import SearchQuery
|
8
|
+
|
9
|
+
|
10
|
+
def test_empty_query() -> None:
|
11
|
+
activity_meta = pd.DataFrame(
|
12
|
+
{
|
13
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
14
|
+
"id": pd.Series([1, 2, 3]),
|
15
|
+
"kind": pd.Series(["X", "X", "Y"]),
|
16
|
+
"name": ["Test1", "Test2", "Test3"],
|
17
|
+
"start": [
|
18
|
+
datetime.datetime(2024, 12, 24, 10),
|
19
|
+
datetime.datetime(2025, 1, 1, 10),
|
20
|
+
None,
|
21
|
+
],
|
22
|
+
}
|
23
|
+
)
|
24
|
+
|
25
|
+
search_query = SearchQuery()
|
26
|
+
|
27
|
+
actual = apply_search_query(activity_meta, search_query)
|
28
|
+
assert (actual["id"] == activity_meta["id"]).all()
|
29
|
+
|
30
|
+
|
31
|
+
def test_equipment_query() -> None:
|
32
|
+
activity_meta = pd.DataFrame(
|
33
|
+
{
|
34
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
35
|
+
"id": pd.Series([1, 2, 3]),
|
36
|
+
"kind": pd.Series(["X", "X", "Y"]),
|
37
|
+
"name": ["Test1", "Test2", "Test3"],
|
38
|
+
"start": [
|
39
|
+
datetime.datetime(2024, 12, 24, 10),
|
40
|
+
datetime.datetime(2025, 1, 1, 10),
|
41
|
+
None,
|
42
|
+
],
|
43
|
+
}
|
44
|
+
)
|
45
|
+
search_query = SearchQuery(equipment=["B"])
|
46
|
+
actual = apply_search_query(activity_meta, search_query)
|
47
|
+
assert set(actual["id"]) == {2, 3}
|
48
|
+
|
49
|
+
|
50
|
+
def test_date_query() -> None:
|
51
|
+
activity_meta = pd.DataFrame(
|
52
|
+
{
|
53
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
54
|
+
"id": pd.Series([1, 2, 3]),
|
55
|
+
"kind": pd.Series(["X", "X", "Y"]),
|
56
|
+
"name": ["Test1", "Test2", "Test3"],
|
57
|
+
"start": [
|
58
|
+
datetime.datetime(2024, 12, 24, 10),
|
59
|
+
datetime.datetime(2025, 1, 1, 10),
|
60
|
+
None,
|
61
|
+
],
|
62
|
+
}
|
63
|
+
)
|
64
|
+
search_query = SearchQuery(start_begin=datetime.date(2024, 12, 31))
|
65
|
+
actual = apply_search_query(activity_meta, search_query)
|
66
|
+
assert set(actual["id"]) == {2}
|
67
|
+
|
68
|
+
|
69
|
+
def test_name_query() -> None:
|
70
|
+
activity_meta = pd.DataFrame(
|
71
|
+
{
|
72
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
73
|
+
"id": pd.Series([1, 2, 3]),
|
74
|
+
"kind": pd.Series(["X", "X", "Y"]),
|
75
|
+
"name": ["Test1", "Test2", "Test3"],
|
76
|
+
"start": [
|
77
|
+
datetime.datetime(2024, 12, 24, 10),
|
78
|
+
datetime.datetime(2025, 1, 1, 10),
|
79
|
+
None,
|
80
|
+
],
|
81
|
+
}
|
82
|
+
)
|
83
|
+
search_query = SearchQuery(name="Test1")
|
84
|
+
actual = apply_search_query(activity_meta, search_query)
|
85
|
+
assert set(actual["id"]) == {1}
|
86
|
+
|
87
|
+
|
88
|
+
def test_make_mask() -> None:
|
89
|
+
index = [1, 2]
|
90
|
+
assert (_make_mask(index, True) == pd.Series([True, True], index=index)).all()
|
91
|
+
assert (_make_mask(index, False) == pd.Series([False, False], index=index)).all()
|
92
|
+
|
93
|
+
|
94
|
+
def test_search_query_from_primitives() -> None:
|
95
|
+
search_query = SearchQuery.from_primitives(
|
96
|
+
{"start_end": "2025-01-04", "equipment": ["A", "B"]}
|
97
|
+
)
|
98
|
+
assert search_query.start_end == datetime.date(2025, 1, 4)
|
99
|
+
assert search_query.equipment == ["A", "B"]
|
100
|
+
assert search_query.kind == []
|
@@ -0,0 +1,108 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import pandas as pd
|
4
|
+
import pytest
|
5
|
+
|
6
|
+
from geo_activity_playground.core.summary_stats import get_equipment_use_table
|
7
|
+
|
8
|
+
|
9
|
+
@pytest.fixture
|
10
|
+
def activity_meta() -> pd.DataFrame:
|
11
|
+
"""
|
12
|
+
calories: float
|
13
|
+
commute: bool
|
14
|
+
consider_for_achievements: bool
|
15
|
+
distance_km: float
|
16
|
+
elapsed_time: datetime.timedelta
|
17
|
+
end_latitude: float
|
18
|
+
end_longitude: float
|
19
|
+
equipment: str
|
20
|
+
id: int
|
21
|
+
kind: str
|
22
|
+
moving_time: datetime.timedelta
|
23
|
+
name: str
|
24
|
+
path: str
|
25
|
+
start_latitude: float
|
26
|
+
start_longitude: float
|
27
|
+
start: np.datetime64
|
28
|
+
steps: int
|
29
|
+
"""
|
30
|
+
return pd.DataFrame(
|
31
|
+
{
|
32
|
+
"calories": pd.Series([None, 1000, 2000]),
|
33
|
+
"commute": pd.Series([True, False, True]),
|
34
|
+
"consider_for_achievements": pd.Series([True, True, False]),
|
35
|
+
"distance_km": pd.Series([9.8, 4.4, 4.3]),
|
36
|
+
"elapsed_time": pd.Series(
|
37
|
+
[
|
38
|
+
datetime.timedelta(minutes=0.34),
|
39
|
+
datetime.timedelta(minutes=0.67),
|
40
|
+
None,
|
41
|
+
]
|
42
|
+
),
|
43
|
+
"end_latitude": pd.Series([0.58, 0.5, 0.19]),
|
44
|
+
"end_longitude": pd.Series([0.2, 0.94, 0.69]),
|
45
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
46
|
+
"id": pd.Series([1, 2, 3]),
|
47
|
+
"kind": pd.Series(["X", "X", "Y"]),
|
48
|
+
"moving_time": pd.Series(
|
49
|
+
[
|
50
|
+
datetime.timedelta(minutes=0.32),
|
51
|
+
datetime.timedelta(minutes=0.83),
|
52
|
+
None,
|
53
|
+
]
|
54
|
+
),
|
55
|
+
"name": pd.Series(["Test1", "Test2", "Test1"]),
|
56
|
+
"path": pd.Series(["Test1.fit", "Test2.gpx", "Test1.kml"]),
|
57
|
+
"start_latitude": pd.Series([0.22, 0.02, 0.35]),
|
58
|
+
"start_longitude": pd.Series([0.95, 0.95, 0.81]),
|
59
|
+
"start": pd.Series(
|
60
|
+
[
|
61
|
+
datetime.datetime(2024, 12, 24, 10),
|
62
|
+
datetime.datetime(2025, 1, 1, 10),
|
63
|
+
None,
|
64
|
+
]
|
65
|
+
),
|
66
|
+
"steps": pd.Series([1234, None, 5432]),
|
67
|
+
}
|
68
|
+
)
|
69
|
+
|
70
|
+
|
71
|
+
def test_activity_meta(activity_meta) -> None:
|
72
|
+
print()
|
73
|
+
print(activity_meta)
|
74
|
+
|
75
|
+
|
76
|
+
def test_equipment_use_table(activity_meta) -> None:
|
77
|
+
activity_meta = pd.DataFrame(
|
78
|
+
{
|
79
|
+
"distance_km": pd.Series([9.8, 4.4, 4.3]),
|
80
|
+
"equipment": pd.Series(["A", "B", "B"]),
|
81
|
+
"start": pd.Series(
|
82
|
+
[
|
83
|
+
datetime.datetime(2024, 12, 24, 10),
|
84
|
+
datetime.datetime(2025, 1, 1, 10),
|
85
|
+
None,
|
86
|
+
]
|
87
|
+
),
|
88
|
+
}
|
89
|
+
)
|
90
|
+
|
91
|
+
offsets = {"A": 4.0}
|
92
|
+
|
93
|
+
expected = [
|
94
|
+
{
|
95
|
+
"equipment": "B",
|
96
|
+
"total_distance_km": 9,
|
97
|
+
"first_use": "2025-01-01",
|
98
|
+
"last_use": "2025-01-01",
|
99
|
+
},
|
100
|
+
{
|
101
|
+
"equipment": "A",
|
102
|
+
"total_distance_km": 14,
|
103
|
+
"first_use": "2024-12-24",
|
104
|
+
"last_use": "2024-12-24",
|
105
|
+
},
|
106
|
+
]
|
107
|
+
actual = get_equipment_use_table(activity_meta, offsets)
|
108
|
+
assert actual == expected
|
@@ -108,6 +108,8 @@ class ActivityController:
|
|
108
108
|
result["heart_zones_plot"] = heart_rate_zone_plot(heart_zones)
|
109
109
|
if "altitude" in time_series.columns:
|
110
110
|
result["altitude_time_plot"] = altitude_time_plot(time_series)
|
111
|
+
if "elevation_gain_cum" in time_series.columns:
|
112
|
+
result["elevation_gain_cum_plot"] = elevation_gain_cum_plot(time_series)
|
111
113
|
if "heartrate" in time_series.columns:
|
112
114
|
result["heartrate_time_plot"] = heart_rate_time_plot(time_series)
|
113
115
|
if "cadence" in time_series.columns:
|
@@ -323,6 +325,24 @@ def altitude_time_plot(time_series: pd.DataFrame) -> str:
|
|
323
325
|
)
|
324
326
|
|
325
327
|
|
328
|
+
def elevation_gain_cum_plot(time_series: pd.DataFrame) -> str:
|
329
|
+
return (
|
330
|
+
alt.Chart(time_series, title="Altitude Gain")
|
331
|
+
.mark_line()
|
332
|
+
.encode(
|
333
|
+
alt.X("time", title="Time"),
|
334
|
+
alt.Y(
|
335
|
+
"elevation_gain_cum",
|
336
|
+
scale=alt.Scale(zero=False),
|
337
|
+
title="Altitude gain / m",
|
338
|
+
),
|
339
|
+
alt.Color("segment_id:N", title="Segment"),
|
340
|
+
)
|
341
|
+
.interactive(bind_y=False)
|
342
|
+
.to_json(format="vega")
|
343
|
+
)
|
344
|
+
|
345
|
+
|
326
346
|
def heart_rate_time_plot(time_series: pd.DataFrame) -> str:
|
327
347
|
return (
|
328
348
|
alt.Chart(time_series, title="Heart Rate")
|
@@ -9,7 +9,7 @@
|
|
9
9
|
|
10
10
|
|
11
11
|
<div class="row mb-3">
|
12
|
-
<div class="col-
|
12
|
+
<div class="col-12">
|
13
13
|
<div id="activity-map" style="height: 500px;"></div>
|
14
14
|
<script>
|
15
15
|
var map = L.map('activity-map', {
|
@@ -26,15 +26,6 @@
|
|
26
26
|
map.fitBounds(geojson.getBounds());
|
27
27
|
</script>
|
28
28
|
</div>
|
29
|
-
<div class="col-md-3">
|
30
|
-
<ol>
|
31
|
-
{% for activity in activities %}
|
32
|
-
<li><span style="color: {{ activity['color'] }};">█</span> <a
|
33
|
-
href="{{ url_for('.show', id=activity.id) }}">{{
|
34
|
-
activity.name }}</a></li>
|
35
|
-
{% endfor %}
|
36
|
-
</ol>
|
37
|
-
</div>
|
38
29
|
</div>
|
39
30
|
|
40
31
|
<div class="row mb-3">
|
@@ -48,6 +39,7 @@
|
|
48
39
|
<th>Date</th>
|
49
40
|
<th>Distance / km</th>
|
50
41
|
<th>Elapsed time</th>
|
42
|
+
<th>Speed / km/h</th>
|
51
43
|
<th>Equipment</th>
|
52
44
|
<th>Kind</th>
|
53
45
|
</tr>
|
@@ -61,6 +53,7 @@
|
|
61
53
|
<td>{{ activity.start|dt }}</td>
|
62
54
|
<td>{{ activity.distance_km | round(1) }}</td>
|
63
55
|
<td>{{ activity.elapsed_time|td }}</td>
|
56
|
+
<td>{{ activity.average_speed_moving_kmh|round(1) }}</td>
|
64
57
|
<td>{{ activity["equipment"] }}</td>
|
65
58
|
<td>{{ activity["kind"] }}</td>
|
66
59
|
</tr>
|
@@ -57,6 +57,7 @@
|
|
57
57
|
<th>Date</th>
|
58
58
|
<th class="numeric-sort">Distance / km</th>
|
59
59
|
<th>Elapsed time</th>
|
60
|
+
<th>Speed / km/h</th>
|
60
61
|
<th>Equipment</th>
|
61
62
|
<th>Kind</th>
|
62
63
|
</tr>
|
@@ -70,6 +71,7 @@
|
|
70
71
|
<td>{{ activity.start|dt }}</td>
|
71
72
|
<td>{{ activity.distance_km | round(1) }}</td>
|
72
73
|
<td>{{ activity.elapsed_time|td }}</td>
|
74
|
+
<td>{{ activity.average_speed_moving_kmh|round(1) }}</td>
|
73
75
|
<td>{{ activity["equipment"] }}</td>
|
74
76
|
<td>{{ activity["kind"] }}</td>
|
75
77
|
</tr>
|
@@ -22,6 +22,12 @@
|
|
22
22
|
<dd>{{ activity.elapsed_time|td }}</dd>
|
23
23
|
<dt>Moving time</dt>
|
24
24
|
<dd>{{ activity.moving_time|td }}</dd>
|
25
|
+
<dt>Average moving speed</dt>
|
26
|
+
<dd>{{ activity.average_speed_moving_kmh|round(1) }} km/h = {{
|
27
|
+
(60/activity.average_speed_moving_kmh)|round(1) }} min/km</dd>
|
28
|
+
<dt>Average elapsed speed</dt>
|
29
|
+
<dd>{{ activity.average_speed_elapsed_kmh|round(1) }} km/h = {{
|
30
|
+
(60/activity.average_speed_elapsed_kmh)|round(1) }} min/km</dd>
|
25
31
|
<dt>Start time</dt>
|
26
32
|
<dd><a href="{{ url_for('activity.day', year=date.year, month=date.month, day=date.day) }}">{{ date }}</a>
|
27
33
|
{{ time }}
|
@@ -30,6 +36,10 @@
|
|
30
36
|
<dd>{{ activity.calories }}</dd>
|
31
37
|
<dt>Steps</dt>
|
32
38
|
<dd>{{ activity.steps }}</dd>
|
39
|
+
{% if activity.elevation_gain is defined %}
|
40
|
+
<dt>Elevation gain</dt>
|
41
|
+
<dd>{{ activity.elevation_gain|round(0) }} m</dd>
|
42
|
+
{% endif %}
|
33
43
|
<dt>Equipment</dt>
|
34
44
|
<dd>{{ activity['equipment'] }}</dd>
|
35
45
|
<dt>New Explorer Tiles</dt>
|
@@ -100,6 +110,7 @@
|
|
100
110
|
</div>
|
101
111
|
</div>
|
102
112
|
|
113
|
+
{% if altitude_time_plot is defined %}
|
103
114
|
<div class="row mb-3">
|
104
115
|
<div class="col">
|
105
116
|
<h2>Altitude</h2>
|
@@ -110,7 +121,13 @@
|
|
110
121
|
<div class="col-md-4">
|
111
122
|
{{ vega_direct("altitude_time_plot", altitude_time_plot) }}
|
112
123
|
</div>
|
124
|
+
{% if elevation_gain_cum_plot is defined %}
|
125
|
+
<div class="col-md-4">
|
126
|
+
{{ vega_direct("elevation_gain_cum_plot", elevation_gain_cum_plot) }}
|
127
|
+
</div>
|
128
|
+
{% endif %}
|
113
129
|
</div>
|
130
|
+
{% endif %}
|
114
131
|
|
115
132
|
{% if heartrate_time_plot is defined %}
|
116
133
|
<h2>Heart rate</h2>
|