geo-activity-playground 0.26.2__py3-none-any.whl → 0.27.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/__main__.py +23 -20
- geo_activity_playground/core/activities.py +1 -44
- geo_activity_playground/core/config.py +111 -0
- geo_activity_playground/core/enrichment.py +22 -3
- geo_activity_playground/core/heart_rate.py +49 -0
- geo_activity_playground/core/paths.py +6 -0
- geo_activity_playground/core/tasks.py +14 -0
- geo_activity_playground/core/tiles.py +1 -1
- geo_activity_playground/explorer/tile_visits.py +23 -11
- geo_activity_playground/importers/csv_parser.py +73 -0
- geo_activity_playground/importers/directory.py +17 -8
- geo_activity_playground/importers/strava_api.py +20 -44
- geo_activity_playground/importers/strava_checkout.py +63 -36
- geo_activity_playground/importers/test_csv_parser.py +49 -0
- geo_activity_playground/webui/activity/blueprint.py +3 -4
- geo_activity_playground/webui/activity/controller.py +40 -14
- geo_activity_playground/webui/activity/templates/activity/show.html.j2 +6 -2
- geo_activity_playground/webui/app.py +26 -26
- geo_activity_playground/webui/eddington/controller.py +1 -1
- geo_activity_playground/webui/equipment/blueprint.py +5 -2
- geo_activity_playground/webui/equipment/controller.py +5 -6
- geo_activity_playground/webui/explorer/blueprint.py +14 -2
- geo_activity_playground/webui/explorer/controller.py +21 -1
- geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +12 -1
- geo_activity_playground/webui/settings/blueprint.py +106 -0
- geo_activity_playground/webui/settings/controller.py +228 -0
- geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2 +44 -0
- geo_activity_playground/webui/settings/templates/settings/heart-rate.html.j2 +102 -0
- geo_activity_playground/webui/settings/templates/settings/index.html.j2 +74 -0
- geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2 +30 -0
- geo_activity_playground/webui/settings/templates/settings/metadata-extraction.html.j2 +55 -0
- geo_activity_playground/webui/settings/templates/settings/privacy-zones.html.j2 +81 -0
- geo_activity_playground/webui/{strava/templates/strava/client-id.html.j2 → settings/templates/settings/strava.html.j2} +17 -7
- geo_activity_playground/webui/templates/page.html.j2 +5 -1
- geo_activity_playground/webui/upload/blueprint.py +10 -1
- geo_activity_playground/webui/upload/controller.py +24 -11
- geo_activity_playground/webui/upload/templates/upload/reload.html.j2 +16 -0
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/METADATA +2 -2
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/RECORD +42 -35
- geo_activity_playground/webui/strava/__init__.py +0 -0
- geo_activity_playground/webui/strava/blueprint.py +0 -33
- geo_activity_playground/webui/strava/controller.py +0 -49
- geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +0 -14
- geo_activity_playground/webui/templates/settings.html.j2 +0 -24
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/WHEEL +0 -0
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,8 @@
|
|
1
1
|
import datetime
|
2
|
-
import functools
|
3
|
-
import json
|
4
2
|
import logging
|
5
3
|
import pathlib
|
6
4
|
import pickle
|
7
5
|
import time
|
8
|
-
from typing import Any
|
9
6
|
|
10
7
|
import pandas as pd
|
11
8
|
from stravalib import Client
|
@@ -15,46 +12,28 @@ from stravalib.exc import RateLimitExceeded
|
|
15
12
|
from tqdm import tqdm
|
16
13
|
|
17
14
|
from geo_activity_playground.core.activities import ActivityMeta
|
18
|
-
from geo_activity_playground.core.config import
|
15
|
+
from geo_activity_playground.core.config import Config
|
19
16
|
from geo_activity_playground.core.paths import activity_extracted_meta_dir
|
20
17
|
from geo_activity_playground.core.paths import activity_extracted_time_series_dir
|
21
|
-
from geo_activity_playground.core.paths import cache_dir
|
22
18
|
from geo_activity_playground.core.paths import strava_api_dir
|
23
|
-
from geo_activity_playground.core.paths import
|
19
|
+
from geo_activity_playground.core.paths import strava_last_activity_date_path
|
20
|
+
from geo_activity_playground.core.tasks import get_state
|
21
|
+
from geo_activity_playground.core.tasks import set_state
|
24
22
|
from geo_activity_playground.core.time_conversion import convert_to_datetime_ns
|
25
23
|
|
26
24
|
|
27
25
|
logger = logging.getLogger(__name__)
|
28
26
|
|
29
27
|
|
30
|
-
def
|
31
|
-
|
32
|
-
with open(path) as f:
|
33
|
-
return json.load(f)
|
34
|
-
|
35
|
-
|
36
|
-
def set_state(path: pathlib.Path, state: Any) -> None:
|
37
|
-
path.parent.mkdir(exist_ok=True, parents=True)
|
38
|
-
with open(path, "w") as f:
|
39
|
-
json.dump(state, f, indent=2, sort_keys=True, ensure_ascii=False)
|
40
|
-
|
41
|
-
|
42
|
-
def get_current_access_token() -> str:
|
43
|
-
if strava_dynamic_config_path().exists():
|
44
|
-
with open(strava_dynamic_config_path()) as f:
|
45
|
-
strava_config = json.load(f)
|
46
|
-
else:
|
47
|
-
config = get_config()
|
48
|
-
strava_config = config["strava"]
|
49
|
-
|
50
|
-
tokens = get_state(strava_api_dir() / "strava_tokens.json")
|
28
|
+
def get_current_access_token(config: Config) -> str:
|
29
|
+
tokens = get_state(strava_api_dir() / "strava_tokens.json", None)
|
51
30
|
if not tokens:
|
52
31
|
logger.info("Create Strava access token …")
|
53
32
|
client = Client()
|
54
33
|
token_response = client.exchange_code_for_token(
|
55
|
-
client_id=
|
56
|
-
client_secret=
|
57
|
-
code=
|
34
|
+
client_id=config.strava_client_id,
|
35
|
+
client_secret=config.strava_client_secret,
|
36
|
+
code=config.strava_client_code,
|
58
37
|
)
|
59
38
|
tokens = {
|
60
39
|
"access": token_response["access_token"],
|
@@ -66,8 +45,8 @@ def get_current_access_token() -> str:
|
|
66
45
|
logger.info("Renew Strava access token …")
|
67
46
|
client = Client()
|
68
47
|
token_response = client.refresh_access_token(
|
69
|
-
client_id=
|
70
|
-
client_secret=
|
48
|
+
client_id=config.strava_client_id,
|
49
|
+
client_secret=config.strava_client_secret,
|
71
50
|
refresh_token=tokens["refresh"],
|
72
51
|
)
|
73
52
|
tokens = {
|
@@ -89,8 +68,8 @@ def round_to_next_quarter_hour(date: datetime.datetime) -> datetime.datetime:
|
|
89
68
|
return next_quarter
|
90
69
|
|
91
70
|
|
92
|
-
def import_from_strava_api() -> None:
|
93
|
-
while try_import_strava():
|
71
|
+
def import_from_strava_api(config: Config) -> None:
|
72
|
+
while try_import_strava(config):
|
94
73
|
now = datetime.datetime.now()
|
95
74
|
next_quarter = round_to_next_quarter_hour(now)
|
96
75
|
seconds_to_wait = (next_quarter - now).total_seconds() + 10
|
@@ -100,17 +79,12 @@ def import_from_strava_api() -> None:
|
|
100
79
|
time.sleep(seconds_to_wait)
|
101
80
|
|
102
81
|
|
103
|
-
def try_import_strava() -> bool:
|
104
|
-
|
105
|
-
if last_activity_date_path.exists():
|
106
|
-
with open(last_activity_date_path) as f:
|
107
|
-
get_after = json.load(f)
|
108
|
-
else:
|
109
|
-
get_after = "2000-01-01T00:00:00Z"
|
82
|
+
def try_import_strava(config: Config) -> bool:
|
83
|
+
get_after = get_state(strava_last_activity_date_path(), "2000-01-01T00:00:00Z")
|
110
84
|
|
111
85
|
gear_names = {None: "None"}
|
112
86
|
|
113
|
-
client = Client(access_token=get_current_access_token())
|
87
|
+
client = Client(access_token=get_current_access_token(config))
|
114
88
|
|
115
89
|
try:
|
116
90
|
for activity in tqdm(
|
@@ -178,8 +152,10 @@ def try_import_strava() -> bool:
|
|
178
152
|
) as f:
|
179
153
|
pickle.dump(activity_meta, f)
|
180
154
|
|
181
|
-
|
182
|
-
|
155
|
+
set_state(
|
156
|
+
strava_last_activity_date_path(),
|
157
|
+
activity.start_date.isoformat().replace("+00:00", "Z"),
|
158
|
+
)
|
183
159
|
|
184
160
|
limit_exceeded = False
|
185
161
|
except RateLimitExceeded:
|
@@ -13,13 +13,18 @@ import numpy as np
|
|
13
13
|
import pandas as pd
|
14
14
|
from tqdm import tqdm
|
15
15
|
|
16
|
+
from geo_activity_playground.core.activities import ActivityMeta
|
16
17
|
from geo_activity_playground.core.paths import activity_extracted_meta_dir
|
17
18
|
from geo_activity_playground.core.paths import activity_extracted_time_series_dir
|
19
|
+
from geo_activity_playground.core.paths import strava_last_activity_date_path
|
20
|
+
from geo_activity_playground.core.tasks import get_state
|
21
|
+
from geo_activity_playground.core.tasks import set_state
|
18
22
|
from geo_activity_playground.core.tasks import work_tracker_path
|
19
23
|
from geo_activity_playground.core.tasks import WorkTracker
|
20
24
|
from geo_activity_playground.core.time_conversion import convert_to_datetime_ns
|
21
25
|
from geo_activity_playground.importers.activity_parsers import ActivityParseError
|
22
26
|
from geo_activity_playground.importers.activity_parsers import read_activity
|
27
|
+
from geo_activity_playground.importers.csv_parser import parse_csv
|
23
28
|
|
24
29
|
|
25
30
|
logger = logging.getLogger(__name__)
|
@@ -48,9 +53,9 @@ EXPECTED_COLUMNS = [
|
|
48
53
|
"Filename",
|
49
54
|
"Athlete Weight",
|
50
55
|
"Bike Weight",
|
51
|
-
"Elapsed Time
|
56
|
+
"Elapsed Time",
|
52
57
|
"Moving Time",
|
53
|
-
"Distance
|
58
|
+
"Distance",
|
54
59
|
"Max Speed",
|
55
60
|
"Average Speed",
|
56
61
|
"Elevation Gain",
|
@@ -63,14 +68,14 @@ EXPECTED_COLUMNS = [
|
|
63
68
|
"Average Negative Grade",
|
64
69
|
"Max Cadence",
|
65
70
|
"Average Cadence",
|
66
|
-
"Max Heart Rate
|
71
|
+
"Max Heart Rate",
|
67
72
|
"Average Heart Rate",
|
68
73
|
"Max Watts",
|
69
74
|
"Average Watts",
|
70
75
|
"Calories",
|
71
76
|
"Max Temperature",
|
72
77
|
"Average Temperature",
|
73
|
-
"Relative Effort
|
78
|
+
"Relative Effort",
|
74
79
|
"Total Work",
|
75
80
|
"Number of Runs",
|
76
81
|
"Uphill Time",
|
@@ -83,7 +88,7 @@ EXPECTED_COLUMNS = [
|
|
83
88
|
"Power Count",
|
84
89
|
"Prefer Perceived Exertion",
|
85
90
|
"Perceived Relative Effort",
|
86
|
-
"Commute
|
91
|
+
"Commute",
|
87
92
|
"Total Weight Lifted",
|
88
93
|
"From Upload",
|
89
94
|
"Grade Adjusted Distance",
|
@@ -130,34 +135,41 @@ EXPECTED_COLUMNS = [
|
|
130
135
|
]
|
131
136
|
|
132
137
|
|
133
|
-
def
|
134
|
-
|
135
|
-
return
|
136
|
-
|
137
|
-
|
138
|
+
def float_with_comma_or_period(x: str) -> Optional[float]:
|
139
|
+
if len(x) == 0:
|
140
|
+
return 0
|
141
|
+
|
142
|
+
if "," in x:
|
143
|
+
x = x.replace(",", ".")
|
144
|
+
return float(x)
|
138
145
|
|
139
146
|
|
140
147
|
def import_from_strava_checkout() -> None:
|
141
148
|
checkout_path = pathlib.Path("Strava Export")
|
142
|
-
|
149
|
+
with open(checkout_path / "activities.csv") as f:
|
150
|
+
rows = parse_csv(f.read())
|
151
|
+
header = rows[0]
|
152
|
+
|
153
|
+
if len(header) != len(EXPECTED_COLUMNS):
|
154
|
+
logger.error(
|
155
|
+
f"You are trying to import a Strava checkout where the `activities.csv` contains an unexpected header format. In order to import this, we need to map these to the English ones. Unfortunately Strava often changes the number of columns. Your file has {len(header)} but we expect {len(EXPECTED_COLUMNS)}. This means that the program needs to be updated to match the new Strava export format. Please go to https://github.com/martin-ueding/geo-activity-playground/issues and open a new issue and share the following output in the ticket:"
|
156
|
+
)
|
157
|
+
print(header)
|
158
|
+
sys.exit(1)
|
143
159
|
|
144
|
-
if
|
160
|
+
if header[0] == EXPECTED_COLUMNS[0]:
|
145
161
|
dayfirst = False
|
146
|
-
if
|
147
|
-
|
148
|
-
if len(activities.columns) != len(EXPECTED_COLUMNS):
|
149
|
-
logger.error(
|
150
|
-
f"You are trying to import a Strava checkout where the `activities.csv` contains German column headers. In order to import this, we need to map these to the English ones. Unfortunately Strava has changed the number of columns. Your file has {len(activities.columns)} but we expect {len(EXPECTED_COLUMNS)}. This means that the program needs to be updated to match the new Strava export format. Please go to https://github.com/martin-ueding/geo-activity-playground/issues and open a new issue and share the following output in the ticket:"
|
151
|
-
)
|
152
|
-
print(activities.columns)
|
153
|
-
print(activities.dtypes)
|
154
|
-
sys.exit(1)
|
155
|
-
activities.columns = EXPECTED_COLUMNS
|
162
|
+
if header[0] == "Aktivitäts-ID":
|
163
|
+
header = EXPECTED_COLUMNS
|
156
164
|
dayfirst = True
|
157
165
|
|
158
|
-
|
166
|
+
table = {
|
167
|
+
header[i]: [rows[r][i] for r in range(1, len(rows))] for i in range(len(header))
|
168
|
+
}
|
169
|
+
all_activity_ids = [int(value) for value in table["Activity ID"]]
|
170
|
+
|
159
171
|
work_tracker = WorkTracker(work_tracker_path("import-strava-checkout-activities"))
|
160
|
-
activities_ids_to_parse = work_tracker.filter(
|
172
|
+
activities_ids_to_parse = work_tracker.filter(all_activity_ids)
|
161
173
|
activities_ids_to_parse = [
|
162
174
|
activity_id
|
163
175
|
for activity_id in activities_ids_to_parse
|
@@ -166,16 +178,23 @@ def import_from_strava_checkout() -> None:
|
|
166
178
|
|
167
179
|
for activity_id in tqdm(activities_ids_to_parse, desc="Import from Strava export"):
|
168
180
|
work_tracker.mark_done(activity_id)
|
169
|
-
|
181
|
+
index = all_activity_ids.index(activity_id)
|
182
|
+
row = {column: table[column][index] for column in header}
|
183
|
+
|
170
184
|
# Some manually recorded activities have no file name. Pandas reads that as a float. We skip those.
|
171
|
-
if
|
185
|
+
if not row["Filename"]:
|
172
186
|
continue
|
187
|
+
|
188
|
+
start_datetime = dateutil.parser.parse(row["Activity Date"], dayfirst=dayfirst)
|
189
|
+
|
173
190
|
activity_file = checkout_path / row["Filename"]
|
174
|
-
table_activity_meta = {
|
175
|
-
"calories":
|
191
|
+
table_activity_meta: ActivityMeta = {
|
192
|
+
"calories": float_with_comma_or_period(row["Calories"]),
|
176
193
|
"commute": row["Commute"] == "true",
|
177
194
|
"distance_km": row["Distance"],
|
178
|
-
"elapsed_time": datetime.timedelta(
|
195
|
+
"elapsed_time": datetime.timedelta(
|
196
|
+
seconds=float_with_comma_or_period(row["Elapsed Time"])
|
197
|
+
),
|
179
198
|
"equipment": str(
|
180
199
|
nan_as_none(row["Activity Gear"])
|
181
200
|
or nan_as_none(row["Bike"])
|
@@ -186,15 +205,13 @@ def import_from_strava_checkout() -> None:
|
|
186
205
|
"id": activity_id,
|
187
206
|
"name": row["Activity Name"],
|
188
207
|
"path": str(activity_file),
|
189
|
-
"start": convert_to_datetime_ns(
|
190
|
-
|
191
|
-
),
|
208
|
+
"start": convert_to_datetime_ns(start_datetime),
|
209
|
+
"steps": float_with_comma_or_period(row["Total Steps"]),
|
192
210
|
}
|
193
|
-
meta_path = activity_extracted_meta_dir() / f"{activity_id}.pickle"
|
194
|
-
with open(meta_path, "wb") as f:
|
195
|
-
pickle.dump(table_activity_meta, f)
|
196
211
|
|
197
|
-
time_series_path =
|
212
|
+
time_series_path = (
|
213
|
+
activity_extracted_time_series_dir() / f"{activity_id}.parquet"
|
214
|
+
)
|
198
215
|
if time_series_path.exists():
|
199
216
|
time_series = pd.read_parquet(time_series_path)
|
200
217
|
else:
|
@@ -216,7 +233,17 @@ def import_from_strava_checkout() -> None:
|
|
216
233
|
if "latitude" not in time_series.columns:
|
217
234
|
continue
|
218
235
|
|
236
|
+
meta_path = activity_extracted_meta_dir() / f"{activity_id}.pickle"
|
237
|
+
with open(meta_path, "wb") as f:
|
238
|
+
pickle.dump(table_activity_meta, f)
|
219
239
|
time_series.to_parquet(time_series_path)
|
240
|
+
|
241
|
+
start_str = (
|
242
|
+
pd.Timestamp(table_activity_meta["start"]).to_pydatetime().isoformat() + "Z"
|
243
|
+
)
|
244
|
+
latest_start_str = get_state(strava_last_activity_date_path(), start_str)
|
245
|
+
set_state(strava_last_activity_date_path(), max(start_str, latest_start_str))
|
246
|
+
|
220
247
|
work_tracker.close()
|
221
248
|
|
222
249
|
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import pytest
|
2
|
+
|
3
|
+
from geo_activity_playground.importers.csv_parser import _parse_cell
|
4
|
+
from geo_activity_playground.importers.csv_parser import _parse_line
|
5
|
+
from geo_activity_playground.importers.csv_parser import parse_csv
|
6
|
+
|
7
|
+
|
8
|
+
def test_parse_csv() -> None:
|
9
|
+
data = """
|
10
|
+
A,B,C
|
11
|
+
a,"b,b",c
|
12
|
+
d,"e
|
13
|
+
f",g
|
14
|
+
"""
|
15
|
+
expected = [["A", "B", "C"], ["a", "b,b", "c"], ["d", "e\nf", "g"]]
|
16
|
+
assert parse_csv(data) == expected
|
17
|
+
|
18
|
+
|
19
|
+
def test_parse_cell_plain() -> None:
|
20
|
+
assert _parse_cell("foo", 0) == ("foo", 3)
|
21
|
+
|
22
|
+
|
23
|
+
def test_parse_cell_with_quotes() -> None:
|
24
|
+
assert _parse_cell('"foo"', 0) == ("foo", 5)
|
25
|
+
|
26
|
+
|
27
|
+
def test_parse_cell_with_escape() -> None:
|
28
|
+
assert _parse_cell('"f\\"oo"', 0) == ('f"oo', 7)
|
29
|
+
|
30
|
+
|
31
|
+
def test_parse_cell_with_newline() -> None:
|
32
|
+
assert _parse_cell('"f\noo"', 0) == ("f\noo", 6)
|
33
|
+
|
34
|
+
|
35
|
+
def test_parse_cell_empty() -> None:
|
36
|
+
assert _parse_cell("", 0) == ("", 0)
|
37
|
+
|
38
|
+
|
39
|
+
def test_parse_line() -> None:
|
40
|
+
assert _parse_line("a,b,c\n", 0) == (["a", "b", "c"], 6)
|
41
|
+
|
42
|
+
|
43
|
+
def test_parse_line_empty_cell() -> None:
|
44
|
+
assert _parse_line("a,,c\n", 0) == (["a", "", "c"], 5)
|
45
|
+
|
46
|
+
|
47
|
+
@pytest.mark.xfail
|
48
|
+
def test_parse_line_empty_cell_at_end() -> None:
|
49
|
+
assert _parse_line("a,b,\n", 0) == (["a", "b", ""], 5)
|
@@ -8,19 +8,18 @@ from flask import Response
|
|
8
8
|
from ...core.activities import ActivityRepository
|
9
9
|
from ...explorer.tile_visits import TileVisitAccessor
|
10
10
|
from .controller import ActivityController
|
11
|
+
from geo_activity_playground.core.config import Config
|
11
12
|
from geo_activity_playground.core.privacy_zones import PrivacyZone
|
12
13
|
|
13
14
|
|
14
15
|
def make_activity_blueprint(
|
15
16
|
repository: ActivityRepository,
|
16
17
|
tile_visit_accessor: TileVisitAccessor,
|
17
|
-
|
18
|
+
config: Config,
|
18
19
|
) -> Blueprint:
|
19
20
|
blueprint = Blueprint("activity", __name__, template_folder="templates")
|
20
21
|
|
21
|
-
activity_controller = ActivityController(
|
22
|
-
repository, tile_visit_accessor, privacy_zones
|
23
|
-
)
|
22
|
+
activity_controller = ActivityController(repository, tile_visit_accessor, config)
|
24
23
|
|
25
24
|
@blueprint.route("/all")
|
26
25
|
def all():
|
@@ -1,26 +1,24 @@
|
|
1
1
|
import datetime
|
2
|
-
import functools
|
3
2
|
import io
|
4
3
|
import logging
|
5
4
|
import re
|
6
|
-
from
|
5
|
+
from typing import Optional
|
7
6
|
|
8
7
|
import altair as alt
|
9
8
|
import geojson
|
10
9
|
import matplotlib
|
11
|
-
import matplotlib.pyplot as pl
|
12
10
|
import numpy as np
|
13
11
|
import pandas as pd
|
14
12
|
from PIL import Image
|
15
13
|
from PIL import ImageDraw
|
16
|
-
from PIL import ImageFont
|
17
14
|
|
18
15
|
from geo_activity_playground.core.activities import ActivityMeta
|
19
16
|
from geo_activity_playground.core.activities import ActivityRepository
|
20
|
-
from geo_activity_playground.core.activities import extract_heart_rate_zones
|
21
17
|
from geo_activity_playground.core.activities import make_geojson_color_line
|
22
18
|
from geo_activity_playground.core.activities import make_geojson_from_time_series
|
23
19
|
from geo_activity_playground.core.activities import make_speed_color_bar
|
20
|
+
from geo_activity_playground.core.config import Config
|
21
|
+
from geo_activity_playground.core.heart_rate import HeartRateZoneComputer
|
24
22
|
from geo_activity_playground.core.heatmap import add_margin_to_geo_bounds
|
25
23
|
from geo_activity_playground.core.heatmap import build_map_from_tiles
|
26
24
|
from geo_activity_playground.core.heatmap import GeoBounds
|
@@ -41,13 +39,13 @@ class ActivityController:
|
|
41
39
|
self,
|
42
40
|
repository: ActivityRepository,
|
43
41
|
tile_visit_accessor: TileVisitAccessor,
|
44
|
-
|
42
|
+
config: Config,
|
45
43
|
) -> None:
|
46
44
|
self._repository = repository
|
47
45
|
self._tile_visit_accessor = tile_visit_accessor
|
48
|
-
self.
|
46
|
+
self._config = config
|
47
|
+
self._heart_rate_zone_computer = HeartRateZoneComputer(config)
|
49
48
|
|
50
|
-
@functools.lru_cache()
|
51
49
|
def render_activity(self, id: int) -> dict:
|
52
50
|
activity = self._repository.get_activity_by_id(id)
|
53
51
|
|
@@ -82,18 +80,23 @@ class ActivityController:
|
|
82
80
|
"time": activity["start"].time(),
|
83
81
|
"new_tiles": new_tiles,
|
84
82
|
}
|
85
|
-
if (
|
86
|
-
|
83
|
+
if (
|
84
|
+
heart_zones := _extract_heart_rate_zones(
|
85
|
+
time_series, self._heart_rate_zone_computer
|
86
|
+
)
|
87
|
+
) is not None:
|
88
|
+
result["heart_zones_plot"] = heart_rate_zone_plot(heart_zones)
|
87
89
|
if "altitude" in time_series.columns:
|
88
90
|
result["altitude_time_plot"] = altitude_time_plot(time_series)
|
89
91
|
if "heartrate" in time_series.columns:
|
90
|
-
result["heartrate_time_plot"] =
|
92
|
+
result["heartrate_time_plot"] = heart_rate_time_plot(time_series)
|
91
93
|
return result
|
92
94
|
|
93
95
|
def render_sharepic(self, id: int) -> bytes:
|
94
96
|
activity = self._repository.get_activity_by_id(id)
|
95
97
|
time_series = self._repository.get_time_series(id)
|
96
|
-
for
|
98
|
+
for coordinates in self._config.privacy_zones.values():
|
99
|
+
privacy_zone = PrivacyZone(coordinates)
|
97
100
|
time_series = privacy_zone.filter_time_series(time_series)
|
98
101
|
if len(time_series) == 0:
|
99
102
|
time_series = self._repository.get_time_series(id)
|
@@ -280,7 +283,7 @@ def altitude_time_plot(time_series: pd.DataFrame) -> str:
|
|
280
283
|
)
|
281
284
|
|
282
285
|
|
283
|
-
def
|
286
|
+
def heart_rate_time_plot(time_series: pd.DataFrame) -> str:
|
284
287
|
return (
|
285
288
|
alt.Chart(time_series, title="Heart Rate")
|
286
289
|
.mark_line()
|
@@ -294,7 +297,7 @@ def heartrate_time_plot(time_series: pd.DataFrame) -> str:
|
|
294
297
|
)
|
295
298
|
|
296
299
|
|
297
|
-
def
|
300
|
+
def heart_rate_zone_plot(heart_zones: pd.DataFrame) -> str:
|
298
301
|
return (
|
299
302
|
alt.Chart(heart_zones, title="Heart Rate Zones")
|
300
303
|
.mark_bar()
|
@@ -471,3 +474,26 @@ def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
|
|
471
474
|
img.save(f, format="png")
|
472
475
|
# pl.imsave(f, background, format="png")
|
473
476
|
return bytes(f.getbuffer())
|
477
|
+
|
478
|
+
|
479
|
+
def _extract_heart_rate_zones(
|
480
|
+
time_series: pd.DataFrame, heart_rate_zone_computer: HeartRateZoneComputer
|
481
|
+
) -> Optional[pd.DataFrame]:
|
482
|
+
if "heartrate" not in time_series:
|
483
|
+
return
|
484
|
+
|
485
|
+
try:
|
486
|
+
zones = heart_rate_zone_computer.compute_zones(
|
487
|
+
time_series["heartrate"], time_series["time"].iloc[0].year
|
488
|
+
)
|
489
|
+
except RuntimeError:
|
490
|
+
return
|
491
|
+
|
492
|
+
df = pd.DataFrame({"heartzone": zones, "step": time_series["time"].diff()}).dropna()
|
493
|
+
duration_per_zone = df.groupby("heartzone").sum()["step"].dt.total_seconds() / 60
|
494
|
+
duration_per_zone.name = "minutes"
|
495
|
+
for i in range(6):
|
496
|
+
if i not in duration_per_zone:
|
497
|
+
duration_per_zone.loc[i] = 0.0
|
498
|
+
result = duration_per_zone.reset_index()
|
499
|
+
return result
|
@@ -121,11 +121,15 @@
|
|
121
121
|
<div class="col-md-4">
|
122
122
|
{{ vega_direct("heartrate_time_plot", heartrate_time_plot) }}
|
123
123
|
</div>
|
124
|
-
{% if heart_zones_plot is defined %}
|
125
124
|
<div class="col-md-4">
|
125
|
+
{% if heart_zones_plot is defined %}
|
126
126
|
{{ vega_direct("heart_zones_plot", heart_zones_plot) }}
|
127
|
+
{% else %}
|
128
|
+
<p>Your activity has heart data, but this program doesn't know your maximum heart rate (or birth year) and
|
129
|
+
therefore cannot compute the heart rate zones. Go to the <a
|
130
|
+
href="{{ url_for('settings.heart_rate') }}">settings</a>.</p>
|
131
|
+
{% endif %}
|
127
132
|
</div>
|
128
|
-
{% endif %}
|
129
133
|
</div>
|
130
134
|
{% endif %}
|
131
135
|
|
@@ -18,11 +18,11 @@ from .explorer.blueprint import make_explorer_blueprint
|
|
18
18
|
from .heatmap.blueprint import make_heatmap_blueprint
|
19
19
|
from .search_controller import SearchController
|
20
20
|
from .square_planner.blueprint import make_square_planner_blueprint
|
21
|
-
from .strava.blueprint import make_strava_blueprint
|
22
21
|
from .summary.blueprint import make_summary_blueprint
|
23
22
|
from .tile.blueprint import make_tile_blueprint
|
24
23
|
from .upload.blueprint import make_upload_blueprint
|
25
|
-
from geo_activity_playground.core.
|
24
|
+
from geo_activity_playground.core.config import ConfigAccessor
|
25
|
+
from geo_activity_playground.webui.settings.blueprint import make_settings_blueprint
|
26
26
|
|
27
27
|
|
28
28
|
def route_search(app: Flask, repository: ActivityRepository) -> None:
|
@@ -45,12 +45,6 @@ def route_start(app: Flask, repository: ActivityRepository) -> None:
|
|
45
45
|
return render_template("home.html.j2", **entry_controller.render())
|
46
46
|
|
47
47
|
|
48
|
-
def route_settings(app: Flask) -> None:
|
49
|
-
@app.route("/settings/")
|
50
|
-
def settings():
|
51
|
-
return render_template("settings.html.j2")
|
52
|
-
|
53
|
-
|
54
48
|
def get_secret_key():
|
55
49
|
secret_file = pathlib.Path("Cache/flask-secret.json")
|
56
50
|
if secret_file.exists():
|
@@ -63,30 +57,28 @@ def get_secret_key():
|
|
63
57
|
return secret
|
64
58
|
|
65
59
|
|
66
|
-
def
|
60
|
+
def web_ui_main(
|
67
61
|
repository: ActivityRepository,
|
68
62
|
tile_visit_accessor: TileVisitAccessor,
|
69
|
-
|
63
|
+
config_accessor: ConfigAccessor,
|
70
64
|
host: str,
|
71
65
|
port: int,
|
72
66
|
) -> None:
|
73
|
-
app = Flask(__name__)
|
74
67
|
|
75
|
-
|
76
|
-
route_start(app, repository)
|
77
|
-
route_settings(app)
|
68
|
+
repository.reload()
|
78
69
|
|
70
|
+
app = Flask(__name__)
|
79
71
|
app.config["UPLOAD_FOLDER"] = "Activities"
|
80
72
|
app.secret_key = get_secret_key()
|
81
73
|
|
74
|
+
route_search(app, repository)
|
75
|
+
route_start(app, repository)
|
76
|
+
|
82
77
|
app.register_blueprint(
|
83
78
|
make_activity_blueprint(
|
84
79
|
repository,
|
85
80
|
tile_visit_accessor,
|
86
|
-
|
87
|
-
PrivacyZone(points)
|
88
|
-
for points in config.get("privacy_zones", {}).values()
|
89
|
-
],
|
81
|
+
config_accessor(),
|
90
82
|
),
|
91
83
|
url_prefix="/activity",
|
92
84
|
)
|
@@ -95,14 +87,19 @@ def webui_main(
|
|
95
87
|
make_eddington_blueprint(repository), url_prefix="/eddington"
|
96
88
|
)
|
97
89
|
app.register_blueprint(
|
98
|
-
make_equipment_blueprint(repository), url_prefix="/equipment"
|
90
|
+
make_equipment_blueprint(repository, config_accessor()), url_prefix="/equipment"
|
99
91
|
)
|
100
92
|
app.register_blueprint(
|
101
|
-
make_explorer_blueprint(repository, tile_visit_accessor),
|
93
|
+
make_explorer_blueprint(repository, tile_visit_accessor, config_accessor),
|
94
|
+
url_prefix="/explorer",
|
102
95
|
)
|
103
96
|
app.register_blueprint(
|
104
97
|
make_heatmap_blueprint(repository, tile_visit_accessor), url_prefix="/heatmap"
|
105
98
|
)
|
99
|
+
app.register_blueprint(
|
100
|
+
make_settings_blueprint(config_accessor),
|
101
|
+
url_prefix="/settings",
|
102
|
+
)
|
106
103
|
app.register_blueprint(
|
107
104
|
make_square_planner_blueprint(repository, tile_visit_accessor),
|
108
105
|
url_prefix="/square-planner",
|
@@ -111,21 +108,24 @@ def webui_main(
|
|
111
108
|
make_summary_blueprint(repository),
|
112
109
|
url_prefix="/summary",
|
113
110
|
)
|
114
|
-
app.register_blueprint(
|
115
|
-
make_strava_blueprint(host, port),
|
116
|
-
url_prefix="/strava",
|
117
|
-
)
|
118
111
|
app.register_blueprint(make_tile_blueprint(), url_prefix="/tile")
|
119
112
|
app.register_blueprint(
|
120
|
-
make_upload_blueprint(repository, tile_visit_accessor,
|
113
|
+
make_upload_blueprint(repository, tile_visit_accessor, config_accessor()),
|
121
114
|
url_prefix="/upload",
|
122
115
|
)
|
123
116
|
|
124
117
|
@app.context_processor
|
125
118
|
def inject_global_variables() -> dict:
|
126
119
|
return {
|
127
|
-
"version":
|
120
|
+
"version": _try_get_version(),
|
128
121
|
"num_activities": len(repository),
|
129
122
|
}
|
130
123
|
|
131
124
|
app.run(host=host, port=port)
|
125
|
+
|
126
|
+
|
127
|
+
def _try_get_version():
|
128
|
+
try:
|
129
|
+
return importlib.metadata.version("geo-activity-playground")
|
130
|
+
except importlib.metadata.PackageNotFoundError:
|
131
|
+
pass
|
@@ -16,7 +16,7 @@ class EddingtonController:
|
|
16
16
|
activities["day"] = [start.date() for start in activities["start"]]
|
17
17
|
|
18
18
|
sum_per_day = activities.groupby("day").apply(
|
19
|
-
lambda group: int(sum(group["distance_km"]))
|
19
|
+
lambda group: int(sum(group["distance_km"])), include_groups=False
|
20
20
|
)
|
21
21
|
counts = dict(zip(*np.unique(sorted(sum_per_day), return_counts=True)))
|
22
22
|
eddington = pd.DataFrame(
|
@@ -3,12 +3,15 @@ from flask import render_template
|
|
3
3
|
|
4
4
|
from ...core.activities import ActivityRepository
|
5
5
|
from .controller import EquipmentController
|
6
|
+
from geo_activity_playground.core.config import Config
|
6
7
|
|
7
8
|
|
8
|
-
def make_equipment_blueprint(
|
9
|
+
def make_equipment_blueprint(
|
10
|
+
repository: ActivityRepository, config: Config
|
11
|
+
) -> Blueprint:
|
9
12
|
blueprint = Blueprint("equipment", __name__, template_folder="templates")
|
10
13
|
|
11
|
-
equipment_controller = EquipmentController(repository)
|
14
|
+
equipment_controller = EquipmentController(repository, config)
|
12
15
|
|
13
16
|
@blueprint.route("/")
|
14
17
|
def index():
|