Fix SDK field names - use camelCase throughout
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 51s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s

This commit is contained in:
2026-06-06 19:27:35 +01:00
parent f609931ebc
commit 0fd3ff7414
3 changed files with 297 additions and 241 deletions
+32 -30
View File
@@ -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",
+249 -211
View File
@@ -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 {}
Executable
+16
View File
@@ -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."