Body
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -144,8 +144,9 @@ async def intraday_health(
|
|||||||
)
|
)
|
||||||
metric = result.scalar_one_or_none()
|
metric = result.scalar_one_or_none()
|
||||||
return {
|
return {
|
||||||
"hr_values": metric.intraday_hr if metric else None,
|
"hr_values": metric.intraday_hr if metric else None,
|
||||||
"body_battery": metric.body_battery 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.
@@ -244,8 +244,9 @@ class HealthMetric(Base):
|
|||||||
active_calories = Column(Float, nullable=True)
|
active_calories = Column(Float, nullable=True)
|
||||||
total_calories = Column(Float, nullable=True)
|
total_calories = Column(Float, nullable=True)
|
||||||
spo2_avg = Column(Float, nullable=True)
|
spo2_avg = Column(Float, nullable=True)
|
||||||
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
|
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 = 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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -258,12 +258,19 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
|
|||||||
row["body_battery"] = _json.dumps(bb)
|
row["body_battery"] = _json.dumps(bb)
|
||||||
|
|
||||||
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
|
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
|
||||||
|
intraday = None
|
||||||
if hr_raw:
|
if hr_raw:
|
||||||
raw_vals = hr_raw.get("heartRateValues") or []
|
raw_vals = hr_raw.get("heartRateValues") or []
|
||||||
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
||||||
if intraday:
|
if intraday:
|
||||||
row["intraday_hr"] = 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:
|
if not row:
|
||||||
continue
|
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):
|
def _safe(fn, *args):
|
||||||
try:
|
try:
|
||||||
return fn(*args)
|
return fn(*args)
|
||||||
|
|||||||
Binary file not shown.
@@ -100,7 +100,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="distance_m"
|
dataKey="distance_m"
|
||||||
|
|||||||
@@ -71,19 +71,31 @@ function bbLevelColor(level) {
|
|||||||
return '#ef4444'
|
return '#ef4444'
|
||||||
}
|
}
|
||||||
|
|
||||||
function BodyBatteryChart({ bb }) {
|
function BodyBatteryChart({ bb, hiresValues }) {
|
||||||
if (!bb) return null
|
if (!bb) return null
|
||||||
const { charged, drained, start_level, end_level, values } = bb
|
const { charged, drained, start_level, end_level, values } = bb
|
||||||
if (!values?.length && end_level == null) return null
|
if (!values?.length && end_level == null) return null
|
||||||
|
|
||||||
const chartData = (values || []).map(([ts, level, type]) => ({
|
// Background bars use the raw checkpoint type codes to colour activity segments.
|
||||||
t: ts,
|
const bgData = (values || []).map(([ts, , type]) => ({ t: ts, type: type ?? 4, bar: 100 }))
|
||||||
level,
|
|
||||||
type: type ?? 4,
|
// Line uses hi-res data when available, otherwise the raw checkpoints.
|
||||||
bar: 100,
|
const lineData = hiresValues?.length
|
||||||
|
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
||||||
|
: (values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||||
|
|
||||||
|
// Merge into a single dataset keyed by timestamp so both series share the same XAxis.
|
||||||
|
const tsSet = new Set([...bgData.map(d => d.t), ...lineData.map(d => d.t)])
|
||||||
|
const bgMap = Object.fromEntries(bgData.map(d => [d.t, d]))
|
||||||
|
const lineMap = Object.fromEntries(lineData.map(d => [d.t, d]))
|
||||||
|
const chartData = [...tsSet].sort((a, b) => a - b).map(t => ({
|
||||||
|
t,
|
||||||
|
bar: bgMap[t]?.bar ?? null,
|
||||||
|
type: bgMap[t]?.type ?? null,
|
||||||
|
level: lineMap[t]?.level ?? null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
const presentTypes = [...new Set(bgData.map(d => d.type))]
|
||||||
const levelColor = bbLevelColor(end_level)
|
const levelColor = bbLevelColor(end_level)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,12 +125,13 @@ function BodyBatteryChart({ bb }) {
|
|||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
|
<YAxis domain={[0, 100]} hide />
|
||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
|
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
|
||||||
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
|
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
|
||||||
{chartData.map((d, i) => (
|
{chartData.map((d, i) => (
|
||||||
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.8} />
|
<Cell key={i} fill={d.type != null ? (BB_TYPE_COLOR[d.type] ?? '#374151') : 'transparent'} fillOpacity={0.8} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
|
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
|
||||||
@@ -180,7 +193,7 @@ function NavArrow({ onClick, disabled, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer, hasOlder, hasNewer }) {
|
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||||
if (!day) return (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<p className="text-3xl mb-2">📊</p>
|
||||||
@@ -334,7 +347,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BodyBatteryChart bb={bodyBattery} />
|
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -580,6 +593,7 @@ export default function HealthPage() {
|
|||||||
avg30={summary?.avg_30d}
|
avg30={summary?.avg_30d}
|
||||||
intradayHr={intradayData?.hr_values}
|
intradayHr={intradayData?.hr_values}
|
||||||
bodyBattery={intradayData?.body_battery}
|
bodyBattery={intradayData?.body_battery}
|
||||||
|
bbHires={intradayData?.body_battery_hires}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
|
|||||||
Reference in New Issue
Block a user