geo-activity-playground 0.36.2__py3-none-any.whl → 0.38.0__py3-none-any.whl
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/core/activities.py +12 -0
- geo_activity_playground/core/config.py +6 -2
- geo_activity_playground/core/enrichment.py +9 -0
- geo_activity_playground/core/meta_search.py +157 -0
- geo_activity_playground/core/summary_stats.py +30 -0
- geo_activity_playground/core/test_meta_search.py +100 -0
- geo_activity_playground/core/test_summary_stats.py +108 -0
- geo_activity_playground/webui/activity/controller.py +20 -0
- geo_activity_playground/webui/activity/templates/activity/day.html.j2 +3 -10
- geo_activity_playground/webui/activity/templates/activity/name.html.j2 +2 -0
- geo_activity_playground/webui/activity/templates/activity/show.html.j2 +17 -0
- geo_activity_playground/webui/app.py +27 -10
- geo_activity_playground/webui/eddington_blueprint.py +167 -58
- geo_activity_playground/webui/{equipment/controller.py → equipment_blueprint.py} +29 -42
- geo_activity_playground/webui/explorer/blueprint.py +4 -0
- geo_activity_playground/webui/heatmap/blueprint.py +10 -29
- geo_activity_playground/webui/heatmap/heatmap_controller.py +45 -103
- geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +5 -37
- geo_activity_playground/webui/search_blueprint.py +40 -71
- geo_activity_playground/webui/search_util.py +64 -0
- geo_activity_playground/webui/summary_blueprint.py +40 -39
- geo_activity_playground/webui/templates/eddington/index.html.j2 +73 -1
- geo_activity_playground/webui/{equipment/templates → templates}/equipment/index.html.j2 +3 -5
- geo_activity_playground/webui/templates/search/index.html.j2 +34 -87
- geo_activity_playground/webui/templates/search_form.html.j2 +116 -0
- geo_activity_playground/webui/templates/summary/index.html.j2 +5 -1
- {geo_activity_playground-0.36.2.dist-info → geo_activity_playground-0.38.0.dist-info}/METADATA +1 -1
- {geo_activity_playground-0.36.2.dist-info → geo_activity_playground-0.38.0.dist-info}/RECORD +31 -27
- geo_activity_playground/webui/equipment/__init__.py +0 -0
- geo_activity_playground/webui/equipment/blueprint.py +0 -16
- {geo_activity_playground-0.36.2.dist-info → geo_activity_playground-0.38.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.36.2.dist-info → geo_activity_playground-0.38.0.dist-info}/WHEEL +0 -0
- {geo_activity_playground-0.36.2.dist-info → geo_activity_playground-0.38.0.dist-info}/entry_points.txt +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>
|