All tweaks added
This commit is contained in:
@@ -4,4 +4,4 @@ can be started with: celery -A app.workers.celery_app worker
|
||||
"""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
__all__ = ["celery_app"]
|
||||
|
||||
+147
-134
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
if existing:
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user's configured max HR for accurate zone calculation
|
||||
# Falls back to: user-set value → 220-age → activity max → 190
|
||||
from app.models.user import User as UserModel
|
||||
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
|
||||
user_max_hr = None
|
||||
if user_obj:
|
||||
user_max_hr = user_obj.max_heart_rate
|
||||
if not user_max_hr and user_obj.birth_year:
|
||||
from datetime import date as _date
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
# Last resort: use activity max but warn this may shift zones
|
||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||
|
||||
hr_zones = calculate_hr_zones(
|
||||
parsed.get("data_points", []),
|
||||
parsed.get("max_heart_rate") or 190
|
||||
user_max_hr
|
||||
)
|
||||
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
@@ -169,6 +184,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
activity_id = activity.id
|
||||
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
# Auto route detection for running and cycling
|
||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||
detect_route.delay(activity_id, user_id)
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
|
||||
|
||||
@@ -176,161 +194,156 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"""
|
||||
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
||||
These files contain resting HR, HRV, sleep, stress, SpO2 etc.
|
||||
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
||||
"""
|
||||
import fitparse
|
||||
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import HealthMetric
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
try:
|
||||
fit = fitparse.FitFile(file_path)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
result = _parse(file_path)
|
||||
if result.get("error"):
|
||||
return {"status": "error", "error": result["error"], "file": file_path}
|
||||
|
||||
# Collect all monitoring/daily summary records keyed by date
|
||||
daily = {} # date -> dict of fields
|
||||
|
||||
def get_or_create_day(d: date) -> dict:
|
||||
if d not in daily:
|
||||
daily[d] = {}
|
||||
return daily[d]
|
||||
|
||||
for record in fit.get_messages():
|
||||
name = record.name
|
||||
fields = {f.name: f.value for f in record if f.value is not None}
|
||||
|
||||
if name == "monitoring_info":
|
||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
day.setdefault("resting_hr", fields.get("resting_heart_rate"))
|
||||
|
||||
elif name == "monitoring":
|
||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if not d:
|
||||
continue
|
||||
day = get_or_create_day(d)
|
||||
# Accumulate steps (they're stored as increments)
|
||||
if "steps" in fields:
|
||||
day["steps"] = day.get("steps", 0) + int(fields["steps"])
|
||||
if "heart_rate" in fields:
|
||||
hrs = day.setdefault("heart_rates", [])
|
||||
hrs.append(int(fields["heart_rate"]))
|
||||
if "stress_level_value" in fields:
|
||||
stresses = day.setdefault("stress_values", [])
|
||||
stresses.append(int(fields["stress_level_value"]))
|
||||
|
||||
elif name == "hrv_status_summary":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
day.setdefault("hrv_nightly_avg", fields.get("weekly_average"))
|
||||
day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high"))
|
||||
day.setdefault("hrv_status", str(fields.get("hrv_status", "")))
|
||||
|
||||
elif name == "sleep_level":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
levels = day.setdefault("sleep_levels", [])
|
||||
levels.append(fields.get("sleep_level"))
|
||||
|
||||
elif name == "stress":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
if "stress_level_value" in fields:
|
||||
stresses = day.setdefault("stress_values", [])
|
||||
stresses.append(int(fields["stress_level_value"]))
|
||||
|
||||
elif name == "spo2_data":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
readings = day.setdefault("spo2_readings", [])
|
||||
if "spo2_percent" in fields:
|
||||
readings.append(fields["spo2_percent"])
|
||||
|
||||
if not daily:
|
||||
days = result.get("days", {})
|
||||
if not days:
|
||||
return {"status": "no_data", "file": file_path}
|
||||
|
||||
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
|
||||
with SyncSessionLocal() as db:
|
||||
for day_date, data in daily.items():
|
||||
hrs = data.pop("heart_rates", [])
|
||||
stresses = data.pop("stress_values", [])
|
||||
spo2s = data.pop("spo2_readings", [])
|
||||
sleep_levels = data.pop("sleep_levels", [])
|
||||
|
||||
resting_hr = data.get("resting_hr")
|
||||
avg_hr = (sum(hrs) / len(hrs)) if hrs else None
|
||||
avg_stress = (sum(stresses) / len(stresses)) if stresses else None
|
||||
spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None
|
||||
|
||||
# Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem
|
||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None
|
||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None
|
||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None
|
||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None
|
||||
sleep_duration_s = (
|
||||
(sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0)
|
||||
) or None
|
||||
|
||||
for day_date, data in days.items():
|
||||
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
||||
|
||||
# ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data
|
||||
db.execute(text("""
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress,
|
||||
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps,
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
||||
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||
steps, floors_climbed, active_calories, total_calories,
|
||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress,
|
||||
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps,
|
||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||
:steps, :floors, :active_cal, :total_cal,
|
||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
||||
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
||||
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
||||
"""), {
|
||||
"user_id": user_id, "date": date_dt,
|
||||
"resting_hr": resting_hr, "avg_hr": avg_hr,
|
||||
"avg_stress": avg_stress, "spo2_avg": spo2_avg,
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr": data.get("avg_hr_day"),
|
||||
"max_hr": data.get("max_hr_day"),
|
||||
"avg_stress": data.get("avg_stress"),
|
||||
"spo2_avg": data.get("spo2_avg"),
|
||||
"hrv_avg": data.get("hrv_nightly_avg"),
|
||||
"hrv_high": data.get("hrv_5min_high"),
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s,
|
||||
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s,
|
||||
"sleep_awake": sleep_awake_s,
|
||||
"floors": data.get("floors_climbed"),
|
||||
"active_cal": data.get("active_calories"),
|
||||
"total_cal": data.get("total_calories"),
|
||||
"sleep_dur": data.get("sleep_duration_s"),
|
||||
"sleep_deep": data.get("sleep_deep_s"),
|
||||
"sleep_light": data.get("sleep_light_s"),
|
||||
"sleep_rem": data.get("sleep_rem_s"),
|
||||
"sleep_awake": data.get("sleep_awake_s"),
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "days_processed": len(daily), "file": file_path}
|
||||
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||
|
||||
@celery_app.task(name="detect_route")
|
||||
def detect_route(activity_id: int, user_id: int):
|
||||
"""
|
||||
After importing an activity, check if it matches any existing named routes.
|
||||
If two+ unassigned activities match each other, auto-create a named route.
|
||||
"""
|
||||
from app.services.route_matcher import routes_are_similar
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, NamedRoute
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
# Get the new activity
|
||||
new_act = db.execute(
|
||||
select(Activity).where(Activity.id == activity_id)
|
||||
).scalar_one_or_none()
|
||||
if not new_act or not new_act.polyline:
|
||||
return {"status": "no_polyline"}
|
||||
|
||||
# Already assigned to a route?
|
||||
if new_act.named_route_id:
|
||||
return {"status": "already_assigned"}
|
||||
|
||||
# Check against existing named routes first
|
||||
routes = db.execute(
|
||||
select(NamedRoute).where(
|
||||
NamedRoute.user_id == user_id,
|
||||
NamedRoute.sport_type == new_act.sport_type,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for route in routes:
|
||||
if route.reference_polyline and routes_are_similar(
|
||||
new_act.polyline, route.reference_polyline,
|
||||
new_act.bounding_box, route.bounding_box,
|
||||
):
|
||||
new_act.named_route_id = route.id
|
||||
db.commit()
|
||||
return {"status": "matched_existing", "route_id": route.id}
|
||||
|
||||
# No existing route matched - check unassigned activities for a match
|
||||
candidates = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == new_act.sport_type,
|
||||
Activity.named_route_id == None,
|
||||
Activity.id != activity_id,
|
||||
Activity.polyline != None,
|
||||
# Within 20% distance
|
||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for candidate in candidates:
|
||||
if routes_are_similar(
|
||||
new_act.polyline, candidate.polyline,
|
||||
new_act.bounding_box, candidate.bounding_box,
|
||||
):
|
||||
# Auto-create a route from the older activity
|
||||
older = candidate if candidate.start_time < new_act.start_time else new_act
|
||||
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||
|
||||
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
|
||||
new_route = NamedRoute(
|
||||
user_id=user_id,
|
||||
name=route_name,
|
||||
sport_type=older.sport_type,
|
||||
reference_polyline=older.polyline,
|
||||
bounding_box=older.bounding_box,
|
||||
distance_m=older.distance_m,
|
||||
auto_detected=True,
|
||||
)
|
||||
db.add(new_route)
|
||||
db.flush()
|
||||
older.named_route_id = new_route.id
|
||||
newer.named_route_id = new_route.id
|
||||
db.commit()
|
||||
return {"status": "auto_created", "route_id": new_route.id}
|
||||
|
||||
return {"status": "no_match"}
|
||||
|
||||
|
||||
@celery_app.task(name="compute_personal_records")
|
||||
@@ -435,4 +448,4 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"spo2": data.get("avgSpo2"),
|
||||
})
|
||||
|
||||
db.commit()
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user