Fix SDK field names - use camelCase throughout
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
FIT and GPX file parser using the official Garmin FIT Python SDK.
|
FIT and GPX file parser using the official Garmin FIT Python SDK.
|
||||||
|
Field names from the SDK are camelCase as per the SDK documentation.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -59,6 +60,7 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
merge_heart_rates=True,
|
merge_heart_rates=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SDK returns camelCase keys
|
||||||
sessions = messages.get("session", [{}])
|
sessions = messages.get("session", [{}])
|
||||||
session = sessions[0] if sessions else {}
|
session = sessions[0] if sessions else {}
|
||||||
records = messages.get("record", [])
|
records = messages.get("record", [])
|
||||||
@@ -73,12 +75,12 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
}
|
}
|
||||||
sport_type = sport_map.get(sport, sport)
|
sport_type = sport_map.get(sport, sport)
|
||||||
|
|
||||||
start_time = _ensure_utc(session.get("start_time"))
|
start_time = _ensure_utc(session.get("startTime"))
|
||||||
|
|
||||||
coords = []
|
coords = []
|
||||||
for r in records:
|
for r in records:
|
||||||
lat = r.get("position_lat")
|
lat = r.get("positionLat")
|
||||||
lon = r.get("position_long")
|
lon = r.get("positionLong")
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
coords.append((lat, lon))
|
coords.append((lat, lon))
|
||||||
@@ -89,8 +91,8 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
normalized_points = []
|
normalized_points = []
|
||||||
for r in records:
|
for r in records:
|
||||||
ts = _ensure_utc(r.get("timestamp"))
|
ts = _ensure_utc(r.get("timestamp"))
|
||||||
lat = r.get("position_lat")
|
lat = r.get("positionLat")
|
||||||
lon = r.get("position_long")
|
lon = r.get("positionLong")
|
||||||
|
|
||||||
if lat is not None and not (-90 <= lat <= 90):
|
if lat is not None and not (-90 <= lat <= 90):
|
||||||
lat = None
|
lat = None
|
||||||
@@ -101,10 +103,10 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"timestamp": ts.isoformat() if ts else None,
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
"latitude": _safe_float(lat),
|
"latitude": _safe_float(lat),
|
||||||
"longitude": _safe_float(lon),
|
"longitude": _safe_float(lon),
|
||||||
"altitude_m": _safe_float(r.get("altitude") or r.get("enhanced_altitude")),
|
"altitude_m": _safe_float(r.get("altitude") or r.get("enhancedAltitude")),
|
||||||
"heart_rate": _safe_float(r.get("heart_rate")),
|
"heart_rate": _safe_float(r.get("heartRate")),
|
||||||
"cadence": _safe_float(r.get("cadence")),
|
"cadence": _safe_float(r.get("cadence")),
|
||||||
"speed_ms": _safe_float(r.get("speed") or r.get("enhanced_speed")),
|
"speed_ms": _safe_float(r.get("speed") or r.get("enhancedSpeed")),
|
||||||
"power": _safe_float(r.get("power")),
|
"power": _safe_float(r.get("power")),
|
||||||
"temperature_c": _safe_float(r.get("temperature")),
|
"temperature_c": _safe_float(r.get("temperature")),
|
||||||
"distance_m": _safe_float(r.get("distance")),
|
"distance_m": _safe_float(r.get("distance")),
|
||||||
@@ -112,16 +114,16 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
|
|
||||||
normalized_laps = []
|
normalized_laps = []
|
||||||
for i, lap in enumerate(laps):
|
for i, lap in enumerate(laps):
|
||||||
ls = _ensure_utc(lap.get("start_time"))
|
ls = _ensure_utc(lap.get("startTime"))
|
||||||
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,
|
||||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
"duration_s": _safe_float(lap.get("totalElapsedTime")),
|
||||||
"distance_m": _safe_float(lap.get("total_distance")),
|
"distance_m": _safe_float(lap.get("totalDistance")),
|
||||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
"avg_heart_rate": _safe_float(lap.get("avgHeartRate")),
|
||||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
"avg_cadence": _safe_float(lap.get("avgCadence")),
|
||||||
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
"avg_speed_ms": _safe_float(lap.get("avgSpeed") or lap.get("enhancedAvgSpeed")),
|
||||||
"avg_power": _safe_float(lap.get("avg_power")),
|
"avg_power": _safe_float(lap.get("avgPower")),
|
||||||
})
|
})
|
||||||
|
|
||||||
name = sport_type.title()
|
name = sport_type.title()
|
||||||
@@ -132,21 +134,21 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"sport_type": sport_type,
|
"sport_type": sport_type,
|
||||||
"start_time": start_time.isoformat() if start_time else None,
|
"start_time": start_time.isoformat() if start_time else None,
|
||||||
"distance_m": _safe_float(session.get("total_distance")),
|
"distance_m": _safe_float(session.get("totalDistance")),
|
||||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
"duration_s": _safe_float(session.get("totalElapsedTime")),
|
||||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
"elevation_gain_m": _safe_float(session.get("totalAscent")),
|
||||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
"elevation_loss_m": _safe_float(session.get("totalDescent")),
|
||||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
"avg_heart_rate": _safe_float(session.get("avgHeartRate")),
|
||||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
"max_heart_rate": _safe_float(session.get("maxHeartRate")),
|
||||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
"avg_cadence": _safe_float(session.get("avgCadence")),
|
||||||
"avg_power": _safe_float(session.get("avg_power")),
|
"avg_power": _safe_float(session.get("avgPower")),
|
||||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
"normalized_power": _safe_float(session.get("normalizedPower")),
|
||||||
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
"avg_speed_ms": _safe_float(session.get("avgSpeed") or session.get("enhancedAvgSpeed")),
|
||||||
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
"max_speed_ms": _safe_float(session.get("maxSpeed") or session.get("enhancedMaxSpeed")),
|
||||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
"avg_temperature_c": _safe_float(session.get("avgTemperature")),
|
||||||
"calories": _safe_float(session.get("total_calories")),
|
"calories": _safe_float(session.get("totalCalories")),
|
||||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
"training_stress_score": _safe_float(session.get("trainingStressScore")),
|
||||||
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
"vo2max_estimate": _safe_float(session.get("totalTrainingEffect")),
|
||||||
"polyline": encoded_polyline,
|
"polyline": encoded_polyline,
|
||||||
"bounding_box": bounding_box,
|
"bounding_box": bounding_box,
|
||||||
"source_type": "fit",
|
"source_type": "fit",
|
||||||
|
|||||||
@@ -1,246 +1,284 @@
|
|||||||
"""
|
"""
|
||||||
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
|
FIT and GPX file parser using the official Garmin FIT Python SDK.
|
||||||
|
Field names from the SDK are camelCase as per the SDK documentation.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone, date
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import gpxpy
|
||||||
|
import polyline as polyline_lib
|
||||||
from garmin_fit_sdk import Decoder, Stream
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
|
||||||
|
|
||||||
FIT_EPOCH_S = 631065600
|
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||||
|
R = 6371000
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||||
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
def _fit_ts(raw) -> Optional[datetime]:
|
def _safe_float(val) -> Optional[float]:
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
s = int(raw)
|
return float(val) if val is not None else None
|
||||||
if s <= 0 or s == 0xFFFFFFFF:
|
except (TypeError, ValueError):
|
||||||
return None
|
|
||||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
|
||||||
except (TypeError, ValueError, OverflowError, OSError):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _to_date(val) -> Optional[date]:
|
def _bounding_box(coords: list) -> Optional[dict]:
|
||||||
if val is None:
|
if not coords:
|
||||||
return None
|
return None
|
||||||
if isinstance(val, datetime):
|
lats = [c[0] for c in coords]
|
||||||
if val.tzinfo is None:
|
lons = [c[1] for c in coords]
|
||||||
val = val.replace(tzinfo=timezone.utc)
|
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||||
return val.date()
|
"min_lon": min(lons), "max_lon": max(lons)}
|
||||||
if isinstance(val, (int, float)):
|
|
||||||
dt = _fit_ts(val)
|
|
||||||
return dt.date() if dt else None
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_wellness_fit(file_path: str) -> dict:
|
def parse_fit_file(filepath: str) -> dict:
|
||||||
"""
|
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
||||||
Parse a Garmin wellness/monitoring FIT file.
|
stream = Stream.from_file(filepath)
|
||||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
decoder = Decoder(stream)
|
||||||
"""
|
|
||||||
daily = {}
|
|
||||||
|
|
||||||
def ensure_day(d: date) -> dict:
|
messages, errors = decoder.read(
|
||||||
if d not in daily:
|
apply_scale_and_offset=True,
|
||||||
daily[d] = {
|
convert_datetimes_to_dates=True,
|
||||||
"heart_rates": [],
|
convert_types_to_strings=True,
|
||||||
"stress_values": [],
|
enable_crc_check=False,
|
||||||
"spo2_readings": [],
|
expand_sub_fields=True,
|
||||||
"sleep_levels": [],
|
expand_components=True,
|
||||||
"steps": None,
|
merge_heart_rates=True,
|
||||||
"floors_climbed": None,
|
)
|
||||||
"active_calories": None,
|
|
||||||
"total_calories": None,
|
|
||||||
"resting_hr": None,
|
|
||||||
"hrv_nightly_avg": None,
|
|
||||||
"hrv_5min_high": None,
|
|
||||||
"hrv_status": None,
|
|
||||||
}
|
|
||||||
return daily[d]
|
|
||||||
|
|
||||||
def listener(mesg_num: int, msg: dict):
|
# SDK returns camelCase keys
|
||||||
|
sessions = messages.get("session", [{}])
|
||||||
|
session = sessions[0] if sessions else {}
|
||||||
|
records = messages.get("record", [])
|
||||||
|
laps = messages.get("lap", [])
|
||||||
|
|
||||||
if mesg_num == 147:
|
sport = str(session.get("sport", "generic")).lower()
|
||||||
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
sport_map = {
|
||||||
rhr = msg.get("resting_heart_rate")
|
"running": "running", "cycling": "cycling",
|
||||||
if d and rhr and 20 < rhr < 120:
|
"hiking": "hiking", "walking": "walking",
|
||||||
ensure_day(d)["resting_hr"] = int(rhr)
|
"generic": "other", "trail_running": "running",
|
||||||
|
"e_biking": "cycling", "open_water_swimming": "other",
|
||||||
|
}
|
||||||
|
sport_type = sport_map.get(sport, sport)
|
||||||
|
|
||||||
elif mesg_num == 148:
|
start_time = _ensure_utc(session.get("startTime"))
|
||||||
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
|
||||||
if not d:
|
|
||||||
return
|
|
||||||
entry = ensure_day(d)
|
|
||||||
hr = msg.get("heart_rate")
|
|
||||||
if hr and 20 < hr < 250:
|
|
||||||
entry["heart_rates"].append(int(hr))
|
|
||||||
steps = msg.get("steps") or msg.get("cycles")
|
|
||||||
if steps and steps > 0:
|
|
||||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
|
||||||
stress = msg.get("stress_level_value")
|
|
||||||
if stress is not None and stress >= 0:
|
|
||||||
entry["stress_values"].append(int(stress))
|
|
||||||
|
|
||||||
elif mesg_num == 275:
|
coords = []
|
||||||
d = _to_date(msg.get("timestamp"))
|
for r in records:
|
||||||
if not d:
|
lat = r.get("positionLat")
|
||||||
return
|
lon = r.get("positionLong")
|
||||||
entry = ensure_day(d)
|
if lat is not None and lon is not None:
|
||||||
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
v = msg.get(key)
|
coords.append((lat, lon))
|
||||||
if v and v > 0:
|
|
||||||
entry["hrv_nightly_avg"] = float(v)
|
|
||||||
break
|
|
||||||
high = msg.get("last_night_5_min_high")
|
|
||||||
if high:
|
|
||||||
entry["hrv_5min_high"] = float(high)
|
|
||||||
status = msg.get("hrv_status")
|
|
||||||
if status:
|
|
||||||
entry["hrv_status"] = str(status)
|
|
||||||
|
|
||||||
elif mesg_num == 132:
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
bounding_box = _bounding_box(coords)
|
||||||
if not d:
|
|
||||||
return
|
|
||||||
stress = msg.get("stress_level_value")
|
|
||||||
if stress is not None and stress >= 0:
|
|
||||||
ensure_day(d)["stress_values"].append(int(stress))
|
|
||||||
|
|
||||||
elif mesg_num == 258:
|
normalized_points = []
|
||||||
d = _to_date(msg.get("timestamp"))
|
for r in records:
|
||||||
if not d:
|
ts = _ensure_utc(r.get("timestamp"))
|
||||||
return
|
lat = r.get("positionLat")
|
||||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
lon = r.get("positionLong")
|
||||||
if spo2 and 50 < spo2 <= 100:
|
|
||||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
|
||||||
|
|
||||||
elif mesg_num == 269:
|
if lat is not None and not (-90 <= lat <= 90):
|
||||||
d = _to_date(msg.get("timestamp"))
|
lat = None
|
||||||
if not d:
|
if lon is not None and not (-180 <= lon <= 180):
|
||||||
return
|
lon = None
|
||||||
level = msg.get("sleep_level")
|
|
||||||
if level is not None:
|
|
||||||
if isinstance(level, str):
|
|
||||||
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
|
||||||
level = level_map.get(level.lower())
|
|
||||||
if level is not None:
|
|
||||||
ensure_day(d)["sleep_levels"].append(int(level))
|
|
||||||
|
|
||||||
elif mesg_num == 227:
|
normalized_points.append({
|
||||||
ts_raw = msg.get(1) or msg.get("1") or msg.get("unknown_1")
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
hr_raw = msg.get(2) or msg.get("2") or msg.get("unknown_2")
|
"latitude": _safe_float(lat),
|
||||||
stress_raw = msg.get(0) or msg.get("0") or msg.get("unknown_0")
|
"longitude": _safe_float(lon),
|
||||||
d = _to_date(ts_raw)
|
"altitude_m": _safe_float(r.get("altitude") or r.get("enhancedAltitude")),
|
||||||
if not d:
|
"heart_rate": _safe_float(r.get("heartRate")),
|
||||||
return
|
"cadence": _safe_float(r.get("cadence")),
|
||||||
entry = ensure_day(d)
|
"speed_ms": _safe_float(r.get("speed") or r.get("enhancedSpeed")),
|
||||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
"power": _safe_float(r.get("power")),
|
||||||
entry["heart_rates"].append(int(hr_raw))
|
"temperature_c": _safe_float(r.get("temperature")),
|
||||||
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
|
"distance_m": _safe_float(r.get("distance")),
|
||||||
entry["stress_values"].append(int(stress_raw))
|
})
|
||||||
|
|
||||||
elif mesg_num == 103:
|
normalized_laps = []
|
||||||
ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
|
for i, lap in enumerate(laps):
|
||||||
d = _to_date(ts_raw)
|
ls = _ensure_utc(lap.get("startTime"))
|
||||||
if not d:
|
normalized_laps.append({
|
||||||
return
|
"lap_number": i + 1,
|
||||||
entry = ensure_day(d)
|
"start_time": ls.isoformat() if ls else None,
|
||||||
steps = msg.get(3) or msg.get("3")
|
"duration_s": _safe_float(lap.get("totalElapsedTime")),
|
||||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
"distance_m": _safe_float(lap.get("totalDistance")),
|
||||||
entry["steps"] = int(steps)
|
"avg_heart_rate": _safe_float(lap.get("avgHeartRate")),
|
||||||
floors = msg.get(4) or msg.get("4")
|
"avg_cadence": _safe_float(lap.get("avgCadence")),
|
||||||
if floors and isinstance(floors, (int, float)) and floors > 0:
|
"avg_speed_ms": _safe_float(lap.get("avgSpeed") or lap.get("enhancedAvgSpeed")),
|
||||||
f = float(floors)
|
"avg_power": _safe_float(lap.get("avgPower")),
|
||||||
entry["floors_climbed"] = round(f / 100 if f > 1000 else f, 1)
|
})
|
||||||
active_cal = msg.get(5) or msg.get("5")
|
|
||||||
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
|
|
||||||
entry["active_calories"] = float(active_cal)
|
|
||||||
total_cal = msg.get(7) or msg.get("7")
|
|
||||||
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
|
|
||||||
entry["total_calories"] = float(total_cal)
|
|
||||||
|
|
||||||
elif mesg_num == 211:
|
name = sport_type.title()
|
||||||
ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
|
if start_time:
|
||||||
d = _to_date(ts_raw)
|
name += " " + start_time.strftime("%Y-%m-%d")
|
||||||
if not d:
|
|
||||||
return
|
|
||||||
entry = ensure_day(d)
|
|
||||||
rhr = msg.get(0) or msg.get("0")
|
|
||||||
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
|
||||||
entry["resting_hr"] = int(rhr)
|
|
||||||
hrv = msg.get(1) or msg.get("1")
|
|
||||||
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
|
|
||||||
entry["hrv_nightly_avg"] = float(hrv)
|
|
||||||
|
|
||||||
elif mesg_num == 55:
|
return {
|
||||||
ts_raw = msg.get(253) or msg.get("253") or msg.get("timestamp")
|
"name": name,
|
||||||
d = _to_date(ts_raw)
|
"sport_type": sport_type,
|
||||||
if not d:
|
"start_time": start_time.isoformat() if start_time else None,
|
||||||
return
|
"distance_m": _safe_float(session.get("totalDistance")),
|
||||||
entry = ensure_day(d)
|
"duration_s": _safe_float(session.get("totalElapsedTime")),
|
||||||
steps = msg.get(2) or msg.get("2")
|
"elevation_gain_m": _safe_float(session.get("totalAscent")),
|
||||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
"elevation_loss_m": _safe_float(session.get("totalDescent")),
|
||||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
"avg_heart_rate": _safe_float(session.get("avgHeartRate")),
|
||||||
hr = msg.get(19) or msg.get("19")
|
"max_heart_rate": _safe_float(session.get("maxHeartRate")),
|
||||||
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
|
"avg_cadence": _safe_float(session.get("avgCadence")),
|
||||||
entry["heart_rates"].append(int(hr))
|
"avg_power": _safe_float(session.get("avgPower")),
|
||||||
|
"normalized_power": _safe_float(session.get("normalizedPower")),
|
||||||
|
"avg_speed_ms": _safe_float(session.get("avgSpeed") or session.get("enhancedAvgSpeed")),
|
||||||
|
"max_speed_ms": _safe_float(session.get("maxSpeed") or session.get("enhancedMaxSpeed")),
|
||||||
|
"avg_temperature_c": _safe_float(session.get("avgTemperature")),
|
||||||
|
"calories": _safe_float(session.get("totalCalories")),
|
||||||
|
"training_stress_score": _safe_float(session.get("trainingStressScore")),
|
||||||
|
"vo2max_estimate": _safe_float(session.get("totalTrainingEffect")),
|
||||||
|
"polyline": encoded_polyline,
|
||||||
|
"bounding_box": bounding_box,
|
||||||
|
"source_type": "fit",
|
||||||
|
"data_points": normalized_points,
|
||||||
|
"laps": normalized_laps,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
|
||||||
stream = Stream.from_file(file_path)
|
|
||||||
decoder = Decoder(stream)
|
|
||||||
messages, errors = decoder.read(
|
|
||||||
apply_scale_and_offset=True,
|
|
||||||
convert_datetimes_to_dates=True,
|
|
||||||
convert_types_to_strings=True,
|
|
||||||
enable_crc_check=False,
|
|
||||||
expand_sub_fields=True,
|
|
||||||
expand_components=True,
|
|
||||||
merge_heart_rates=False,
|
|
||||||
mesg_listener=listener,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e), "days": {}}
|
|
||||||
|
|
||||||
result = {}
|
def parse_gpx_file(filepath: str) -> dict:
|
||||||
for day_date, data in daily.items():
|
"""Parse a GPX file."""
|
||||||
hrs = data.pop("heart_rates", [])
|
with open(filepath) as f:
|
||||||
stresses = data.pop("stress_values", [])
|
gpx = gpxpy.parse(f)
|
||||||
spo2s = data.pop("spo2_readings", [])
|
|
||||||
sleep_levels = data.pop("sleep_levels", [])
|
|
||||||
|
|
||||||
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
data_points = []
|
||||||
max_hr = max(hrs) if hrs else None
|
track = gpx.tracks[0] if gpx.tracks else None
|
||||||
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
|
if not track:
|
||||||
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
raise ValueError("No tracks found in GPX file")
|
||||||
|
|
||||||
if sleep_levels:
|
for segment in track.segments:
|
||||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
|
for pt in segment.points:
|
||||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
|
ts = pt.time
|
||||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
|
if ts and ts.tzinfo is None:
|
||||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
|
|
||||||
|
extensions = {}
|
||||||
|
if pt.extensions:
|
||||||
|
for ext in pt.extensions:
|
||||||
|
for child in ext:
|
||||||
|
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||||
|
try:
|
||||||
|
extensions[tag] = float(child.text)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
data_points.append({
|
||||||
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
|
"latitude": pt.latitude,
|
||||||
|
"longitude": pt.longitude,
|
||||||
|
"altitude_m": pt.elevation,
|
||||||
|
"heart_rate": extensions.get("hr"),
|
||||||
|
"cadence": extensions.get("cad"),
|
||||||
|
"speed_ms": extensions.get("speed"),
|
||||||
|
"power": extensions.get("power"),
|
||||||
|
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||||
|
"distance_m": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||||
|
if p["latitude"] and p["longitude"]]
|
||||||
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
|
total_dist = 0.0
|
||||||
|
prev = None
|
||||||
|
for p in data_points:
|
||||||
|
if p["latitude"] and p["longitude"]:
|
||||||
|
if prev:
|
||||||
|
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
||||||
|
prev = (p["latitude"], p["longitude"])
|
||||||
|
p["distance_m"] = total_dist
|
||||||
|
|
||||||
|
uphill, downhill = 0.0, 0.0
|
||||||
|
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||||
|
for i in range(1, len(alts)):
|
||||||
|
diff = alts[i] - alts[i-1]
|
||||||
|
if diff > 0:
|
||||||
|
uphill += diff
|
||||||
else:
|
else:
|
||||||
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
downhill += abs(diff)
|
||||||
|
|
||||||
result[day_date] = {
|
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||||
"resting_hr": data.get("resting_hr"),
|
start_time_str = data_points[0]["timestamp"] if data_points else None
|
||||||
"avg_hr_day": avg_hr,
|
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||||
"max_hr_day": max_hr,
|
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||||
"avg_stress": avg_stress,
|
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||||
"spo2_avg": spo2_avg,
|
|
||||||
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
|
|
||||||
"hrv_5min_high": data.get("hrv_5min_high"),
|
|
||||||
"hrv_status": data.get("hrv_status"),
|
|
||||||
"steps": data.get("steps"),
|
|
||||||
"floors_climbed": data.get("floors_climbed"),
|
|
||||||
"active_calories": data.get("active_calories"),
|
|
||||||
"total_calories": data.get("total_calories"),
|
|
||||||
"sleep_duration_s": sleep_duration_s,
|
|
||||||
"sleep_deep_s": sleep_deep_s,
|
|
||||||
"sleep_light_s": sleep_light_s,
|
|
||||||
"sleep_rem_s": sleep_rem_s,
|
|
||||||
"sleep_awake_s": sleep_awake_s,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"days": result, "error": None}
|
sport = "running"
|
||||||
|
if track.type:
|
||||||
|
sport = track.type.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||||
|
"sport_type": sport,
|
||||||
|
"start_time": start_time_str,
|
||||||
|
"distance_m": total_dist,
|
||||||
|
"duration_s": duration,
|
||||||
|
"elevation_gain_m": uphill,
|
||||||
|
"elevation_loss_m": downhill,
|
||||||
|
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||||
|
"max_heart_rate": max(hrs) if hrs else None,
|
||||||
|
"avg_cadence": None,
|
||||||
|
"avg_power": None,
|
||||||
|
"normalized_power": None,
|
||||||
|
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||||
|
"max_speed_ms": None,
|
||||||
|
"avg_temperature_c": None,
|
||||||
|
"calories": None,
|
||||||
|
"training_stress_score": None,
|
||||||
|
"vo2max_estimate": None,
|
||||||
|
"polyline": encoded_polyline,
|
||||||
|
"bounding_box": bounding_box,
|
||||||
|
"source_type": "gpx",
|
||||||
|
"data_points": data_points,
|
||||||
|
"laps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
|
"""Calculate % time in each HR zone using user's configured max HR."""
|
||||||
|
if not user_max_hr or user_max_hr < 100:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||||
|
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||||
|
zones = {k: 0 for k in zone_keys}
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for p in data_points:
|
||||||
|
hr = p.get("heart_rate")
|
||||||
|
if not hr or hr < 20:
|
||||||
|
continue
|
||||||
|
pct = hr / user_max_hr
|
||||||
|
total += 1
|
||||||
|
for i, key in enumerate(zone_keys):
|
||||||
|
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||||
|
zones[key] += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
zones["z5"] += 1
|
||||||
|
|
||||||
|
if total:
|
||||||
|
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MESSAGE="${1:-update}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "$MESSAGE"
|
||||||
|
git push
|
||||||
|
|
||||||
|
cd ../milevault_docker
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Run 'docker compose pull && docker compose up -d' when the build completes."
|
||||||
Reference in New Issue
Block a user