From e123923fef0cf193305c5b427cb73f703a3db022 Mon Sep 17 00:00:00 2001 From: Federico Izzo Date: Mon, 13 Jan 2025 18:54:10 +0100 Subject: [PATCH 1/2] feat: add indoor climbing and bouldering climbing activities --- garmindb/activity_fit_file_processor.py | 118 +++++++++++++++++++++++- garmindb/garmindb/__init__.py | 4 +- garmindb/garmindb/activities_db.py | 81 +++++++++++++++- test/test_activities_db.py | 5 +- 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/garmindb/activity_fit_file_processor.py b/garmindb/activity_fit_file_processor.py index cbe2e21..33b4e9d 100644 --- a/garmindb/activity_fit_file_processor.py +++ b/garmindb/activity_fit_file_processor.py @@ -9,7 +9,7 @@ import fitfile -from .garmindb import File, ActivitiesDb, Activities, ActivityRecords, ActivityLaps, ActivitiesDevices, StepsActivities, CycleActivities, PaddleActivities +from .garmindb import File, ActivitiesDb, Activities, ActivityRecords, ActivityLaps, ActivitySplits, ActivitiesDevices, StepsActivities, CycleActivities, ClimbingActivities, PaddleActivities from .fit_file_processor import FitFileProcessor @@ -48,6 +48,11 @@ def _write_lap(self, fit_file, message_type, messages): for lap_num, message in enumerate(messages): self._write_lap_entry(fit_file, message.fields, lap_num) + def _write_split(self, fit_file, message_type, messages): + """Write all split messages to the database.""" + for split_num, message in enumerate(messages): + self._write_split_entry(fit_file, message.fields, split_num) + def _write_record(self, fit_file, message_type, messages): """Write all record messages to the database.""" for record_num, message in enumerate(messages): @@ -114,6 +119,109 @@ def _write_lap_entry(self, fit_file, message_fields, lap_num): root_logger.debug("writing lap %r for %s", lap, fit_file.filename) self.garmin_act_db_session.add(ActivityLaps(**lap)) + def _write_split_entry(self, fit_file, message_fields, split_num): + # we don't get splits data from multiple sources so we don't need to coellesce data in the DB. + # It's fastest to just write new data out if the it doesn't currently exist. + activity_id = File.id_from_path(fit_file.filename) + plugin_split = self._plugin_dispatch('write_split_entry', self.garmin_act_db_session, fit_file, activity_id, message_fields, split_num) + + if not ActivitySplits.s_exists(self.garmin_act_db_session, {'activity_id' : activity_id, 'split' : split_num}): + split = { + 'activity_id' : File.id_from_path(fit_file.filename), + 'split' : split_num, + 'start_time' : fit_file.utc_datetime_to_local(message_fields.start_time), + 'stop_time' : fit_file.utc_datetime_to_local(message_fields.timestamp), + 'elapsed_time' : message_fields.get('total_elapsed_time'), + 'moving_time' : message_fields.get('total_timer_time'), + 'avg_hr' : message_fields.get('min_heart_rate'), + 'max_hr' : message_fields.get('max_heart_rate'), + 'calories' : message_fields.get('total_calories'), + 'ascent' : message_fields.get('total_ascent'), + 'descent' : message_fields.get('total_descent'), + 'avg_speed' : message_fields.get('avg_vertical_speed'), + } + bouldering_font_grade = { + 0: '1', + 1: '2', + 2: '3', + 3: '4', + 4: '4+', + 5: '5', + 6: '5+', + 7: '6A', + 8: '6A+', + 9: '6B', + 10: '6B+', + 11: '6C', + 12: '6C+', + 13: '7A', + 14: '7A+', + 15: '7B', + 16: '7B+', + 17: '7C', + 18: '7C+', + 19: '8A', + 20: '8A+', + 21: '8B', + 22: '8B+', + 23: '8C', + 24: '8C+', + 25: '9A', + } + indoor_font_grade = { + 0: '1', + 1: '2', + 2: '3', + 3: '4a', + 4: '4b', + 5: '4c', + 6: '5a', + 7: '5b', + 8: '5c', + 9: '6a', + 10: '6a+', + 11: '6b', + 12: '6b+', + 13: '6c', + 14: '6c+', + 15: '7a', + 16: '7a+', + 17: '7b', + 18: '7b+', + 19: '7c', + 20: '7c+', + 21: '8a', + 22: '8a+', + 23: '8b', + 24: '8b+', + 25: '8c', + 26: '8c+', + 27: '9a', + 28: '9a+', + 29: '9b', + } + + route = { + 'activity_id' : activity_id, + 'grade' : message_fields.get('grade'), + 'completed' : message_fields.get('completed'), + 'falls' : message_fields.get('falls'), + } + + if 'grade' in route and route.get('grade') is not None: + if fitfile.SubSport.bouldering == fit_file.sub_sport_type: + route['grade'] = bouldering_font_grade[int(route['grade'])] + elif fitfile.SubSport.indoor_climbing == fit_file.sub_sport_type: + route['grade'] = indoor_font_grade[int(route['grade'])] + + split.update(plugin_split) + split.update(route) + + # there are some empty splits we want to filter out + if route.get('grade') is not None and route.get('completed') is not None: + root_logger.debug("writing split %r for %s", split, fit_file.filename) + self.garmin_act_db_session.add(ActivitySplits(**split)) + def _write_steps_entry(self, fit_file, activity_id, sub_sport, message_fields): steps = { 'activity_id' : activity_id, @@ -150,6 +258,14 @@ def _write_cycling_entry(self, fit_file, activity_id, sub_sport, message_fields) ride.update(self._plugin_dispatch('write_cycle_entry', self.garmin_act_db_session, fit_file, activity_id, sub_sport, message_fields)) CycleActivities.s_insert_or_update(self.garmin_act_db_session, ride, ignore_none=True, ignore_zero=True) + def _write_rock_climbing_entry(self, fit_file, activity_id, sub_sport, message_fields): + entry = { + 'activity_id' : activity_id, + 'total_routes': len([s for s in fit_file.split if s.fields.get('grade')]), + } + entry.update(self._plugin_dispatch('write_rock_climbing_entry', self.garmin_act_db_session, fit_file, activity_id, sub_sport, message_fields)) + ClimbingActivities.s_insert_or_update(self.garmin_act_db_session, entry, ignore_none=True, ignore_zero=True) + def _write_stand_up_paddleboarding_entry(self, fit_file, activity_id, sub_sport, message_fields): root_logger.debug("sup sport entry: %r", message_fields) paddle = { diff --git a/garmindb/garmindb/__init__.py b/garmindb/garmindb/__init__.py index 7c989e6..617a8b3 100644 --- a/garmindb/garmindb/__init__.py +++ b/garmindb/garmindb/__init__.py @@ -9,6 +9,6 @@ from .garmin_db import GarminDb, Attributes, Device, DeviceInfo, File, Weight, Stress, Sleep, SleepEvents, RestingHeartRate, DailySummary from .monitoring_db import MonitoringDb, MonitoringInfo, MonitoringHeartRate, MonitoringIntensity, MonitoringClimb, Monitoring, \ MonitoringRespirationRate, MonitoringPulseOx -from .activities_db import ActivitiesDb, Activities, ActivityLaps, ActivityRecords, ActivitiesDevices, SportActivities, StepsActivities, \ - PaddleActivities, CycleActivities +from .activities_db import ActivitiesDb, Activities, ActivityLaps, ActivityRecords, ActivitiesDevices, ActivitySplits, SportActivities, StepsActivities, \ + PaddleActivities, CycleActivities, ClimbingActivities from .garmin_summary_db import GarminSummaryDb, Summary, YearsSummary, MonthsSummary, WeeksSummary, DaysSummary, IntensityHR diff --git a/garmindb/garmindb/activities_db.py b/garmindb/garmindb/activities_db.py index 8bc3b9d..1be2be6 100644 --- a/garmindb/garmindb/activities_db.py +++ b/garmindb/garmindb/activities_db.py @@ -6,7 +6,7 @@ import logging import datetime -from sqlalchemy import Column, String, Float, Integer, DateTime, Time, Enum, ForeignKey, PrimaryKeyConstraint, desc, literal_column +from sqlalchemy import Column, String, Float, Integer, Boolean, DateTime, Time, Enum, ForeignKey, PrimaryKeyConstraint, desc, literal_column from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property @@ -230,6 +230,47 @@ def start_loc(self, start_location): self.start_long = start_location.long_deg +class ActivitySplits(ActivitiesDb.Base, ActivitiesCommon): + """Class that holds data for an activity split.""" + + __tablename__ = 'activity_splits' + + db = ActivitiesDb + table_version = 1 + + activity_id = Column(String, ForeignKey('activities.activity_id')) + split = Column(Integer) + grade = Column(String) # climbing grade + completed = Column(Boolean) # climbing route + falls = Column(Integer) # climbing number of falls + + __table_args__ = (PrimaryKeyConstraint("activity_id", "split"),) + + @classmethod + def s_get(cls, session, activity_id, split_number, default=None): + """Return a single instance for the given id.""" + instance = session.query(cls).filter(cls.activity_id == activity_id).filter(cls.split == split_number).scalar() + if instance is None: + return default + return instance + + @classmethod + def s_get_from_dict(cls, session, values_dict): + """Return a single activity instance for the given id.""" + return cls.s_get(session, values_dict['activity_id'], values_dict['split']) + + @classmethod + def s_get_activity(cls, session, activity_id): + """Return all splits for a given activity_id.""" + return session.query(cls).filter(cls.activity_id == activity_id).all() + + @classmethod + def get_activity(cls, db, activity_id): + """Return all splits for a given activity_id.""" + with db.managed_session() as session: + return cls.s_get_activity(session, activity_id) + + class ActivityRecords(ActivitiesDb.Base, idbutils.DbObject): """Encapsilates record for a single point in time from an activity.""" @@ -565,3 +606,41 @@ def _view_selectable(cls): cls.google_map_loc('start'), cls.google_map_loc('stop'), ] + + +class ClimbingActivities(ActivitiesDb.Base, SportActivities): + """Climbing based activity table.""" + + __tablename__ = 'climbing_activities' + + db = ActivitiesDb + table_version = 1 + view_version = 1 + + total_routes = Column(Integer) + + @classmethod + def _view_selectable(cls): + return [ + Activities.activity_id.label('activity_id'), + Activities.name.label('name'), + Activities.description.label('description'), + Activities.sub_sport.label('sub_sport'), + Activities.start_time.label('start_time'), + Activities.stop_time.label('stop_time'), + Activities.elapsed_time.label('elapsed_time'), + Activities.moving_time.label('moving_time'), + Activities.avg_hr.label('avg_hr'), + Activities.max_hr.label('max_hr'), + Activities.calories.label('calories'), + Activities.ascent.label('ascent'), + Activities.descent.label('descent'), + cls.total_routes.label('total_routes'), + Activities.training_effect.label('training_effect'), + Activities.anaerobic_training_effect.label('anaerobic_training_effect'), + Activities.hrz_1_time.label('heart_rate_zone_one_time'), + Activities.hrz_2_time.label('heart_rate_zone_two_time'), + Activities.hrz_3_time.label('heart_rate_zone_three_time'), + Activities.hrz_4_time.label('heart_rate_zone_four_time'), + Activities.hrz_5_time.label('heart_rate_zone_five_time'), + ] diff --git a/test/test_activities_db.py b/test/test_activities_db.py index b496f9d..a95a6cb 100644 --- a/test/test_activities_db.py +++ b/test/test_activities_db.py @@ -12,7 +12,7 @@ from garmindb import GarminActivitiesFitData, GarminTcxData, GarminJsonSummaryData, GarminJsonDetailsData, ActivityFitFileProcessor, GarminConnectConfigManager, PluginManager from garmindb.garmindb import GarminDb, Device, File, DeviceInfo -from garmindb.garmindb import ActivitiesDb, Activities, ActivityLaps, ActivityRecords, StepsActivities, PaddleActivities, CycleActivities +from garmindb.garmindb import ActivitiesDb, Activities, ActivityLaps, ActivitySplits, ActivityRecords, StepsActivities, PaddleActivities, CycleActivities, ClimbingActivities from test_db_base import TestDBBase @@ -39,6 +39,7 @@ def setUpClass(cls): table_dict = { 'activities_table' : Activities, 'activity_laps_table' : ActivityLaps, + 'activity_splits_table' : ActivitySplits, 'activity_records_table' : ActivityRecords, 'run_activities_table' : StepsActivities, 'paddle_activities_table' : PaddleActivities, @@ -56,10 +57,12 @@ def setUpClass(cls): def test_garmin_act_db_tables_exists(self): self.assertGreater(Activities.row_count(self.garmin_act_db), 0) self.assertGreater(ActivityLaps.row_count(self.garmin_act_db), 0) + self.assertGreater(ActivitySplits.row_count(self.garmin_act_db), 0) self.assertGreater(ActivityRecords.row_count(self.garmin_act_db), 0) self.assertGreater(StepsActivities.row_count(self.garmin_act_db), 0) self.assertGreater(PaddleActivities.row_count(self.garmin_act_db), 0) self.assertGreater(CycleActivities.row_count(self.garmin_act_db), 0) + self.assertGreater(ClimbingActivities.row_count(self.garmin_act_db), 0) def check_activities_fields(self, fields_list): self.check_not_none_cols(self.test_act_db, {Activities : fields_list}) From c6b824485cb782db1cfccf94194bab355c50ebc7 Mon Sep 17 00:00:00 2001 From: Federico Izzo Date: Mon, 13 Jan 2025 19:01:59 +0100 Subject: [PATCH 2/2] fix: message field key --- garmindb/activity_fit_file_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/garmindb/activity_fit_file_processor.py b/garmindb/activity_fit_file_processor.py index 33b4e9d..5b7058c 100644 --- a/garmindb/activity_fit_file_processor.py +++ b/garmindb/activity_fit_file_processor.py @@ -133,7 +133,7 @@ def _write_split_entry(self, fit_file, message_fields, split_num): 'stop_time' : fit_file.utc_datetime_to_local(message_fields.timestamp), 'elapsed_time' : message_fields.get('total_elapsed_time'), 'moving_time' : message_fields.get('total_timer_time'), - 'avg_hr' : message_fields.get('min_heart_rate'), + 'avg_hr' : message_fields.get('avg_heart_rate'), 'max_hr' : message_fields.get('max_heart_rate'), 'calories' : message_fields.get('total_calories'), 'ascent' : message_fields.get('total_ascent'),