Body
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 1m15s
Build and push images / build-worker (push) Successful in 1m13s
Build and push images / build-frontend (push) Successful in 51s

This commit is contained in:
2026-06-07 15:26:54 +01:00
parent 568dc31e97
commit da9c1e04cb
15 changed files with 104 additions and 15 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -144,8 +144,9 @@ async def intraday_health(
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
}
Binary file not shown.
+3 -2
View File
@@ -244,8 +244,9 @@ class HealthMetric(Base):
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
@@ -258,12 +258,19 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
intraday = None
if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday:
row["intraday_hr"] = intraday
# High-resolution body battery derived from BB checkpoints + intraday HR
if bb and intraday:
hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
if hires:
row["body_battery_hires"] = _json.dumps(hires)
if not row:
continue
@@ -351,6 +358,72 @@ def _parse_body_battery(bb_response, day_str: str):
}
def _compute_body_battery_hires(bb_values, intraday_hr):
"""
Produce a higher-resolution body battery series by interpolating between
sparse BB checkpoints using intraday HR as a proxy for effort.
During drain segments (BB falling) the drain is distributed proportionally
to how much each HR reading exceeds the day's median — peaks spend battery
faster than valleys. During recovery segments (BB rising) recovery is
spread uniformly over time.
Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
if inputs are insufficient.
"""
if not bb_values or not intraday_hr or len(bb_values) < 2:
return None
bb = sorted(bb_values, key=lambda x: x[0])
hr = sorted(intraday_hr, key=lambda x: x[0])
hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
if not hr_vals:
return None
hr_median = sorted(hr_vals)[len(hr_vals) // 2]
result = []
for i in range(len(bb) - 1):
t1, L1 = bb[i][0], bb[i][1]
t2, L2 = bb[i + 1][0], bb[i + 1][1]
delta = L2 - L1
seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
result.append([t1, round(float(L1), 1)])
if not seg_hr or abs(delta) < 1:
continue
if delta < 0:
# Drain: weight each reading by HR above median
efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
total = sum(efforts) or 1.0
cumul = 0.0
for j, (ts, bpm) in enumerate(seg_hr):
cumul += efforts[j] * delta / total
level = max(0.0, min(100.0, L1 + cumul))
result.append([ts, round(level, 1)])
else:
# Recovery: linear over time
span = max(1, t2 - t1)
for ts, _ in seg_hr:
frac = (ts - t1) / span
level = max(0.0, min(100.0, L1 + delta * frac))
result.append([ts, round(level, 1)])
result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
# Deduplicate and sort
seen, out = set(), []
for item in sorted(result, key=lambda x: x[0]):
if item[0] not in seen:
seen.add(item[0])
out.append(item)
return out if len(out) > 4 else None
def _safe(fn, *args):
try:
return fn(*args)