geo-activity-playground 0.24.2__tar.gz → 0.26.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 (103) hide show
  1. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/PKG-INFO +1 -1
  2. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/__main__.py +0 -2
  3. geo_activity_playground-0.26.0/geo_activity_playground/core/activities.py +254 -0
  4. geo_activity_playground-0.26.0/geo_activity_playground/core/enrichment.py +164 -0
  5. geo_activity_playground-0.26.0/geo_activity_playground/core/paths.py +56 -0
  6. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/tasks.py +27 -4
  7. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/explorer/tile_visits.py +78 -42
  8. {geo_activity_playground-0.24.2/geo_activity_playground/core → geo_activity_playground-0.26.0/geo_activity_playground/importers}/activity_parsers.py +7 -14
  9. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/directory.py +36 -27
  10. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/strava_api.py +45 -38
  11. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/strava_checkout.py +24 -16
  12. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/controller.py +2 -2
  13. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/templates/activity/show.html.j2 +2 -0
  14. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/app.py +11 -31
  15. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/entry_controller.py +5 -5
  16. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/heatmap/heatmap_controller.py +6 -0
  17. geo_activity_playground-0.26.0/geo_activity_playground/webui/static/bootstrap-dark-mode.js +78 -0
  18. geo_activity_playground-0.26.0/geo_activity_playground/webui/strava/blueprint.py +33 -0
  19. geo_activity_playground-0.26.0/geo_activity_playground/webui/strava/controller.py +49 -0
  20. geo_activity_playground-0.26.0/geo_activity_playground/webui/strava/templates/strava/client-id.html.j2 +36 -0
  21. geo_activity_playground-0.26.0/geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +14 -0
  22. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/templates/home.html.j2 +5 -0
  23. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/templates/page.html.j2 +44 -12
  24. geo_activity_playground-0.26.0/geo_activity_playground/webui/templates/settings.html.j2 +24 -0
  25. geo_activity_playground-0.26.0/geo_activity_playground/webui/upload/__init__.py +0 -0
  26. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/upload/controller.py +13 -17
  27. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/pyproject.toml +1 -1
  28. geo_activity_playground-0.24.2/geo_activity_playground/core/activities.py +0 -332
  29. geo_activity_playground-0.24.2/geo_activity_playground/core/cache_migrations.py +0 -133
  30. geo_activity_playground-0.24.2/geo_activity_playground/core/paths.py +0 -37
  31. geo_activity_playground-0.24.2/geo_activity_playground/webui/strava_controller.py +0 -27
  32. geo_activity_playground-0.24.2/geo_activity_playground/webui/templates/strava-connect.html.j2 +0 -30
  33. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/LICENSE +0 -0
  34. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/__init__.py +0 -0
  35. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/__init__.py +0 -0
  36. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/config.py +0 -0
  37. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/coordinates.py +0 -0
  38. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/heatmap.py +0 -0
  39. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/privacy_zones.py +0 -0
  40. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/similarity.py +0 -0
  41. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/test_tiles.py +0 -0
  42. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/test_time_conversion.py +0 -0
  43. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/tiles.py +0 -0
  44. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/core/time_conversion.py +0 -0
  45. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/explorer/__init__.py +0 -0
  46. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/explorer/grid_file.py +0 -0
  47. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/explorer/video.py +0 -0
  48. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/__init__.py +0 -0
  49. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/test_directory.py +0 -0
  50. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/importers/test_strava_api.py +0 -0
  51. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/__init__.py +0 -0
  52. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/__init__.py +0 -0
  53. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/blueprint.py +0 -0
  54. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/templates/activity/day.html.j2 +0 -0
  55. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/templates/activity/lines.html.j2 +0 -0
  56. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/activity/templates/activity/name.html.j2 +0 -0
  57. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/calendar/__init__.py +0 -0
  58. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/calendar/blueprint.py +0 -0
  59. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/calendar/controller.py +0 -0
  60. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/calendar/templates/calendar/index.html.j2 +0 -0
  61. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/calendar/templates/calendar/month.html.j2 +0 -0
  62. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/eddington/__init__.py +0 -0
  63. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/eddington/blueprint.py +0 -0
  64. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/eddington/controller.py +0 -0
  65. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/eddington/templates/eddington/index.html.j2 +0 -0
  66. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/equipment/__init__.py +0 -0
  67. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/equipment/blueprint.py +0 -0
  68. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/equipment/controller.py +0 -0
  69. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/equipment/templates/equipment/index.html.j2 +0 -0
  70. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/explorer/__init__.py +0 -0
  71. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/explorer/blueprint.py +0 -0
  72. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/explorer/controller.py +0 -0
  73. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +0 -0
  74. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/heatmap/__init__.py +0 -0
  75. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/heatmap/blueprint.py +0 -0
  76. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +0 -0
  77. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/search_controller.py +0 -0
  78. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/square_planner/__init__.py +0 -0
  79. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/square_planner/blueprint.py +0 -0
  80. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/square_planner/controller.py +0 -0
  81. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/square_planner/templates/square_planner/index.html.j2 +0 -0
  82. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  83. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  84. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/android-chrome-512x512.png +0 -0
  85. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  86. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  87. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  88. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  89. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  90. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  91. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  92. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  93. {geo_activity_playground-0.24.2/geo_activity_playground/webui/summary → geo_activity_playground-0.26.0/geo_activity_playground/webui/strava}/__init__.py +0 -0
  94. {geo_activity_playground-0.24.2/geo_activity_playground/webui/tile → geo_activity_playground-0.26.0/geo_activity_playground/webui/summary}/__init__.py +0 -0
  95. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/summary/blueprint.py +0 -0
  96. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/summary/controller.py +0 -0
  97. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/summary/templates/summary/index.html.j2 +0 -0
  98. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/templates/search.html.j2 +0 -0
  99. {geo_activity_playground-0.24.2/geo_activity_playground/webui/upload → geo_activity_playground-0.26.0/geo_activity_playground/webui/tile}/__init__.py +0 -0
  100. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/tile/blueprint.py +0 -0
  101. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/tile/controller.py +0 -0
  102. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/upload/blueprint.py +0 -0
  103. {geo_activity_playground-0.24.2 → geo_activity_playground-0.26.0}/geo_activity_playground/webui/upload/templates/upload/index.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.24.2
3
+ Version: 0.26.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -8,7 +8,6 @@ import coloredlogs
8
8
 
9
9
  from .importers.strava_checkout import convert_strava_checkout
10
10
  from geo_activity_playground.core.activities import ActivityRepository
11
- from geo_activity_playground.core.cache_migrations import apply_cache_migrations
12
11
  from geo_activity_playground.core.config import get_config
13
12
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
14
13
  from geo_activity_playground.explorer.video import explorer_video_main
@@ -97,7 +96,6 @@ def make_activity_repository(
97
96
  basedir: pathlib.Path, skip_strava: bool
98
97
  ) -> tuple[ActivityRepository, TileVisitAccessor, dict]:
99
98
  os.chdir(basedir)
100
- apply_cache_migrations()
101
99
  config = get_config()
102
100
 
103
101
  if not config.get("prefer_metadata_from_file", True):
@@ -0,0 +1,254 @@
1
+ import datetime
2
+ import functools
3
+ import logging
4
+ import pickle
5
+ from typing import Iterator
6
+ from typing import Optional
7
+ from typing import TypedDict
8
+
9
+ import geojson
10
+ import matplotlib
11
+ import numpy as np
12
+ import pandas as pd
13
+ from tqdm import tqdm
14
+
15
+ from geo_activity_playground.core.config import get_config
16
+ from geo_activity_playground.core.paths import activities_file
17
+ from geo_activity_playground.core.paths import activity_enriched_meta_dir
18
+ from geo_activity_playground.core.paths import activity_enriched_time_series_dir
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ActivityMeta(TypedDict):
24
+ calories: float
25
+ commute: bool
26
+ consider_for_achievements: bool
27
+ distance_km: float
28
+ elapsed_time: datetime.timedelta
29
+ end_latitude: float
30
+ end_longitude: float
31
+ equipment: str
32
+ id: int
33
+ kind: str
34
+ moving_time: datetime.timedelta
35
+ name: str
36
+ path: str
37
+ start_latitude: float
38
+ start_longitude: float
39
+ start: datetime.datetime
40
+ steps: int
41
+
42
+
43
+ def make_activity_meta() -> ActivityMeta:
44
+ return ActivityMeta(
45
+ calories=None,
46
+ commute=False,
47
+ consider_for_achievements=True,
48
+ equipment="Unknown",
49
+ kind="Unknown",
50
+ steps=None,
51
+ )
52
+
53
+
54
+ def build_activity_meta() -> None:
55
+ if activities_file().exists():
56
+ meta = pd.read_parquet(activities_file())
57
+ present_ids = set(meta["id"])
58
+ else:
59
+ meta = pd.DataFrame(columns=["id"])
60
+ present_ids = set()
61
+
62
+ available_ids = {
63
+ int(path.stem) for path in activity_enriched_meta_dir().glob("*.pickle")
64
+ }
65
+ new_ids = available_ids - present_ids
66
+ deleted_ids = present_ids - available_ids
67
+
68
+ # Remove updated activities and read these again.
69
+ if activities_file().exists():
70
+ meta_mtime = activities_file().stat().st_mtime
71
+ updated_ids = {
72
+ int(path.stem)
73
+ for path in activity_enriched_meta_dir().glob("*.pickle")
74
+ if path.stat().st_mtime > meta_mtime
75
+ }
76
+ new_ids.update(updated_ids)
77
+ deleted_ids.update(updated_ids & present_ids)
78
+
79
+ if deleted_ids:
80
+ logger.debug(f"Removing activities {deleted_ids} from repository.")
81
+ meta.drop(sorted(deleted_ids), axis="index", inplace=True)
82
+
83
+ rows = []
84
+ for new_id in tqdm(new_ids, desc="Register new activities"):
85
+ with open(activity_enriched_meta_dir() / f"{new_id}.pickle", "rb") as f:
86
+ rows.append(pickle.load(f))
87
+
88
+ if rows:
89
+ new_shard = pd.DataFrame(rows)
90
+ new_shard.index = new_shard["id"]
91
+ new_shard.index.name = "index"
92
+ meta = pd.concat([meta, new_shard])
93
+
94
+ if len(meta):
95
+ assert pd.api.types.is_dtype_equal(meta["start"].dtype, "datetime64[ns]"), (
96
+ meta["start"].dtype,
97
+ meta["start"].iloc[0],
98
+ )
99
+
100
+ meta.sort_values("start", inplace=True)
101
+
102
+ meta.to_parquet(activities_file())
103
+
104
+
105
+ class ActivityRepository:
106
+ def __init__(self) -> None:
107
+ self.meta = None
108
+
109
+ def __len__(self) -> int:
110
+ return len(self.meta)
111
+
112
+ def reload(self) -> None:
113
+ self.meta = pd.read_parquet(activities_file())
114
+
115
+ def has_activity(self, activity_id: int) -> bool:
116
+ if len(self.meta):
117
+ if activity_id in self.meta["id"]:
118
+ return True
119
+
120
+ for activity_meta in self._loose_activities:
121
+ if activity_meta["id"] == activity_id:
122
+ return True
123
+
124
+ return False
125
+
126
+ def last_activity_date(self) -> Optional[datetime.datetime]:
127
+ if len(self.meta):
128
+ return self.meta.iloc[-1]["start"]
129
+ else:
130
+ return None
131
+
132
+ def get_activity_ids(self, only_achievements: bool = False) -> set[int]:
133
+ if only_achievements:
134
+ return set(self.meta.loc[self.meta["consider_for_achievements"]].index)
135
+ else:
136
+ return set(self.meta.index)
137
+
138
+ def iter_activities(self, new_to_old=True, dropna=False) -> Iterator[ActivityMeta]:
139
+ direction = -1 if new_to_old else 1
140
+ for index, row in self.meta[::direction].iterrows():
141
+ if not dropna or not pd.isna(row["start"]):
142
+ yield row
143
+
144
+ @functools.lru_cache()
145
+ def get_activity_by_id(self, id: int) -> ActivityMeta:
146
+ activity = self.meta.loc[id]
147
+ assert isinstance(activity["name"], str), activity["name"]
148
+ return activity
149
+
150
+ @functools.lru_cache(maxsize=3000)
151
+ def get_time_series(self, id: int) -> pd.DataFrame:
152
+ path = activity_enriched_time_series_dir() / f"{id}.parquet"
153
+ try:
154
+ df = pd.read_parquet(path)
155
+ except OSError as e:
156
+ logger.error(f"Error while reading {path}, deleting cache file …")
157
+ path.unlink(missing_ok=True)
158
+ raise
159
+
160
+ return df
161
+
162
+
163
+ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
164
+ fc = geojson.FeatureCollection(
165
+ features=[
166
+ geojson.LineString(
167
+ [(lon, lat) for lat, lon in zip(group["latitude"], group["longitude"])]
168
+ )
169
+ for _, group in time_series.groupby("segment_id")
170
+ ]
171
+ )
172
+ return geojson.dumps(fc)
173
+
174
+
175
+ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
176
+ speed_without_na = time_series["speed"].dropna()
177
+ low = min(speed_without_na)
178
+ high = max(speed_without_na)
179
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
180
+
181
+ cmap = matplotlib.colormaps["viridis"]
182
+ features = [
183
+ geojson.Feature(
184
+ geometry=geojson.LineString(
185
+ coordinates=[
186
+ [row["longitude"], row["latitude"]],
187
+ [next["longitude"], next["latitude"]],
188
+ ]
189
+ ),
190
+ properties={
191
+ "speed": next["speed"] if np.isfinite(next["speed"]) else 0.0,
192
+ "color": matplotlib.colors.to_hex(cmap(clamp_speed(next["speed"]))),
193
+ },
194
+ )
195
+ for _, group in time_series.groupby("segment_id")
196
+ for (_, row), (_, next) in zip(group.iterrows(), group.iloc[1:].iterrows())
197
+ ]
198
+ feature_collection = geojson.FeatureCollection(features)
199
+ return geojson.dumps(feature_collection)
200
+
201
+
202
+ def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, str]:
203
+ speed_without_na = time_series["speed"].dropna()
204
+ low = min(speed_without_na)
205
+ high = max(speed_without_na)
206
+ cmap = matplotlib.colormaps["viridis"]
207
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
208
+ colors = [
209
+ (f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
210
+ for speed in np.linspace(low, high, 10)
211
+ ]
212
+ return {"low": low, "high": high, "colors": colors}
213
+
214
+
215
+ def extract_heart_rate_zones(time_series: pd.DataFrame) -> Optional[pd.DataFrame]:
216
+ if "heartrate" not in time_series:
217
+ return None
218
+ config = get_config()
219
+ try:
220
+ heart_config = config["heart"]
221
+ except KeyError:
222
+ logger.warning(
223
+ "Missing config entry `heart`, cannot determine heart rate zones."
224
+ )
225
+ return None
226
+
227
+ birthyear = heart_config.get("birthyear", None)
228
+ maximum = heart_config.get("maximum", None)
229
+ resting = heart_config.get("resting", None)
230
+
231
+ if not maximum and birthyear:
232
+ age = time_series["time"].iloc[0].year - birthyear
233
+ maximum = 220 - age
234
+ if not resting:
235
+ resting = 0
236
+ if not maximum:
237
+ logger.warning(
238
+ "Missing config entry `heart.maximum` or `heart.birthyear`, cannot determine heart rate zones."
239
+ )
240
+ return None
241
+
242
+ zones: pd.Series = (time_series["heartrate"] - resting) * 10 // (
243
+ maximum - resting
244
+ ) - 4
245
+ zones.loc[zones < 0] = 0
246
+ zones.loc[zones > 5] = 5
247
+ df = pd.DataFrame({"heartzone": zones, "step": time_series["time"].diff()}).dropna()
248
+ duration_per_zone = df.groupby("heartzone").sum()["step"].dt.total_seconds() / 60
249
+ duration_per_zone.name = "minutes"
250
+ for i in range(6):
251
+ if i not in duration_per_zone:
252
+ duration_per_zone.loc[i] = 0.0
253
+ result = duration_per_zone.reset_index()
254
+ return result
@@ -0,0 +1,164 @@
1
+ import datetime
2
+ import logging
3
+ import pickle
4
+ from typing import Any
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from tqdm import tqdm
10
+
11
+ from geo_activity_playground.core.activities import ActivityMeta
12
+ from geo_activity_playground.core.activities import make_activity_meta
13
+ from geo_activity_playground.core.coordinates import get_distance
14
+ from geo_activity_playground.core.paths import activity_enriched_meta_dir
15
+ from geo_activity_playground.core.paths import activity_enriched_time_series_dir
16
+ from geo_activity_playground.core.paths import activity_extracted_meta_dir
17
+ from geo_activity_playground.core.paths import activity_extracted_time_series_dir
18
+ from geo_activity_playground.core.tiles import compute_tile_float
19
+ from geo_activity_playground.core.time_conversion import convert_to_datetime_ns
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def enrich_activities(kind_defaults: dict[dict[str, Any]]) -> None:
25
+ # Delete removed activities.
26
+ for enriched_metadata_path in activity_enriched_meta_dir().glob("*.pickle"):
27
+ if not (activity_extracted_meta_dir() / enriched_metadata_path.name).exists():
28
+ logger.warning(f"Deleting {enriched_metadata_path}")
29
+ enriched_metadata_path.unlink()
30
+ for enriched_time_series_path in activity_enriched_time_series_dir().glob(
31
+ "*.parquet"
32
+ ):
33
+ if not (
34
+ activity_extracted_time_series_dir() / enriched_time_series_path.name
35
+ ).exists():
36
+ logger.warning(f"Deleting {enriched_time_series_path}")
37
+ enriched_time_series_path.unlink()
38
+
39
+ # Get new metadata paths.
40
+ new_extracted_metadata_paths = []
41
+ for extracted_metadata_path in activity_extracted_meta_dir().glob("*.pickle"):
42
+ enriched_metadata_path = (
43
+ activity_enriched_meta_dir() / extracted_metadata_path.name
44
+ )
45
+ if (
46
+ not enriched_metadata_path.exists()
47
+ or enriched_metadata_path.stat().st_mtime
48
+ < extracted_metadata_path.stat().st_mtime
49
+ ):
50
+ new_extracted_metadata_paths.append(extracted_metadata_path)
51
+
52
+ for extracted_metadata_path in tqdm(
53
+ new_extracted_metadata_paths, desc="Enrich new activity data"
54
+ ):
55
+ # Read extracted data.
56
+ activity_id = extracted_metadata_path.stem
57
+ extracted_time_series_path = (
58
+ activity_extracted_time_series_dir() / f"{activity_id}.parquet"
59
+ )
60
+ time_series = pd.read_parquet(extracted_time_series_path)
61
+ with open(extracted_metadata_path, "rb") as f:
62
+ extracted_metadata = pickle.load(f)
63
+
64
+ metadata = make_activity_meta()
65
+ metadata.update(extracted_metadata)
66
+
67
+ # Enrich time series.
68
+ metadata.update(kind_defaults.get(metadata["kind"], {}))
69
+ time_series = _embellish_single_time_series(
70
+ time_series, metadata.get("start", None)
71
+ )
72
+ metadata.update(_get_metadata_from_timeseries(time_series))
73
+
74
+ # Write enriched data.
75
+ enriched_metadata_path = activity_enriched_meta_dir() / f"{activity_id}.pickle"
76
+ enriched_time_series_path = (
77
+ activity_enriched_time_series_dir() / f"{activity_id}.parquet"
78
+ )
79
+ with open(enriched_metadata_path, "wb") as f:
80
+ pickle.dump(metadata, f)
81
+ time_series.to_parquet(enriched_time_series_path)
82
+
83
+
84
+ def _get_metadata_from_timeseries(timeseries: pd.DataFrame) -> ActivityMeta:
85
+ metadata = ActivityMeta()
86
+
87
+ # Extract some meta data from the time series.
88
+ metadata["start"] = timeseries["time"].iloc[0]
89
+ metadata["elapsed_time"] = timeseries["time"].iloc[-1] - timeseries["time"].iloc[0]
90
+ metadata["distance_km"] = timeseries["distance_km"].iloc[-1]
91
+ if "calories" in timeseries.columns:
92
+ metadata["calories"] = timeseries["calories"].iloc[-1]
93
+ metadata["moving_time"] = _compute_moving_time(timeseries)
94
+
95
+ metadata["start_latitude"] = timeseries["latitude"].iloc[0]
96
+ metadata["end_latitude"] = timeseries["latitude"].iloc[-1]
97
+ metadata["start_longitude"] = timeseries["longitude"].iloc[0]
98
+ metadata["end_longitude"] = timeseries["longitude"].iloc[-1]
99
+
100
+ return metadata
101
+
102
+
103
+ def _compute_moving_time(time_series: pd.DataFrame) -> datetime.timedelta:
104
+ def moving_time(group) -> datetime.timedelta:
105
+ selection = group["speed"] > 1.0
106
+ time_diff = group["time"].diff().loc[selection]
107
+ return time_diff.sum()
108
+
109
+ return (
110
+ time_series.groupby("segment_id").apply(moving_time, include_groups=False).sum()
111
+ )
112
+
113
+
114
+ def _embellish_single_time_series(
115
+ timeseries: pd.DataFrame, start: Optional[datetime.datetime] = None
116
+ ) -> pd.DataFrame:
117
+ if start is not None and pd.api.types.is_dtype_equal(
118
+ timeseries["time"].dtype, "int64"
119
+ ):
120
+ time = timeseries["time"]
121
+ del timeseries["time"]
122
+ timeseries["time"] = [
123
+ convert_to_datetime_ns(start + datetime.timedelta(seconds=t)) for t in time
124
+ ]
125
+ timeseries["time"] = convert_to_datetime_ns(timeseries["time"])
126
+ assert pd.api.types.is_dtype_equal(timeseries["time"].dtype, "datetime64[ns]"), (
127
+ timeseries["time"].dtype,
128
+ timeseries["time"].iloc[0],
129
+ )
130
+
131
+ distances = get_distance(
132
+ timeseries["latitude"].shift(1),
133
+ timeseries["longitude"].shift(1),
134
+ timeseries["latitude"],
135
+ timeseries["longitude"],
136
+ ).fillna(0.0)
137
+ time_diff_threshold_seconds = 30
138
+ time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
139
+ jump_indices = time_diff >= time_diff_threshold_seconds
140
+ distances.loc[jump_indices] = 0.0
141
+
142
+ if "distance_km" not in timeseries.columns:
143
+ timeseries["distance_km"] = pd.Series(np.cumsum(distances)) / 1000
144
+
145
+ if "speed" not in timeseries.columns:
146
+ timeseries["speed"] = (
147
+ timeseries["distance_km"].diff()
148
+ / (timeseries["time"].diff().dt.total_seconds() + 1e-3)
149
+ * 3600
150
+ )
151
+
152
+ potential_jumps = (timeseries["speed"] > 40) & (timeseries["speed"].diff() > 10)
153
+ if np.any(potential_jumps):
154
+ timeseries = timeseries.loc[~potential_jumps].copy()
155
+
156
+ if "segment_id" not in timeseries.columns:
157
+ timeseries["segment_id"] = np.cumsum(jump_indices)
158
+
159
+ if "x" not in timeseries.columns:
160
+ x, y = compute_tile_float(timeseries["latitude"], timeseries["longitude"], 0)
161
+ timeseries["x"] = x
162
+ timeseries["y"] = y
163
+
164
+ return timeseries
@@ -0,0 +1,56 @@
1
+ """
2
+ Paths within the playground and cache.
3
+ """
4
+ import functools
5
+ import pathlib
6
+ import typing
7
+
8
+
9
+ def dir_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
10
+ @functools.cache
11
+ def wrapper() -> pathlib.Path:
12
+ path.mkdir(exist_ok=True, parents=True)
13
+ return path
14
+
15
+ return wrapper
16
+
17
+
18
+ def file_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
19
+ @functools.cache
20
+ def wrapper() -> pathlib.Path:
21
+ path.parent.mkdir(exist_ok=True, parents=True)
22
+ return path
23
+
24
+ return wrapper
25
+
26
+
27
+ _cache_dir = pathlib.Path("Cache")
28
+
29
+ _activity_dir = _cache_dir / "Activity"
30
+ _activity_extracted_dir = _activity_dir / "Extracted"
31
+ _activity_extracted_meta_dir = _activity_extracted_dir / "Meta"
32
+ _activity_extracted_time_series_dir = _activity_extracted_dir / "Time Series"
33
+
34
+ _activity_enriched_dir = _activity_dir / "Enriched"
35
+ _activity_enriched_meta_dir = _activity_enriched_dir / "Meta"
36
+ _activity_enriched_time_series_dir = _activity_enriched_dir / "Time Series"
37
+ _activities_file = _activity_dir / "activities.parquet"
38
+
39
+ _tiles_per_time_series = _cache_dir / "Tiles" / "Tiles Per Time Series"
40
+
41
+ _strava_api_dir = pathlib.Path("Strava API")
42
+ _strava_dynamic_config_path = _strava_api_dir / "strava-client-id.json"
43
+
44
+
45
+ cache_dir = dir_wrapper(_cache_dir)
46
+
47
+ activity_extracted_dir = dir_wrapper(_activity_extracted_dir)
48
+ activity_extracted_meta_dir = dir_wrapper(_activity_extracted_meta_dir)
49
+ activity_extracted_time_series_dir = dir_wrapper(_activity_extracted_time_series_dir)
50
+ activity_enriched_meta_dir = dir_wrapper(_activity_enriched_meta_dir)
51
+ activity_enriched_time_series_dir = dir_wrapper(_activity_enriched_time_series_dir)
52
+ tiles_per_time_series = dir_wrapper(_tiles_per_time_series)
53
+ strava_api_dir = dir_wrapper(_strava_api_dir)
54
+
55
+ activities_file = file_wrapper(_activities_file)
56
+ strava_dynamic_config_path = file_wrapper(_strava_dynamic_config_path)
@@ -46,12 +46,12 @@ def work_tracker(path: pathlib.Path):
46
46
  yield s
47
47
 
48
48
  with open(path, "w") as f:
49
- json.dump(list(s), f)
49
+ json.dump(list(s), f, indent=2, sort_keys=True)
50
50
 
51
51
 
52
52
  class WorkTracker:
53
- def __init__(self, name: str) -> None:
54
- self._path = work_tracker_path(name)
53
+ def __init__(self, path: pathlib.Path) -> None:
54
+ self._path = path
55
55
 
56
56
  if self._path.exists():
57
57
  with open(self._path, "rb") as f:
@@ -59,12 +59,15 @@ class WorkTracker:
59
59
  else:
60
60
  self._done = set()
61
61
 
62
- def filter(self, ids: Iterable[int]) -> set[int]:
62
+ def filter(self, ids: Iterable) -> set:
63
63
  return set(ids) - self._done
64
64
 
65
65
  def mark_done(self, id: int) -> None:
66
66
  self._done.add(id)
67
67
 
68
+ def discard(self, id) -> None:
69
+ self._done.discard(id)
70
+
68
71
  def close(self) -> None:
69
72
  with open(self._path, "wb") as f:
70
73
  pickle.dump(self._done, f)
@@ -77,3 +80,23 @@ def try_load_pickle(path: pathlib.Path) -> Any:
77
80
  return pickle.load(f)
78
81
  except ModuleNotFoundError:
79
82
  pass
83
+
84
+
85
+ class TransformVersion:
86
+ def __init__(self, path: pathlib.Path, code_version: int) -> None:
87
+ self._path = path
88
+ self._code_version = code_version
89
+
90
+ with open(path) as f:
91
+ self._actual_version = json.load(f)
92
+
93
+ assert (
94
+ self._actual_version <= self._code_version
95
+ ), "You attempt to use a more modern playground with an older code version, that is not supported."
96
+
97
+ def outdated(self) -> bool:
98
+ return self._actual_version < self._code_version
99
+
100
+ def write(self) -> None:
101
+ with open(self._path, "w") as f:
102
+ json.dump(self._code_version, f)