geo-activity-playground 0.15.2__tar.gz → 0.16.1__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.15.2 → geo_activity_playground-0.16.1}/PKG-INFO +1 -2
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/__main__.py +7 -4
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/activities.py +5 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/activity_parsers.py +5 -4
- geo_activity_playground-0.16.1/geo_activity_playground/core/cache_migrations.py +72 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/heatmap.py +12 -52
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/tile_visits.py +0 -2
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/directory.py +1 -0
- geo_activity_playground-0.16.1/geo_activity_playground/importers/strava_checkout.py +54 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/app.py +24 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/equipment_controller.py +11 -2
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/explorer_controller.py +14 -10
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/heatmap_controller.py +46 -0
- geo_activity_playground-0.16.1/geo_activity_playground/webui/search_controller.py +29 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/heatmap.html.j2 +8 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/index.html.j2 +9 -4
- geo_activity_playground-0.16.1/geo_activity_playground/webui/templates/search.html.j2 +38 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/pyproject.toml +1 -2
- geo_activity_playground-0.15.2/geo_activity_playground/core/cache_migrations.py +0 -34
- geo_activity_playground-0.15.2/geo_activity_playground/heatmap.py +0 -92
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/LICENSE +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/__init__.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/__init__.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/config.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/coordinates.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/tasks.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/test_tiles.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/tiles.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/__init__.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/grid_file.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/video.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/strava_api.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/test_strava_api.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/activity_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/calendar_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/eddington_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/entry_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/grayscale_tile_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon.ico +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/site.webmanifest +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/summary_controller.py +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/activity.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/calendar.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/explorer.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
- {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/summary-statistics.html.j2 +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: geo-activity-playground
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.16.1
|
4
4
|
Summary: Analysis of geo data activities like rides, runs or hikes.
|
5
5
|
License: MIT
|
6
6
|
Author: Martin Ueding
|
@@ -26,7 +26,6 @@ Requires-Dist: pandas (>=2.0,<3.0)
|
|
26
26
|
Requires-Dist: pyarrow (>=12.0.1,<13.0.0)
|
27
27
|
Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
|
28
28
|
Requires-Dist: requests (>=2.28.1,<3.0.0)
|
29
|
-
Requires-Dist: scikit-learn (>=1.3.2,<2.0.0)
|
30
29
|
Requires-Dist: scipy (>=1.8.1,<2.0.0)
|
31
30
|
Requires-Dist: stravalib (>=1.3.3,<2.0.0)
|
32
31
|
Requires-Dist: tcxreader (>=0.4.5,<0.5.0)
|
@@ -6,6 +6,7 @@ import sys
|
|
6
6
|
|
7
7
|
import coloredlogs
|
8
8
|
|
9
|
+
from .importers.strava_checkout import convert_strava_checkout
|
9
10
|
from geo_activity_playground.core.activities import ActivityRepository
|
10
11
|
from geo_activity_playground.core.activities import embellish_time_series
|
11
12
|
from geo_activity_playground.core.cache_migrations import apply_cache_migrations
|
@@ -13,7 +14,6 @@ from geo_activity_playground.core.config import get_config
|
|
13
14
|
from geo_activity_playground.explorer.tile_visits import compute_tile_evolution
|
14
15
|
from geo_activity_playground.explorer.tile_visits import compute_tile_visits
|
15
16
|
from geo_activity_playground.explorer.video import explorer_video_main
|
16
|
-
from geo_activity_playground.heatmap import generate_heatmaps_per_cluster
|
17
17
|
from geo_activity_playground.importers.directory import import_from_directory
|
18
18
|
from geo_activity_playground.importers.strava_api import import_from_strava_api
|
19
19
|
from geo_activity_playground.webui.app import webui_main
|
@@ -53,13 +53,16 @@ def main() -> None:
|
|
53
53
|
subparser.set_defaults(func=lambda options: explorer_video_main())
|
54
54
|
|
55
55
|
subparser = subparsers.add_parser(
|
56
|
-
"
|
56
|
+
"convert-strava-checkout",
|
57
|
+
help="Converts a Strava checkout to the structure used by this program.",
|
57
58
|
)
|
58
59
|
subparser.set_defaults(
|
59
|
-
func=lambda options:
|
60
|
-
|
60
|
+
func=lambda options: convert_strava_checkout(
|
61
|
+
options.checkout_path, options.playground_path
|
61
62
|
)
|
62
63
|
)
|
64
|
+
subparser.add_argument("checkout_path", type=pathlib.Path)
|
65
|
+
subparser.add_argument("playground_path", type=pathlib.Path)
|
63
66
|
|
64
67
|
subparser = subparsers.add_parser("serve", help="Launch webserver")
|
65
68
|
subparser.set_defaults(
|
@@ -138,6 +138,11 @@ def embellish_time_series(repository: ActivityRepository) -> None:
|
|
138
138
|
)
|
139
139
|
changed = True
|
140
140
|
|
141
|
+
potential_jumps = (df["speed"] > 40) & (df["speed"].diff() > 10)
|
142
|
+
if np.any(potential_jumps):
|
143
|
+
df = df.loc[~potential_jumps]
|
144
|
+
changed = True
|
145
|
+
|
141
146
|
if "x" not in df.columns:
|
142
147
|
x, y = compute_tile_float(df["latitude"], df["longitude"], 0)
|
143
148
|
df["x"] = x
|
@@ -1,7 +1,6 @@
|
|
1
1
|
import datetime
|
2
2
|
import gzip
|
3
3
|
import pathlib
|
4
|
-
import tempfile
|
5
4
|
import xml
|
6
5
|
|
7
6
|
import dateutil.parser
|
@@ -159,10 +158,12 @@ def read_tcx_activity(path: pathlib.Path, open) -> pd.DataFrame:
|
|
159
158
|
|
160
159
|
with open(path, "rb") as f:
|
161
160
|
content = f.read().strip()
|
162
|
-
|
161
|
+
|
162
|
+
stripped_file = pathlib.Path("Cache/temp.tcx")
|
163
|
+
with open(stripped_file, "wb") as f:
|
163
164
|
f.write(content)
|
164
|
-
|
165
|
-
|
165
|
+
data = tcx_reader.read(str(stripped_file))
|
166
|
+
stripped_file.unlink()
|
166
167
|
|
167
168
|
for trackpoint in data.trackpoints:
|
168
169
|
if trackpoint.latitude and trackpoint.longitude:
|
@@ -0,0 +1,72 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
import pathlib
|
4
|
+
import shutil
|
5
|
+
|
6
|
+
logger = logging.getLogger(__name__)
|
7
|
+
|
8
|
+
|
9
|
+
def delete_activities_per_tile() -> None:
|
10
|
+
paths = [
|
11
|
+
pathlib.Path("Cache/activities-per-tile.pickle"),
|
12
|
+
pathlib.Path("Cache/activities-per-tile-task.json"),
|
13
|
+
]
|
14
|
+
for path in paths:
|
15
|
+
path.unlink(missing_ok=True)
|
16
|
+
|
17
|
+
|
18
|
+
def delete_work_tracker(name: str):
|
19
|
+
def migration() -> None:
|
20
|
+
path = pathlib.Path(f"Cache/work-tracker-{name}.pickle")
|
21
|
+
path.unlink(missing_ok=True)
|
22
|
+
|
23
|
+
return migration
|
24
|
+
|
25
|
+
|
26
|
+
def reset_time_series_embellishment() -> None:
|
27
|
+
pathlib.Path("Cache/work-tracker-embellish-time-series.pickle").unlink(
|
28
|
+
missing_ok=True
|
29
|
+
)
|
30
|
+
|
31
|
+
|
32
|
+
def delete_tile_visits() -> None:
|
33
|
+
paths = [
|
34
|
+
pathlib.Path("Cache/tile-evolution-state.pickle"),
|
35
|
+
pathlib.Path("Cache/tile-history.pickle"),
|
36
|
+
pathlib.Path("Cache/tile-visits.pickle"),
|
37
|
+
pathlib.Path("Cache/work-tracker-parse-activity-files.pickle"),
|
38
|
+
pathlib.Path("Cache/work-tracker-tile-visits.pickle"),
|
39
|
+
]
|
40
|
+
for path in paths:
|
41
|
+
path.unlink(missing_ok=True)
|
42
|
+
|
43
|
+
|
44
|
+
def delete_heatmap_cache() -> None:
|
45
|
+
path = pathlib.Path("Cache/Heatmap")
|
46
|
+
if path.exists():
|
47
|
+
shutil.rmtree(path)
|
48
|
+
|
49
|
+
|
50
|
+
def apply_cache_migrations() -> None:
|
51
|
+
logger.info("Apply cache migration if needed …")
|
52
|
+
cache_status_file = pathlib.Path("Cache/status.json")
|
53
|
+
if cache_status_file.exists():
|
54
|
+
with open(cache_status_file) as f:
|
55
|
+
cache_status = json.load(f)
|
56
|
+
else:
|
57
|
+
cache_status = {"num_applied_migrations": 0}
|
58
|
+
|
59
|
+
migrations = [
|
60
|
+
delete_activities_per_tile,
|
61
|
+
reset_time_series_embellishment,
|
62
|
+
delete_tile_visits,
|
63
|
+
delete_heatmap_cache,
|
64
|
+
]
|
65
|
+
|
66
|
+
for migration in migrations[cache_status["num_applied_migrations"] :]:
|
67
|
+
logger.info(f"Applying cache migration {migration.__name__} …")
|
68
|
+
migration()
|
69
|
+
cache_status["num_applied_migrations"] += 1
|
70
|
+
cache_status_file.parent.mkdir(exist_ok=True, parents=True)
|
71
|
+
with open(cache_status_file, "w") as f:
|
72
|
+
json.dump(cache_status, f)
|
@@ -2,16 +2,11 @@
|
|
2
2
|
This code is based on https://github.com/remisalmon/Strava-local-heatmap.
|
3
3
|
"""
|
4
4
|
import dataclasses
|
5
|
-
import functools
|
6
5
|
import logging
|
7
|
-
import pathlib
|
8
6
|
|
9
7
|
import matplotlib.pyplot as pl
|
10
8
|
import numpy as np
|
11
|
-
import pandas as pd
|
12
9
|
|
13
|
-
from geo_activity_playground.core.activities import ActivityRepository
|
14
|
-
from geo_activity_playground.core.tasks import work_tracker
|
15
10
|
from geo_activity_playground.core.tiles import compute_tile_float
|
16
11
|
from geo_activity_playground.core.tiles import get_tile
|
17
12
|
from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
@@ -20,34 +15,6 @@ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
|
20
15
|
logger = logging.getLogger(__name__)
|
21
16
|
|
22
17
|
|
23
|
-
@functools.cache
|
24
|
-
def get_all_points(repository: ActivityRepository) -> pd.DataFrame:
|
25
|
-
logger.info("Gathering all points …")
|
26
|
-
all_points_path = pathlib.Path("Cache/all-points.parquet")
|
27
|
-
if all_points_path.exists():
|
28
|
-
all_points = pd.read_parquet(all_points_path)
|
29
|
-
else:
|
30
|
-
all_points = pd.DataFrame()
|
31
|
-
new_shards = []
|
32
|
-
with work_tracker(pathlib.Path("Cache/all-points-task.json")) as tracker:
|
33
|
-
for activity in repository.iter_activities():
|
34
|
-
if activity.id in tracker:
|
35
|
-
continue
|
36
|
-
tracker.add(activity.id)
|
37
|
-
|
38
|
-
logger.info(f"Parsing points from {activity.id} …")
|
39
|
-
time_series = repository.get_time_series(activity.id)
|
40
|
-
if len(time_series) == 0 or "latitude" not in time_series.columns:
|
41
|
-
continue
|
42
|
-
shard = time_series[["latitude", "longitude"]].copy()
|
43
|
-
shard["activity_id"] = activity.id
|
44
|
-
new_shards.append(shard)
|
45
|
-
logger.info("Concatenating shards …")
|
46
|
-
all_points = pd.concat([all_points] + new_shards)
|
47
|
-
all_points.to_parquet(all_points_path)
|
48
|
-
return all_points
|
49
|
-
|
50
|
-
|
51
18
|
@dataclasses.dataclass
|
52
19
|
class GeoBounds:
|
53
20
|
lat_min: float
|
@@ -212,30 +179,28 @@ def gaussian_filter(image, sigma):
|
|
212
179
|
|
213
180
|
|
214
181
|
def build_heatmap_image(
|
215
|
-
|
182
|
+
xy_data: np.ndarray,
|
183
|
+
mean_latitude: float,
|
184
|
+
num_activities: int,
|
185
|
+
tile_bounds: TileBounds,
|
216
186
|
) -> np.ndarray:
|
217
|
-
|
218
|
-
sigma_pixel = 1
|
187
|
+
assert xy_data.shape[1] == 2
|
219
188
|
|
220
189
|
data = np.zeros(tile_bounds.shape)
|
221
190
|
|
222
|
-
xy_data =
|
223
|
-
lat_lon_data[:, 0], lat_lon_data[:, 1], tile_bounds.zoom
|
224
|
-
)
|
225
|
-
xy_data = np.array(xy_data).T
|
191
|
+
xy_data = np.array(xy_data)
|
226
192
|
xy_data = np.round(
|
227
193
|
(xy_data - [tile_bounds.x_tile_min, tile_bounds.y_tile_min]) * OSM_TILE_SIZE
|
228
|
-
)
|
194
|
+
)
|
229
195
|
|
196
|
+
sigma_pixel = 1
|
230
197
|
for j, i in xy_data.astype(int):
|
231
198
|
data[
|
232
199
|
i - sigma_pixel : i + sigma_pixel, j - sigma_pixel : j + sigma_pixel
|
233
200
|
] += 1.0
|
234
201
|
|
235
202
|
res_pixel = (
|
236
|
-
156543.03
|
237
|
-
* np.cos(np.radians(np.mean(lat_lon_data[:, 0])))
|
238
|
-
/ (2.0**tile_bounds.zoom)
|
203
|
+
156543.03 * np.cos(np.radians(mean_latitude)) / (2.0**tile_bounds.zoom)
|
239
204
|
) # from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
|
240
205
|
|
241
206
|
# trackpoint max accumulation per pixel = 1/5 (trackpoint/meter) * res_pixel (meter/pixel) * activities
|
@@ -246,20 +211,15 @@ def build_heatmap_image(
|
|
246
211
|
|
247
212
|
# equalize histogram and compute kernel density estimation
|
248
213
|
data_hist, _ = np.histogram(data, bins=int(m + 1))
|
249
|
-
|
250
214
|
data_hist = np.cumsum(data_hist) / data.size # normalized cumulated histogram
|
251
|
-
|
252
215
|
for i in range(data.shape[0]):
|
253
216
|
for j in range(data.shape[1]):
|
254
|
-
data[i, j] = m * data_hist[int(data[i, j])]
|
217
|
+
data[i, j] = m * data_hist[int(data[i, j])]
|
255
218
|
|
256
|
-
data = gaussian_filter(
|
257
|
-
data, float(sigma_pixel)
|
258
|
-
) # kernel density estimation with normal kernel
|
219
|
+
data = gaussian_filter(data, float(sigma_pixel))
|
259
220
|
|
260
|
-
data = (data - data.min()) / (data.max() - data.min())
|
221
|
+
data = (data - data.min()) / (data.max() - data.min())
|
261
222
|
|
262
|
-
# colorize
|
263
223
|
cmap = pl.get_cmap("hot")
|
264
224
|
|
265
225
|
data_color = cmap(data)
|
@@ -44,7 +44,6 @@ def compute_tile_visits(repository: ActivityRepository) -> None:
|
|
44
44
|
tile = (tile_x, tile_y)
|
45
45
|
if tile in tile_visits[zoom]:
|
46
46
|
d = tile_visits[zoom][tile]
|
47
|
-
d["count"] += 1
|
48
47
|
if d["first_time"] > time:
|
49
48
|
d["first_time"] = time
|
50
49
|
d["first_id"] = activity_id
|
@@ -54,7 +53,6 @@ def compute_tile_visits(repository: ActivityRepository) -> None:
|
|
54
53
|
d["activity_ids"].add(activity_id)
|
55
54
|
else:
|
56
55
|
tile_visits[zoom][tile] = {
|
57
|
-
"count": 1,
|
58
56
|
"first_time": time,
|
59
57
|
"first_id": activity_id,
|
60
58
|
"last_time": time,
|
@@ -29,6 +29,7 @@ def import_from_directory() -> None:
|
|
29
29
|
activity_paths = {
|
30
30
|
int(hashlib.sha3_224(str(path).encode()).hexdigest(), 16) % 2**62: path
|
31
31
|
for path in pathlib.Path("Activities").rglob("*.*")
|
32
|
+
if path.is_file()
|
32
33
|
}
|
33
34
|
activities_ids_to_parse = work_tracker.filter(activity_paths.keys())
|
34
35
|
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import pathlib
|
2
|
+
import shutil
|
3
|
+
|
4
|
+
import dateutil.parser
|
5
|
+
import numpy as np
|
6
|
+
import pandas as pd
|
7
|
+
from tqdm import tqdm
|
8
|
+
|
9
|
+
|
10
|
+
def nan_as_none(elem):
|
11
|
+
if isinstance(elem, float) and np.isnan(elem):
|
12
|
+
return None
|
13
|
+
else:
|
14
|
+
return elem
|
15
|
+
|
16
|
+
|
17
|
+
def convert_strava_checkout(
|
18
|
+
checkout_path: pathlib.Path, playground_path: pathlib.Path
|
19
|
+
) -> None:
|
20
|
+
activities = pd.read_csv(checkout_path / "activities.csv")
|
21
|
+
print(activities)
|
22
|
+
|
23
|
+
for _, row in tqdm(activities.iterrows(), desc="Import activity files"):
|
24
|
+
activity_date = dateutil.parser.parse(row["Activity Date"])
|
25
|
+
activity_name = row["Activity Name"]
|
26
|
+
activity_kind = row["Activity Type"]
|
27
|
+
is_commute = row["Commute"] == "true"
|
28
|
+
equipment = (
|
29
|
+
nan_as_none(row["Activity Gear"])
|
30
|
+
or nan_as_none(row["Bike"])
|
31
|
+
or nan_as_none(row["Gear"])
|
32
|
+
or ""
|
33
|
+
)
|
34
|
+
activity_file = checkout_path / row["Filename"]
|
35
|
+
|
36
|
+
activity_target = playground_path / "Activities" / str(activity_kind)
|
37
|
+
if equipment:
|
38
|
+
activity_target /= str(equipment)
|
39
|
+
if is_commute:
|
40
|
+
activity_target /= "Commute"
|
41
|
+
|
42
|
+
activity_target /= "".join(
|
43
|
+
[
|
44
|
+
f"{activity_date.year:04d}-{activity_date.month:02d}-{activity_date.day:02d}",
|
45
|
+
" ",
|
46
|
+
f"{activity_date.hour:02d}-{activity_date.minute:02d}-{activity_date.second:02d}",
|
47
|
+
" ",
|
48
|
+
activity_name,
|
49
|
+
]
|
50
|
+
+ activity_file.suffixes
|
51
|
+
)
|
52
|
+
|
53
|
+
activity_target.parent.mkdir(exist_ok=True, parents=True)
|
54
|
+
shutil.copy(activity_file, activity_target)
|
@@ -1,7 +1,9 @@
|
|
1
1
|
from flask import Flask
|
2
2
|
from flask import render_template
|
3
|
+
from flask import request
|
3
4
|
from flask import Response
|
4
5
|
|
6
|
+
from .search_controller import SearchController
|
5
7
|
from geo_activity_playground.core.activities import ActivityRepository
|
6
8
|
from geo_activity_playground.webui.activity_controller import ActivityController
|
7
9
|
from geo_activity_playground.webui.calendar_controller import CalendarController
|
@@ -28,11 +30,20 @@ def webui_main(repository: ActivityRepository, host: str, port: int) -> None:
|
|
28
30
|
heatmap_controller = HeatmapController(repository)
|
29
31
|
grayscale_tile_controller = GrayscaleTileController()
|
30
32
|
summary_controller = SummaryController(repository)
|
33
|
+
search_controller = SearchController(repository)
|
31
34
|
|
32
35
|
@app.route("/")
|
33
36
|
def index():
|
34
37
|
return render_template("index.html.j2", **entry_controller.render())
|
35
38
|
|
39
|
+
@app.route("/search", methods=["POST"])
|
40
|
+
def search():
|
41
|
+
form_input = request.form
|
42
|
+
return render_template(
|
43
|
+
"search.html.j2",
|
44
|
+
**search_controller.render_search_results(form_input["name"])
|
45
|
+
)
|
46
|
+
|
36
47
|
@app.route("/activity/<id>")
|
37
48
|
def activity(id: str):
|
38
49
|
return render_template(
|
@@ -126,6 +137,19 @@ def webui_main(repository: ActivityRepository, host: str, port: int) -> None:
|
|
126
137
|
mimetype="image/png",
|
127
138
|
)
|
128
139
|
|
140
|
+
@app.route("/heatmap-download/<north>/<east>/<south>/<west>")
|
141
|
+
def heatmap_download(north: str, east: str, south: str, west: str):
|
142
|
+
return Response(
|
143
|
+
heatmap_controller.download_heatmap(
|
144
|
+
float(north),
|
145
|
+
float(east),
|
146
|
+
float(south),
|
147
|
+
float(west),
|
148
|
+
),
|
149
|
+
mimetype="image/png",
|
150
|
+
headers={"Content-disposition": 'attachment; filename="heatmap.png"'},
|
151
|
+
)
|
152
|
+
|
129
153
|
@app.route("/grayscale-tile/<z>/<x>/<y>.png")
|
130
154
|
def grayscale_tile(x: str, y: str, z: str):
|
131
155
|
return Response(
|
@@ -3,6 +3,7 @@ import functools
|
|
3
3
|
import altair as alt
|
4
4
|
import pandas as pd
|
5
5
|
|
6
|
+
from ..core.config import get_config
|
6
7
|
from geo_activity_playground.core.activities import ActivityRepository
|
7
8
|
|
8
9
|
|
@@ -58,10 +59,18 @@ class EquipmentController:
|
|
58
59
|
)
|
59
60
|
.reset_index()
|
60
61
|
.sort_values("last_use", ascending=False)
|
61
|
-
.to_dict(orient="records")
|
62
62
|
)
|
63
63
|
|
64
|
+
config = get_config()
|
65
|
+
print(config)
|
66
|
+
if "offsets" in config:
|
67
|
+
print(equipment_summary)
|
68
|
+
for equipment, offset in config["offsets"].items():
|
69
|
+
equipment_summary.loc[
|
70
|
+
equipment_summary["equipment"] == equipment, "total_distance"
|
71
|
+
] += offset
|
72
|
+
|
64
73
|
return {
|
65
74
|
"total_distances_plot": plot,
|
66
|
-
"equipment_summary": equipment_summary,
|
75
|
+
"equipment_summary": equipment_summary.to_dict(orient="records"),
|
67
76
|
}
|
@@ -40,14 +40,18 @@ def get_three_color_tiles(
|
|
40
40
|
cmap_first = matplotlib.colormaps["plasma"]
|
41
41
|
cmap_last = matplotlib.colormaps["plasma"]
|
42
42
|
tile_dict = {}
|
43
|
-
for tile,
|
44
|
-
first_age_days = (today -
|
45
|
-
last_age_days = (today -
|
43
|
+
for tile, tile_data in tile_visits.items():
|
44
|
+
first_age_days = (today - tile_data["first_time"].date()).days
|
45
|
+
last_age_days = (today - tile_data["last_time"].date()).days
|
46
46
|
tile_dict[tile] = {
|
47
|
-
"first_activity_id": str(
|
48
|
-
"first_activity_name": repository.get_activity_by_id(
|
49
|
-
|
50
|
-
|
47
|
+
"first_activity_id": str(tile_data["first_id"]),
|
48
|
+
"first_activity_name": repository.get_activity_by_id(
|
49
|
+
tile_data["first_id"]
|
50
|
+
).name,
|
51
|
+
"last_activity_id": str(tile_data["last_id"]),
|
52
|
+
"last_activity_name": repository.get_activity_by_id(
|
53
|
+
tile_data["last_id"]
|
54
|
+
).name,
|
51
55
|
"first_age_days": first_age_days,
|
52
56
|
"first_age_color": matplotlib.colors.to_hex(
|
53
57
|
cmap_first(max(1 - first_age_days / (2 * 365), 0.0))
|
@@ -58,9 +62,9 @@ def get_three_color_tiles(
|
|
58
62
|
),
|
59
63
|
"cluster": False,
|
60
64
|
"color": "#303030",
|
61
|
-
"first_visit":
|
62
|
-
"last_visit":
|
63
|
-
"num_visits":
|
65
|
+
"first_visit": tile_data["first_time"].date().isoformat(),
|
66
|
+
"last_visit": tile_data["last_time"].date().isoformat(),
|
67
|
+
"num_visits": len(tile_data["activity_ids"]),
|
64
68
|
"square": False,
|
65
69
|
}
|
66
70
|
|
@@ -13,7 +13,12 @@ from PIL import Image
|
|
13
13
|
from PIL import ImageDraw
|
14
14
|
|
15
15
|
from geo_activity_playground.core.activities import ActivityRepository
|
16
|
+
from geo_activity_playground.core.heatmap import build_heatmap_image
|
17
|
+
from geo_activity_playground.core.heatmap import build_map_from_tiles
|
16
18
|
from geo_activity_playground.core.heatmap import convert_to_grayscale
|
19
|
+
from geo_activity_playground.core.heatmap import crop_image_to_bounds
|
20
|
+
from geo_activity_playground.core.heatmap import GeoBounds
|
21
|
+
from geo_activity_playground.core.heatmap import get_sensible_zoom_level
|
17
22
|
from geo_activity_playground.core.tasks import work_tracker
|
18
23
|
from geo_activity_playground.core.tiles import get_tile
|
19
24
|
from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
@@ -114,3 +119,44 @@ class HeatmapController:
|
|
114
119
|
f = io.BytesIO()
|
115
120
|
pl.imsave(f, map_tile, format="png")
|
116
121
|
return bytes(f.getbuffer())
|
122
|
+
|
123
|
+
def download_heatmap(self, north, east, south, west) -> bytes:
|
124
|
+
geo_bounds = GeoBounds(south, west, north, east)
|
125
|
+
tile_bounds = get_sensible_zoom_level(geo_bounds, (2160, 3840))
|
126
|
+
background = build_map_from_tiles(tile_bounds)
|
127
|
+
background = convert_to_grayscale(background)
|
128
|
+
background = 1.0 - background
|
129
|
+
|
130
|
+
relevant_activities = set()
|
131
|
+
|
132
|
+
for tile_x in range(tile_bounds.x_tile_min, tile_bounds.x_tile_max):
|
133
|
+
for tile_y in range(tile_bounds.y_tile_min, tile_bounds.y_tile_max):
|
134
|
+
tile = (tile_x, tile_y)
|
135
|
+
if tile in self.tile_visits[tile_bounds.zoom]:
|
136
|
+
relevant_activities |= self.tile_visits[tile_bounds.zoom][tile][
|
137
|
+
"activity_ids"
|
138
|
+
]
|
139
|
+
|
140
|
+
points = pd.concat(map(self._repository.get_time_series, relevant_activities))
|
141
|
+
xy_data = np.array([points["x"], points["y"]]).T * 2**tile_bounds.zoom
|
142
|
+
|
143
|
+
within = (
|
144
|
+
(tile_bounds.x_tile_min <= xy_data[:, 0])
|
145
|
+
& (xy_data[:, 0] <= tile_bounds.x_tile_max)
|
146
|
+
& (tile_bounds.y_tile_min <= xy_data[:, 1])
|
147
|
+
& (xy_data[:, 1] <= tile_bounds.y_tile_max)
|
148
|
+
)
|
149
|
+
xy_data = xy_data[within]
|
150
|
+
|
151
|
+
data_color = build_heatmap_image(
|
152
|
+
xy_data, np.mean(points["latitude"]), len(relevant_activities), tile_bounds
|
153
|
+
)
|
154
|
+
for c in range(3):
|
155
|
+
background[:, :, c] = (1.0 - data_color[:, :, c]) * background[
|
156
|
+
:, :, c
|
157
|
+
] + data_color[:, :, c]
|
158
|
+
background = crop_image_to_bounds(background, geo_bounds, tile_bounds)
|
159
|
+
|
160
|
+
f = io.BytesIO()
|
161
|
+
pl.imsave(f, background, format="png")
|
162
|
+
return bytes(f.getbuffer())
|
@@ -0,0 +1,29 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
from ..core.activities import ActivityRepository
|
4
|
+
|
5
|
+
logger = logging.getLogger(__name__)
|
6
|
+
|
7
|
+
|
8
|
+
class SearchController:
|
9
|
+
def __init__(self, repository: ActivityRepository) -> None:
|
10
|
+
self._repository = repository
|
11
|
+
|
12
|
+
def render_search_results(self, name: str) -> dict:
|
13
|
+
logger.info(f"Searching for {name=}")
|
14
|
+
activities = []
|
15
|
+
for _, row in self._repository.meta.iterrows():
|
16
|
+
if name in row["name"]:
|
17
|
+
print(row["name"])
|
18
|
+
activities.append(
|
19
|
+
{
|
20
|
+
"name": row["name"],
|
21
|
+
"start": row["start"].isoformat(),
|
22
|
+
"kind": row["kind"],
|
23
|
+
"distance/km": row["distance"],
|
24
|
+
"elapsed_time": row["elapsed_time"],
|
25
|
+
"commute": row["commute"],
|
26
|
+
}
|
27
|
+
)
|
28
|
+
|
29
|
+
return {"activities": activities}
|
@@ -10,6 +10,7 @@
|
|
10
10
|
<div class="row mb-3">
|
11
11
|
<div class="col">
|
12
12
|
<div id="heatmap" style="height: 800px;"></div>
|
13
|
+
<p><a href="#" onclick="downloadAs()">Download heatmap in visible area</a></p>
|
13
14
|
|
14
15
|
<script>
|
15
16
|
let map = L.map('heatmap', {
|
@@ -26,6 +27,13 @@
|
|
26
27
|
if (bbox) {
|
27
28
|
map.fitBounds(L.geoJSON(bbox).getBounds())
|
28
29
|
}
|
30
|
+
|
31
|
+
|
32
|
+
function downloadAs() {
|
33
|
+
bounds = map.getBounds()
|
34
|
+
window.location.href =
|
35
|
+
`/heatmap-download/${bounds.getNorth()}/${bounds.getEast()}/${bounds.getSouth()}/${bounds.getWest()}`
|
36
|
+
}
|
29
37
|
</script>
|
30
38
|
</div>
|
31
39
|
</div>
|
@@ -2,15 +2,20 @@
|
|
2
2
|
|
3
3
|
{% block container %}
|
4
4
|
<div class="row mb-3">
|
5
|
-
<div class="col">
|
6
|
-
<h2>Last 30 days</h2>
|
7
|
-
</div>
|
8
5
|
</div>
|
9
6
|
|
10
7
|
<div class="row mb-3">
|
11
|
-
<div class="col">
|
8
|
+
<div class="col-md-9">
|
9
|
+
<h2>Last 30 days</h2>
|
12
10
|
{{ vega_direct("distance-last-30-days", distance_last_30_days_plot) }}
|
13
11
|
</div>
|
12
|
+
<div class="col-md-3">
|
13
|
+
<h2>Search activities</h2>
|
14
|
+
<form method="post" action="/search">
|
15
|
+
<input type="search" name="name" />
|
16
|
+
<input type="submit" class="button" />
|
17
|
+
</form>
|
18
|
+
</div>
|
14
19
|
</div>
|
15
20
|
|
16
21
|
|
@@ -0,0 +1,38 @@
|
|
1
|
+
{% extends "page.html.j2" %}
|
2
|
+
|
3
|
+
{% block container %}
|
4
|
+
<div class="row mb-3">
|
5
|
+
<div class="col">
|
6
|
+
<h1>Search Results</h1>
|
7
|
+
</div>
|
8
|
+
</div>
|
9
|
+
|
10
|
+
<div class="row mb-3">
|
11
|
+
<div class="col">
|
12
|
+
<table class="table">
|
13
|
+
<thead>
|
14
|
+
<tr>
|
15
|
+
<th>Name</th>
|
16
|
+
<th>Start</th>
|
17
|
+
<th>Kind</th>
|
18
|
+
<th>Distance</th>
|
19
|
+
<th>Elapsed time</th>
|
20
|
+
<th>Commute</th>
|
21
|
+
</tr>
|
22
|
+
</thead>
|
23
|
+
<tbody>
|
24
|
+
{% for activity in activities %}
|
25
|
+
<tr>
|
26
|
+
<td>{{ activity.name }}</td>
|
27
|
+
<td>{{ activity.start }}</td>
|
28
|
+
<td>{{ activity.kind }}</td>
|
29
|
+
<td>{{ '%.1f' % activity["distance/km"] }} km</td>
|
30
|
+
<td>{{ activity.elapsed_time }}</td>
|
31
|
+
<td>{{ activity.commute }}</td>
|
32
|
+
</tr>
|
33
|
+
{% endfor %}
|
34
|
+
</tbody>
|
35
|
+
</table>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
{% endblock %}
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "geo-activity-playground"
|
3
|
-
version = "0.
|
3
|
+
version = "0.16.1"
|
4
4
|
description = "Analysis of geo data activities like rides, runs or hikes."
|
5
5
|
authors = ["Martin Ueding <mu@martin-ueding.de>"]
|
6
6
|
license = "MIT"
|
@@ -26,7 +26,6 @@ Pillow = "^9.2.0"
|
|
26
26
|
pyarrow = "^12.0.1"
|
27
27
|
python-dateutil = "^2.8.2"
|
28
28
|
requests = "^2.28.1"
|
29
|
-
scikit-learn = "^1.3.2"
|
30
29
|
scipy = "^1.8.1"
|
31
30
|
stravalib = "^1.3.3"
|
32
31
|
tcxreader = "^0.4.5"
|
@@ -1,34 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
import logging
|
3
|
-
import pathlib
|
4
|
-
|
5
|
-
logger = logging.getLogger(__name__)
|
6
|
-
|
7
|
-
|
8
|
-
def delete_activities_per_tile() -> None:
|
9
|
-
paths = [
|
10
|
-
pathlib.Path("Cache/activities-per-tile.pickle"),
|
11
|
-
pathlib.Path("Cache/activities-per-tile-task.json"),
|
12
|
-
]
|
13
|
-
for path in paths:
|
14
|
-
path.unlink(missing_ok=True)
|
15
|
-
|
16
|
-
|
17
|
-
def apply_cache_migrations() -> None:
|
18
|
-
logger.info("Apply cache migration if needed …")
|
19
|
-
cache_status_file = pathlib.Path("Cache/status.json")
|
20
|
-
if cache_status_file.exists():
|
21
|
-
with open(cache_status_file) as f:
|
22
|
-
cache_status = json.load(f)
|
23
|
-
else:
|
24
|
-
cache_status = {"num_applied_migrations": 0}
|
25
|
-
|
26
|
-
migrations = [delete_activities_per_tile]
|
27
|
-
|
28
|
-
for migration in migrations[cache_status["num_applied_migrations"] :]:
|
29
|
-
logger.info(f"Applying cache migration {migration.__name__} …")
|
30
|
-
migration()
|
31
|
-
cache_status["num_applied_migrations"] += 1
|
32
|
-
cache_status_file.parent.mkdir(exist_ok=True, parents=True)
|
33
|
-
with open(cache_status_file, "w") as f:
|
34
|
-
json.dump(cache_status, f)
|
@@ -1,92 +0,0 @@
|
|
1
|
-
import logging
|
2
|
-
import pathlib
|
3
|
-
|
4
|
-
import matplotlib.pyplot as plt
|
5
|
-
import numpy as np
|
6
|
-
import pandas as pd
|
7
|
-
import sklearn.cluster
|
8
|
-
|
9
|
-
from .core.tiles import compute_tile
|
10
|
-
from geo_activity_playground.core.activities import ActivityRepository
|
11
|
-
from geo_activity_playground.core.heatmap import add_margin_to_geo_bounds
|
12
|
-
from geo_activity_playground.core.heatmap import build_heatmap_image
|
13
|
-
from geo_activity_playground.core.heatmap import build_map_from_tiles
|
14
|
-
from geo_activity_playground.core.heatmap import convert_to_grayscale
|
15
|
-
from geo_activity_playground.core.heatmap import crop_image_to_bounds
|
16
|
-
from geo_activity_playground.core.heatmap import get_bounds
|
17
|
-
from geo_activity_playground.core.heatmap import get_sensible_zoom_level
|
18
|
-
|
19
|
-
|
20
|
-
logger = logging.getLogger(__name__)
|
21
|
-
|
22
|
-
|
23
|
-
def render_heatmap(
|
24
|
-
lat_lon_data: np.ndarray, num_activities: int, arg_zoom: int = -1
|
25
|
-
) -> np.ndarray:
|
26
|
-
geo_bounds = get_bounds(lat_lon_data)
|
27
|
-
geo_bounds = add_margin_to_geo_bounds(geo_bounds)
|
28
|
-
tile_bounds = get_sensible_zoom_level(geo_bounds, (2160, 3840))
|
29
|
-
background = build_map_from_tiles(tile_bounds)
|
30
|
-
background = convert_to_grayscale(background)
|
31
|
-
background = 1.0 - background
|
32
|
-
data_color = build_heatmap_image(lat_lon_data, num_activities, tile_bounds)
|
33
|
-
for c in range(3):
|
34
|
-
background[:, :, c] = (1.0 - data_color[:, :, c]) * background[
|
35
|
-
:, :, c
|
36
|
-
] + data_color[:, :, c]
|
37
|
-
background = crop_image_to_bounds(background, geo_bounds, tile_bounds)
|
38
|
-
return background
|
39
|
-
|
40
|
-
|
41
|
-
def generate_heatmaps_per_cluster(repository: ActivityRepository) -> None:
|
42
|
-
logger.info("Gathering data points …")
|
43
|
-
arrays = []
|
44
|
-
names = []
|
45
|
-
for activity in repository.iter_activities():
|
46
|
-
df = repository.get_time_series(activity.id)
|
47
|
-
if "latitude" in df.columns:
|
48
|
-
latlon = np.column_stack([df["latitude"], df["longitude"]])
|
49
|
-
names.extend([activity.id] * len(df))
|
50
|
-
arrays.append(latlon)
|
51
|
-
latlon = np.row_stack(arrays)
|
52
|
-
del arrays
|
53
|
-
|
54
|
-
logger.info("Compute tiles for each point …")
|
55
|
-
tiles = [compute_tile(lat, lon, 14) for lat, lon in latlon]
|
56
|
-
|
57
|
-
unique_tiles = set(tiles)
|
58
|
-
unique_tiles_array = np.array(list(unique_tiles))
|
59
|
-
|
60
|
-
logger.info("Run DBSCAN cluster finding algorithm …")
|
61
|
-
dbscan = sklearn.cluster.DBSCAN(eps=5, min_samples=3)
|
62
|
-
labels = dbscan.fit_predict(unique_tiles_array)
|
63
|
-
|
64
|
-
cluster_mapping = {
|
65
|
-
tuple(xy): label for xy, label in zip(unique_tiles_array, labels)
|
66
|
-
}
|
67
|
-
|
68
|
-
all_df = pd.DataFrame(latlon, columns=["lat", "lon"])
|
69
|
-
all_df["cluster"] = [cluster_mapping[xy] for xy in tiles]
|
70
|
-
all_df["activity"] = names
|
71
|
-
|
72
|
-
del labels
|
73
|
-
del names
|
74
|
-
|
75
|
-
output_dir = pathlib.Path("Heatmaps")
|
76
|
-
output_dir.mkdir(exist_ok=True)
|
77
|
-
for old_image in output_dir.glob("*.png"):
|
78
|
-
old_image.unlink()
|
79
|
-
|
80
|
-
logger.info(f"Found {len(all_df.cluster.unique())} clusters …")
|
81
|
-
for i, (cluster_id, group) in enumerate(
|
82
|
-
sorted(all_df.groupby("cluster"), key=lambda elem: len(elem[1]), reverse=True),
|
83
|
-
start=1,
|
84
|
-
):
|
85
|
-
if cluster_id == -1:
|
86
|
-
continue
|
87
|
-
logger.info(
|
88
|
-
f"Rendering heatmap for cluster {cluster_id} with {len(group)} elements …"
|
89
|
-
)
|
90
|
-
latlon = np.column_stack([group.lat, group.lon])
|
91
|
-
heatmap = render_heatmap(latlon, num_activities=len(group.activity.unique()))
|
92
|
-
plt.imsave(output_dir / f"Cluster-{i}.png", heatmap)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|