All tweaks added
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
FIT and GPX file parser using:
|
||||
- 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
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
"""Distance in metres between two GPS points."""
|
||||
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 _safe_float(val) -> Optional[float]:
|
||||
try:
|
||||
return float(val) if val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list) -> Optional[dict]:
|
||||
if not coords:
|
||||
return None
|
||||
lats = [c[0] for c in coords]
|
||||
lons = [c[1] for c in coords]
|
||||
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||
"min_lon": min(lons), "max_lon": max(lons)}
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
"""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)
|
||||
decoder = Decoder(stream)
|
||||
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=True,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
|
||||
# Map sport type
|
||||
sport = str(session.get("sport", "generic")).lower()
|
||||
sport_map = {
|
||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||
"open_water_swimming": "swimming", "trail_running": "running",
|
||||
"e_biking": "cycling",
|
||||
}
|
||||
sport_type = sport_map.get(sport, sport)
|
||||
|
||||
start_time = session.get("start_time")
|
||||
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# 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
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Normalize data points
|
||||
normalized_points = []
|
||||
for r in records:
|
||||
ts = r.get("timestamp")
|
||||
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
normalized_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": r.get("position_lat"),
|
||||
"longitude": r.get("position_long"),
|
||||
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
|
||||
"heart_rate": r.get("heart_rate"),
|
||||
"cadence": r.get("cadence") or r.get("fractional_cadence"),
|
||||
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
|
||||
"power": r.get("power"),
|
||||
"temperature_c": r.get("temperature"),
|
||||
"distance_m": r.get("distance"),
|
||||
})
|
||||
|
||||
# Normalize laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = lap.get("start_time")
|
||||
if isinstance(ls, datetime) and ls.tzinfo is None:
|
||||
ls = ls.replace(tzinfo=timezone.utc)
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
||||
"distance_m": _safe_float(lap.get("total_distance")),
|
||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
||||
"avg_power": _safe_float(lap.get("avg_power")),
|
||||
})
|
||||
|
||||
# Build activity name
|
||||
name = session.get("sport", "Activity").title()
|
||||
if start_time:
|
||||
name += " " + start_time.strftime("%Y-%m-%d")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(session.get("total_distance")),
|
||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||
"avg_power": _safe_float(session.get("avg_power")),
|
||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
||||
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||
"calories": _safe_float(session.get("total_calories")),
|
||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
"data_points": normalized_points,
|
||||
"laps": normalized_laps,
|
||||
}
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
"""Parse a GPX file."""
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
for segment in track.segments:
|
||||
for pt in segment.points:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
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)
|
||||
|
||||
# Add cumulative distance
|
||||
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
|
||||
|
||||
# Elevation gain/loss
|
||||
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:
|
||||
downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = data_points[0]["timestamp"] if data_points else None
|
||||
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else 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 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:
|
||||
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 # anything above 90% goes to z5
|
||||
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Route matching: identifies when multiple activities were on the same route.
|
||||
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
|
||||
"""
|
||||
import math
|
||||
from typing import Optional
|
||||
import polyline as polyline_lib
|
||||
import numpy as np
|
||||
|
||||
|
||||
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
|
||||
return polyline_lib.decode(encoded)
|
||||
|
||||
|
||||
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
|
||||
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
|
||||
return (
|
||||
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
|
||||
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
|
||||
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
|
||||
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
|
||||
)
|
||||
|
||||
|
||||
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
|
||||
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
|
||||
if len(coords) <= n:
|
||||
return coords
|
||||
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
|
||||
return [coords[i] for i in indices]
|
||||
|
||||
|
||||
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
|
||||
"""
|
||||
Compute DTW distance between two GPS tracks.
|
||||
Each point is (lat, lon). Returns average distance in metres per matched pair.
|
||||
"""
|
||||
n, m = len(track1), len(track2)
|
||||
dtw = np.full((n + 1, m + 1), np.inf)
|
||||
dtw[0][0] = 0.0
|
||||
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
cost = haversine_m(track1[i-1], track2[j-1])
|
||||
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
|
||||
|
||||
return dtw[n][m] / max(n, m)
|
||||
|
||||
|
||||
def haversine_m(p1: tuple, p2: tuple) -> float:
|
||||
R = 6371000
|
||||
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def routes_are_similar(
|
||||
poly1: str,
|
||||
poly2: str,
|
||||
bb1: Optional[dict],
|
||||
bb2: Optional[dict],
|
||||
dtw_threshold_m: float = 80.0,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if two activities are on sufficiently similar routes.
|
||||
First does a cheap bounding box check, then DTW on downsampled tracks.
|
||||
"""
|
||||
if bb1 and bb2:
|
||||
if not bounding_boxes_overlap(bb1, bb2):
|
||||
return False
|
||||
|
||||
try:
|
||||
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
|
||||
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not coords1 or not coords2:
|
||||
return False
|
||||
|
||||
dist = dtw_distance(coords1, coords2)
|
||||
return dist < dtw_threshold_m
|
||||
|
||||
|
||||
def find_segment_times(
|
||||
data_points: list[dict],
|
||||
start_dist_m: float,
|
||||
end_dist_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Given activity data points (with cumulative distance_m),
|
||||
find the time to traverse from start_dist_m to end_dist_m.
|
||||
Returns duration in seconds, or None if not found.
|
||||
"""
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
for p in data_points:
|
||||
dist = p.get("distance_m")
|
||||
ts = p.get("timestamp")
|
||||
if dist is None or ts is None:
|
||||
continue
|
||||
|
||||
if start_time is None and dist >= start_dist_m:
|
||||
start_time = ts
|
||||
|
||||
if start_time is not None and dist >= end_dist_m:
|
||||
end_time = ts
|
||||
break
|
||||
|
||||
if start_time and end_time:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
||||
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
||||
return (t2 - t1).total_seconds()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_best_split_time(
|
||||
data_points: list[dict],
|
||||
target_distance_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Find the best (fastest) time over any target_distance_m window within an activity.
|
||||
E.g. fastest 1km split in a 10km run.
|
||||
Returns duration in seconds.
|
||||
"""
|
||||
points_with_dist = [
|
||||
p for p in data_points
|
||||
if p.get("distance_m") is not None and p.get("timestamp") is not None
|
||||
]
|
||||
|
||||
if not points_with_dist:
|
||||
return None
|
||||
|
||||
best = None
|
||||
j = 0
|
||||
|
||||
for i, start_p in enumerate(points_with_dist):
|
||||
start_dist = start_p["distance_m"]
|
||||
start_ts = start_p["timestamp"]
|
||||
|
||||
# Advance j until distance covered >= target
|
||||
while j < len(points_with_dist):
|
||||
end_p = points_with_dist[j]
|
||||
covered = end_p["distance_m"] - start_dist
|
||||
if covered >= target_distance_m:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
|
||||
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if best is None or duration < best:
|
||||
best = duration
|
||||
break
|
||||
j += 1
|
||||
|
||||
if j >= len(points_with_dist):
|
||||
break
|
||||
|
||||
return best
|
||||
|
||||
|
||||
STANDARD_DISTANCES = [
|
||||
(400, "400m"),
|
||||
(800, "800m"),
|
||||
(1000, "1k"),
|
||||
(1609.34, "1 mile"),
|
||||
(3000, "3k"),
|
||||
(5000, "5k"),
|
||||
(10000, "10k"),
|
||||
(21097.5, "Half marathon"),
|
||||
(42195, "Marathon"),
|
||||
(50000, "50k"),
|
||||
(100000, "100k"),
|
||||
]
|
||||
|
||||
|
||||
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
|
||||
"""Compute best split times for all standard distances that fit within the activity."""
|
||||
results = {}
|
||||
for dist_m, label in STANDARD_DISTANCES:
|
||||
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
|
||||
best = find_best_split_time(data_points, dist_m)
|
||||
if best:
|
||||
results[label] = best
|
||||
return results
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
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 typing import Optional
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
|
||||
|
||||
|
||||
def fit_ts(seconds) -> Optional[datetime]:
|
||||
"""Convert FIT timestamp to UTC datetime."""
|
||||
if seconds is None:
|
||||
return None
|
||||
try:
|
||||
s = int(seconds)
|
||||
if s == 0 or s == 0xFFFFFFFF:
|
||||
return None
|
||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_datetime(v) -> bool:
|
||||
return isinstance(v, datetime)
|
||||
|
||||
|
||||
def parse_wellness_fit(file_path: str) -> dict:
|
||||
"""
|
||||
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
|
||||
|
||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
if d not in daily:
|
||||
daily[d] = {
|
||||
"heart_rates": [],
|
||||
"stress_values": [],
|
||||
"spo2_readings": [],
|
||||
"sleep_levels": [],
|
||||
"steps": None,
|
||||
"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 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):
|
||||
"""Called for every message after full decoding."""
|
||||
|
||||
# ── Standard: monitoring (148) ────────────────────────────────────
|
||||
if mesg_num == 148:
|
||||
d = get_date(msg, "timestamp", "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))
|
||||
|
||||
# ── 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:
|
||||
d = get_date(msg, "timestamp")
|
||||
if not d:
|
||||
return
|
||||
entry = ensure_day(d)
|
||||
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
||||
v = msg.get(key)
|
||||
if v:
|
||||
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)
|
||||
|
||||
# ── Standard: stress_level (132) ──────────────────────────────────
|
||||
elif mesg_num == 132:
|
||||
d = get_date(msg, "stress_level_time", "timestamp")
|
||||
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))
|
||||
|
||||
# ── Standard: spo2_data (258) ─────────────────────────────────────
|
||||
elif mesg_num == 258:
|
||||
d = get_date(msg, "timestamp")
|
||||
if not d:
|
||||
return
|
||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||
if spo2 and 50 < spo2 <= 100:
|
||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||
|
||||
# ── Standard: sleep_level (269) ───────────────────────────────────
|
||||
elif mesg_num == 269:
|
||||
d = get_date(msg, "timestamp")
|
||||
if not d:
|
||||
return
|
||||
level = msg.get("sleep_level")
|
||||
if level is not None:
|
||||
# Convert string level names to numeric codes if SDK decoded them
|
||||
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))
|
||||
|
||||
# ── Proprietary 227: per-minute stress + HR ───────────────────────
|
||||
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
|
||||
elif mesg_num == 227:
|
||||
# SDK stores unknown fields as "unknown_N" or by def_num
|
||||
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
|
||||
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
|
||||
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
|
||||
|
||||
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
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||
entry["heart_rates"].append(int(hr_raw))
|
||||
|
||||
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
|
||||
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:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
steps = msg.get(3)
|
||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||
entry["steps"] = int(steps)
|
||||
|
||||
floors = msg.get(4)
|
||||
if floors and isinstance(floors, (int, float)) and floors > 0:
|
||||
f = float(floors)
|
||||
if f > 1000:
|
||||
f = f / 100
|
||||
entry["floors_climbed"] = round(f, 1)
|
||||
|
||||
active_cal = 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)
|
||||
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
|
||||
entry["total_calories"] = float(total_cal)
|
||||
|
||||
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
|
||||
elif mesg_num == 211:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
rhr = msg.get(0)
|
||||
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
||||
entry["resting_hr"] = int(rhr)
|
||||
|
||||
hrv = msg.get(1)
|
||||
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
|
||||
entry["hrv_nightly_avg"] = float(hrv)
|
||||
|
||||
# ── Proprietary 55: activity accumulation snapshots ───────────────
|
||||
elif mesg_num == 55:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
steps = msg.get(2)
|
||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
|
||||
hr = msg.get(19)
|
||||
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
|
||||
# Decode the file
|
||||
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, # wellness files sometimes have bad CRCs
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=True,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "days": {}}
|
||||
|
||||
# Aggregate per-day
|
||||
result = {}
|
||||
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", [])
|
||||
|
||||
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
||||
max_hr = max(hrs) if hrs 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
|
||||
|
||||
# Sleep stage seconds (each level record = 30s epoch)
|
||||
if sleep_levels:
|
||||
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_rem_s = sum(30 for l in sleep_levels if l == 4) or None
|
||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
|
||||
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
|
||||
else:
|
||||
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
||||
|
||||
result[day_date] = {
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr_day": avg_hr,
|
||||
"max_hr_day": max_hr,
|
||||
"avg_stress": avg_stress,
|
||||
"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}
|
||||
Reference in New Issue
Block a user