garmin
A tool to work with Garmin data.
Home
Source code
git clone https://projects.rickardlindberg.me/scm/garmin.git
Recent events
2025-08-27 21:21 Rickard pushed to garmin
commit 38a02c31ccd6139b25b3b5bd14819030e91bfbec
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Wed Aug 27 21:15:59 2025 +0200
Quick and dirty way to store shoe information for activities
diff --git a/garmin b/garmin
index 55abca1..5944003 100755
--- a/garmin
+++ b/garmin
@@ -30,6 +30,13 @@ COLOR_BORDER_SOFT = (0.2, 0.2, 0.2)
DEFAULT_NUMBER_OF_WEEKS = 30
+MY_SHOES = [
+ None,
+ "Altra Olympus 5",
+ "Saucony Triumph 22",
+]
+
+
class GtkPanel(Gtk.DrawingArea):
def __init__(self, panel):
@@ -311,6 +318,7 @@ class ActivityEditor:
self.activity = activity
self.record_points = []
self.hovered_point_record = None
+ self.activity.load_extra()
def draw(self, canvas, rect):
self.record_points = []
@@ -320,6 +328,12 @@ class ActivityEditor:
max_pace = max(record.pace for record in records)
record_points = []
points = []
+ self.shoe_rect = Rect(
+ x=inner.x,
+ y=inner.y+inner.height,
+ width=200,
+ height=20,
+ )
if max_pace > Pace.in_meters_per_second(0):
for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
r = pace_sub_rect.scale_down(record.pace / max_pace)
@@ -380,6 +394,7 @@ class ActivityEditor:
canvas.draw_rect(pace_rect, color=COLOR_BORDER_SOFT)
canvas.draw_rect(hr_rect, color=COLOR_BORDER_SOFT)
canvas.draw_text(rect.top_slice(20), self.activity.summary)
+ canvas.draw_text(self.shoe_rect, self.activity.format_shoe())
if self.hovered_point_record:
point, record = self.hovered_point_record
canvas.fill_rect(Rect(
@@ -409,7 +424,8 @@ class ActivityEditor:
pass
def mouse_scroll(self, point, amount):
- pass
+ if self.shoe_rect.contains(point):
+ self.activity.alternate_shoe(amount)
class ActivityDatabase:
@@ -632,6 +648,39 @@ class Activity:
self.db = db
self.json = json
+ def alternate_shoe(self, amount):
+ self.shoe += amount
+ self.write_shoe()
+
+ def format_shoe(self):
+ return f"Shoe: {MY_SHOES[self.shoe % len(MY_SHOES)]}"
+
+ def load_extra(self):
+ data = self.read_extra()
+ self.shoe = MY_SHOES.index(data.get("shoe"))
+
+ def write_shoe(self):
+ data = self.read_extra()
+ data["shoe"] = MY_SHOES[self.shoe]
+ self.write_extra(data)
+
+ def read_extra(self):
+ if os.path.exists(self.extra_path()):
+ with open(self.extra_path()) as f:
+ return json.load(f)
+ else:
+ return {}
+
+ def write_extra(self, data):
+ with open(self.extra_path(), "w") as f:
+ f.write(json.dumps(data, indent=2))
+
+ def extra_path(self):
+ path = self.db.fit_path(self.json.get("fit_path"))
+ root, ext = os.path.splitext(path)
+ path = root + ".json"
+ return path
+
def get_paces_at_heart_rate(self, heart_rate, variation):
paces = []
subs = []
2025-08-17 21:24 Rickard pushed to garmin
commit 81ace6c61b47f0bd6d506e59f2d5d79528432aa5
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 17 21:22:16 2025 +0200
Fix mistake in graph_area assignment.
diff --git a/garmin b/garmin
index 60b37cf..55abca1 100755
--- a/garmin
+++ b/garmin
@@ -173,8 +173,8 @@ class TotalDistance:
slots = Timestamp.week_range(DEFAULT_NUMBER_OF_WEEKS)
activity_groups = [self.activities.get_year_week(x) for x in slots]
total_max = max(activities.distance for activities in activity_groups)
+ graph_area = rect.deflate(15, 40)
if total_max > Distance.in_meters(0):
- graph_area = rect.deflate(15, 40)
if self.hovered_activity:
canvas.draw_text(Rect(
x=graph_area.x,
commit 9ff9f6b3fa2bb6c1735e87a32251a77fe60ca501
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 17 21:21:10 2025 +0200
Fix more divide by zero cases.
diff --git a/garmin b/garmin
index f8ba46e..60b37cf 100755
--- a/garmin
+++ b/garmin
@@ -173,43 +173,44 @@ class TotalDistance:
slots = Timestamp.week_range(DEFAULT_NUMBER_OF_WEEKS)
activity_groups = [self.activities.get_year_week(x) for x in slots]
total_max = max(activities.distance for activities in activity_groups)
- graph_area = rect.deflate(15, 40)
- if self.hovered_activity:
- canvas.draw_text(Rect(
- x=graph_area.x,
- y=graph_area.y+graph_area.height,
- width=graph_area.width,
- height=20,
- ), self.hovered_activity.summary)
- for rect, slot, activities in zip(
- graph_area.split_into_columns(count=len(slots)),
- slots,
- activity_groups,
- ):
- bar_rect = rect.deflate(dx=5, dy=0).scale_down(activities.distance / total_max)
- inner_bar_rect = bar_rect.deflate(dx=0, dy=0)
- y = inner_bar_rect.y + inner_bar_rect.height
- color = COLOR_A
- for activity in activities:
- percent = activity.distance / activities.distance
- if percent > 0:
- my_height = inner_bar_rect.height*percent
- activity_rect = Rect(
- x=inner_bar_rect.x,
- y=y-my_height,
- width=inner_bar_rect.width,
- height=my_height,
- )
- self.activity_rects.append((activity, activity_rect))
- canvas.fill_rect(activity_rect, color=color)
- y -= my_height
- if color == COLOR_A:
- color = COLOR_B
- else:
- color = COLOR_A
- canvas.draw_rect(bar_rect, color=COLOR_BORDER_SOFT, size=2)
- if bar_rect.height > 0:
- canvas.draw_text(bar_rect.top_slice(20).move(dy=-20), activities.distance.format())
+ if total_max > Distance.in_meters(0):
+ graph_area = rect.deflate(15, 40)
+ if self.hovered_activity:
+ canvas.draw_text(Rect(
+ x=graph_area.x,
+ y=graph_area.y+graph_area.height,
+ width=graph_area.width,
+ height=20,
+ ), self.hovered_activity.summary)
+ for rect, slot, activities in zip(
+ graph_area.split_into_columns(count=len(slots)),
+ slots,
+ activity_groups,
+ ):
+ bar_rect = rect.deflate(dx=5, dy=0).scale_down(activities.distance / total_max)
+ inner_bar_rect = bar_rect.deflate(dx=0, dy=0)
+ y = inner_bar_rect.y + inner_bar_rect.height
+ color = COLOR_A
+ for activity in activities:
+ percent = activity.distance / activities.distance
+ if percent > 0:
+ my_height = inner_bar_rect.height*percent
+ activity_rect = Rect(
+ x=inner_bar_rect.x,
+ y=y-my_height,
+ width=inner_bar_rect.width,
+ height=my_height,
+ )
+ self.activity_rects.append((activity, activity_rect))
+ canvas.fill_rect(activity_rect, color=color)
+ y -= my_height
+ if color == COLOR_A:
+ color = COLOR_B
+ else:
+ color = COLOR_A
+ canvas.draw_rect(bar_rect, color=COLOR_BORDER_SOFT, size=2)
+ if bar_rect.height > 0:
+ canvas.draw_text(bar_rect.top_slice(20).move(dy=-20), activities.distance.format())
canvas.draw_rect(graph_area, color=COLOR_BORDER_SOFT, size=1)
if self.hovered_rect:
canvas.draw_rect(self.hovered_rect, color=COLOR_BORDER_HARD, size=3)
@@ -319,10 +320,11 @@ class ActivityEditor:
max_pace = max(record.pace for record in records)
record_points = []
points = []
- for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
- r = pace_sub_rect.scale_down(record.pace / max_pace)
- record_points.append((r.top_mid, record))
- points.append(r.top_mid)
+ if max_pace > Pace.in_meters_per_second(0):
+ for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
+ r = pace_sub_rect.scale_down(record.pace / max_pace)
+ record_points.append((r.top_mid, record))
+ points.append(r.top_mid)
self.record_points.append((pace_rect, record_points))
points.insert(0, Point(x=points[0].x, y=pace_rect.y+pace_rect.height))
points.append(Point(x=points[-1].x, y=pace_rect.y+pace_rect.height))
commit c5663c36ceba086dfe46a040ccfdc32fb3988737
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 17 21:13:23 2025 +0200
Fix one divide by zero.
diff --git a/garmin b/garmin
index 1b42864..f8ba46e 100755
--- a/garmin
+++ b/garmin
@@ -913,11 +913,17 @@ class Pace:
"""
>>> Pace.in_meters_per_second(2.6).format()
'6:24 min/km'
+
+ >>> Pace.in_meters_per_second(0).format()
+ 'inf min/km'
"""
- minutes_per_km = 1 / self.km_per_minute
- minutes = int(minutes_per_km)
- seconds = int((minutes_per_km-minutes)*60)
- return f"{minutes}:{seconds:02d} min/km"
+ if self.km_per_minute > 0:
+ minutes_per_km = 1 / self.km_per_minute
+ minutes = int(minutes_per_km)
+ seconds = int((minutes_per_km-minutes)*60)
+ return f"{minutes}:{seconds:02d} min/km"
+ else:
+ return f"inf min/km"
class Point:
2025-08-17 10:50 Rickard pushed to garmin
commit 5f39d7b788963094cdd798134721e8c4cf150017
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 17 10:49:43 2025 +0200
Don't import cairo. Figure out the constant instead.
diff --git a/garmin b/garmin
index de8c90f..1b42864 100755
--- a/garmin
+++ b/garmin
@@ -16,7 +16,9 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from gi.repository import Gdk
-import cairo
+
+
+CAIRO_LINE_JOIN_ROUND = 1
COLOR_BACKGROUND = (1, 0.7, 1)
@@ -88,7 +90,7 @@ class GtkCanvas:
self.context.set_line_width(size)
self.context.set_source_rgb(*color)
self.context.rectangle(rect.x, rect.y, rect.width, rect.height)
- self.context.set_line_join(cairo.LineJoin.ROUND)
+ self.context.set_line_join(CAIRO_LINE_JOIN_ROUND)
self.context.stroke()
def fill_rect(self, rect, color):
@@ -101,7 +103,7 @@ class GtkCanvas:
self.context.set_source_rgb(*color)
for point in path:
self.context.line_to(point.x, point.y)
- self.context.set_line_join(cairo.LineJoin.ROUND)
+ self.context.set_line_join(CAIRO_LINE_JOIN_ROUND)
self.context.stroke()
def fill_path(self, path, color, size=2):
2025-08-17 10:39 Rickard pushed to garmin
commit a8a4c3598182055080f83b418e6c8551c58af7be
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Tue Aug 12 21:40:27 2025 +0200
Document understanding of fromtimestamp in test.
diff --git a/garmin b/garmin
index 70ed726..de8c90f 100755
--- a/garmin
+++ b/garmin
@@ -503,6 +503,9 @@ class Timestamp:
>>> datetime.datetime.fromtimestamp(0)
datetime.datetime(1970, 1, 1, 1, 0)
+ >>> datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
+ datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
+
>>> d = datetime.datetime(2025, 8, 7, 12, 0, 0)
>>> d
datetime.datetime(2025, 8, 7, 12, 0)
commit 0f1be951808fa43d199659e2310ab6a5868c6b5b
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Tue Aug 12 21:29:06 2025 +0200
Add notes.
diff --git a/notes.md b/notes.md
index 080383f..88c435c 100644
--- a/notes.md
+++ b/notes.md
@@ -1,3 +1,5 @@
+# Notes
+
* https://wiki.openstreetmap.org/wiki/FIT
* sudo dnf install gpsbabel
* gpsbabel -i garmin_fit -f F81G0144.FIT -o gpx,garminextensions -F - | less
@@ -53,6 +55,4 @@ last record in period: 2025-07-11T16:44:55 | 127 bpm | 7:08 min/km
# TODO
-~~1. draw each individual event in the box~~
-2. show details when event is hovered
-3. show event in "event editor" when clicked
+* Is average calculation for PaceAtHeartRate correct?
commit 93bc5f011cb43fd80112d0f6a7602bbae7e9164f
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 13:42:42 2025 +0200
Always fill cache from db.
diff --git a/garmin b/garmin
index a22e466..70ed726 100755
--- a/garmin
+++ b/garmin
@@ -418,6 +418,8 @@ class ActivityDatabase:
if os.path.exists(self.path):
with open(self.path) as f:
self.json = json.load(f)
+ for fit_name in glob.glob(os.path.join(os.path.dirname(self.path), "*.fit")):
+ self.import_fit_activity(os.path.basename(fit_name))
def save(self):
with open(self.path, "w") as f:
commit da1a3a72ba41e335563b7803a8c1f07267ff22b0
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 13:32:43 2025 +0200
More readable db json for better git diff.
diff --git a/garmin b/garmin
index a41642f..a22e466 100755
--- a/garmin
+++ b/garmin
@@ -421,7 +421,7 @@ class ActivityDatabase:
def save(self):
with open(self.path, "w") as f:
- f.write(json.dumps(self.json))
+ f.write(json.dumps(self.json, indent=2))
def fit_path(self, fit_name):
return os.path.join(os.path.dirname(self.path), fit_name)
commit a24997c301390f8f92002f374d6c4aa8f055a88b
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 13:32:28 2025 +0200
Take db_dir and import_dir from args.
diff --git a/garmin b/garmin
index 565c61f..a41642f 100755
--- a/garmin
+++ b/garmin
@@ -1016,9 +1016,10 @@ if __name__ == "__main__":
if "--selftest" in sys.argv[1:]:
print(doctest.testmod())
else:
- db = ActivityDatabase("db/db.json")
+ db_dir, import_dir = sys.argv[1:]
+ db = ActivityDatabase(os.path.join(db_dir, "cache.json"))
db.load()
- db.import_fit_files_from_folder("GARMIN/GARMIN/ACTIVITY")
+ db.import_fit_files_from_folder(import_dir)
db.save()
GtkApp().open_diagram(Dashboard([
TotalDistance(db),
2025-08-10 11:11 Rickard pushed to garmin
commit 1c51a081801a444f2cd72870c96a1d258ac16f0e
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 11:11:26 2025 +0200
Nicer line join.
diff --git a/garmin b/garmin
index 4aadfb3..565c61f 100755
--- a/garmin
+++ b/garmin
@@ -16,6 +16,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from gi.repository import Gdk
+import cairo
COLOR_BACKGROUND = (1, 0.7, 1)
@@ -87,6 +88,7 @@ class GtkCanvas:
self.context.set_line_width(size)
self.context.set_source_rgb(*color)
self.context.rectangle(rect.x, rect.y, rect.width, rect.height)
+ self.context.set_line_join(cairo.LineJoin.ROUND)
self.context.stroke()
def fill_rect(self, rect, color):
@@ -99,6 +101,7 @@ class GtkCanvas:
self.context.set_source_rgb(*color)
for point in path:
self.context.line_to(point.x, point.y)
+ self.context.set_line_join(cairo.LineJoin.ROUND)
self.context.stroke()
def fill_path(self, path, color, size=2):
commit f1238bd2133c66396bf9909638c3c97510ae219f
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 11:06:35 2025 +0200
Highlight hoovered record.
diff --git a/garmin b/garmin
index 2ced040..4aadfb3 100755
--- a/garmin
+++ b/garmin
@@ -303,20 +303,22 @@ class ActivityEditor:
def __init__(self, activity):
self.activity = activity
- self.record_rects = []
- self.hovered_record = None
+ self.record_points = []
+ self.hovered_point_record = None
def draw(self, canvas, rect):
- self.record_rects = []
+ self.record_points = []
inner = rect.deflate(dx=5, dy=30)
pace_rect, hr_rect = [x.deflate(5, 5) for x in inner.split_into_rows(2)]
records = self.activity.records
max_pace = max(record.pace for record in records)
+ record_points = []
points = []
for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
r = pace_sub_rect.scale_down(record.pace / max_pace)
- self.record_rects.append((r, record))
+ record_points.append((r.top_mid, record))
points.append(r.top_mid)
+ self.record_points.append((pace_rect, record_points))
points.insert(0, Point(x=points[0].x, y=pace_rect.y+pace_rect.height))
points.append(Point(x=points[-1].x, y=pace_rect.y+pace_rect.height))
canvas.fill_path(
@@ -338,13 +340,15 @@ class ActivityEditor:
r,
lap.averate_pace.format()
)
+ record_points = []
max_hr = max(record.heart_rate for record in records)
if max_hr > HeartRate.in_bpm(0):
points = []
for hr_sub_rect, record in zip(hr_rect.split_into_columns_percents(records.percents), records):
r = hr_sub_rect.scale_down(record.heart_rate / max_hr)
- self.record_rects.append((r, record))
+ record_points.append((r.top_mid, record))
points.append(r.top_mid)
+ self.record_points.append((hr_rect, record_points))
points.insert(0, Point(x=points[0].x, y=hr_rect.y+hr_rect.height))
points.append(Point(x=points[-1].x, y=hr_rect.y+hr_rect.height))
canvas.fill_path(
@@ -369,20 +373,30 @@ class ActivityEditor:
canvas.draw_rect(pace_rect, color=COLOR_BORDER_SOFT)
canvas.draw_rect(hr_rect, color=COLOR_BORDER_SOFT)
canvas.draw_text(rect.top_slice(20), self.activity.summary)
- if self.hovered_record:
+ if self.hovered_point_record:
+ point, record = self.hovered_point_record
+ canvas.fill_rect(Rect(
+ x=point.x-1,
+ y=point.y-1,
+ width=3,
+ height=3,
+ ), color=(1, 1, 0.2))
canvas.draw_text(Rect(
x=inner.x,
y=inner.y+inner.height,
width=inner.width,
height=20,
- ), self.hovered_record.summary)
+ ), record.summary)
def mouse(self, point):
- for rect, record in self.record_rects:
- if rect.contains(point):
- self.hovered_record = record
+ for rect, points in self.record_points:
+ if rect.contains(point) and points:
+ self.hovered_point_record = min(
+ points,
+ key=lambda point_record: point_record[0].x_y_distance_to(point)
+ )
return
- self.hovered_record = None
+ self.hovered_point_record = None
def mouse_click(self, point, app):
pass
@@ -902,6 +916,9 @@ class Point:
self.x = x
self.y = y
+ def x_y_distance_to(self, other):
+ return (int(abs(self.x-other.x)), int(abs(self.y-other.y)))
+
class Rect:
commit e5fc5c307ffc2a939273eb12ec36b21a8933e5bb
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 10:42:33 2025 +0200
Add assert.
diff --git a/garmin b/garmin
index aae18fc..2ced040 100755
--- a/garmin
+++ b/garmin
@@ -812,6 +812,7 @@ class Records:
(record.date - last_date) / self.total_time
)
last_date = record.date
+ assert len(records) == 0
return percents
def interpolate(self):
commit 80c1ab05ec19cdec4e6322dd30c60804d39cf4ba
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 10:38:36 2025 +0200
Better handling of multiple timer periods.
diff --git a/garmin b/garmin
index 32b7416..aae18fc 100755
--- a/garmin
+++ b/garmin
@@ -440,6 +440,19 @@ class ActivityDatabase:
decoder = Decoder(stream)
messages, errors = decoder.read(convert_datetimes_to_dates=True)
activity["fit_path"] = fit_name
+ activity["timer_periods"] = []
+ last_start = None
+ for event in messages.get("event_mesgs", []):
+ if event["event"] == "timer":
+ if event["event_type"] == "start":
+ last_start = event["timestamp"].timestamp()
+ elif event["event_type"] == "stop_all":
+ assert last_start is not None
+ activity["timer_periods"].append({
+ "start": last_start,
+ "end": event["timestamp"].timestamp(),
+ })
+ last_start = None
if messages["file_id_mesgs"][0]["type"] == "activity":
activity["type"] = messages["session_mesgs"][0]["sport"]
activity["distance"] = messages["session_mesgs"][0]["total_distance"]
@@ -493,6 +506,9 @@ class Timestamp:
def __lt__(self, other):
return self.timestamp < other.timestamp
+ def __le__(self, other):
+ return self.timestamp <= other.timestamp
+
def __sub__(self, other):
return Duration.in_seconds(self.timestamp - other.timestamp)
@@ -644,6 +660,10 @@ class Activity:
def laps(self):
return [Lap(x) for x in self.json.get("laps", [])]
+ @property
+ def timer_periods(self):
+ return [TimerPeriod(x) for x in self.json["timer_periods"]]
+
@property
def summary(self):
return f"{self.date.format()} | {self.type} | {self.distance.format()} | {self.duration.format()}"
@@ -657,6 +677,7 @@ class Activity:
start_date=self.date,
total_time=self.duration,
laps=Laps([Lap(x) for x in messages.get("lap_mesgs", [])]),
+ timer_periods=self.timer_periods,
records=[
Record(json)
for json
@@ -696,6 +717,23 @@ class Lap:
return Pace.in_meters_per_second(self.json["avg_speed"])
+class TimerPeriod:
+
+ def __init__(self, json):
+ self.json = json
+
+ @property
+ def start(self):
+ return Timestamp(self.json["start"])
+
+ @property
+ def end(self):
+ return Timestamp(self.json["end"])
+
+ def contains(self, timestamp):
+ return self.start <= timestamp <= self.end
+
+
class Duration:
@classmethod
@@ -742,11 +780,12 @@ class Duration:
class Records:
- def __init__(self, start_date, total_time, laps, records):
+ def __init__(self, start_date, total_time, laps, records, timer_periods):
self.start_date = start_date
self.total_time = total_time
self.laps = laps
self.records = records
+ self.timer_periods = timer_periods
def __iter__(self):
return iter(self.interpolate())
@@ -763,14 +802,16 @@ class Records:
@property
def percents(self):
- total_time = self.total_time
- last_date = self.start_date
percents = []
- for record in self.interpolate():
- percents.append(
- (record.date - last_date) / total_time
- )
- last_date = record.date
+ records = self.interpolate()
+ for timer_period in self.timer_periods:
+ last_date = timer_period.start
+ while records and timer_period.contains(records[0].date):
+ record = records.pop(0)
+ percents.append(
+ (record.date - last_date) / self.total_time
+ )
+ last_date = record.date
return percents
def interpolate(self):
diff --git a/notes.md b/notes.md
index 2457713..080383f 100644
--- a/notes.md
+++ b/notes.md
@@ -46,6 +46,11 @@
print((dur_1.seconds, dur_2.seconds))
assert abs(dur_1.seconds-dur_2.seconds) < 0.00001
+first record in period: 2025-07-11T15:29:59 | 121 bpm | 27:30 min/km
+last record in period: 2025-07-11T16:04:22 | 137 bpm | 6:25 min/km
+first record in period: 2025-07-11T16:04:44 | 154 bpm | 4:13 min/km
+last record in period: 2025-07-11T16:44:55 | 127 bpm | 7:08 min/km
+
# TODO
~~1. draw each individual event in the box~~
commit 2c199bafd7d5a97be6fb2074ede30ab213f75c8c
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 10:03:05 2025 +0200
Fix percentage_laps.
diff --git a/garmin b/garmin
index 0d8a6a3..32b7416 100755
--- a/garmin
+++ b/garmin
@@ -653,11 +653,16 @@ class Activity:
stream = Stream.from_file(self.fit_path)
decoder = Decoder(stream)
messages, errors = decoder.read(convert_datetimes_to_dates=True)
- return Records(self.date, Laps([Lap(x) for x in messages.get("lap_mesgs", [])]), [
- Record(json)
- for json
- in messages["record_mesgs"]
- ])
+ return Records(
+ start_date=self.date,
+ total_time=self.duration,
+ laps=Laps([Lap(x) for x in messages.get("lap_mesgs", [])]),
+ records=[
+ Record(json)
+ for json
+ in messages["record_mesgs"]
+ ],
+ )
class Laps:
@@ -737,8 +742,9 @@ class Duration:
class Records:
- def __init__(self, start_date, laps, records):
+ def __init__(self, start_date, total_time, laps, records):
self.start_date = start_date
+ self.total_time = total_time
self.laps = laps
self.records = records
@@ -748,19 +754,12 @@ class Records:
def __len__(self):
return len(self.interpolate())
- @property
- def total_time(self):
- return self.interpolate()[-1].date - self.start_date
-
@property
def percents_laps(self):
- total_time = self.total_time
- percents = []
- for lap in self.laps:
- percents.append(
- lap.duration / total_time
- )
- return percents
+ return [
+ lap.duration / self.total_time
+ for lap in self.laps
+ ]
@property
def percents(self):
commit a07ff60a077dda5393045cc3b3f9a486461b4bf2
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sun Aug 10 10:01:30 2025 +0200
Add notes.
diff --git a/notes.md b/notes.md
index 9394812..2457713 100644
--- a/notes.md
+++ b/notes.md
@@ -20,6 +20,32 @@
unzip UploadedFiles_0-_Part1.zip
mv *.fit ~/projects/garmin/GARMIN/GARMIN/ACTIVITY/
+* session.start_time is when the session started
+* session.timestamp is when the message was created
+
+* session start: 1121174999
+* start: 1121174999
+* stop: 1121177062
+* start: 1121177084
+ * diff: 1121177084-1121177062=22
+* stop: 1121179495
+
+* first record: 1121174999
+* record before split: 1121177062 (328)
+* record after split: 1121177084 (329)
+* last record: 1121179495
+
+* lap 1 start time: 1121174999
+
+* Assert lap durations sums to activity duration
+
+ for x in db.json.get("activities"):
+ a = Activity(db, x)
+ dur_1 = a.duration
+ dur_2 = sum([lap.duration for lap in a.laps], start=Duration.in_seconds(0))
+ print((dur_1.seconds, dur_2.seconds))
+ assert abs(dur_1.seconds-dur_2.seconds) < 0.00001
+
# TODO
~~1. draw each individual event in the box~~
commit 7e9f35e4b3d77f56da80472ee2927b3e4cd2fad7
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sat Aug 9 15:57:53 2025 +0200
Remove unused key event handler.
diff --git a/garmin b/garmin
index e7c9d35..0d8a6a3 100755
--- a/garmin
+++ b/garmin
@@ -34,13 +34,11 @@ class GtkPanel(Gtk.DrawingArea):
self.add_events(
self.get_events()
| Gdk.EventMask.POINTER_MOTION_MASK
- | Gdk.EventMask.KEY_PRESS_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.SCROLL_MASK
)
self.connect("draw", self.on_draw)
self.connect("motion-notify-event", self.on_motion_notify_event)
- self.connect("key-press-event", self.on_key_press_event)
self.connect("button-press-event", self.on_button_press_event)
self.connect("scroll-event", self.on_scroll_event)
self.set_can_focus(True)
@@ -64,34 +62,6 @@ class GtkPanel(Gtk.DrawingArea):
self.panel.mouse(Point(x=x, y=y))
self.queue_draw()
- def on_key_press_event(self, widget, event):
- #unicode = Gdk.keyval_to_unicode(event.keyval)
- #if event.keyval == 65361:
- # self.editor.cursor_backward()
- #elif event.keyval == 65363:
- # self.editor.cursor_forward()
- #elif event.keyval == 65288: # backspace
- # self.editor.delete_whole_or_before()
- #elif event.keyval == 65535: # del
- # self.editor.delete_whole_or_after()
- #elif event.keyval == 65293: # enter
- # self.editor.update_text("\n")
- #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("h"):
- # self.editor.selection_expand()
- #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("l"):
- # self.editor.selection_contract()
- #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("j"):
- # self.editor.select_next_node()
- #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("k"):
- # self.editor.select_previous_node()
- #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("s"):
- # self.editor.save()
- #elif unicode >= 32:
- # self.editor.update_text(chr(unicode))
- #else:
- # return
- self.queue_draw()
-
def on_button_press_event(self, widget, event):
x, y = self.translate_coordinates(self, event.x, event.y)
self.panel.mouse_click(point=Point(x=x, y=y), app=GtkApp())
2025-08-09 15:27 Rickard pushed to garmin
commit 267f022f00a0a995bb8ccf756932ef17129b997a
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sat Aug 9 15:24:39 2025 +0200
Better name.
diff --git a/guistat.py b/garmin
similarity index 100%
rename from guistat.py
rename to garmin
commit 992a4c3d6f4020de3b67ba965db554672488db38
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date: Sat Aug 9 15:23:16 2025 +0200
Remove old stat.py.
diff --git a/stat.py b/stat.py
deleted file mode 100755
index 8049cc5..0000000
--- a/stat.py
+++ /dev/null
@@ -1,153 +0,0 @@
-#!/usr/bin/env python
-
-from collections import namedtuple
-from datetime import datetime
-from datetime import timedelta
-import math
-import subprocess
-import xml.etree.ElementTree as ET
-
-from garmin_fit_sdk import Decoder, Stream
-
-xml = subprocess.check_output([
- "gpsbabel",
- "-i", "garmin_fit",
- "-f", "GARMIN/GARMIN/ACTIVITY/F81G0144.FIT",
- "-o", "gpx,garminextensions",
- "-F", "-",
-])
-
-root = ET.fromstring(xml)
-
-def parse_node_text(node, fn):
- if node is None:
- return None
- else:
- return fn(node.text)
-
-class Coordinate(namedtuple("Coordinate", "lat,lon")):
-
- def distance_to(self, other):
- delta_lat = math.radians(self.lat - other.lat)
- delta_lon = math.radians(self.lon - other.lon)
- a = math.sin(delta_lat / 2)**2 + math.cos(math.radians(other.lat)) * math.cos(math.radians(self.lat)) * math.sin(delta_lon / 2) ** 2
- great_circle_distance = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
- return 6371.008 * great_circle_distance
-
-class TrackPoint(namedtuple("TrackPoint", "coordinate,ele,time,hr,cad")):
-
- def distance_to(self, other):
- return self.coordinate.distance_to(other)
-
- def diff(self, other):
- return Diff(
- distance=self.distance_to(other.coordinate),
- time=(self.time-other.time).total_seconds(),
- )
-
-class Diff(namedtuple("Diff", "distance,time")):
- pass
-
-class Segment(namedtuple("Segment", "track_points")):
-
- def split(self, waypoints):
- if waypoints:
- waypoint = waypoints[0]
- x_distance = None
- for split_index, point in enumerate(self.track_points):
- if point is not None:
- distance = point.distance_to(waypoint)
- if distance < 100/1000:
- break
- lap = self.track_points[:split_index]
- rest = self.track_points[split_index:]
- return Laps(segments=[Segment(track_points=lap)]).merge(Segment(track_points=rest).split(waypoints[1:]))
- else:
- return Laps([self])
-
- def stat(self):
- total_seconds = 0
- total_distance = 0
- last = None
- for point in self.track_points:
- if last is not None and point is not None:
- diff = point.diff(last)
- total_seconds += diff.time
- total_distance += diff.distance
- last = point
- print(timedelta(seconds=total_seconds))
- print(total_distance)
-
-class Laps(namedtuple("Laps", "segments")):
-
- def merge(self, other):
- return Laps(self.segments+other.segments)
-
- def stat(self):
- for index, segment in enumerate(self.segments):
- print(f"Lap {index+1}:")
- segment.stat()
- print("Total:")
- total_seconds = 0
- total_distance = 0
- last = None
- for segment in self.segments:
- for point in segment.track_points:
- if last is not None and point is not None:
- diff = point.diff(last)
- total_seconds += diff.time
- total_distance += diff.distance
- last = point
- print(timedelta(seconds=total_seconds))
- print(total_distance)
-
-class Track(namedtuple("Track", "segments,waypoints")):
-
- def normalize(self):
- track_points = []
- for index, segment in enumerate(self.segments):
- if index > 0:
- track_points.append(None)
- track_points.extend(segment.track_points)
- return Segment(track_points).split(self.waypoints)
-
- def stat(self):
- self.normalize().stat()
-
-def parse():
- for trk in root.findall("{http://www.topografix.com/GPX/1/1}trk"):
- segments = []
- for trkseg in trk.findall("{http://www.topografix.com/GPX/1/1}trkseg"):
- track_points = []
- for trkpt in trkseg.findall("{http://www.topografix.com/GPX/1/1}trkpt"):
- lat = float(trkpt.attrib["lat"])
- lon = float(trkpt.attrib["lon"])
- coordinate = Coordinate(lat=lat, lon=lon)
- ele = float(trkpt.find("{http://www.topografix.com/GPX/1/1}ele").text)
- time = datetime.fromisoformat(trkpt.find("{http://www.topografix.com/GPX/1/1}time").text)
- extensions = trkpt.find("{http://www.topografix.com/GPX/1/1}extensions")
- track_point_extension = extensions.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}TrackPointExtension")
- hr = parse_node_text(
- track_point_extension.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}hr"),
- int
- )
- cad = parse_node_text(
- track_point_extension.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}cad"),
- int
- )
- track_points.append(TrackPoint(coordinate=coordinate, ele=ele, time=time, hr=hr, cad=cad))
- segments.append(Segment(track_points=track_points))
- waypoints = []
- for wpt in root.findall("{http://www.topografix.com/GPX/1/1}wpt"):
- lat = float(wpt.attrib["lat"])
- lon = float(wpt.attrib["lon"])
- waypoints.append(Coordinate(lat=lat, lon=lon))
- return Track(segments=segments, waypoints=waypoints)
-
-if __name__ == "__main__":
- #parse().stat()
- stream = Stream.from_file("GARMIN/GARMIN/ACTIVITY/F81G0144.FIT")
- decoder = Decoder(stream)
- messages, errors = decoder.read(convert_datetimes_to_dates=False)
- import json
- print(json.dumps(messages))
2025-08-09 15:22 Rickard pushed to garmin
fatal: Invalid revision range 0000000000000000000000000000000000000000..fc69f5ccd1f6311cb4f0fc402e256a23b5f6d376