geo-activity-playground 0.40.0__py3-none-any.whl → 0.41.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.
Files changed (30) hide show
  1. geo_activity_playground/alembic/versions/38882503dc7c_add_tags_to_activities.py +70 -0
  2. geo_activity_playground/alembic/versions/script.py.mako +0 -6
  3. geo_activity_playground/core/activities.py +17 -30
  4. geo_activity_playground/core/datamodel.py +83 -2
  5. geo_activity_playground/core/test_datamodel.py +14 -1
  6. geo_activity_playground/importers/strava_checkout.py +2 -5
  7. geo_activity_playground/webui/app.py +6 -2
  8. geo_activity_playground/webui/blueprints/activity_blueprint.py +20 -3
  9. geo_activity_playground/webui/blueprints/bubble_chart_blueprint.py +50 -25
  10. geo_activity_playground/webui/blueprints/calendar_blueprint.py +12 -4
  11. geo_activity_playground/webui/blueprints/eddington_blueprints.py +253 -0
  12. geo_activity_playground/webui/blueprints/entry_views.py +30 -15
  13. geo_activity_playground/webui/blueprints/explorer_blueprint.py +83 -9
  14. geo_activity_playground/webui/blueprints/summary_blueprint.py +102 -42
  15. geo_activity_playground/webui/columns.py +37 -0
  16. geo_activity_playground/webui/templates/activity/show.html.j2 +15 -4
  17. geo_activity_playground/webui/templates/bubble_chart/index.html.j2 +24 -8
  18. geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +150 -0
  19. geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +150 -0
  20. geo_activity_playground/webui/templates/explorer/server-side.html.j2 +72 -0
  21. geo_activity_playground/webui/templates/home.html.j2 +14 -5
  22. geo_activity_playground/webui/templates/page.html.j2 +10 -1
  23. geo_activity_playground/webui/templates/summary/index.html.j2 +91 -2
  24. {geo_activity_playground-0.40.0.dist-info → geo_activity_playground-0.41.0.dist-info}/METADATA +1 -1
  25. {geo_activity_playground-0.40.0.dist-info → geo_activity_playground-0.41.0.dist-info}/RECORD +29 -24
  26. geo_activity_playground/webui/blueprints/eddington_blueprint.py +0 -194
  27. /geo_activity_playground/webui/templates/eddington/{index.html.j2 → distance.html.j2} +0 -0
  28. {geo_activity_playground-0.40.0.dist-info → geo_activity_playground-0.41.0.dist-info}/LICENSE +0 -0
  29. {geo_activity_playground-0.40.0.dist-info → geo_activity_playground-0.41.0.dist-info}/WHEEL +0 -0
  30. {geo_activity_playground-0.40.0.dist-info → geo_activity_playground-0.41.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,253 @@
1
+ import datetime
2
+ from math import ceil
3
+
4
+ import altair as alt
5
+ import numpy as np
6
+ import pandas as pd
7
+ from flask import Blueprint
8
+ from flask import render_template
9
+ from flask import Request
10
+ from flask import request
11
+
12
+ from ...core.activities import ActivityRepository
13
+ from ...core.meta_search import apply_search_query
14
+ from ..columns import column_distance
15
+ from ..columns import column_elevation_gain
16
+ from ..columns import ColumnDescription
17
+ from ..search_util import search_query_from_form
18
+ from ..search_util import SearchQueryHistory
19
+
20
+
21
+ def register_eddington_blueprint(
22
+ repository: ActivityRepository, search_query_history: SearchQueryHistory
23
+ ) -> Blueprint:
24
+ blueprint = Blueprint("eddington", __name__, template_folder="templates")
25
+
26
+ @blueprint.route("/")
27
+ def distance():
28
+ return _render_eddington_template(
29
+ repository, request, search_query_history, "distance", column_distance, [1]
30
+ )
31
+
32
+ @blueprint.route("/elevation_gain")
33
+ def elevation_gain():
34
+ return _render_eddington_template(
35
+ repository,
36
+ request,
37
+ search_query_history,
38
+ "elevation_gain",
39
+ column_elevation_gain,
40
+ [20, 10, 1],
41
+ )
42
+
43
+ return blueprint
44
+
45
+
46
+ def _render_eddington_template(
47
+ repository: ActivityRepository,
48
+ request: Request,
49
+ search_query_history: SearchQueryHistory,
50
+ template_name,
51
+ column: ColumnDescription,
52
+ divisor_values_avail: list[int],
53
+ ) -> str:
54
+
55
+ column_name = column.name
56
+ display_name = column.display_name
57
+ divisor = int(request.args.get("eddington_divisor") or divisor_values_avail[0])
58
+
59
+ query = search_query_from_form(request.args)
60
+ search_query_history.register_query(query)
61
+ activities = (
62
+ apply_search_query(repository.meta, query)
63
+ .dropna(subset=["start", column_name])
64
+ .copy()
65
+ )
66
+
67
+ activities["year"] = [start.year for start in activities["start"]]
68
+ activities["date"] = [start.date() for start in activities["start"]]
69
+ activities["isoyear"] = [start.isocalendar().year for start in activities["start"]]
70
+ activities["isoweek"] = [start.isocalendar().week for start in activities["start"]]
71
+
72
+ en_per_day, eddington_df_per_day = _get_values_per_group(
73
+ activities.groupby("date"), column_name, divisor
74
+ )
75
+ en_per_week, eddington_df_per_week = _get_values_per_group(
76
+ activities.groupby(["isoyear", "isoweek"]), column_name, divisor
77
+ )
78
+
79
+ return render_template(
80
+ f"eddington/{template_name}.html.j2",
81
+ eddington_number=en_per_day,
82
+ logarithmic_plot=_make_eddington_plot(
83
+ eddington_df_per_day, en_per_day, "Days", column_name, display_name, divisor
84
+ ),
85
+ eddington_per_week=en_per_week,
86
+ eddington_per_week_plot=_make_eddington_plot(
87
+ eddington_df_per_week,
88
+ en_per_week,
89
+ "Weeks",
90
+ column_name,
91
+ display_name,
92
+ divisor,
93
+ ),
94
+ eddington_table=eddington_df_per_day.loc[
95
+ (eddington_df_per_day[column_name] > en_per_day)
96
+ & (eddington_df_per_day[column_name] <= en_per_day + 10 * divisor)
97
+ & (eddington_df_per_day[column_name] % divisor == 0)
98
+ ].to_dict(orient="records"),
99
+ eddington_table_weeks=eddington_df_per_week.loc[
100
+ (eddington_df_per_week[column_name] > en_per_week)
101
+ & (eddington_df_per_week[column_name] <= en_per_week + 10 * divisor)
102
+ & (eddington_df_per_week[column_name] % divisor == 0)
103
+ ].to_dict(orient="records"),
104
+ query=query.to_jinja(),
105
+ yearly_eddington=_get_yearly_eddington(activities, column_name, divisor),
106
+ eddington_number_history_plot=_get_eddington_number_history(
107
+ activities, column_name, divisor
108
+ ),
109
+ eddington_divisor=divisor,
110
+ divisor_values_avail=divisor_values_avail,
111
+ )
112
+
113
+
114
+ def _get_values_per_group(grouped, columnName, divisor) -> tuple[int, pd.DataFrame]:
115
+ sum_per_group = grouped.apply(
116
+ lambda group: int(sum(group[columnName])), include_groups=False
117
+ )
118
+ counts = dict(zip(*np.unique(sorted(sum_per_group), return_counts=True)))
119
+ eddington = pd.DataFrame(
120
+ {columnName: d, "count": counts.get(d, 0)}
121
+ for d in range(max(counts.keys()) + 1)
122
+ )
123
+ eddington["total"] = eddington["count"][::-1].cumsum()[::-1]
124
+ eddington[f"{columnName}_div"] = eddington[columnName] // divisor
125
+ en = (
126
+ eddington.loc[eddington["total"] >= eddington[f"{columnName}_div"]][
127
+ "total"
128
+ ].iloc[-1]
129
+ * divisor
130
+ )
131
+ eddington["missing"] = eddington[f"{columnName}_div"] - eddington["total"]
132
+
133
+ return en, eddington
134
+
135
+
136
+ def _make_eddington_plot(
137
+ eddington_df: pd.DataFrame,
138
+ en: int,
139
+ interval: str,
140
+ column_name: str,
141
+ display_name: str,
142
+ divisor: int,
143
+ ) -> dict:
144
+ x = list(range(1, max(eddington_df[column_name]) + 1))
145
+ y = [v / divisor for v in x]
146
+ return (
147
+ (
148
+ (
149
+ alt.Chart(
150
+ eddington_df,
151
+ height=500,
152
+ width=800,
153
+ title=f"{display_name} Eddington Number {en}",
154
+ )
155
+ .mark_area(interpolate="step")
156
+ .encode(
157
+ alt.X(
158
+ column_name,
159
+ scale=alt.Scale(domainMin=0, domainMax=en * 3),
160
+ title=display_name,
161
+ ),
162
+ alt.Y(
163
+ "total",
164
+ scale=alt.Scale(domainMax=en / divisor * 1.5),
165
+ title=f"{interval} exceeding {display_name}",
166
+ ),
167
+ [
168
+ alt.Tooltip(column_name, title=display_name),
169
+ alt.Tooltip(
170
+ "total", title=f"{interval} exceeding {display_name}"
171
+ ),
172
+ alt.Tooltip("missing", title=f"{interval} missing for next"),
173
+ ],
174
+ )
175
+ )
176
+ + (
177
+ alt.Chart(pd.DataFrame({column_name: x, "total": y}))
178
+ .mark_line(color="red")
179
+ .encode(alt.X(column_name), alt.Y("total"))
180
+ )
181
+ )
182
+ .interactive(bind_x=True, bind_y=True)
183
+ .to_json(format="vega")
184
+ )
185
+
186
+
187
+ def _get_eddington_number(elevation_gains: pd.Series, divisor: int) -> int:
188
+ if len(elevation_gains) == 1:
189
+ if elevation_gains.iloc[0] >= 1:
190
+ return 1
191
+ else:
192
+ 0
193
+
194
+ sorted_elevation_gains = sorted(elevation_gains, reverse=True)
195
+
196
+ for number_of_days, elevation_gain in enumerate(sorted_elevation_gains, 1):
197
+ if elevation_gain / divisor < number_of_days:
198
+ return (number_of_days - 1) * divisor
199
+
200
+
201
+ def _get_yearly_eddington(
202
+ meta: pd.DataFrame, columnName: str, divisor: int
203
+ ) -> dict[int, int]:
204
+ meta = meta.dropna(subset=["start", columnName]).copy()
205
+ meta["year"] = [start.year for start in meta["start"]]
206
+ meta["date"] = [start.date() for start in meta["start"]]
207
+
208
+ yearly_eddington = meta.groupby("year").apply(
209
+ lambda group: _get_eddington_number(
210
+ group.groupby("date").apply(
211
+ lambda group2: int(group2[columnName].sum()), include_groups=False
212
+ ),
213
+ divisor,
214
+ ),
215
+ include_groups=False,
216
+ )
217
+ return yearly_eddington.to_dict()
218
+
219
+
220
+ def _get_eddington_number_history(
221
+ meta: pd.DataFrame, columnName: str, divisor: int
222
+ ) -> dict:
223
+
224
+ daily_elevation_gains = meta.groupby("date").apply(
225
+ lambda group2: int(group2[columnName].sum()), include_groups=False
226
+ )
227
+
228
+ eddington_number_history = {"date": [], "eddington_number": []}
229
+ top_days = []
230
+ for date, elevation_gain in daily_elevation_gains.items():
231
+ elevation_gain = elevation_gain / divisor
232
+ if len(top_days) == 0:
233
+ top_days.append(elevation_gain)
234
+ else:
235
+ if elevation_gain >= top_days[0]:
236
+ top_days.append(elevation_gain)
237
+ top_days.sort()
238
+ while top_days[0] < len(top_days):
239
+ top_days.pop(0)
240
+ eddington_number_history["date"].append(
241
+ datetime.datetime.combine(date, datetime.datetime.min.time())
242
+ )
243
+ eddington_number_history["eddington_number"].append(len(top_days) * divisor)
244
+ history = pd.DataFrame(eddington_number_history)
245
+
246
+ return (
247
+ alt.Chart(history)
248
+ .mark_line(interpolate="step-after")
249
+ .encode(
250
+ alt.X("date", title="Date"),
251
+ alt.Y("eddington_number", title="Eddington number"),
252
+ )
253
+ ).to_json(format="vega")
@@ -4,12 +4,18 @@ import datetime
4
4
  import altair as alt
5
5
  import flask
6
6
  import pandas as pd
7
+ import sqlalchemy
7
8
  from flask import render_template
8
9
  from flask import Response
9
10
 
10
11
  from ...core.activities import ActivityRepository
11
12
  from ...core.activities import make_geojson_from_time_series
12
13
  from ...core.config import Config
14
+ from ...core.datamodel import Activity
15
+ from ...core.datamodel import DB
16
+ from ..columns import column_distance
17
+ from ..columns import column_elevation_gain
18
+ from ..columns import ColumnDescription
13
19
  from ..plot_util import make_kind_scale
14
20
 
15
21
 
@@ -22,27 +28,32 @@ def register_entry_views(
22
28
 
23
29
  if len(repository):
24
30
  kind_scale = make_kind_scale(repository.meta, config)
25
- context["distance_last_30_days_plot"] = _distance_last_30_days_meta_plot(
26
- repository.meta, kind_scale
31
+ context["distance_last_30_days_plot"] = _last_30_days_meta_plot(
32
+ repository.meta, kind_scale, column_distance
33
+ )
34
+ context["elevation_gain_last_30_days_plot"] = _last_30_days_meta_plot(
35
+ repository.meta, kind_scale, column_elevation_gain
27
36
  )
28
37
 
29
- meta = repository.meta.copy()
30
- meta["date"] = meta["start"].dt.date
31
-
32
- context["latest_activities"] = collections.defaultdict(list)
33
- for date, activity_meta in list(meta.groupby("date"))[:-30:-1]:
34
- for index, activity in activity_meta.iterrows():
35
- time_series = repository.get_time_series(activity["id"])
36
- context["latest_activities"][date].append(
38
+ context["latest_activities"] = collections.defaultdict(list)
39
+ for activity in DB.session.scalars(
40
+ sqlalchemy.select(Activity).order_by(Activity.start.desc()).limit(100)
41
+ ):
42
+ context["latest_activities"][activity.start.date()].append(
37
43
  {
38
44
  "activity": activity,
39
- "line_geojson": make_geojson_from_time_series(time_series),
45
+ "line_geojson": make_geojson_from_time_series(
46
+ activity.time_series
47
+ ),
40
48
  }
41
49
  )
50
+
42
51
  return render_template("home.html.j2", **context)
43
52
 
44
53
 
45
- def _distance_last_30_days_meta_plot(meta: pd.DataFrame, kind_scale: alt.Scale) -> str:
54
+ def _last_30_days_meta_plot(
55
+ meta: pd.DataFrame, kind_scale: alt.Scale, column: ColumnDescription
56
+ ) -> str:
46
57
  before_30_days = pd.to_datetime(
47
58
  datetime.datetime.now() - datetime.timedelta(days=31)
48
59
  )
@@ -51,17 +62,21 @@ def _distance_last_30_days_meta_plot(meta: pd.DataFrame, kind_scale: alt.Scale)
51
62
  meta.loc[meta["start"] > before_30_days],
52
63
  width=700,
53
64
  height=200,
54
- title="Distance per day",
65
+ title=f"{column.display_name} per day",
55
66
  )
56
67
  .mark_bar()
57
68
  .encode(
58
69
  alt.X("yearmonthdate(start)", title="Date"),
59
- alt.Y("sum(distance_km)", title="Distance / km"),
70
+ alt.Y(f"sum({column.name})", title=f"{column.name} / {column.unit}"),
60
71
  alt.Color("kind", scale=kind_scale, title="Kind"),
61
72
  [
62
73
  alt.Tooltip("yearmonthdate(start)", title="Date"),
63
74
  alt.Tooltip("kind", title="Kind"),
64
- alt.Tooltip("sum(distance_km)", format=".1f", title="Distance / km"),
75
+ alt.Tooltip(
76
+ f"sum({column.name})",
77
+ format=column.format,
78
+ title=f"{column.display_name} / {column.unit}",
79
+ ),
65
80
  ],
66
81
  )
67
82
  .to_json(format="vega")
@@ -1,12 +1,15 @@
1
1
  import datetime
2
+ import io
2
3
  import itertools
3
4
  import logging
4
5
 
5
6
  import altair as alt
6
7
  import geojson
7
8
  import matplotlib
9
+ import matplotlib.pyplot as pl
8
10
  import numpy as np
9
11
  import pandas as pd
12
+ import sqlalchemy
10
13
  from flask import Blueprint
11
14
  from flask import flash
12
15
  from flask import redirect
@@ -17,6 +20,10 @@ from flask import url_for
17
20
  from ...core.activities import ActivityRepository
18
21
  from ...core.config import ConfigAccessor
19
22
  from ...core.coordinates import Bounds
23
+ from ...core.datamodel import Activity
24
+ from ...core.datamodel import DB
25
+ from ...core.raster_map import ImageTransform
26
+ from ...core.raster_map import TileGetter
20
27
  from ...core.tiles import compute_tile
21
28
  from ...core.tiles import get_tile_upper_left_lat_lon
22
29
  from ...explorer.grid_file import get_border_tiles
@@ -38,9 +45,10 @@ logger = logging.getLogger(__name__)
38
45
 
39
46
  def make_explorer_blueprint(
40
47
  authenticator: Authenticator,
41
- repository: ActivityRepository,
42
48
  tile_visit_accessor: TileVisitAccessor,
43
49
  config_accessor: ConfigAccessor,
50
+ tile_getter: TileGetter,
51
+ image_transforms: dict[str, ImageTransform],
44
52
  ) -> Blueprint:
45
53
  blueprint = Blueprint("explorer", __name__, template_folder="templates")
46
54
 
@@ -59,7 +67,7 @@ def make_explorer_blueprint(
59
67
  )
60
68
 
61
69
  explored = get_three_color_tiles(
62
- tile_visits[zoom], repository, tile_evolution_states[zoom], zoom
70
+ tile_visits[zoom], tile_evolution_states[zoom], zoom
63
71
  )
64
72
 
65
73
  context = {
@@ -151,12 +159,74 @@ def make_explorer_blueprint(
151
159
  headers={"Content-disposition": "attachment"},
152
160
  )
153
161
 
162
+ @blueprint.route("/<int:zoom>/server-side")
163
+ def server_side(zoom: int):
164
+ if zoom not in config_accessor().explorer_zoom_levels:
165
+ return {"zoom_level_not_generated": zoom}
166
+
167
+ tile_evolution_states = tile_visit_accessor.tile_state["evolution_state"]
168
+ tile_histories = tile_visit_accessor.tile_state["tile_history"]
169
+
170
+ medians = tile_histories[zoom].median()
171
+ median_lat, median_lon = get_tile_upper_left_lat_lon(
172
+ medians["tile_x"], medians["tile_y"], zoom
173
+ )
174
+
175
+ context = {
176
+ "center": {
177
+ "latitude": median_lat,
178
+ "longitude": median_lon,
179
+ "bbox": (
180
+ bounding_box_for_biggest_cluster(
181
+ tile_evolution_states[zoom].clusters.values(), zoom
182
+ )
183
+ if len(tile_evolution_states[zoom].memberships) > 0
184
+ else {}
185
+ ),
186
+ },
187
+ "plot_tile_evolution": plot_tile_evolution(tile_histories[zoom]),
188
+ "plot_cluster_evolution": plot_cluster_evolution(
189
+ tile_evolution_states[zoom].cluster_evolution
190
+ ),
191
+ "plot_square_evolution": plot_square_evolution(
192
+ tile_evolution_states[zoom].square_evolution
193
+ ),
194
+ "zoom": zoom,
195
+ }
196
+ return render_template("explorer/server-side.html.j2", **context)
197
+
198
+ @blueprint.route("/<int:zoom>/tile/<int:z>/<int:x>/<int:y>.png")
199
+ def tile(zoom: int, z: int, x: int, y: int) -> Response:
200
+ tile_visits = tile_visit_accessor.tile_state["tile_visits"][zoom]
201
+
202
+ map_tile = np.array(tile_getter.get_tile(z, x, y)) / 255
203
+ if z >= zoom:
204
+ factor = 2 ** (z - zoom)
205
+ if (x // factor, y // factor) in tile_visits:
206
+ map_tile = image_transforms["color"].transform_image(map_tile)
207
+ else:
208
+ map_tile = image_transforms["color"].transform_image(map_tile) / 1.2
209
+ else:
210
+ grayscale = image_transforms["color"].transform_image(map_tile) / 1.2
211
+ factor = 2 ** (zoom - z)
212
+ width = 256 // factor
213
+ for xo in range(factor):
214
+ for yo in range(factor):
215
+ if (x * factor + xo, y * factor + yo) not in tile_visits:
216
+ map_tile[
217
+ yo * width : (yo + 1) * width, xo * width : (xo + 1) * width
218
+ ] = grayscale[
219
+ yo * width : (yo + 1) * width, xo * width : (xo + 1) * width
220
+ ]
221
+ f = io.BytesIO()
222
+ pl.imsave(f, map_tile, format="png")
223
+ return Response(bytes(f.getbuffer()), mimetype="image/png")
224
+
154
225
  return blueprint
155
226
 
156
227
 
157
228
  def get_three_color_tiles(
158
229
  tile_visits: dict,
159
- repository: ActivityRepository,
160
230
  cluster_state: TileEvolutionState,
161
231
  zoom: int,
162
232
  ) -> str:
@@ -174,13 +244,17 @@ def get_three_color_tiles(
174
244
  last_age_days = 10000
175
245
  tile_dict[tile] = {
176
246
  "first_activity_id": str(tile_data["first_id"]),
177
- "first_activity_name": repository.get_activity_by_id(tile_data["first_id"])[
178
- "name"
179
- ],
247
+ "first_activity_name": DB.session.scalar(
248
+ sqlalchemy.select(Activity.name).where(
249
+ Activity.id == tile_data["first_id"]
250
+ )
251
+ ),
180
252
  "last_activity_id": str(tile_data["last_id"]),
181
- "last_activity_name": repository.get_activity_by_id(tile_data["last_id"])[
182
- "name"
183
- ],
253
+ "last_activity_name": DB.session.scalar(
254
+ sqlalchemy.select(Activity.name).where(
255
+ Activity.id == tile_data["last_id"]
256
+ )
257
+ ),
184
258
  "first_age_days": first_age_days,
185
259
  "first_age_color": matplotlib.colors.to_hex(
186
260
  cmap_first(max(1 - first_age_days / (2 * 365), 0.0))