geo-activity-playground 0.20.0__tar.gz → 0.21.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/PKG-INFO +1 -2
  2. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/__main__.py +16 -3
  3. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/activities.py +47 -29
  4. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/activity_parsers.py +4 -0
  5. geo_activity_playground-0.21.0/geo_activity_playground/importers/directory.py +127 -0
  6. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/importers/strava_checkout.py +10 -1
  7. geo_activity_playground-0.21.0/geo_activity_playground/importers/test_directory.py +22 -0
  8. geo_activity_playground-0.21.0/geo_activity_playground/webui/activity_controller.py +363 -0
  9. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/app.py +30 -3
  10. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/calendar_controller.py +8 -4
  11. geo_activity_playground-0.21.0/geo_activity_playground/webui/templates/activity-day.html.j2 +71 -0
  12. geo_activity_playground-0.21.0/geo_activity_playground/webui/templates/activity-lines.html.j2 +36 -0
  13. geo_activity_playground-0.21.0/geo_activity_playground/webui/templates/activity-name.html.j2 +81 -0
  14. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/activity.html.j2 +36 -12
  15. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +7 -0
  16. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/tile_controller.py +10 -0
  17. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/pyproject.toml +1 -2
  18. geo_activity_playground-0.20.0/geo_activity_playground/importers/directory.py +0 -103
  19. geo_activity_playground-0.20.0/geo_activity_playground/webui/activity_controller.py +0 -195
  20. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/LICENSE +0 -0
  21. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/__init__.py +0 -0
  22. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/__init__.py +0 -0
  23. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/cache_migrations.py +0 -0
  24. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/config.py +0 -0
  25. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/coordinates.py +0 -0
  26. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/heatmap.py +0 -0
  27. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/paths.py +0 -0
  28. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/similarity.py +0 -0
  29. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/tasks.py +0 -0
  30. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/test_tiles.py +0 -0
  31. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/core/tiles.py +0 -0
  32. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/explorer/__init__.py +0 -0
  33. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/explorer/grid_file.py +0 -0
  34. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/explorer/tile_visits.py +0 -0
  35. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/explorer/video.py +0 -0
  36. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/importers/strava_api.py +0 -0
  37. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/importers/test_strava_api.py +0 -0
  38. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/config_controller.py +0 -0
  39. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
  40. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/entry_controller.py +0 -0
  41. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
  42. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/explorer_controller.py +0 -0
  43. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/heatmap_controller.py +0 -0
  44. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/locations_controller.py +0 -0
  45. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/search_controller.py +0 -0
  46. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/square_planner_controller.py +0 -0
  47. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  48. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  49. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/android-chrome-512x512.png +0 -0
  50. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  51. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  52. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  53. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  54. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  55. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  56. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  57. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  58. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/strava_controller.py +0 -0
  59. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/summary_controller.py +0 -0
  60. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/calendar.html.j2 +0 -0
  61. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/config.html.j2 +0 -0
  62. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
  63. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
  64. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/explorer.html.j2 +0 -0
  65. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +0 -0
  66. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
  67. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/locations.html.j2 +0 -0
  68. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
  69. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/search.html.j2 +0 -0
  70. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/square-planner.html.j2 +0 -0
  71. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/strava-connect.html.j2 +0 -0
  72. {geo_activity_playground-0.20.0 → geo_activity_playground-0.21.0}/geo_activity_playground/webui/templates/summary.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.20.0
3
+ Version: 0.21.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -20,7 +20,6 @@ Requires-Dist: fitdecode (>=0.10.0,<0.11.0)
20
20
  Requires-Dist: flask (>=3.0.0,<4.0.0)
21
21
  Requires-Dist: geojson (>=3.0.1,<4.0.0)
22
22
  Requires-Dist: gpxpy (>=1.5.0,<2.0.0)
23
- Requires-Dist: imagehash (>=4.3.1,<5.0.0)
24
23
  Requires-Dist: jinja2 (>=3.1.2,<4.0.0)
25
24
  Requires-Dist: matplotlib (>=3.6.3,<4.0.0)
26
25
  Requires-Dist: numpy (>=1.22.4,<2.0.0)
@@ -6,7 +6,6 @@ import sys
6
6
 
7
7
  import coloredlogs
8
8
 
9
- from .core.similarity import precompute_activity_distances
10
9
  from .importers.strava_checkout import convert_strava_checkout
11
10
  from .importers.strava_checkout import import_from_strava_checkout
12
11
  from geo_activity_playground.core.activities import ActivityRepository
@@ -105,17 +104,31 @@ def make_activity_repository(
105
104
  apply_cache_migrations()
106
105
  config = get_config()
107
106
 
107
+ if not config.get("prefer_metadata_from_file", True):
108
+ logger.error(
109
+ "The config option `prefer_metadata_from_file` is deprecated. If you want to prefer extract metadata from the activity file paths, please use the new `metadata_extraction_regexes` as explained at https://martin-ueding.github.io/geo-activity-playground/getting-started/using-activity-files/#directory-structure."
110
+ )
111
+ sys.exit(1)
112
+
108
113
  repository = ActivityRepository()
109
114
 
110
115
  if pathlib.Path("Activities").exists():
111
- import_from_directory(repository, config.get("prefer_metadata_from_file", True))
116
+ import_from_directory(
117
+ repository,
118
+ config.get("metadata_extraction_regexes", []),
119
+ )
112
120
  if pathlib.Path("Strava Export").exists():
113
121
  import_from_strava_checkout(repository)
114
122
  if "strava" in config and not skip_strava:
115
123
  import_from_strava_api(repository)
116
124
 
125
+ if len(repository) == 0:
126
+ logger.error(
127
+ f"No activities found. You need to either add activity files (GPX, FIT, …) to {basedir/'Activities'} or set up the Strava API. Starting without any activities is unfortunately not supported."
128
+ )
129
+ sys.exit(1)
130
+
117
131
  embellish_time_series(repository)
118
- precompute_activity_distances(repository)
119
132
  compute_tile_visits(repository)
120
133
  compute_tile_evolution()
121
134
  return repository
@@ -37,6 +37,7 @@ class ActivityMeta(TypedDict):
37
37
  start_latitude: float
38
38
  start_longitude: float
39
39
  start: datetime.datetime
40
+ steps: int
40
41
 
41
42
 
42
43
  class ActivityRepository:
@@ -163,9 +164,6 @@ def embellish_single_time_series(
163
164
  timeseries: pd.DataFrame, start: Optional[datetime.datetime] = None
164
165
  ) -> bool:
165
166
  changed = False
166
- time_diff_threshold_seconds = 30
167
- time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
168
- jump_indices = time_diff >= time_diff_threshold_seconds
169
167
 
170
168
  if start is not None and pd.api.types.is_dtype_equal(
171
169
  timeseries["time"].dtype, "int64"
@@ -176,31 +174,37 @@ def embellish_single_time_series(
176
174
  changed = True
177
175
  assert pd.api.types.is_dtype_equal(timeseries["time"].dtype, "datetime64[ns, UTC]")
178
176
 
179
- # Add distance column if missing.
180
- if "distance_km" not in timeseries.columns:
181
- distances = get_distance(
182
- timeseries["latitude"].shift(1),
183
- timeseries["longitude"].shift(1),
184
- timeseries["latitude"],
185
- timeseries["longitude"],
186
- ).fillna(0.0)
187
- distances.loc[jump_indices] = 0.0
177
+ distances = get_distance(
178
+ timeseries["latitude"].shift(1),
179
+ timeseries["longitude"].shift(1),
180
+ timeseries["latitude"],
181
+ timeseries["longitude"],
182
+ ).fillna(0.0)
183
+ time_diff_threshold_seconds = 30
184
+ time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
185
+ jump_indices = (time_diff >= time_diff_threshold_seconds) & (distances > 100)
186
+ distances.loc[jump_indices] = 0.0
187
+
188
+ if not "distance_km" in timeseries.columns:
188
189
  timeseries["distance_km"] = pd.Series(np.cumsum(distances)) / 1000
189
190
  changed = True
190
191
 
191
- if "distance_km" in timeseries.columns:
192
- if "speed" not in timeseries.columns:
193
- timeseries["speed"] = (
194
- timeseries["distance_km"].diff()
195
- / (timeseries["time"].diff().dt.total_seconds() + 1e-3)
196
- * 3600
197
- )
198
- changed = True
192
+ if "speed" not in timeseries.columns:
193
+ timeseries["speed"] = (
194
+ timeseries["distance_km"].diff()
195
+ / (timeseries["time"].diff().dt.total_seconds() + 1e-3)
196
+ * 3600
197
+ )
198
+ changed = True
199
199
 
200
- potential_jumps = (timeseries["speed"] > 40) & (timeseries["speed"].diff() > 10)
201
- if np.any(potential_jumps):
202
- timeseries = timeseries.loc[~potential_jumps]
203
- changed = True
200
+ potential_jumps = (timeseries["speed"] > 40) & (timeseries["speed"].diff() > 10)
201
+ if np.any(potential_jumps):
202
+ timeseries = timeseries.loc[~potential_jumps].copy()
203
+ changed = True
204
+
205
+ if "segment_id" not in timeseries.columns:
206
+ timeseries["segment_id"] = np.cumsum(jump_indices)
207
+ changed = True
204
208
 
205
209
  if "x" not in timeseries.columns:
206
210
  x, y = compute_tile_float(timeseries["latitude"], timeseries["longitude"], 0)
@@ -208,10 +212,6 @@ def embellish_single_time_series(
208
212
  timeseries["y"] = y
209
213
  changed = True
210
214
 
211
- if "segment_id" not in timeseries.columns:
212
- timeseries["segment_id"] = np.cumsum(jump_indices)
213
- changed = True
214
-
215
215
  return timeseries, changed
216
216
 
217
217
 
@@ -228,6 +228,11 @@ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
228
228
 
229
229
 
230
230
  def make_geojson_color_line(time_series: pd.DataFrame) -> str:
231
+ speed_without_na = time_series["speed"].dropna()
232
+ low = min(speed_without_na)
233
+ high = max(speed_without_na)
234
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
235
+
231
236
  cmap = matplotlib.colormaps["viridis"]
232
237
  features = [
233
238
  geojson.Feature(
@@ -239,7 +244,7 @@ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
239
244
  ),
240
245
  properties={
241
246
  "speed": next["speed"] if np.isfinite(next["speed"]) else 0.0,
242
- "color": matplotlib.colors.to_hex(cmap(min(next["speed"] / 35, 1.0))),
247
+ "color": matplotlib.colors.to_hex(cmap(clamp_speed(next["speed"]))),
243
248
  },
244
249
  )
245
250
  for _, group in time_series.groupby("segment_id")
@@ -249,6 +254,19 @@ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
249
254
  return geojson.dumps(feature_collection)
250
255
 
251
256
 
257
+ def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, str]:
258
+ speed_without_na = time_series["speed"].dropna()
259
+ low = min(speed_without_na)
260
+ high = max(speed_without_na)
261
+ cmap = matplotlib.colormaps["viridis"]
262
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
263
+ colors = [
264
+ (f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
265
+ for speed in np.linspace(low, high, 10)
266
+ ]
267
+ return {"low": low, "high": high, "colors": colors}
268
+
269
+
252
270
  def extract_heart_rate_zones(time_series: pd.DataFrame) -> Optional[pd.DataFrame]:
253
271
  if "heartrate" not in time_series:
254
272
  return None
@@ -168,6 +168,10 @@ def read_fit_activity(path: pathlib.Path, open) -> tuple[ActivityMeta, pd.DataFr
168
168
  metadata["kind"] = str(values["sport"])
169
169
  if "sub_sport" in values:
170
170
  metadata["kind"] += " " + str(values["sub_sport"])
171
+ if "total_calories" in fields:
172
+ metadata["calories"] = values["total_calories"]
173
+ if "total_strides" in fields:
174
+ metadata["steps"] = 2 * int(values["total_strides"])
171
175
 
172
176
  return metadata, pd.DataFrame(rows)
173
177
 
@@ -0,0 +1,127 @@
1
+ import hashlib
2
+ import logging
3
+ import multiprocessing
4
+ import pathlib
5
+ import pickle
6
+ import re
7
+ import sys
8
+ import traceback
9
+ from typing import Optional
10
+
11
+ import pandas as pd
12
+ from tqdm import tqdm
13
+
14
+ from geo_activity_playground.core.activities import ActivityMeta
15
+ from geo_activity_playground.core.activities import ActivityRepository
16
+ from geo_activity_playground.core.activity_parsers import ActivityParseError
17
+ from geo_activity_playground.core.activity_parsers import read_activity
18
+ from geo_activity_playground.core.tasks import WorkTracker
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ ACTIVITY_DIR = pathlib.Path("Activities")
23
+
24
+
25
+ def import_from_directory(
26
+ repository: ActivityRepository, metadata_extraction_regexes: list[str] = []
27
+ ) -> None:
28
+ paths_with_errors = []
29
+ work_tracker = WorkTracker("parse-activity-files")
30
+
31
+ activity_paths = [
32
+ path
33
+ for path in ACTIVITY_DIR.rglob("*.*")
34
+ if path.is_file() and path.suffixes and not path.stem.startswith(".")
35
+ ]
36
+ new_activity_paths = work_tracker.filter(activity_paths)
37
+
38
+ activity_stream_dir = pathlib.Path("Cache/Activity Timeseries")
39
+ activity_stream_dir.mkdir(exist_ok=True, parents=True)
40
+ file_metadata_dir = pathlib.Path("Cache/Activity Metadata")
41
+ file_metadata_dir.mkdir(exist_ok=True, parents=True)
42
+
43
+ with multiprocessing.Pool() as pool:
44
+ paths_with_errors = tqdm(
45
+ pool.imap(_cache_single_file, new_activity_paths),
46
+ desc="Parse activity metadata",
47
+ total=len(new_activity_paths),
48
+ )
49
+ paths_with_errors = [error for error in paths_with_errors if error]
50
+
51
+ for path in tqdm(new_activity_paths, desc="Collate activity metadata"):
52
+ activity_id = _get_file_hash(path)
53
+ file_metadata_path = file_metadata_dir / f"{activity_id}.pickle"
54
+ work_tracker.mark_done(activity_id)
55
+
56
+ if not file_metadata_path.exists():
57
+ continue
58
+
59
+ with open(file_metadata_path, "rb") as f:
60
+ activity_meta_from_file = pickle.load(f)
61
+
62
+ activity_meta = ActivityMeta(
63
+ id=activity_id,
64
+ # https://stackoverflow.com/a/74718395/653152
65
+ name=path.name.removesuffix("".join(path.suffixes)),
66
+ path=str(path),
67
+ kind="Unknown",
68
+ equipment="Unknown",
69
+ )
70
+ activity_meta.update(activity_meta_from_file)
71
+ activity_meta.update(_get_metadata_from_path(path, metadata_extraction_regexes))
72
+ repository.add_activity(activity_meta)
73
+
74
+ if paths_with_errors:
75
+ logger.warning(
76
+ "There were errors while parsing some of the files. These were skipped and tried again next time."
77
+ )
78
+ for path, error in paths_with_errors:
79
+ logger.error(f"{path}: {error}")
80
+
81
+ repository.commit()
82
+
83
+ work_tracker.close()
84
+
85
+
86
+ def _cache_single_file(path: pathlib.Path) -> Optional[tuple[pathlib.Path, str]]:
87
+ activity_stream_dir = pathlib.Path("Cache/Activity Timeseries")
88
+ file_metadata_dir = pathlib.Path("Cache/Activity Metadata")
89
+
90
+ activity_id = _get_file_hash(path)
91
+ timeseries_path = activity_stream_dir / f"{activity_id}.parquet"
92
+ file_metadata_path = file_metadata_dir / f"{activity_id}.pickle"
93
+
94
+ if not timeseries_path.exists():
95
+ try:
96
+ activity_meta_from_file, timeseries = read_activity(path)
97
+ except ActivityParseError as e:
98
+ logger.error(f"Error while parsing file {path}:")
99
+ traceback.print_exc()
100
+ return (path, str(e))
101
+ except:
102
+ logger.error(f"Encountered a problem with {path=}, see details below.")
103
+ raise
104
+
105
+ if len(timeseries) == 0:
106
+ return
107
+
108
+ timeseries.to_parquet(timeseries_path)
109
+ with open(file_metadata_path, "wb") as f:
110
+ pickle.dump(activity_meta_from_file, f)
111
+
112
+
113
+ def _get_file_hash(path: pathlib.Path) -> int:
114
+ file_hash = hashlib.blake2s()
115
+ with open(path, "rb") as f:
116
+ while chunk := f.read(8192):
117
+ file_hash.update(chunk)
118
+ return int(file_hash.hexdigest(), 16) % 2**62
119
+
120
+
121
+ def _get_metadata_from_path(
122
+ path: pathlib.Path, metadata_extraction_regexes: list[str]
123
+ ) -> dict[str, str]:
124
+ for regex in metadata_extraction_regexes:
125
+ if m := re.search(regex, str(path.relative_to(ACTIVITY_DIR))):
126
+ return m.groupdict()
127
+ return {}
@@ -3,6 +3,8 @@ import logging
3
3
  import pathlib
4
4
  import shutil
5
5
  import traceback
6
+ from typing import Optional
7
+ from typing import Union
6
8
 
7
9
  import dateutil.parser
8
10
  import numpy as np
@@ -116,6 +118,13 @@ EXPECTED_COLUMNS = [
116
118
  ]
117
119
 
118
120
 
121
+ def float_or_none(x: Union[float, str]) -> Optional[float]:
122
+ try:
123
+ return float(x)
124
+ except ValueError:
125
+ return None
126
+
127
+
119
128
  def import_from_strava_checkout(repository: ActivityRepository) -> None:
120
129
  checkout_path = pathlib.Path("Strava Export")
121
130
  activities = pd.read_csv(checkout_path / "activities.csv")
@@ -143,7 +152,7 @@ def import_from_strava_checkout(repository: ActivityRepository) -> None:
143
152
  row = activities.loc[activity_id]
144
153
  activity_file = checkout_path / row["Filename"]
145
154
  table_activity_meta = {
146
- "calories": row["Calories"],
155
+ "calories": float_or_none(row["Calories"]),
147
156
  "commute": row["Commute"] == "true",
148
157
  "distance_km": row["Distance"],
149
158
  "elapsed_time": datetime.timedelta(seconds=int(row["Elapsed Time"])),
@@ -0,0 +1,22 @@
1
+ import pathlib
2
+
3
+ from geo_activity_playground.importers.directory import _get_metadata_from_path
4
+
5
+
6
+ def test_get_metadata_from_path() -> None:
7
+ expected = {
8
+ "kind": "Radfahrt",
9
+ "equipment": "Bike 2019",
10
+ "name": "Foo-Bar to Baz24",
11
+ }
12
+ actual = _get_metadata_from_path(
13
+ pathlib.Path(
14
+ "Activities/Radfahrt/Bike 2019/Foo-Bar to Baz24/2024-03-03-17-42-10 Something something.fit"
15
+ ),
16
+ [
17
+ r"(?P<kind>[^/]+)/(?P<equipment>[^/]+)/(?P<name>[^/]+)/",
18
+ r"(?P<kind>[^/]+)/(?P<equipment>[^/]+)/[-\d_ ]+(?P<name>[^/\.]+)(?:\.\w+)+$",
19
+ r"(?P<kind>[^/]+)/[-\d_ ]+(?P<name>[^/\.]+)(?:\.\w+)+$",
20
+ ],
21
+ )
22
+ assert actual == expected