Add initial project
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
# Import providers to register them with the factory
|
||||
# from backend.integrations import google_fit_provider
|
||||
from backend.integrations import health_connect_provider
|
||||
|
||||
__all__ = [
|
||||
"HealthDataProvider",
|
||||
"DataSource",
|
||||
"HealthProviderFactory",
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Health Connect Provider - Implementation for Health Connect API.
|
||||
|
||||
This module provides the Health Connect implementation that reads data
|
||||
pushed from the Android companion app via the /api/health-connect/sync endpoint.
|
||||
"""
|
||||
import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
|
||||
class HealthConnectProvider(HealthDataProvider):
|
||||
"""
|
||||
Health Connect implementation of HealthDataProvider.
|
||||
|
||||
This provider reads data that has been pushed from an Android companion app
|
||||
via the /api/health-connect/sync endpoint and stored in the database.
|
||||
"""
|
||||
|
||||
@property
|
||||
def source(self) -> DataSource:
|
||||
return DataSource.HEALTH_CONNECT
|
||||
|
||||
def get_auth_url(self) -> str:
|
||||
"""
|
||||
Health Connect doesn't use OAuth - permissions are granted on-device.
|
||||
|
||||
Returns a URL to download the Android companion app.
|
||||
"""
|
||||
# TODO: Replace with actual Play Store URL when app is published
|
||||
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
|
||||
|
||||
async def fetch_daily_metrics(
|
||||
self,
|
||||
access_token: Optional[str], # Not used for Health Connect, but required by interface
|
||||
days: int = 7,
|
||||
timezone: str = "UTC",
|
||||
user_id: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch daily metrics from Health Connect data store.
|
||||
|
||||
Reads data that was pushed from the Android companion app.
|
||||
"""
|
||||
if not user_id:
|
||||
print("[HealthConnect] ERROR: user_id is required")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectDailyMetrics
|
||||
from datetime import timedelta, date as date_type
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = date_type.today() - timedelta(days=days)
|
||||
|
||||
metrics = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == user_id,
|
||||
HealthConnectDailyMetrics.date >= start_date
|
||||
).order_by(HealthConnectDailyMetrics.date.desc()).all()
|
||||
|
||||
if not metrics:
|
||||
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Found {len(metrics)} days of data")
|
||||
|
||||
# Convert to standard format
|
||||
return [
|
||||
{
|
||||
"date": m.date,
|
||||
"step_count": m.step_count or 0,
|
||||
"calories_burned": m.calories_burned or 0.0,
|
||||
"distance_meters": m.distance_meters or 0.0,
|
||||
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
|
||||
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
|
||||
"light_sleep_minutes": m.light_sleep_minutes or 0,
|
||||
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
|
||||
"awake_duration_minutes": m.awake_duration_minutes or 0,
|
||||
"avg_heart_rate": m.avg_heart_rate or 0,
|
||||
"min_heart_rate": m.min_heart_rate or 0,
|
||||
"max_heart_rate": m.max_heart_rate or 0,
|
||||
"avg_spo2": m.avg_spo2 or 0.0,
|
||||
"pai_score": 0.0, # Calculate if needed
|
||||
"weight": m.weight,
|
||||
"resting_heart_rate": m.resting_heart_rate,
|
||||
"hrv": None
|
||||
}
|
||||
for m in metrics
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_workouts(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch exercise sessions from Health Connect data store.
|
||||
"""
|
||||
# Note: We need user_id but the interface doesn't pass it for workouts
|
||||
# This is a limitation we'll need to address in the interface
|
||||
print(f"[HealthConnect] fetch_workouts called")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectExerciseSession
|
||||
from datetime import timedelta
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = datetime.datetime.now() - timedelta(days=days)
|
||||
|
||||
# Query all sessions (we can't filter by user without user_id)
|
||||
# In practice, this would be called with user context
|
||||
sessions = db.query(HealthConnectExerciseSession).filter(
|
||||
HealthConnectExerciseSession.start_time >= start_date
|
||||
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"start_time": s.start_time,
|
||||
"end_time": s.end_time,
|
||||
"activity_type": s.exercise_type_name,
|
||||
"activity_type_id": s.exercise_type,
|
||||
"duration_seconds": s.duration_seconds,
|
||||
"distance_meters": s.distance_meters or 0.0,
|
||||
"calories": s.calories_burned or 0.0,
|
||||
"avg_hr": s.avg_heart_rate,
|
||||
"max_hr": s.max_heart_rate,
|
||||
"min_hr": s.min_heart_rate
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_sleep_sessions(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch sleep sessions from Health Connect data store.
|
||||
|
||||
Note: Health Connect stores sleep as part of daily metrics.
|
||||
This returns an empty list as sleep is handled in fetch_daily_metrics.
|
||||
"""
|
||||
print(f"[HealthConnect] fetch_sleep_sessions called")
|
||||
# Sleep data is included in daily metrics for Health Connect
|
||||
return []
|
||||
|
||||
|
||||
# Register the provider with the factory
|
||||
HealthProviderFactory.register("health_connect", HealthConnectProvider)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Health Data Provider - Abstract interface for health data sources.
|
||||
|
||||
This module provides an abstraction layer that allows switching between
|
||||
different health data backends (Google Fit, Health Connect, etc.) without
|
||||
changing the API layer.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Enum for tracking data provenance."""
|
||||
HEALTH_CONNECT = "health_connect"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class HealthDataProvider(ABC):
|
||||
"""
|
||||
Abstract base class for health data providers.
|
||||
|
||||
Implementations must provide methods to fetch daily metrics,
|
||||
workout sessions, and sleep data from their respective sources.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source(self) -> DataSource:
|
||||
"""Return the data source identifier."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_daily_metrics(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 7,
|
||||
timezone: str = "UTC",
|
||||
user_id: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch aggregated daily health metrics.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
timezone: User's timezone for day boundaries
|
||||
user_id: Optional user ID for logging
|
||||
|
||||
Returns:
|
||||
List of daily metric dictionaries with keys:
|
||||
- date: date object
|
||||
- step_count: int
|
||||
- calories_burned: float
|
||||
- distance_meters: float
|
||||
- sleep_duration_minutes: int
|
||||
- deep_sleep_minutes: int
|
||||
- light_sleep_minutes: int
|
||||
- rem_sleep_minutes: int
|
||||
- awake_duration_minutes: int
|
||||
- avg_heart_rate: int
|
||||
- min_heart_rate: int
|
||||
- max_heart_rate: int
|
||||
- avg_spo2: float
|
||||
- pai_score: float
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_workouts(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch workout sessions.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
|
||||
Returns:
|
||||
List of workout dictionaries with keys:
|
||||
- start_time: datetime
|
||||
- end_time: datetime
|
||||
- activity_type: str (human readable)
|
||||
- activity_type_id: int (source-specific ID)
|
||||
- duration_seconds: int
|
||||
- distance_meters: float
|
||||
- calories: float
|
||||
- avg_hr: int
|
||||
- max_hr: int
|
||||
- min_hr: int
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_sleep_sessions(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch sleep sessions for total duration validation.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
|
||||
Returns:
|
||||
List of sleep session dictionaries with keys:
|
||||
- date: date object (wake-up date)
|
||||
- duration_minutes: int
|
||||
- start_time: datetime
|
||||
- end_time: datetime
|
||||
- type: str
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_auth_url(self) -> str:
|
||||
"""
|
||||
Get the OAuth authorization URL for this provider.
|
||||
|
||||
Returns:
|
||||
Authorization URL string
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HealthProviderFactory:
|
||||
"""
|
||||
Factory for creating health data provider instances.
|
||||
|
||||
Uses the HEALTH_PROVIDER environment variable to determine
|
||||
which provider to instantiate.
|
||||
"""
|
||||
|
||||
_providers: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, provider_class: type):
|
||||
"""Register a provider class with a name."""
|
||||
cls._providers[name] = provider_class
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, name: Optional[str] = None) -> HealthDataProvider:
|
||||
"""
|
||||
Get a provider instance by name.
|
||||
|
||||
Args:
|
||||
name: Provider name. If None, uses HEALTH_PROVIDER env var.
|
||||
|
||||
Returns:
|
||||
HealthDataProvider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider name is not registered
|
||||
"""
|
||||
from backend.core.config import settings
|
||||
|
||||
provider_name = name or getattr(settings, 'HEALTH_PROVIDER', 'health_connect')
|
||||
|
||||
if provider_name not in cls._providers:
|
||||
available = list(cls._providers.keys())
|
||||
raise ValueError(
|
||||
f"Unknown provider '{provider_name}'. Available: {available}"
|
||||
)
|
||||
|
||||
return cls._providers[provider_name]()
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> List[str]:
|
||||
"""List all registered provider names."""
|
||||
return list(cls._providers.keys())
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Zepp Data Import Module
|
||||
|
||||
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
|
||||
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.database import SessionLocal, engine, Base
|
||||
|
||||
# --- Configuration ---
|
||||
EXPORT_DIR = "export-zepp"
|
||||
USER_ID = 1 # Default user ID since we are single user for now or running locally
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
# Remove timezone for simplicity
|
||||
clean_str = dt_str.split('+')[0].split('-0')[0]
|
||||
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_export_dir(base_dir):
|
||||
"""Find the numbered directory inside export-zepp."""
|
||||
subdirs = glob.glob(os.path.join(base_dir, "*"))
|
||||
if not subdirs:
|
||||
return None
|
||||
# Return the most recent one (sorted by name, which includes timestamp)
|
||||
return sorted(subdirs)[-1]
|
||||
|
||||
|
||||
def import_activity(session, export_path):
|
||||
"""Import daily activity summary (steps, distance, calories)."""
|
||||
activity_dir = os.path.join(export_path, "ACTIVITY")
|
||||
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
|
||||
if not csv_files:
|
||||
print("No Activity CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Activity: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
steps = int(row.get('steps', 0))
|
||||
distance = float(row.get('distance', 0))
|
||||
calories = float(row.get('calories', 0))
|
||||
|
||||
# Upsert
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
# Overwrite with Zepp data as it's authoritative
|
||||
if steps > 0:
|
||||
metric.step_count = steps
|
||||
if distance > 0:
|
||||
metric.distance_meters = distance
|
||||
if calories > 0:
|
||||
metric.calories_burned = calories
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} activity records.")
|
||||
|
||||
|
||||
def import_sleep(session, export_path):
|
||||
"""Import sleep data with phases (deep, light, REM, awake)."""
|
||||
sleep_dir = os.path.join(export_path, "SLEEP")
|
||||
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sleep: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
deep = int(row.get('deepSleepTime', 0))
|
||||
light = int(row.get('shallowSleepTime', 0))
|
||||
wake = int(row.get('wakeTime', 0))
|
||||
rem = int(row.get('REMTime', 0))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
total_sleep = deep + light + rem
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
if total_sleep > 0:
|
||||
metric.sleep_duration_minutes = total_sleep
|
||||
metric.deep_sleep_minutes = deep
|
||||
metric.light_sleep_minutes = light
|
||||
metric.rem_sleep_minutes = rem
|
||||
metric.awake_duration_minutes = wake
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} sleep records.")
|
||||
|
||||
|
||||
def import_heart_rate(session, export_path):
|
||||
"""Import manual heart rate readings."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
daily_hr = {} # date -> [hr_values]
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not ts_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_heart_rate_auto(session, export_path):
|
||||
"""Import automatic heart rate monitoring data."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No HEARTRATE_AUTO CSV found.")
|
||||
return
|
||||
|
||||
daily_hr = {}
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate Auto: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = row.get('date')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not date_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
d_date = parse_date(date_str)
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate and Save
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
rhr = min_hr # Resting HR approximation
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
metric.resting_heart_rate = rhr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_body(session, export_path):
|
||||
"""Import body measurements (weight, etc.)."""
|
||||
body_dir = os.path.join(export_path, "BODY")
|
||||
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Body: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
w_str = row.get('weight', '0')
|
||||
weight = float(w_str)
|
||||
|
||||
if not ts_str or weight <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.weight = weight
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} body measurements.")
|
||||
|
||||
|
||||
def import_sport(session, export_path):
|
||||
"""Import sport/workout sessions."""
|
||||
sport_dir = os.path.join(export_path, "SPORT")
|
||||
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No SPORT CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sport: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
|
||||
# distance(m), avgPace(/meter), calories(kcal)
|
||||
for row in reader:
|
||||
try:
|
||||
type_id = int(row.get('type', 0))
|
||||
start_time_str = row.get('startTime')
|
||||
duration_seconds = int(row.get('sportTime(s)', 0))
|
||||
distance = float(row.get('distance(m)', 0))
|
||||
calories = float(row.get('calories(kcal)', 0))
|
||||
max_pace = float(row.get('maxPace(/meter)', 0))
|
||||
min_pace = float(row.get('minPace(/meter)', 0))
|
||||
avg_pace = float(row.get('avgPace(/meter)', 0))
|
||||
|
||||
start_time = parse_datetime(start_time_str)
|
||||
if not start_time:
|
||||
continue
|
||||
|
||||
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
|
||||
activity_name = WorkoutSession.get_activity_name(type_id)
|
||||
|
||||
# Check if workout already exists (by start time and user)
|
||||
existing = session.query(WorkoutSession).filter_by(
|
||||
user_id=USER_ID, start_time=start_time
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration_seconds
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout = WorkoutSession(
|
||||
user_id=USER_ID,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
activity_type=activity_name,
|
||||
activity_type_id=type_id,
|
||||
duration_seconds=duration_seconds,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None
|
||||
)
|
||||
session.add(workout)
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing workout: {e}")
|
||||
continue
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} workout sessions.")
|
||||
|
||||
|
||||
def calculate_pai_scores(session):
|
||||
"""Calculate PAI scores for all days based on activity and heart rate data."""
|
||||
print("Calculating PAI scores...")
|
||||
|
||||
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
|
||||
|
||||
for metric in metrics:
|
||||
# Simple PAI calculation based on activity
|
||||
# Real PAI requires continuous HR data, this is an approximation
|
||||
pai = 0.0
|
||||
|
||||
# Base PAI from steps (rough approximation)
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
|
||||
|
||||
# Bonus from calories burned (beyond baseline)
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
|
||||
|
||||
# Bonus from elevated heart rate activities
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
intensity_bonus = (metric.avg_heart_rate - 100) / 10
|
||||
pai += intensity_bonus
|
||||
|
||||
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
|
||||
|
||||
session.commit()
|
||||
print(f" Updated PAI scores for {len(metrics)} days.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main import function."""
|
||||
print("=" * 60)
|
||||
print("Starting Zepp Data Import...")
|
||||
print("=" * 60)
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
base_export = get_latest_export_dir(EXPORT_DIR)
|
||||
if not base_export:
|
||||
print(f"No export directory found in {EXPORT_DIR}")
|
||||
return
|
||||
|
||||
print(f"Using export directory: {base_export}\n")
|
||||
|
||||
# Import all data types
|
||||
import_activity(session, base_export)
|
||||
import_sleep(session, base_export)
|
||||
import_heart_rate(session, base_export)
|
||||
import_heart_rate_auto(session, base_export)
|
||||
import_body(session, base_export)
|
||||
import_sport(session, base_export)
|
||||
|
||||
# Calculate derived metrics
|
||||
calculate_pai_scores(session)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Import completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user