geo-activity-playground 1.1.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geo_activity_playground/alembic/versions/85fe0348e8a2_add_time_series_uuid_field.py +28 -0
- geo_activity_playground/alembic/versions/f2f50843be2d_make_all_fields_in_activity_nullable.py +34 -0
- geo_activity_playground/core/coordinates.py +12 -1
- geo_activity_playground/core/copernicus_dem.py +95 -0
- geo_activity_playground/core/datamodel.py +78 -22
- geo_activity_playground/core/enrichment.py +226 -164
- geo_activity_playground/core/paths.py +8 -0
- geo_activity_playground/core/test_pandas_timezone.py +36 -0
- geo_activity_playground/core/test_time_zone_from_location.py +7 -0
- geo_activity_playground/core/test_time_zone_import.py +93 -0
- geo_activity_playground/core/test_timezone_sqlalchemy.py +44 -0
- geo_activity_playground/core/tiles.py +4 -1
- geo_activity_playground/core/time_conversion.py +42 -14
- geo_activity_playground/explorer/tile_visits.py +7 -4
- geo_activity_playground/importers/activity_parsers.py +21 -22
- geo_activity_playground/importers/directory.py +62 -108
- geo_activity_playground/importers/strava_api.py +53 -36
- geo_activity_playground/importers/strava_checkout.py +30 -56
- geo_activity_playground/webui/app.py +40 -2
- geo_activity_playground/webui/blueprints/activity_blueprint.py +13 -11
- geo_activity_playground/webui/blueprints/entry_views.py +1 -1
- geo_activity_playground/webui/blueprints/explorer_blueprint.py +1 -7
- geo_activity_playground/webui/blueprints/heatmap_blueprint.py +2 -2
- geo_activity_playground/webui/blueprints/photo_blueprint.py +65 -56
- geo_activity_playground/webui/blueprints/settings_blueprint.py +20 -14
- geo_activity_playground/webui/blueprints/summary_blueprint.py +6 -6
- geo_activity_playground/webui/blueprints/time_zone_fixer_blueprint.py +69 -0
- geo_activity_playground/webui/blueprints/upload_blueprint.py +3 -16
- geo_activity_playground/webui/columns.py +9 -1
- geo_activity_playground/webui/templates/activity/show.html.j2 +3 -1
- geo_activity_playground/webui/templates/equipment/index.html.j2 +3 -3
- geo_activity_playground/webui/templates/hall_of_fame/index.html.j2 +2 -3
- geo_activity_playground/webui/templates/home.html.j2 +4 -10
- geo_activity_playground/webui/templates/page.html.j2 +2 -0
- geo_activity_playground/webui/templates/photo/new.html.j2 +1 -1
- geo_activity_playground/webui/templates/settings/index.html.j2 +9 -0
- geo_activity_playground/webui/templates/settings/tile-source.html.j2 +33 -0
- geo_activity_playground/webui/templates/time_zone_fixer/index.html.j2 +31 -0
- {geo_activity_playground-1.1.0.dist-info → geo_activity_playground-1.3.0.dist-info}/METADATA +7 -3
- {geo_activity_playground-1.1.0.dist-info → geo_activity_playground-1.3.0.dist-info}/RECORD +43 -34
- geo_activity_playground/core/test_time_conversion.py +0 -37
- {geo_activity_playground-1.1.0.dist-info → geo_activity_playground-1.3.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-1.1.0.dist-info → geo_activity_playground-1.3.0.dist-info}/WHEEL +0 -0
- {geo_activity_playground-1.1.0.dist-info → geo_activity_playground-1.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
from typing import Sequence
|
2
|
+
from typing import Union
|
3
|
+
|
4
|
+
import sqlalchemy as sa
|
5
|
+
from alembic import op
|
6
|
+
|
7
|
+
|
8
|
+
# revision identifiers, used by Alembic.
|
9
|
+
revision: str = "85fe0348e8a2"
|
10
|
+
down_revision: Union[str, None] = "f2f50843be2d"
|
11
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
12
|
+
depends_on: Union[str, Sequence[str], None] = None
|
13
|
+
|
14
|
+
|
15
|
+
def upgrade() -> None:
|
16
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
17
|
+
with op.batch_alter_table("activities", schema=None) as batch_op:
|
18
|
+
batch_op.add_column(sa.Column("time_series_uuid", sa.String(), nullable=True))
|
19
|
+
|
20
|
+
# ### end Alembic commands ###
|
21
|
+
|
22
|
+
|
23
|
+
def downgrade() -> None:
|
24
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
25
|
+
with op.batch_alter_table("activities", schema=None) as batch_op:
|
26
|
+
batch_op.drop_column("time_series_uuid")
|
27
|
+
|
28
|
+
# ### end Alembic commands ###
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from typing import Sequence
|
2
|
+
from typing import Union
|
3
|
+
|
4
|
+
import sqlalchemy as sa
|
5
|
+
from alembic import op
|
6
|
+
|
7
|
+
|
8
|
+
# revision identifiers, used by Alembic.
|
9
|
+
revision: str = "f2f50843be2d"
|
10
|
+
down_revision: Union[str, None] = "dc8073871da7"
|
11
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
12
|
+
depends_on: Union[str, Sequence[str], None] = None
|
13
|
+
|
14
|
+
|
15
|
+
def upgrade() -> None:
|
16
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
17
|
+
with op.batch_alter_table("activities", schema=None) as batch_op:
|
18
|
+
batch_op.add_column(sa.Column("iana_timezone", sa.String(), nullable=True))
|
19
|
+
batch_op.add_column(sa.Column("start_country", sa.String(), nullable=True))
|
20
|
+
batch_op.alter_column("name", existing_type=sa.VARCHAR(), nullable=True)
|
21
|
+
batch_op.alter_column("distance_km", existing_type=sa.FLOAT(), nullable=True)
|
22
|
+
|
23
|
+
# ### end Alembic commands ###
|
24
|
+
|
25
|
+
|
26
|
+
def downgrade() -> None:
|
27
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
28
|
+
with op.batch_alter_table("activities", schema=None) as batch_op:
|
29
|
+
batch_op.alter_column("distance_km", existing_type=sa.FLOAT(), nullable=False)
|
30
|
+
batch_op.alter_column("name", existing_type=sa.VARCHAR(), nullable=False)
|
31
|
+
batch_op.drop_column("start_country")
|
32
|
+
batch_op.drop_column("iana_timezone")
|
33
|
+
|
34
|
+
# ### end Alembic commands ###
|
@@ -1,4 +1,7 @@
|
|
1
|
+
import typing
|
2
|
+
|
1
3
|
import numpy as np
|
4
|
+
import pandas as pd
|
2
5
|
|
3
6
|
|
4
7
|
class Bounds:
|
@@ -15,7 +18,15 @@ class Bounds:
|
|
15
18
|
return (self.x_min < x < self.x_max) and (self.y_min < y < self.y_max)
|
16
19
|
|
17
20
|
|
18
|
-
|
21
|
+
FloatOrSeries = typing.TypeVar("FloatOrSeries", float, np.ndarray, pd.Series)
|
22
|
+
|
23
|
+
|
24
|
+
def get_distance(
|
25
|
+
lat_1: FloatOrSeries,
|
26
|
+
lon_1: FloatOrSeries,
|
27
|
+
lat_2: FloatOrSeries,
|
28
|
+
lon_2: FloatOrSeries,
|
29
|
+
) -> FloatOrSeries:
|
19
30
|
"""
|
20
31
|
https://en.wikipedia.org/wiki/Haversine_formula
|
21
32
|
"""
|
@@ -0,0 +1,95 @@
|
|
1
|
+
import functools
|
2
|
+
import math
|
3
|
+
import pathlib
|
4
|
+
from typing import Optional
|
5
|
+
|
6
|
+
import boto3
|
7
|
+
import botocore.config
|
8
|
+
import botocore.exceptions
|
9
|
+
import geotiff
|
10
|
+
import numpy as np
|
11
|
+
from scipy.interpolate import RegularGridInterpolator
|
12
|
+
|
13
|
+
from .paths import USER_CACHE_DIR
|
14
|
+
|
15
|
+
|
16
|
+
def s3_path(lat: int, lon: int) -> pathlib.Path:
|
17
|
+
lat_str = f"N{(lat):02d}" if lat >= 0 else f"S{(-lat):02d}"
|
18
|
+
lon_str = f"E{(lon):03d}" if lon >= 0 else f"W{(-lon):03d}"
|
19
|
+
result = (
|
20
|
+
USER_CACHE_DIR
|
21
|
+
/ "Copernicus DEM"
|
22
|
+
/ f"Copernicus_DSM_COG_30_{lat_str}_00_{lon_str}_00_DEM.tif"
|
23
|
+
)
|
24
|
+
|
25
|
+
result.parent.mkdir(exist_ok=True)
|
26
|
+
return result
|
27
|
+
|
28
|
+
|
29
|
+
def ensure_copernicus_file(p: pathlib.Path) -> None:
|
30
|
+
if p.exists():
|
31
|
+
return
|
32
|
+
s3 = boto3.client(
|
33
|
+
"s3", config=botocore.config.Config(signature_version=botocore.UNSIGNED)
|
34
|
+
)
|
35
|
+
try:
|
36
|
+
s3.download_file("copernicus-dem-90m", f"{p.stem}/{p.name}", p)
|
37
|
+
except botocore.exceptions.ClientError as e:
|
38
|
+
pass
|
39
|
+
|
40
|
+
|
41
|
+
@functools.lru_cache(9)
|
42
|
+
def get_elevation_arrays(p: pathlib.Path) -> Optional[np.ndarray]:
|
43
|
+
ensure_copernicus_file(p)
|
44
|
+
if not p.exists():
|
45
|
+
return None
|
46
|
+
gt = geotiff.GeoTiff(p)
|
47
|
+
a = np.array(gt.read())
|
48
|
+
lon_array, lat_array = gt.get_coord_arrays()
|
49
|
+
return np.stack([a, lat_array, lon_array], axis=0)
|
50
|
+
|
51
|
+
|
52
|
+
@functools.lru_cache(1)
|
53
|
+
def get_interpolator(lat: int, lon: int) -> Optional[RegularGridInterpolator]:
|
54
|
+
arrays = get_elevation_arrays(s3_path(lat, lon))
|
55
|
+
# If we don't have data for the current center, we cannot do anything.
|
56
|
+
if arrays is None:
|
57
|
+
return None
|
58
|
+
|
59
|
+
# # Take a look at the neighbors. If all 8 neighbor grid cells are present, we can
|
60
|
+
# neighbor_shapes = [
|
61
|
+
# get_elevation_arrays(s3_path(lat + lat_offset, lon + lon_offset)).shape
|
62
|
+
# for lon_offset in [-1, 0, 1]
|
63
|
+
# for lat_offset in [-1, 0, 1]
|
64
|
+
# if get_elevation_arrays(s3_path(lat + lat_offset, lon + lon_offset)) is not None
|
65
|
+
# ]
|
66
|
+
# if len(neighbor_shapes) == 9 and len(set(neighbor_shapes)) == 1:
|
67
|
+
# arrays = np.concatenate(
|
68
|
+
# [
|
69
|
+
# np.concatenate(
|
70
|
+
# [
|
71
|
+
# get_elevation_arrays(
|
72
|
+
# s3_path(lat + lat_offset, lon + lon_offset)
|
73
|
+
# )
|
74
|
+
# for lon_offset in [-1, 0, 1]
|
75
|
+
# ],
|
76
|
+
# axis=2,
|
77
|
+
# )
|
78
|
+
# for lat_offset in [1, 0, -1]
|
79
|
+
# ],
|
80
|
+
# axis=1,
|
81
|
+
# )
|
82
|
+
lat_labels = arrays[1, :, 0]
|
83
|
+
lon_labels = arrays[2, 0, :]
|
84
|
+
|
85
|
+
return RegularGridInterpolator(
|
86
|
+
(lat_labels, lon_labels), arrays[0], bounds_error=False, fill_value=None
|
87
|
+
)
|
88
|
+
|
89
|
+
|
90
|
+
def get_elevation(lat: float, lon: float) -> float:
|
91
|
+
interpolator = get_interpolator(math.floor(lat), math.floor(lon))
|
92
|
+
if interpolator is not None:
|
93
|
+
return float(interpolator((lat, lon)))
|
94
|
+
else:
|
95
|
+
return 0.0
|
@@ -1,7 +1,11 @@
|
|
1
1
|
import datetime
|
2
2
|
import json
|
3
3
|
import logging
|
4
|
+
import os
|
4
5
|
import pathlib
|
6
|
+
import shutil
|
7
|
+
import uuid
|
8
|
+
import zoneinfo
|
5
9
|
from typing import Any
|
6
10
|
from typing import Optional
|
7
11
|
from typing import TypedDict
|
@@ -32,12 +36,24 @@ logger = logging.getLogger(__name__)
|
|
32
36
|
DEFAULT_UNKNOWN_NAME = "Unknown"
|
33
37
|
|
34
38
|
|
39
|
+
def format_timedelta(v: datetime.timedelta):
|
40
|
+
if pd.isna(v):
|
41
|
+
return "—"
|
42
|
+
else:
|
43
|
+
seconds = v.total_seconds()
|
44
|
+
h = int(seconds // 3600)
|
45
|
+
m = int(seconds // 60 % 60)
|
46
|
+
s = int(seconds // 1 % 60)
|
47
|
+
return f"{h}:{m:02d}:{s:02d}"
|
48
|
+
|
49
|
+
|
35
50
|
class ActivityMeta(TypedDict):
|
36
51
|
average_speed_elapsed_kmh: float
|
37
52
|
average_speed_moving_kmh: float
|
38
53
|
calories: float
|
39
54
|
commute: bool
|
40
55
|
consider_for_achievements: bool
|
56
|
+
copernicus_elevation_gain: float
|
41
57
|
distance_km: float
|
42
58
|
elapsed_time: datetime.timedelta
|
43
59
|
elevation_gain: float
|
@@ -74,27 +90,36 @@ class Activity(DB.Model):
|
|
74
90
|
|
75
91
|
# Housekeeping data:
|
76
92
|
id: Mapped[int] = mapped_column(primary_key=True)
|
77
|
-
name: Mapped[str] = mapped_column(sa.String, nullable=
|
78
|
-
distance_km: Mapped[float] = mapped_column(sa.Float, nullable=
|
93
|
+
name: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
94
|
+
distance_km: Mapped[Optional[float]] = mapped_column(sa.Float, nullable=True)
|
95
|
+
time_series_uuid: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
79
96
|
|
80
97
|
# Where it comes from:
|
81
|
-
path: Mapped[str] = mapped_column(sa.String, nullable=True)
|
82
|
-
upstream_id: Mapped[str] = mapped_column(sa.String, nullable=True)
|
98
|
+
path: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
99
|
+
upstream_id: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
83
100
|
|
84
101
|
# Crop data:
|
85
102
|
index_begin: Mapped[int] = mapped_column(sa.Integer, nullable=True)
|
86
103
|
index_end: Mapped[int] = mapped_column(sa.Integer, nullable=True)
|
87
104
|
|
88
105
|
# Temporal data:
|
89
|
-
start: Mapped[datetime.datetime] = mapped_column(
|
90
|
-
|
91
|
-
|
106
|
+
start: Mapped[Optional[datetime.datetime]] = mapped_column(
|
107
|
+
sa.DateTime, nullable=True
|
108
|
+
)
|
109
|
+
iana_timezone: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
110
|
+
elapsed_time: Mapped[Optional[datetime.timedelta]] = mapped_column(
|
111
|
+
sa.Interval, nullable=True
|
112
|
+
)
|
113
|
+
moving_time: Mapped[Optional[datetime.timedelta]] = mapped_column(
|
114
|
+
sa.Interval, nullable=True
|
115
|
+
)
|
92
116
|
|
93
117
|
# Geographic data:
|
94
118
|
start_latitude: Mapped[float] = mapped_column(sa.Float, nullable=True)
|
95
119
|
start_longitude: Mapped[float] = mapped_column(sa.Float, nullable=True)
|
96
120
|
end_latitude: Mapped[float] = mapped_column(sa.Float, nullable=True)
|
97
121
|
end_longitude: Mapped[float] = mapped_column(sa.Float, nullable=True)
|
122
|
+
start_country: Mapped[Optional[str]] = mapped_column(sa.String, nullable=True)
|
98
123
|
|
99
124
|
# Elevation data:
|
100
125
|
elevation_gain: Mapped[float] = mapped_column(sa.Float, nullable=True)
|
@@ -132,30 +157,36 @@ class Activity(DB.Model):
|
|
132
157
|
|
133
158
|
@property
|
134
159
|
def average_speed_moving_kmh(self) -> Optional[float]:
|
135
|
-
if self.moving_time:
|
160
|
+
if self.distance_km and self.moving_time:
|
136
161
|
return self.distance_km / (self.moving_time.total_seconds() / 3_600)
|
137
162
|
else:
|
138
163
|
return None
|
139
164
|
|
140
165
|
@property
|
141
166
|
def average_speed_elapsed_kmh(self) -> Optional[float]:
|
142
|
-
if self.elapsed_time:
|
167
|
+
if self.distance_km and self.elapsed_time:
|
143
168
|
return self.distance_km / (self.elapsed_time.total_seconds() / 3_600)
|
144
169
|
else:
|
145
170
|
return None
|
146
171
|
|
172
|
+
@property
|
173
|
+
def time_series_path(self) -> pathlib.Path:
|
174
|
+
return TIME_SERIES_DIR() / f"{self.time_series_uuid}.parquet"
|
175
|
+
|
147
176
|
@property
|
148
177
|
def raw_time_series(self) -> pd.DataFrame:
|
149
|
-
path = TIME_SERIES_DIR() / f"{self.id}.parquet"
|
150
178
|
try:
|
151
|
-
time_series = pd.read_parquet(
|
179
|
+
time_series = pd.read_parquet(self.time_series_path)
|
152
180
|
if "altitude" in time_series.columns:
|
153
181
|
time_series.rename(columns={"altitude": "elevation"}, inplace=True)
|
154
182
|
return time_series
|
155
183
|
except OSError as e:
|
156
|
-
logger.error(f"Error while reading {
|
184
|
+
logger.error(f"Error while reading {self.time_series_path}.")
|
157
185
|
raise
|
158
186
|
|
187
|
+
def replace_time_series(self, time_series: pd.DataFrame) -> None:
|
188
|
+
time_series.to_parquet(self.time_series_path)
|
189
|
+
|
159
190
|
@property
|
160
191
|
def time_series(self) -> pd.DataFrame:
|
161
192
|
if self.index_begin or self.index_end:
|
@@ -165,6 +196,23 @@ class Activity(DB.Model):
|
|
165
196
|
else:
|
166
197
|
return self.raw_time_series
|
167
198
|
|
199
|
+
@property
|
200
|
+
def emoji_string(self) -> str:
|
201
|
+
bits = []
|
202
|
+
if self.kind:
|
203
|
+
bits.append(f"{self.kind.name} with")
|
204
|
+
if self.distance_km:
|
205
|
+
bits.append(f"📏 {round(self.distance_km, 1)} km")
|
206
|
+
if self.elapsed_time:
|
207
|
+
bits.append(f"⏱️ {format_timedelta(self.elapsed_time)} h")
|
208
|
+
if self.elevation_gain:
|
209
|
+
bits.append(f"⛰️ {round(self.elevation_gain, 1)} m")
|
210
|
+
if self.calories:
|
211
|
+
bits.append(f"🍭 {self.calories} kcal")
|
212
|
+
if self.steps:
|
213
|
+
bits.append(f"👣 {self.steps}")
|
214
|
+
return " ".join(bits)
|
215
|
+
|
168
216
|
def delete_data(self) -> None:
|
169
217
|
for path in [
|
170
218
|
TIME_SERIES_DIR() / f"{self.id}.parquet",
|
@@ -173,6 +221,15 @@ class Activity(DB.Model):
|
|
173
221
|
]:
|
174
222
|
path.unlink(missing_ok=True)
|
175
223
|
|
224
|
+
@property
|
225
|
+
def start_local_tz(self) -> Optional[datetime.datetime]:
|
226
|
+
if self.start and self.iana_timezone:
|
227
|
+
return self.start.replace(
|
228
|
+
microsecond=0, tzinfo=zoneinfo.ZoneInfo("UTC")
|
229
|
+
).astimezone(zoneinfo.ZoneInfo(self.iana_timezone))
|
230
|
+
else:
|
231
|
+
return self.start
|
232
|
+
|
176
233
|
|
177
234
|
class Tag(DB.Model):
|
178
235
|
__tablename__ = "tags"
|
@@ -229,14 +286,15 @@ def query_activity_meta(clauses: list = []) -> pd.DataFrame:
|
|
229
286
|
.order_by(Activity.start)
|
230
287
|
).all()
|
231
288
|
df = pd.DataFrame(rows)
|
232
|
-
# If the search yields only activities without time information, the dtype isn't derived correctly.
|
233
|
-
df["start"] = pd.to_datetime(df["start"])
|
234
|
-
# start = df["start"].to_list()
|
235
|
-
# random.shuffle(start)
|
236
|
-
# df["start"] = pd.Series(start)
|
237
|
-
df["elapsed_time"] = pd.to_timedelta(df["elapsed_time"])
|
238
289
|
|
239
290
|
if len(df):
|
291
|
+
# If the search yields only activities without time information, the dtype isn't derived correctly.
|
292
|
+
df["start"] = pd.to_datetime(df["start"])
|
293
|
+
# start = df["start"].to_list()
|
294
|
+
# random.shuffle(start)
|
295
|
+
# df["start"] = pd.Series(start)
|
296
|
+
df["elapsed_time"] = pd.to_timedelta(df["elapsed_time"])
|
297
|
+
|
240
298
|
for old, new in [
|
241
299
|
("elapsed_time", "average_speed_elapsed_kmh"),
|
242
300
|
("moving_time", "average_speed_moving_kmh"),
|
@@ -300,7 +358,6 @@ def get_or_make_equipment(name: str, config: Config) -> Equipment:
|
|
300
358
|
equipment = Equipment(
|
301
359
|
name=name, offset_km=config.equipment_offsets.get(name, 0)
|
302
360
|
)
|
303
|
-
DB.session.add(equipment)
|
304
361
|
return equipment
|
305
362
|
|
306
363
|
|
@@ -327,7 +384,7 @@ class Kind(DB.Model):
|
|
327
384
|
__table_args__ = (sa.UniqueConstraint("name", name="kinds_name"),)
|
328
385
|
|
329
386
|
|
330
|
-
def get_or_make_kind(name: str
|
387
|
+
def get_or_make_kind(name: str) -> Kind:
|
331
388
|
kinds = DB.session.scalars(sqlalchemy.select(Kind).where(Kind.name == name)).all()
|
332
389
|
if kinds:
|
333
390
|
assert len(kinds) == 1, f"There must be only one kind with name '{name}'."
|
@@ -335,9 +392,8 @@ def get_or_make_kind(name: str, config: Config) -> Kind:
|
|
335
392
|
else:
|
336
393
|
kind = Kind(
|
337
394
|
name=name,
|
338
|
-
consider_for_achievements=
|
395
|
+
consider_for_achievements=True,
|
339
396
|
)
|
340
|
-
DB.session.add(kind)
|
341
397
|
return kind
|
342
398
|
|
343
399
|
|