Skip to content

Commit b27b5c7

Browse files
authored
Feature/hrv support (#295)
This adds support for importing HRV data from Garmin's HRV_STATUS.fit files, which contain overnight HRV measurements and daily status summaries. Changes: - Add MonitoringHrvValue table for individual HRV RMSSD readings during sleep - Add MonitoringHrvStatus table for daily HRV status summaries - Add HRV message handlers in MonitoringFitFileProcessor - Include FileType.hrv_status in monitoring FIT file processing - Bump monitoring DB version to 7 The HRV data includes: - Individual RMSSD readings (5-minute intervals during sleep) - Weekly average RMSSD - Last night's average RMSSD - Baseline low/high bounds - HRV status (0=unknown, 2=poor, 3=low, 4=balanced) - Reading count Requires corresponding changes in the Fit submodule for: - FileType.hrv_status (68) - MessageType.hrv_status_summary (370) - MessageType.hrv_value (371)
1 parent 9e2caf4 commit b27b5c7

5 files changed

Lines changed: 82 additions & 5 deletions

File tree

garmindb/garmindb/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from .garmin_db import GarminDb, Attributes, Device, DeviceInfo, File, Weight, Stress, Sleep, SleepEvents, RestingHeartRate, DailySummary
1010
from .monitoring_db import MonitoringDb, MonitoringInfo, MonitoringHeartRate, MonitoringIntensity, MonitoringClimb, Monitoring, \
11-
MonitoringRespirationRate, MonitoringPulseOx
11+
MonitoringRespirationRate, MonitoringPulseOx, MonitoringHrvValue, MonitoringHrvStatus
1212
from .activities_db import ActivitiesDb, Activities, ActivityLaps, ActivityRecords, ActivitiesDevices, ActivitySplits, SportActivities, StepsActivities, \
1313
PaddleActivities, CycleActivities, ClimbingActivities
1414
from .garmin_summary_db import GarminSummaryDb, Summary, YearsSummary, MonthsSummary, WeeksSummary, DaysSummary, IntensityHR

garmindb/garmindb/monitoring_db.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19-
MonitoringDb = idbutils.DB.create('garmin_monitoring', 6, "Database for storing daily health monitoring data from a Garmin device.")
19+
MonitoringDb = idbutils.DB.create('garmin_monitoring', 7, "Database for storing daily health monitoring data from a Garmin device.")
2020

2121

2222
class MonitoringInfo(MonitoringDb.Base, idbutils.DbObject):
@@ -290,3 +290,52 @@ def get_stats(cls, session, start_ts, end_ts):
290290
'pulse_ox_min' : cls.s_get_col_min(session, cls.pulse_ox, start_ts, end_ts, True),
291291
'pulse_ox_max' : cls.s_get_col_max(session, cls.pulse_ox, start_ts, end_ts),
292292
}
293+
294+
295+
class MonitoringHrvValue(MonitoringDb.Base, idbutils.DbObject):
296+
"""Class that represents a database table holding individual HRV (RMSSD) readings during sleep."""
297+
298+
__tablename__ = 'monitoring_hrv_value'
299+
300+
db = MonitoringDb
301+
table_version = 1
302+
303+
timestamp = Column(DateTime, primary_key=True)
304+
hrv = Column(Float, nullable=False) # RMSSD in milliseconds
305+
306+
@classmethod
307+
def get_stats(cls, session, start_ts, end_ts):
308+
"""Return a dict of stats for table entries within the time span."""
309+
return {
310+
'hrv_avg' : cls.s_get_col_avg(session, cls.hrv, start_ts, end_ts, True),
311+
'hrv_min' : cls.s_get_col_min(session, cls.hrv, start_ts, end_ts, True),
312+
'hrv_max' : cls.s_get_col_max(session, cls.hrv, start_ts, end_ts),
313+
}
314+
315+
316+
class MonitoringHrvStatus(MonitoringDb.Base, idbutils.DbObject):
317+
"""Class that represents a database table holding daily HRV status summaries."""
318+
319+
__tablename__ = 'monitoring_hrv_status'
320+
321+
db = MonitoringDb
322+
table_version = 1
323+
324+
timestamp = Column(DateTime, primary_key=True)
325+
weekly_average = Column(Float) # Weekly average RMSSD in milliseconds
326+
last_night = Column(Float) # Last night's RMSSD in milliseconds
327+
last_night_average = Column(Float) # Last night's average RMSSD
328+
baseline_low = Column(Float) # Baseline low bound
329+
baseline_high = Column(Float) # Baseline high bound
330+
status = Column(Integer) # HRV status: 0=unknown, 2=poor, 3=low, 4=balanced
331+
reading_count = Column(Integer) # Number of readings
332+
333+
@classmethod
334+
def get_stats(cls, session, start_ts, end_ts):
335+
"""Return a dict of stats for table entries within the time span."""
336+
return {
337+
'hrv_weekly_avg' : cls.s_get_col_avg(session, cls.weekly_average, start_ts, end_ts, True),
338+
'hrv_last_night_avg' : cls.s_get_col_avg(session, cls.last_night, start_ts, end_ts, True),
339+
'hrv_baseline_low_avg' : cls.s_get_col_avg(session, cls.baseline_low, start_ts, end_ts, True),
340+
'hrv_baseline_high_avg' : cls.s_get_col_avg(session, cls.baseline_high, start_ts, end_ts, True),
341+
}

garmindb/import_monitoring.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def __init__(self, input_dir, latest, measurement_system, debug):
7272
debug (Boolean): enable debug logging
7373
7474
"""
75-
super().__init__(input_dir, debug, latest, True, [fitfile.FileType.monitoring_b], measurement_system)
75+
super().__init__(input_dir, debug, latest, True, [fitfile.FileType.monitoring_b, fitfile.FileType.hrv_status], measurement_system)
7676

7777

7878
class GarminSleepFitData(FitData):

garmindb/monitoring_fit_file_processor.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import idbutils
1414

1515
from .garmindb import File
16-
from .garmindb import MonitoringDb, Monitoring, MonitoringInfo, MonitoringHeartRate, MonitoringIntensity, MonitoringClimb, MonitoringRespirationRate, MonitoringPulseOx
16+
from .garmindb import MonitoringDb, Monitoring, MonitoringInfo, MonitoringHeartRate, MonitoringIntensity, MonitoringClimb, MonitoringRespirationRate, MonitoringPulseOx, MonitoringHrvValue, MonitoringHrvStatus
1717
from .fit_file_processor import FitFileProcessor
1818

1919

@@ -109,3 +109,31 @@ def _write_pulse_ox_entry(self, fit_file, message_fields):
109109
MonitoringPulseOx.s_insert_or_update(self.garmin_mon_db_session, pulse_ox_entry)
110110
else:
111111
raise ValueError(f'Unexpected file type {repr(fit_file.type)} for pulse ox')
112+
113+
def _write_hrv_value_entry(self, fit_file, message_fields):
114+
"""Write an HRV reading entry to the database."""
115+
logger.debug("hrv_value message: %r", message_fields)
116+
hrv_value = message_fields.get('hrv_value')
117+
if hrv_value is not None and hrv_value > 0:
118+
# HRV values are scaled by 128 in the FIT file
119+
hrv_entry = {
120+
'timestamp': fit_file.utc_datetime_to_local(message_fields.timestamp),
121+
'hrv': hrv_value / 128.0, # Convert to milliseconds
122+
}
123+
MonitoringHrvValue.s_insert_or_update(self.garmin_mon_db_session, hrv_entry)
124+
125+
def _write_hrv_status_summary_entry(self, fit_file, message_fields):
126+
"""Write an HRV status summary entry to the database."""
127+
logger.debug("hrv_status_summary message: %r", message_fields)
128+
# HRV values are scaled by 128 in the FIT file
129+
hrv_status_entry = {
130+
'timestamp': fit_file.utc_datetime_to_local(message_fields.timestamp),
131+
'weekly_average': message_fields.get('weekly_average', 0) / 128.0 if message_fields.get('weekly_average') else None,
132+
'last_night': message_fields.get('last_night', 0) / 128.0 if message_fields.get('last_night') else None,
133+
'last_night_average': message_fields.get('last_night_average', 0) / 128.0 if message_fields.get('last_night_average') else None,
134+
'baseline_low': message_fields.get('baseline_low', 0) / 128.0 if message_fields.get('baseline_low') else None,
135+
'baseline_high': message_fields.get('baseline_high', 0) / 128.0 if message_fields.get('baseline_high') else None,
136+
'status': message_fields.get('status'),
137+
'reading_count': message_fields.get('reading_count'),
138+
}
139+
MonitoringHrvStatus.s_insert_or_update(self.garmin_mon_db_session, hrv_status_entry)

0 commit comments

Comments
 (0)