From b4558b058208fb198bf5159c29455b81340a53a0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 28 Mar 2026 19:15:31 +0100 Subject: [PATCH] feat: Phase 0c - health_metrics.py module complete Data Layer: - get_resting_heart_rate_data() - avg RHR with min/max trend - get_heart_rate_variability_data() - avg HRV with min/max trend - get_vo2_max_data() - latest VO2 Max with date Placeholder Layer: - get_vitals_avg_hr() - refactored to use data layer - get_vitals_avg_hrv() - refactored to use data layer - get_vitals_vo2_max() - refactored to use data layer All 3 health data functions + 3 placeholder refactors complete. Co-Authored-By: Claude Opus 4.6 --- backend/data_layer/__init__.py | 7 +- backend/data_layer/health_metrics.py | 197 +++++++++++++++++++++++++++ backend/placeholder_resolver.py | 71 +++++----- 3 files changed, 238 insertions(+), 37 deletions(-) create mode 100644 backend/data_layer/health_metrics.py diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index ee89748..70db142 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -33,9 +33,9 @@ from .body_metrics import * from .nutrition_metrics import * from .activity_metrics import * from .recovery_metrics import * +from .health_metrics import * # Future imports (will be added as modules are created): -# from .health_metrics import * # from .goals import * # from .correlations import * @@ -66,4 +66,9 @@ __all__ = [ 'get_sleep_duration_data', 'get_sleep_quality_data', 'get_rest_days_data', + + # Health Metrics + 'get_resting_heart_rate_data', + 'get_heart_rate_variability_data', + 'get_vo2_max_data', ] diff --git a/backend/data_layer/health_metrics.py b/backend/data_layer/health_metrics.py new file mode 100644 index 0000000..0d0f866 --- /dev/null +++ b/backend/data_layer/health_metrics.py @@ -0,0 +1,197 @@ +""" +Health Metrics Data Layer + +Provides structured data for vital signs and health monitoring. + +Functions: + - get_resting_heart_rate_data(): Average RHR with trend + - get_heart_rate_variability_data(): Average HRV with trend + - get_vo2_max_data(): Latest VO2 Max value + +All functions return structured data (dict) without formatting. +Use placeholder_resolver.py for formatted strings for AI. + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +""" + +from typing import Dict, List, Optional +from datetime import datetime, timedelta, date +from db import get_db, get_cursor, r2d +from data_layer.utils import calculate_confidence, safe_float, safe_int + + +def get_resting_heart_rate_data( + profile_id: str, + days: int = 7 +) -> Dict: + """ + Get average resting heart rate with trend. + + Args: + profile_id: User profile ID + days: Analysis window (default 7) + + Returns: + { + "avg_rhr": int, # beats per minute + "min_rhr": int, + "max_rhr": int, + "measurements": int, + "confidence": str, + "days_analyzed": int + } + + Migration from Phase 0b: + OLD: get_vitals_avg_hr(pid, days) formatted string + NEW: Structured data with min/max + """ + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + cur.execute( + """SELECT + AVG(resting_hr) as avg, + MIN(resting_hr) as min, + MAX(resting_hr) as max, + COUNT(*) as count + FROM vitals_baseline + WHERE profile_id=%s + AND date >= %s + AND resting_hr IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if not row or row['count'] == 0: + return { + "avg_rhr": 0, + "min_rhr": 0, + "max_rhr": 0, + "measurements": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + measurements = row['count'] + confidence = calculate_confidence(measurements, days, "general") + + return { + "avg_rhr": safe_int(row['avg']), + "min_rhr": safe_int(row['min']), + "max_rhr": safe_int(row['max']), + "measurements": measurements, + "confidence": confidence, + "days_analyzed": days + } + + +def get_heart_rate_variability_data( + profile_id: str, + days: int = 7 +) -> Dict: + """ + Get average heart rate variability with trend. + + Args: + profile_id: User profile ID + days: Analysis window (default 7) + + Returns: + { + "avg_hrv": int, # milliseconds + "min_hrv": int, + "max_hrv": int, + "measurements": int, + "confidence": str, + "days_analyzed": int + } + + Migration from Phase 0b: + OLD: get_vitals_avg_hrv(pid, days) formatted string + NEW: Structured data with min/max + """ + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + cur.execute( + """SELECT + AVG(hrv) as avg, + MIN(hrv) as min, + MAX(hrv) as max, + COUNT(*) as count + FROM vitals_baseline + WHERE profile_id=%s + AND date >= %s + AND hrv IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if not row or row['count'] == 0: + return { + "avg_hrv": 0, + "min_hrv": 0, + "max_hrv": 0, + "measurements": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + measurements = row['count'] + confidence = calculate_confidence(measurements, days, "general") + + return { + "avg_hrv": safe_int(row['avg']), + "min_hrv": safe_int(row['min']), + "max_hrv": safe_int(row['max']), + "measurements": measurements, + "confidence": confidence, + "days_analyzed": days + } + + +def get_vo2_max_data( + profile_id: str +) -> Dict: + """ + Get latest VO2 Max value with date. + + Args: + profile_id: User profile ID + + Returns: + { + "vo2_max": float, # ml/kg/min + "date": date, + "confidence": str + } + + Migration from Phase 0b: + OLD: get_vitals_vo2_max(pid) formatted string + NEW: Structured data with date + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT vo2_max, date FROM vitals_baseline + WHERE profile_id=%s AND vo2_max IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = cur.fetchone() + + if not row: + return { + "vo2_max": 0.0, + "date": None, + "confidence": "insufficient" + } + + return { + "vo2_max": safe_float(row['vo2_max']), + "date": row['date'], + "confidence": "high" + } diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 318f3d2..1ae94db 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -33,6 +33,11 @@ from data_layer.recovery_metrics import ( get_sleep_quality_data, get_rest_days_data ) +from data_layer.health_metrics import ( + get_resting_heart_rate_data, + get_heart_rate_variability_data, + get_vo2_max_data +) # ── Helper Functions ────────────────────────────────────────────────────────── @@ -351,55 +356,49 @@ def get_rest_days_count(profile_id: str, days: int = 30) -> str: def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: - """Calculate average resting heart rate.""" - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - cur.execute( - """SELECT AVG(resting_hr) as avg FROM vitals_baseline - WHERE profile_id=%s AND date >= %s AND resting_hr IS NOT NULL""", - (profile_id, cutoff) - ) - row = cur.fetchone() + """ + Calculate average resting heart rate. - if row and row['avg']: - return f"{int(row['avg'])} bpm" + Phase 0c: Refactored to use data_layer.health_metrics.get_resting_heart_rate_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_resting_heart_rate_data(profile_id, days) + + if data['confidence'] == 'insufficient': return "nicht verfügbar" + return f"{data['avg_rhr']} bpm" + def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str: - """Calculate average heart rate variability.""" - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - cur.execute( - """SELECT AVG(hrv) as avg FROM vitals_baseline - WHERE profile_id=%s AND date >= %s AND hrv IS NOT NULL""", - (profile_id, cutoff) - ) - row = cur.fetchone() + """ + Calculate average heart rate variability. - if row and row['avg']: - return f"{int(row['avg'])} ms" + Phase 0c: Refactored to use data_layer.health_metrics.get_heart_rate_variability_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_heart_rate_variability_data(profile_id, days) + + if data['confidence'] == 'insufficient': return "nicht verfügbar" + return f"{data['avg_hrv']} ms" + def get_vitals_vo2_max(profile_id: str) -> str: - """Get latest VO2 Max value.""" - with get_db() as conn: - cur = get_cursor(conn) - cur.execute( - """SELECT vo2_max FROM vitals_baseline - WHERE profile_id=%s AND vo2_max IS NOT NULL - ORDER BY date DESC LIMIT 1""", - (profile_id,) - ) - row = cur.fetchone() + """ + Get latest VO2 Max value. - if row and row['vo2_max']: - return f"{row['vo2_max']:.1f} ml/kg/min" + Phase 0c: Refactored to use data_layer.health_metrics.get_vo2_max_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_vo2_max_data(profile_id) + + if data['confidence'] == 'insufficient': return "nicht verfügbar" + return f"{data['vo2_max']:.1f} ml/kg/min" + # ── Phase 0b Calculation Engine Integration ──────────────────────────────────