Remove fitparse entirely - use Garmin SDK only with messages dict approach
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 24s

This commit is contained in:
2026-06-06 19:17:51 +01:00
parent e9811d8d83
commit f609931ebc
3 changed files with 119 additions and 208 deletions
+54 -79
View File
@@ -1,24 +1,15 @@
""" """
FIT and GPX file parser using: FIT and GPX file parser using the official Garmin FIT Python SDK.
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
- gpxpy for .gpx files
The official SDK correctly handles scale/offset, component expansion,
semicircle-to-degree conversion, and HR message merging.
""" """
import math import math
from pathlib import Path from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import gpxpy import gpxpy
import polyline as polyline_lib import polyline as polyline_lib
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
def haversine_distance(lat1, lon1, lat2, lon2) -> float: def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Distance in metres between two GPS points."""
R = 6371000 R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2) phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1) dphi = math.radians(lat2 - lat1)
@@ -43,26 +34,22 @@ def _bounding_box(coords: list) -> Optional[dict]:
"min_lon": min(lons), "max_lon": max(lons)} "min_lon": min(lons), "max_lon": max(lons)}
def _ensure_utc(dt) -> Optional[datetime]:
if dt is None:
return None
if isinstance(dt, datetime):
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
return None
def parse_fit_file(filepath: str) -> dict: def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit activity file using the official Garmin SDK.""" """Parse a Garmin .fit activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
session = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
stream = Stream.from_file(filepath) stream = Stream.from_file(filepath)
decoder = Decoder(stream) decoder = Decoder(stream)
decoder.read(
messages, errors = decoder.read(
apply_scale_and_offset=True, apply_scale_and_offset=True,
convert_datetimes_to_dates=True, convert_datetimes_to_dates=True,
convert_types_to_strings=True, convert_types_to_strings=True,
@@ -70,58 +57,62 @@ def parse_fit_file(filepath: str) -> dict:
expand_sub_fields=True, expand_sub_fields=True,
expand_components=True, expand_components=True,
merge_heart_rates=True, merge_heart_rates=True,
mesg_listener=listener,
) )
# Map sport type sessions = messages.get("session", [{}])
session = sessions[0] if sessions else {}
records = messages.get("record", [])
laps = messages.get("lap", [])
sport = str(session.get("sport", "generic")).lower() sport = str(session.get("sport", "generic")).lower()
sport_map = { sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming", "running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking", "generic": "other", "hiking": "hiking", "walking": "walking",
"open_water_swimming": "swimming", "trail_running": "running", "generic": "other", "trail_running": "running",
"e_biking": "cycling", "e_biking": "cycling", "open_water_swimming": "other",
} }
sport_type = sport_map.get(sport, sport) sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time") start_time = _ensure_utc(session.get("start_time"))
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc) coords = []
for r in records:
lat = r.get("position_lat")
lon = r.get("position_long")
if lat is not None and lon is not None:
if -90 <= lat <= 90 and -180 <= lon <= 180:
coords.append((lat, lon))
# Build GPS track
coords = [
(r["position_lat"], r["position_long"])
for r in records
if r.get("position_lat") is not None and r.get("position_long") is not None
]
encoded_polyline = polyline_lib.encode(coords) if coords else None encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords) bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = [] normalized_points = []
for r in records: for r in records:
ts = r.get("timestamp") ts = _ensure_utc(r.get("timestamp"))
if isinstance(ts, datetime) and ts.tzinfo is None: lat = r.get("position_lat")
ts = ts.replace(tzinfo=timezone.utc) lon = r.get("position_long")
if lat is not None and not (-90 <= lat <= 90):
lat = None
if lon is not None and not (-180 <= lon <= 180):
lon = None
normalized_points.append({ normalized_points.append({
"timestamp": ts.isoformat() if ts else None, "timestamp": ts.isoformat() if ts else None,
"latitude": r.get("position_lat"), "latitude": _safe_float(lat),
"longitude": r.get("position_long"), "longitude": _safe_float(lon),
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"), "altitude_m": _safe_float(r.get("altitude") or r.get("enhanced_altitude")),
"heart_rate": r.get("heart_rate"), "heart_rate": _safe_float(r.get("heart_rate")),
"cadence": r.get("cadence") or r.get("fractional_cadence"), "cadence": _safe_float(r.get("cadence")),
"speed_ms": r.get("speed") or r.get("enhanced_speed"), "speed_ms": _safe_float(r.get("speed") or r.get("enhanced_speed")),
"power": r.get("power"), "power": _safe_float(r.get("power")),
"temperature_c": r.get("temperature"), "temperature_c": _safe_float(r.get("temperature")),
"distance_m": r.get("distance"), "distance_m": _safe_float(r.get("distance")),
}) })
# Normalize laps
normalized_laps = [] normalized_laps = []
for i, lap in enumerate(laps): for i, lap in enumerate(laps):
ls = lap.get("start_time") ls = _ensure_utc(lap.get("start_time"))
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({ normalized_laps.append({
"lap_number": i + 1, "lap_number": i + 1,
"start_time": ls.isoformat() if ls else None, "start_time": ls.isoformat() if ls else None,
@@ -133,8 +124,7 @@ def parse_fit_file(filepath: str) -> dict:
"avg_power": _safe_float(lap.get("avg_power")), "avg_power": _safe_float(lap.get("avg_power")),
}) })
# Build activity name name = sport_type.title()
name = session.get("sport", "Activity").title()
if start_time: if start_time:
name += " " + start_time.strftime("%Y-%m-%d") name += " " + start_time.strftime("%Y-%m-%d")
@@ -209,7 +199,6 @@ def parse_gpx_file(filepath: str) -> dict:
encoded_polyline = polyline_lib.encode(coords) if coords else None encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords) bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0 total_dist = 0.0
prev = None prev = None
for p in data_points: for p in data_points:
@@ -219,7 +208,6 @@ def parse_gpx_file(filepath: str) -> dict:
prev = (p["latitude"], p["longitude"]) prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0 uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]] alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)): for i in range(1, len(alts)):
@@ -267,20 +255,7 @@ def parse_gpx_file(filepath: str) -> dict:
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict: def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
""" """Calculate % time in each HR zone using user's configured max HR."""
Calculate % time in each HR zone using the user's configured max HR.
Zones follow the standard 5-zone model as % of max HR:
Z1 Recovery: < 60%
Z2 Base: 60 - 70%
Z3 Tempo: 70 - 80%
Z4 Threshold: 80 - 90%
Z5 Max: > 90%
user_max_hr should be the user's actual physiological max HR, NOT the
highest HR recorded in this activity. Using activity max shifts all zones
upward and makes easy runs look harder than they are.
"""
if not user_max_hr or user_max_hr < 100: if not user_max_hr or user_max_hr < 100:
return {} return {}
@@ -300,7 +275,7 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
zones[key] += 1 zones[key] += 1
break break
else: else:
zones["z5"] += 1 # anything above 90% goes to z5 zones["z5"] += 1
if total: if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()} return {k: round(v / total * 100, 1) for k, v in zones.items()}
+62 -125
View File
@@ -1,56 +1,45 @@
""" """
Garmin wellness FIT file parser using the official Garmin FIT Python SDK. Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The official SDK (garmin-fit-sdk) correctly handles:
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
- Garmin proprietary messages stored by numeric mesg_num
- Unknown fields stored by field definition number
- Scale/offset application, component expansion, HR merging
Fenix 6X proprietary message numbers identified by binary analysis:
55 - activity accumulation snapshots (cumulative steps, HR per interval)
103 - daily totals summary (total steps, floors, calories)
211 - resting HR + HRV summary
227 - per-minute stress level + heart rate (most valuable for health dashboard)
""" """
from datetime import datetime, timezone, timedelta, date from datetime import datetime, timezone, date
from typing import Optional from typing import Optional
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989) FIT_EPOCH_S = 631065600
def fit_ts(seconds) -> Optional[datetime]: def _fit_ts(raw) -> Optional[datetime]:
"""Convert FIT timestamp to UTC datetime.""" if raw is None:
if seconds is None:
return None return None
try: try:
s = int(seconds) s = int(raw)
if s == 0 or s == 0xFFFFFFFF: if s <= 0 or s == 0xFFFFFFFF:
return None return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc) return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError): except (TypeError, ValueError, OverflowError, OSError):
return None return None
def _is_datetime(v) -> bool: def _to_date(val) -> Optional[date]:
return isinstance(v, datetime) if val is None:
return None
if isinstance(val, datetime):
if val.tzinfo is None:
val = val.replace(tzinfo=timezone.utc)
return val.date()
if isinstance(val, (int, float)):
dt = _fit_ts(val)
return dt.date() if dt else None
return None
def parse_wellness_fit(file_path: str) -> dict: def parse_wellness_fit(file_path: str) -> dict:
""" """
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK. Parse a Garmin wellness/monitoring FIT file.
Returns {"days": {date: metrics_dict}, "error": str|None} Returns {"days": {date: metrics_dict}, "error": str|None}
""" """
try: daily = {}
from garmin_fit_sdk import Decoder, Stream
except ImportError:
# Fall back to fitparse-based parser if SDK not installed yet
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
return _fb(file_path)
daily = {} # date -> aggregation dict
def ensure_day(d: date) -> dict: def ensure_day(d: date) -> dict:
if d not in daily: if d not in daily:
@@ -70,60 +59,37 @@ def parse_wellness_fit(file_path: str) -> dict:
} }
return daily[d] return daily[d]
def get_date(msg: dict, *keys) -> Optional[date]:
"""Extract a date from a message, trying multiple field names."""
for key in keys:
v = msg.get(key)
if v is None:
continue
if _is_datetime(v):
return v.date()
if isinstance(v, (int, float)):
dt = fit_ts(v)
if dt:
return dt.date()
return None
def listener(mesg_num: int, msg: dict): def listener(mesg_num: int, msg: dict):
"""Called for every message after full decoding."""
# ── Standard: monitoring (148) ──────────────────────────────────── if mesg_num == 147:
if mesg_num == 148: d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
d = get_date(msg, "timestamp", "local_timestamp") rhr = msg.get("resting_heart_rate")
if d and rhr and 20 < rhr < 120:
ensure_day(d)["resting_hr"] = int(rhr)
elif mesg_num == 148:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
if not d: if not d:
return return
entry = ensure_day(d) entry = ensure_day(d)
hr = msg.get("heart_rate") hr = msg.get("heart_rate")
if hr and 20 < hr < 250: if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr)) entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles") steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0: if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps)) entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value") stress = msg.get("stress_level_value")
if stress is not None and stress >= 0: if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress)) entry["stress_values"].append(int(stress))
# ── Standard: monitoring_info (147) ───────────────────────────────
elif mesg_num == 147:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
rhr = msg.get("resting_heart_rate")
if rhr and 20 < rhr < 120:
ensure_day(d)["resting_hr"] = int(rhr)
# ── Standard: hrv_status_summary (275) ────────────────────────────
elif mesg_num == 275: elif mesg_num == 275:
d = get_date(msg, "timestamp") d = _to_date(msg.get("timestamp"))
if not d: if not d:
return return
entry = ensure_day(d) entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"): for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key) v = msg.get(key)
if v: if v and v > 0:
entry["hrv_nightly_avg"] = float(v) entry["hrv_nightly_avg"] = float(v)
break break
high = msg.get("last_night_5_min_high") high = msg.get("last_night_5_min_high")
@@ -133,120 +99,93 @@ def parse_wellness_fit(file_path: str) -> dict:
if status: if status:
entry["hrv_status"] = str(status) entry["hrv_status"] = str(status)
# ── Standard: stress_level (132) ──────────────────────────────────
elif mesg_num == 132: elif mesg_num == 132:
d = get_date(msg, "stress_level_time", "timestamp") d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d: if not d:
return return
stress = msg.get("stress_level_value") stress = msg.get("stress_level_value")
if stress is not None and stress >= 0: if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress)) ensure_day(d)["stress_values"].append(int(stress))
# ── Standard: spo2_data (258) ─────────────────────────────────────
elif mesg_num == 258: elif mesg_num == 258:
d = get_date(msg, "timestamp") d = _to_date(msg.get("timestamp"))
if not d: if not d:
return return
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2") spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100: if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2)) ensure_day(d)["spo2_readings"].append(float(spo2))
# ── Standard: sleep_level (269) ───────────────────────────────────
elif mesg_num == 269: elif mesg_num == 269:
d = get_date(msg, "timestamp") d = _to_date(msg.get("timestamp"))
if not d: if not d:
return return
level = msg.get("sleep_level") level = msg.get("sleep_level")
if level is not None: if level is not None:
# Convert string level names to numeric codes if SDK decoded them
if isinstance(level, str): if isinstance(level, str):
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4} level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
level = level_map.get(level.lower()) level = level_map.get(level.lower())
if level is not None: if level is not None:
ensure_day(d)["sleep_levels"].append(int(level)) ensure_day(d)["sleep_levels"].append(int(level))
# ── Proprietary 227: per-minute stress + HR ───────────────────────
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
elif mesg_num == 227: elif mesg_num == 227:
# SDK stores unknown fields as "unknown_N" or by def_num ts_raw = msg.get(1) or msg.get("1") or msg.get("unknown_1")
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1") hr_raw = msg.get(2) or msg.get("2") or msg.get("unknown_2")
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2") stress_raw = msg.get(0) or msg.get("0") or msg.get("unknown_0")
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0") d = _to_date(ts_raw)
if not d:
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
ts_raw if _is_datetime(ts_raw) else None
)
if not ts:
return return
entry = ensure_day(ts.date()) entry = ensure_day(d)
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250: if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw)) entry["heart_rates"].append(int(hr_raw))
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0: if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
entry["stress_values"].append(int(stress_raw)) entry["stress_values"].append(int(stress_raw))
# ── Proprietary 103: daily totals summary ─────────────────────────
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
elif mesg_num == 103: elif mesg_num == 103:
ts_v = msg.get(253) or msg.get("timestamp") ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v) d = _to_date(ts_raw)
if not ts: if not d:
return return
entry = ensure_day(ts.date()) entry = ensure_day(d)
steps = msg.get(3) or msg.get("3")
steps = msg.get(3)
if steps and isinstance(steps, (int, float)) and steps > 0: if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = int(steps) entry["steps"] = int(steps)
floors = msg.get(4) or msg.get("4")
floors = msg.get(4)
if floors and isinstance(floors, (int, float)) and floors > 0: if floors and isinstance(floors, (int, float)) and floors > 0:
f = float(floors) f = float(floors)
if f > 1000: entry["floors_climbed"] = round(f / 100 if f > 1000 else f, 1)
f = f / 100 active_cal = msg.get(5) or msg.get("5")
entry["floors_climbed"] = round(f, 1)
active_cal = msg.get(5)
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0: if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
entry["active_calories"] = float(active_cal) entry["active_calories"] = float(active_cal)
total_cal = msg.get(7) or msg.get("7")
total_cal = msg.get(7)
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0: if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
entry["total_calories"] = float(total_cal) entry["total_calories"] = float(total_cal)
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
elif mesg_num == 211: elif mesg_num == 211:
ts_v = msg.get(253) or msg.get("timestamp") ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v) d = _to_date(ts_raw)
if not ts: if not d:
return return
entry = ensure_day(ts.date()) entry = ensure_day(d)
rhr = msg.get(0) or msg.get("0")
rhr = msg.get(0)
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120: if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr) entry["resting_hr"] = int(rhr)
hrv = msg.get(1) or msg.get("1")
hrv = msg.get(1)
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300: if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
entry["hrv_nightly_avg"] = float(hrv) entry["hrv_nightly_avg"] = float(hrv)
# ── Proprietary 55: activity accumulation snapshots ───────────────
elif mesg_num == 55: elif mesg_num == 55:
ts_v = msg.get(253) or msg.get("timestamp") ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v) d = _to_date(ts_raw)
if not ts: if not d:
return return
entry = ensure_day(ts.date()) entry = ensure_day(d)
steps = msg.get(2) or msg.get("2")
steps = msg.get(2)
if steps and isinstance(steps, (int, float)) and steps > 0: if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps)) entry["steps"] = max(entry["steps"] or 0, int(steps))
hr = msg.get(19) or msg.get("19")
hr = msg.get(19)
if hr and isinstance(hr, (int, float)) and 20 < hr < 250: if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
entry["heart_rates"].append(int(hr)) entry["heart_rates"].append(int(hr))
# Decode the file
try: try:
stream = Stream.from_file(file_path) stream = Stream.from_file(file_path)
decoder = Decoder(stream) decoder = Decoder(stream)
@@ -254,16 +193,15 @@ def parse_wellness_fit(file_path: str) -> dict:
apply_scale_and_offset=True, apply_scale_and_offset=True,
convert_datetimes_to_dates=True, convert_datetimes_to_dates=True,
convert_types_to_strings=True, convert_types_to_strings=True,
enable_crc_check=False, # wellness files sometimes have bad CRCs enable_crc_check=False,
expand_sub_fields=True, expand_sub_fields=True,
expand_components=True, expand_components=True,
merge_heart_rates=True, merge_heart_rates=False,
mesg_listener=listener, mesg_listener=listener,
) )
except Exception as e: except Exception as e:
return {"error": str(e), "days": {}} return {"error": str(e), "days": {}}
# Aggregate per-day
result = {} result = {}
for day_date, data in daily.items(): for day_date, data in daily.items():
hrs = data.pop("heart_rates", []) hrs = data.pop("heart_rates", [])
@@ -276,7 +214,6 @@ def parse_wellness_fit(file_path: str) -> dict:
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Sleep stage seconds (each level record = 30s epoch)
if sleep_levels: if sleep_levels:
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
-1
View File
@@ -13,7 +13,6 @@ httpx==0.27.0
redis[hiredis]==5.0.4 redis[hiredis]==5.0.4
celery[redis]==5.4.0 celery[redis]==5.4.0
garmin-fit-sdk==21.195.0 garmin-fit-sdk==21.195.0
fitparse==1.2.0
gpxpy==1.6.2 gpxpy==1.6.2
numpy==1.26.4 numpy==1.26.4
scipy==1.13.0 scipy==1.13.0