Skip to content

Commit

Permalink
Merge pull request #259 from fedeizzo/develop
Browse files Browse the repository at this point in the history
feat: add indoor climbing and bouldering climbing activities
  • Loading branch information
tcgoetz authored Jan 19, 2025
2 parents ae85ff3 + c6b8244 commit bf9a1b4
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 5 deletions.
118 changes: 117 additions & 1 deletion garmindb/activity_fit_file_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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('avg_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,
Expand Down Expand Up @@ -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 = {
Expand Down
4 changes: 2 additions & 2 deletions garmindb/garmindb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
81 changes: 80 additions & 1 deletion garmindb/garmindb/activities_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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'),
]
5 changes: 4 additions & 1 deletion test/test_activities_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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})
Expand Down

0 comments on commit bf9a1b4

Please sign in to comment.