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.
Files changed (57) hide show
  1. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/PKG-INFO +1 -2
  2. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/__main__.py +7 -4
  3. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/activities.py +5 -0
  4. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/activity_parsers.py +5 -4
  5. geo_activity_playground-0.16.1/geo_activity_playground/core/cache_migrations.py +72 -0
  6. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/heatmap.py +12 -52
  7. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/tile_visits.py +0 -2
  8. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/directory.py +1 -0
  9. geo_activity_playground-0.16.1/geo_activity_playground/importers/strava_checkout.py +54 -0
  10. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/app.py +24 -0
  11. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/equipment_controller.py +11 -2
  12. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/explorer_controller.py +14 -10
  13. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/heatmap_controller.py +46 -0
  14. geo_activity_playground-0.16.1/geo_activity_playground/webui/search_controller.py +29 -0
  15. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/heatmap.html.j2 +8 -0
  16. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/index.html.j2 +9 -4
  17. geo_activity_playground-0.16.1/geo_activity_playground/webui/templates/search.html.j2 +38 -0
  18. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/pyproject.toml +1 -2
  19. geo_activity_playground-0.15.2/geo_activity_playground/core/cache_migrations.py +0 -34
  20. geo_activity_playground-0.15.2/geo_activity_playground/heatmap.py +0 -92
  21. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/LICENSE +0 -0
  22. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/__init__.py +0 -0
  23. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/__init__.py +0 -0
  24. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/config.py +0 -0
  25. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/coordinates.py +0 -0
  26. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/tasks.py +0 -0
  27. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/test_tiles.py +0 -0
  28. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/core/tiles.py +0 -0
  29. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/__init__.py +0 -0
  30. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/grid_file.py +0 -0
  31. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/explorer/video.py +0 -0
  32. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/strava_api.py +0 -0
  33. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/importers/test_strava_api.py +0 -0
  34. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/activity_controller.py +0 -0
  35. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/calendar_controller.py +0 -0
  36. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/eddington_controller.py +0 -0
  37. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/entry_controller.py +0 -0
  38. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/grayscale_tile_controller.py +0 -0
  39. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  40. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  41. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  42. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  43. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  44. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  45. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/favicon.ico +0 -0
  46. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  47. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  48. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  49. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/summary_controller.py +0 -0
  50. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/activity.html.j2 +0 -0
  51. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
  52. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/calendar.html.j2 +0 -0
  53. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
  54. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
  55. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/explorer.html.j2 +0 -0
  56. {geo_activity_playground-0.15.2 → geo_activity_playground-0.16.1}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
  57. {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.15.2
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
- "heatmaps", help="Generate heatmaps from activities"
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: generate_heatmaps_per_cluster(
60
- make_activity_repository(options.basedir)
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
- with tempfile.NamedTemporaryFile("wb", suffix=".tcx") as f:
161
+
162
+ stripped_file = pathlib.Path("Cache/temp.tcx")
163
+ with open(stripped_file, "wb") as f:
163
164
  f.write(content)
164
- f.flush()
165
- data = tcx_reader.read(f.name)
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
- lat_lon_data: np.ndarray, num_activities: int, tile_bounds: TileBounds
182
+ xy_data: np.ndarray,
183
+ mean_latitude: float,
184
+ num_activities: int,
185
+ tile_bounds: TileBounds,
216
186
  ) -> np.ndarray:
217
- # fill trackpoints
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 = compute_tile_float(
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
- ) # to supertile coordinates
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])] # histogram equalization
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()) # normalize to [0,1]
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, row in tile_visits.items():
44
- first_age_days = (today - row["first_time"].date()).days
45
- last_age_days = (today - row["last_time"].date()).days
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(row["first_id"]),
48
- "first_activity_name": repository.get_activity_by_id(row["first_id"]).name,
49
- "last_activity_id": str(row["last_id"]),
50
- "last_activity_name": repository.get_activity_by_id(row["last_id"]).name,
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": row["first_time"].date().isoformat(),
62
- "last_visit": row["last_time"].date().isoformat(),
63
- "num_visits": row["count"],
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.15.2"
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)