geo-activity-playground 0.10.0__tar.gz → 0.11.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 (57) hide show
  1. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/PKG-INFO +1 -2
  2. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/activities.py +9 -8
  3. geo_activity_playground-0.11.0/geo_activity_playground/core/heatmap.py +292 -0
  4. geo_activity_playground-0.11.0/geo_activity_playground/core/test_tiles.py +23 -0
  5. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/tiles.py +26 -0
  6. geo_activity_playground-0.11.0/geo_activity_playground/explorer/clusters.py +260 -0
  7. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/converters.py +41 -13
  8. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/grid_file.py +43 -42
  9. geo_activity_playground-0.11.0/geo_activity_playground/heatmap.py +92 -0
  10. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/importers/directory.py +15 -3
  11. geo_activity_playground-0.11.0/geo_activity_playground/webui/activity_controller.py +174 -0
  12. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/app.py +18 -0
  13. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/entry_controller.py +1 -1
  14. geo_activity_playground-0.11.0/geo_activity_playground/webui/explorer_controller.py +108 -0
  15. geo_activity_playground-0.11.0/geo_activity_playground/webui/grayscale_tile_controller.py +16 -0
  16. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/heatmap_controller.py +14 -35
  17. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/activity.html.j2 +35 -4
  18. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/calendar.html.j2 +5 -2
  19. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/explorer.html.j2 +56 -15
  20. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/pyproject.toml +1 -2
  21. geo_activity_playground-0.10.0/geo_activity_playground/core/heatmap.py +0 -37
  22. geo_activity_playground-0.10.0/geo_activity_playground/core/test_tiles.py +0 -15
  23. geo_activity_playground-0.10.0/geo_activity_playground/heatmap.py +0 -251
  24. geo_activity_playground-0.10.0/geo_activity_playground/webui/activity_controller.py +0 -88
  25. geo_activity_playground-0.10.0/geo_activity_playground/webui/explorer_controller.py +0 -43
  26. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/__init__.py +0 -0
  27. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/__main__.py +0 -0
  28. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/__init__.py +0 -0
  29. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/activity_parsers.py +0 -0
  30. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/config.py +0 -0
  31. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/coordinates.py +0 -0
  32. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/plots.py +0 -0
  33. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/sources.py +0 -0
  34. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/tasks.py +0 -0
  35. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/__init__.py +0 -0
  36. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/video.py +0 -0
  37. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/importers/strava_api.py +0 -0
  38. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/calendar_controller.py +0 -0
  39. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
  40. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
  41. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  42. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  43. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  44. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  45. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  46. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  47. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  48. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  49. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  50. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  51. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
  52. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
  53. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
  54. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +0 -0
  55. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
  56. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
  57. {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/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.10.0
3
+ Version: 0.11.0
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.0,<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)
@@ -59,14 +59,15 @@ class ActivityRepository:
59
59
  df["time"] = [start + datetime.timedelta(seconds=t) for t in time]
60
60
  assert pd.api.types.is_dtype_equal(df["time"].dtype, "datetime64[ns, UTC]")
61
61
 
62
- df["distance/km"] = df["distance"] / 1000
63
-
64
- if "speed" not in df.columns:
65
- df["speed"] = (
66
- df["distance"].diff()
67
- / (df["time"].diff().dt.total_seconds() + 1e-3)
68
- * 3.6
69
- )
62
+ if "distance" in df.columns:
63
+ df["distance/km"] = df["distance"] / 1000
64
+
65
+ if "speed" not in df.columns:
66
+ df["speed"] = (
67
+ df["distance"].diff()
68
+ / (df["time"].diff().dt.total_seconds() + 1e-3)
69
+ * 3.6
70
+ )
70
71
 
71
72
  return df
72
73
 
@@ -0,0 +1,292 @@
1
+ """
2
+ This code is based on https://github.com/remisalmon/Strava-local-heatmap.
3
+ """
4
+ import dataclasses
5
+ import functools
6
+ import logging
7
+ import pathlib
8
+
9
+ import matplotlib.pyplot as pl
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from geo_activity_playground.core.activities import ActivityRepository
14
+ from geo_activity_playground.core.tasks import work_tracker
15
+ from geo_activity_playground.core.tiles import get_tile
16
+ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
17
+ from geo_activity_playground.core.tiles import latlon_to_xy
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
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/task_all_points.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
+ new_shards.append(time_series[["latitude", "longitude"]])
43
+ logger.info("Concatenating shards …")
44
+ all_points = pd.concat([all_points] + new_shards)
45
+ all_points.to_parquet(all_points_path)
46
+ return all_points
47
+
48
+
49
+ @dataclasses.dataclass
50
+ class GeoBounds:
51
+ lat_min: float
52
+ lon_min: float
53
+ lat_max: float
54
+ lon_max: float
55
+
56
+
57
+ def get_bounds(lat_lon_data: np.array) -> GeoBounds:
58
+ return GeoBounds(*np.min(lat_lon_data, axis=0), *np.max(lat_lon_data, axis=0))
59
+
60
+
61
+ def add_margin(lower: float, upper: float) -> tuple[float, float]:
62
+ spread = upper - lower
63
+ margin = spread / 20
64
+ return max(0, lower - margin), upper + margin
65
+
66
+
67
+ def add_margin_to_geo_bounds(bounds: GeoBounds) -> GeoBounds:
68
+ lat_min, lat_max = add_margin(bounds.lat_min, bounds.lat_max)
69
+ lon_min, lon_max = add_margin(bounds.lon_min, bounds.lon_max)
70
+ return GeoBounds(lat_min, lon_min, lat_max, lon_max)
71
+
72
+
73
+ OSM_TILE_SIZE = 256 # OSM tile size in pixel
74
+ OSM_MAX_ZOOM = 19 # OSM maximum zoom level
75
+ MAX_TILE_COUNT = 2000 # maximum number of tiles to download
76
+
77
+
78
+ @dataclasses.dataclass
79
+ class TileBounds:
80
+ zoom: int
81
+ x_tile_min: int
82
+ x_tile_max: int
83
+ y_tile_min: int
84
+ y_tile_max: int
85
+
86
+ @property
87
+ def shape(self) -> tuple[int, int]:
88
+ return (
89
+ (self.y_tile_max - self.y_tile_min) * OSM_TILE_SIZE,
90
+ (self.x_tile_max - self.x_tile_min) * OSM_TILE_SIZE,
91
+ )
92
+
93
+
94
+ def geo_bounds_from_tile_bounds(tile_bounds: TileBounds) -> GeoBounds:
95
+ lat_max, lon_min = get_tile_upper_left_lat_lon(
96
+ tile_bounds.x_tile_min, tile_bounds.y_tile_min, tile_bounds.zoom
97
+ )
98
+ lat_min, lon_max = get_tile_upper_left_lat_lon(
99
+ tile_bounds.x_tile_max, tile_bounds.y_tile_max, tile_bounds.zoom
100
+ )
101
+ return GeoBounds(lat_min, lon_min, lat_max, lon_max)
102
+
103
+
104
+ def get_sensible_zoom_level(
105
+ bounds: GeoBounds, picture_size: tuple[int, int]
106
+ ) -> TileBounds:
107
+ zoom = OSM_MAX_ZOOM
108
+
109
+ while True:
110
+ x_tile_min, y_tile_max = map(
111
+ int, latlon_to_xy(bounds.lat_min, bounds.lon_min, zoom)
112
+ )
113
+ x_tile_max, y_tile_min = map(
114
+ int, latlon_to_xy(bounds.lat_max, bounds.lon_max, zoom)
115
+ )
116
+
117
+ x_tile_max += 1
118
+ y_tile_max += 1
119
+
120
+ if (x_tile_max - x_tile_min) * OSM_TILE_SIZE <= picture_size[0] and (
121
+ y_tile_max - y_tile_min
122
+ ) * OSM_TILE_SIZE <= picture_size[1]:
123
+ break
124
+
125
+ zoom -= 1
126
+
127
+ tile_count = (x_tile_max - x_tile_min) * (y_tile_max - y_tile_min)
128
+
129
+ if tile_count > MAX_TILE_COUNT:
130
+ raise RuntimeError("Zoom value too high, too many tiles to download")
131
+
132
+ return TileBounds(
133
+ zoom=zoom,
134
+ x_tile_min=x_tile_min,
135
+ x_tile_max=x_tile_max,
136
+ y_tile_min=y_tile_min,
137
+ y_tile_max=y_tile_max,
138
+ )
139
+
140
+
141
+ def build_map_from_tiles(tile_bounds: TileBounds) -> np.array:
142
+ background = np.zeros((*tile_bounds.shape, 3))
143
+
144
+ for x in range(tile_bounds.x_tile_min, tile_bounds.x_tile_max):
145
+ for y in range(tile_bounds.y_tile_min, tile_bounds.y_tile_max):
146
+ tile = np.array(get_tile(tile_bounds.zoom, x, y)) / 255
147
+
148
+ i = y - tile_bounds.y_tile_min
149
+ j = x - tile_bounds.x_tile_min
150
+
151
+ background[
152
+ i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
153
+ j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
154
+ :,
155
+ ] = tile[:, :, :3]
156
+
157
+ return background
158
+
159
+
160
+ def convert_to_grayscale(image: np.ndarray) -> np.ndarray:
161
+ image = np.sum(image * [0.2126, 0.7152, 0.0722], axis=2)
162
+ image = np.dstack((image, image, image))
163
+ return image
164
+
165
+
166
+ def crop_image_to_bounds(
167
+ image: np.ndarray, geo_bounds: GeoBounds, tile_bounds: TileBounds
168
+ ) -> np.ndarray:
169
+ min_x, min_y = latlon_to_xy(
170
+ geo_bounds.lat_max, geo_bounds.lon_min, tile_bounds.zoom
171
+ )
172
+ max_x, max_y = latlon_to_xy(
173
+ geo_bounds.lat_min, geo_bounds.lon_max, tile_bounds.zoom
174
+ )
175
+ min_x = int((min_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE)
176
+ min_y = int((min_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE)
177
+ max_x = int((max_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE)
178
+ max_y = int((max_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE)
179
+ image = image[min_y:max_y, min_x:max_x, :]
180
+ return image
181
+
182
+
183
+ def gaussian_filter(image, sigma):
184
+ # returns image filtered with a gaussian function of variance sigma**2
185
+ #
186
+ # input: image = numpy.ndarray
187
+ # sigma = float
188
+ # output: image = numpy.ndarray
189
+
190
+ i, j = np.meshgrid(
191
+ np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij"
192
+ )
193
+
194
+ mu = (int(image.shape[0] / 2.0), int(image.shape[1] / 2.0))
195
+
196
+ gaussian = (
197
+ 1.0
198
+ / (2.0 * np.pi * sigma * sigma)
199
+ * np.exp(-0.5 * (((i - mu[0]) / sigma) ** 2 + ((j - mu[1]) / sigma) ** 2))
200
+ )
201
+
202
+ gaussian = np.roll(gaussian, (-mu[0], -mu[1]), axis=(0, 1))
203
+
204
+ image_fft = np.fft.rfft2(image)
205
+ gaussian_fft = np.fft.rfft2(gaussian)
206
+
207
+ image = np.fft.irfft2(image_fft * gaussian_fft)
208
+
209
+ return image
210
+
211
+
212
+ def build_heatmap_image(
213
+ lat_lon_data: np.ndarray, num_activities: int, tile_bounds: TileBounds
214
+ ) -> np.ndarray:
215
+ # fill trackpoints
216
+ sigma_pixel = 1
217
+
218
+ data = np.zeros(tile_bounds.shape)
219
+
220
+ xy_data = latlon_to_xy(lat_lon_data[:, 0], lat_lon_data[:, 1], tile_bounds.zoom)
221
+ xy_data = np.array(xy_data).T
222
+ xy_data = np.round(
223
+ (xy_data - [tile_bounds.x_tile_min, tile_bounds.y_tile_min]) * OSM_TILE_SIZE
224
+ ) # to supertile coordinates
225
+
226
+ for j, i in xy_data.astype(int):
227
+ data[
228
+ i - sigma_pixel : i + sigma_pixel, j - sigma_pixel : j + sigma_pixel
229
+ ] += 1.0
230
+
231
+ res_pixel = (
232
+ 156543.03
233
+ * np.cos(np.radians(np.mean(lat_lon_data[:, 0])))
234
+ / (2.0**tile_bounds.zoom)
235
+ ) # from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
236
+
237
+ # trackpoint max accumulation per pixel = 1/5 (trackpoint/meter) * res_pixel (meter/pixel) * activities
238
+ # (Strava records trackpoints every 5 meters in average for cycling activites)
239
+ m = np.round((1.0 / 5.0) * res_pixel * num_activities)
240
+
241
+ data[data > m] = m
242
+
243
+ # equalize histogram and compute kernel density estimation
244
+ data_hist, _ = np.histogram(data, bins=int(m + 1))
245
+
246
+ data_hist = np.cumsum(data_hist) / data.size # normalized cumulated histogram
247
+
248
+ for i in range(data.shape[0]):
249
+ for j in range(data.shape[1]):
250
+ data[i, j] = m * data_hist[int(data[i, j])] # histogram equalization
251
+
252
+ data = gaussian_filter(
253
+ data, float(sigma_pixel)
254
+ ) # kernel density estimation with normal kernel
255
+
256
+ data = (data - data.min()) / (data.max() - data.min()) # normalize to [0,1]
257
+
258
+ # colorize
259
+ cmap = pl.get_cmap("hot")
260
+
261
+ data_color = cmap(data)
262
+ data_color[data_color == cmap(0.0)] = 0.0 # remove background color
263
+ return data_color
264
+
265
+
266
+ def build_heatmap_tile(lat_lon_data: np.ndarray, tile_bounds: TileBounds) -> np.ndarray:
267
+ xy_data = latlon_to_xy(lat_lon_data[:, 0], lat_lon_data[:, 1], tile_bounds.zoom)
268
+ xy_data = np.array(xy_data).T
269
+
270
+ xy_data = np.round(
271
+ (xy_data - [tile_bounds.x_tile_min, tile_bounds.y_tile_min]) * OSM_TILE_SIZE
272
+ )
273
+ sigma_pixel = 1
274
+ data = np.zeros((OSM_TILE_SIZE, OSM_TILE_SIZE))
275
+ for j, i in xy_data.astype(int):
276
+ data[
277
+ i - sigma_pixel : i + sigma_pixel, j - sigma_pixel : j + sigma_pixel
278
+ ] += 1.0
279
+
280
+ np.log(data, where=data > 0, out=data)
281
+ data /= 6
282
+ data_max = data.max()
283
+ if data_max > 2:
284
+ logger.warning(f"Maximum data in tile: {data_max}")
285
+ data[data > 1.0] = 1.0
286
+
287
+ # colorize
288
+ cmap = pl.get_cmap("hot")
289
+
290
+ data_color = cmap(data)
291
+ data_color[data_color == cmap(0.0)] = 0.0 # remove background color
292
+ return data_color
@@ -0,0 +1,23 @@
1
+ from .tiles import compute_tile
2
+ from .tiles import get_tile_upper_left_lat_lon
3
+ from .tiles import interpolate_missing_tile
4
+
5
+
6
+ def test_rheinbach() -> None:
7
+ lat, lon = 50.6202, 6.9504
8
+ assert compute_tile(lat, lon, 14) == (8508, 5512)
9
+
10
+
11
+ def test_back() -> None:
12
+ tile_x, tile_y = 8508, 5512
13
+ zoom = 14
14
+ lat, lon = get_tile_upper_left_lat_lon(tile_x, tile_y, zoom)
15
+ print(lat, lon)
16
+ assert compute_tile(lat, lon, zoom) == (tile_x, tile_y)
17
+
18
+
19
+ def test_interpolate() -> None:
20
+ assert interpolate_missing_tile(1.25, 2.25, 2.5, 1.5) == (1, 1)
21
+ assert interpolate_missing_tile(2.5, 1.5, 1.25, 2.25) == (1, 1)
22
+ assert interpolate_missing_tile(2.25, 2.5, 1.75, 1.25) == (2, 1)
23
+ assert interpolate_missing_tile(1.25, 2.25, 2.25, 2.5) == None
@@ -3,6 +3,7 @@ import logging
3
3
  import math
4
4
  import pathlib
5
5
  import time
6
+ from typing import Optional
6
7
 
7
8
  import numpy as np
8
9
  import requests
@@ -20,6 +21,15 @@ def compute_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
20
21
  return int(x * n), int(y * n)
21
22
 
22
23
 
24
+ def compute_tile_float(lat: float, lon: float, zoom: int) -> tuple[int, int]:
25
+ x = np.radians(lon)
26
+ y = np.arcsinh(np.tan(np.radians(lat)))
27
+ x = (1 + x / np.pi) / 2
28
+ y = (1 - y / np.pi) / 2
29
+ n = 2**zoom
30
+ return x * n, y * n
31
+
32
+
23
33
  def get_tile_upper_left_lat_lon(
24
34
  tile_x: int, tile_y: int, zoom: int
25
35
  ) -> tuple[float, float]:
@@ -79,3 +89,19 @@ def xy_to_latlon(x: float, y: float, zoom: int) -> tuple[float, float]:
79
89
  lat_rad = np.arctan(np.sinh(np.pi * (1.0 - 2.0 * y / n)))
80
90
  lat_deg = float(np.degrees(lat_rad))
81
91
  return lat_deg, lon_deg
92
+
93
+
94
+ def interpolate_missing_tile(
95
+ x1: float, y1: float, x2: float, y2: float
96
+ ) -> Optional[tuple[int, int]]:
97
+ # We are only interested in diagonal tile combinations, therefore we skip adjacent ones.
98
+ if int(x1) == int(x2) or int(y1) == int(y2):
99
+ return None
100
+
101
+ x_hat = int(max(x1, x2))
102
+ l = (x_hat - x1) / (x2 - x1)
103
+ y_hat = int(y1 + l * (y2 - y1))
104
+ if y_hat == int(y1):
105
+ return (int(x2), y_hat)
106
+ else:
107
+ return (int(x1), y_hat)
@@ -0,0 +1,260 @@
1
+ import itertools
2
+ import json
3
+ import logging
4
+ import pathlib
5
+ from typing import Iterator
6
+
7
+ import geojson
8
+ import pandas as pd
9
+
10
+ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def adjacent_to(tile: tuple[int, int]) -> Iterator[tuple[int, int]]:
17
+ x, y = tile
18
+ yield (x + 1, y)
19
+ yield (x - 1, y)
20
+ yield (x, y + 1)
21
+ yield (x, y - 1)
22
+
23
+
24
+ class ExplorerClusterState:
25
+ def __init__(self, zoom: int) -> None:
26
+ self.num_neighbors: dict[tuple[int, int], int] = {}
27
+ self.cluster_tiles: dict[tuple[int, int], list[tuple[int, int]]] = {}
28
+ self.cluster_evolution = pd.DataFrame()
29
+ self.start = 0
30
+
31
+ self._state_path = pathlib.Path(f"Cache/explorer_cluster_{zoom}_state.json")
32
+ self._cluster_evolution_path = pathlib.Path(
33
+ f"Cache/explorer_cluster_{zoom}_evolution.parquet"
34
+ )
35
+
36
+ def load(self) -> None:
37
+ logger.info("Loading explorer cluster state …")
38
+ if self._state_path.exists():
39
+ with open(self._state_path) as f:
40
+ data = json.load(f)
41
+ self.num_neighbors = {
42
+ tuple(map(int, key.split("/"))): value
43
+ for key, value in data["num_neighbors"].items()
44
+ }
45
+ self.cluster_tiles = {
46
+ tuple(map(int, key.split("/"))): [
47
+ tuple(t) for t in data["clusters"][str(value)]
48
+ ]
49
+ for key, value in data["memberships"].items()
50
+ }
51
+ self.start = data["start"]
52
+
53
+ if self._cluster_evolution_path.exists():
54
+ self.cluster_evolution = pd.read_parquet(self._cluster_evolution_path)
55
+
56
+ def save(self) -> None:
57
+ logger.info("Saving explorer cluster state …")
58
+ data = {
59
+ "num_neighbors": {
60
+ f"{x}/{y}": count for (x, y), count in self.num_neighbors.items()
61
+ },
62
+ "memberships": {
63
+ f"{x}/{y}": id(members)
64
+ for (x, y), members in self.cluster_tiles.items()
65
+ },
66
+ "clusters": {
67
+ id(members): members for members in self.cluster_tiles.values()
68
+ },
69
+ "start": self.start,
70
+ }
71
+ with open(self._state_path, "w") as f:
72
+ json.dump(data, f)
73
+
74
+ self.cluster_evolution.to_parquet(self._cluster_evolution_path)
75
+
76
+
77
+ def get_explorer_cluster_evolution(zoom: int) -> ExplorerClusterState:
78
+ tiles = pd.read_parquet(pathlib.Path(f"Cache/first_time_per_tile_{zoom}.parquet"))
79
+ tiles.sort_values("first_time", inplace=True)
80
+
81
+ s = ExplorerClusterState(zoom)
82
+ s.load()
83
+
84
+ logger.info("Compute new explorer cluster state …")
85
+
86
+ if len(s.cluster_evolution) > 0:
87
+ max_cluster_so_far = s.cluster_evolution["max_cluster_size"].iloc[-1]
88
+ else:
89
+ max_cluster_so_far = 0
90
+
91
+ rows = []
92
+ for index, row in tiles.iloc[s.start :].iterrows():
93
+ new_clusters = False
94
+ # Current tile.
95
+ tile = (row["tile_x"], row["tile_y"])
96
+
97
+ # This tile is new, therefore it doesn't have an entries in the neighbor list yet.
98
+ s.num_neighbors[tile] = 0
99
+
100
+ # Go through the adjacent tile and check whether there are neighbors.
101
+ for other in adjacent_to(tile):
102
+ if other in s.num_neighbors:
103
+ # The other tile is already visited. That means that the current tile has a neighbor.
104
+ s.num_neighbors[tile] += 1
105
+ # Alto the other tile has gained a neighbor.
106
+ s.num_neighbors[other] += 1
107
+
108
+ # If the current tile has all neighbors, make it it's own cluster.
109
+ if s.num_neighbors[tile] == 4:
110
+ s.cluster_tiles[tile] = [tile]
111
+
112
+ # Also make the adjacent tiles their own clusters, if they are full.
113
+ this_and_neighbors = [tile] + list(adjacent_to(tile))
114
+ for other in this_and_neighbors:
115
+ if s.num_neighbors.get(other, 0) == 4:
116
+ s.cluster_tiles[other] = [other]
117
+
118
+ for candidate in this_and_neighbors:
119
+ if candidate not in s.cluster_tiles:
120
+ continue
121
+ # The candidate is a cluster tile. Let's see whether any of the neighbors are also cluster tiles but with a different cluster. Then we need to join them.
122
+ for other in adjacent_to(candidate):
123
+ if other not in s.cluster_tiles:
124
+ continue
125
+ # The other tile is also a cluster tile.
126
+ if s.cluster_tiles[candidate] is s.cluster_tiles[other]:
127
+ continue
128
+ # The two clusters are not the same. We add the other's cluster tile to this tile.
129
+ s.cluster_tiles[candidate].extend(s.cluster_tiles[other])
130
+ # Update the other cluster tiles that they now point to the new cluster. This also updates the other tile.
131
+ for member in s.cluster_tiles[other]:
132
+ s.cluster_tiles[member] = s.cluster_tiles[candidate]
133
+ new_clusters = True
134
+
135
+ if new_clusters:
136
+ max_cluster_size = max(
137
+ (len(members) for members in s.cluster_tiles.values()),
138
+ default=0,
139
+ )
140
+ if max_cluster_size > max_cluster_so_far:
141
+ rows.append(
142
+ {
143
+ "time": row["first_time"],
144
+ "max_cluster_size": max_cluster_size,
145
+ }
146
+ )
147
+ max_cluster_size = max_cluster_so_far
148
+
149
+ new_cluster_evolution = pd.DataFrame(rows)
150
+ s.cluster_evolution = pd.concat([s.cluster_evolution, new_cluster_evolution])
151
+ s.start = len(tiles)
152
+ s.save()
153
+ return s
154
+
155
+
156
+ def bounding_box_for_biggest_cluster(
157
+ clusters: list[list[tuple[int, int]]], zoom: int
158
+ ) -> str:
159
+ biggest_cluster = max(clusters, key=lambda members: len(members))
160
+ min_x = min(x for x, y in biggest_cluster)
161
+ max_x = max(x for x, y in biggest_cluster)
162
+ min_y = min(y for x, y in biggest_cluster)
163
+ max_y = max(y for x, y in biggest_cluster)
164
+ lat_max, lon_min = get_tile_upper_left_lat_lon(min_x, min_y, zoom)
165
+ lat_min, lon_max = get_tile_upper_left_lat_lon(max_x, max_y, zoom)
166
+ return geojson.dumps(
167
+ geojson.Feature(
168
+ geometry=geojson.Polygon(
169
+ [
170
+ [
171
+ (lon_min, lat_max),
172
+ (lon_max, lat_max),
173
+ (lon_max, lat_min),
174
+ (lon_min, lat_min),
175
+ (lon_min, lat_max),
176
+ ]
177
+ ]
178
+ ),
179
+ )
180
+ )
181
+
182
+
183
+ class SquareHistoryState:
184
+ def __init__(self, zoom: int) -> None:
185
+ self._state_path = pathlib.Path(f"Cache/square_history_{zoom}_state.json")
186
+ self._square_history_path = pathlib.Path(f"Cache/square_history_{zoom}.parquet")
187
+
188
+ self.max_square_size = 0
189
+ self.start = 0
190
+ self.visited_tiles = set()
191
+ self.square_history = pd.DataFrame()
192
+
193
+ def load(self) -> None:
194
+ logger.info("Load explorer square state …")
195
+ if self._state_path.exists():
196
+ with open(self._state_path) as f:
197
+ data = json.load(f)
198
+ self.visited_tiles = set((x, y) for x, y in data["visited_tiles"])
199
+ self.max_square_size = data["max_square_size"]
200
+ self.start = data["start"]
201
+
202
+ if self._square_history_path.exists():
203
+ self.square_history = pd.read_parquet(self._square_history_path)
204
+
205
+ def save(self) -> None:
206
+ logger.info("Save explorer square state …")
207
+ data = {
208
+ "max_square_size": self.max_square_size,
209
+ "visited_tiles": list(self.visited_tiles),
210
+ "start": self.start,
211
+ }
212
+ with open(self._state_path, "w") as f:
213
+ json.dump(data, f)
214
+
215
+ self.square_history.to_parquet(self._square_history_path)
216
+
217
+
218
+ def get_square_history(zoom: int) -> SquareHistoryState:
219
+ tiles = pd.read_parquet(f"Cache/first_time_per_tile_{zoom}.parquet")
220
+ tiles.sort_values("first_time", inplace=True)
221
+ s = SquareHistoryState(zoom)
222
+ s.load()
223
+ logger.info("Compute new explorer square state …")
224
+ rows = []
225
+ for index, row in tiles.iloc[s.start :].iterrows():
226
+ tile = (row["tile_x"], row["tile_y"])
227
+ x, y = tile
228
+ s.visited_tiles.add(tile)
229
+ for square_size in itertools.count(s.max_square_size + 1):
230
+ this_tile_size_viable = False
231
+ for x_offset in range(square_size):
232
+ for y_offset in range(square_size):
233
+ this_offset_viable = True
234
+ for xx in range(square_size):
235
+ for yy in range(square_size):
236
+ if (
237
+ x + xx - x_offset,
238
+ y + yy - y_offset,
239
+ ) not in s.visited_tiles:
240
+ this_offset_viable = False
241
+ break
242
+ if not this_offset_viable:
243
+ break
244
+ if this_offset_viable:
245
+ s.max_square_size = square_size
246
+ rows.append(
247
+ {"time": row["first_time"], "max_square_size": square_size}
248
+ )
249
+ this_tile_size_viable = True
250
+ break
251
+ if this_tile_size_viable:
252
+ break
253
+ if not this_tile_size_viable:
254
+ break
255
+
256
+ new_square_history = pd.DataFrame(rows)
257
+ s.square_history = pd.concat([s.square_history, new_square_history])
258
+ s.start = len(tiles)
259
+ s.save()
260
+ return s