Files
GarminHealthLab/backend/services/fitness_age.py
ericwyuan c70e7ced80 feat(api): 个人资料/单位/同步偏好 + 运动详情 + 身体年龄
设置 (services/settings.py, routes/settings.py)
- user_settings 表:身高/体重/出生日期/性别/单位/自动同步开关/同步频率/历史范围
- GET|PUT /api/settings,GET /api/settings/options(取值由后端给,前端不臆造)
- GET /api/settings/rating-basis:把每条参考区间的来源公开出来。
  一个把数字标成「偏低」的区间是在下判断,用户有权看到依据。

运动详情 (services/garmin.py)
- GET /api/garmin/activities/<id>/detail:概览/分段/心率区间/天气/装备/采样曲线
- 首次打开回源 Garmin 并落库,之后走缓存;?refresh=1 强制刷新
- 采样点在写入时抽稀到 300,手机图表画不了更多,也免得整行撑大

身体年龄 (services/fitness_age.py)
- 0.2.8 版 garminconnect 没有 fitnessage 接口,改为本地按公开常模推算:
  VO₂max 对应年龄为基准,静息心率与 BMI 做有上限的修正
- 返回每一步的中间值,界面照实展示,不做成一个不可追溯的分数
- 高于参考表最年轻一档时按 20 岁计——那里外推会得到「11 岁」这种结果

调度器改为每 5 分钟 tick,是否该同步按各账号自己的频率判断

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:26:26 +08:00

178 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
身体年龄 (body age) — a deterministic estimate, with its working exposed.
This is NOT Garmin's Fitness Age. Garmin's model is proprietary and cannot be
reproduced; asking a language model to invent a number would produce something
unverifiable that changes between runs while looking authoritative. So the
estimate here is computed from published population reference values, and every
step it took is returned alongside the number for the UI to display.
Method
------
1. Base age from VO2max: the age at which the user's VO2max equals the median
for their sex, interpolated over the reference table below. VO2max is the
single strongest fitness predictor and is what Garmin's own model leans on.
2. Resting-heart-rate adjustment, relative to a 60 bpm reference.
3. BMI adjustment, relative to the healthy 18.524.9 band.
4. Clamped to within 20 years of chronological age — beyond that the
extrapolation says more about the table's edges than about the person.
Reference values are 50th-percentile VO2max (ml/kg/min) by age and sex, from
the widely published ACSM / Cooper Institute cardiorespiratory fitness norms.
They are population averages for healthy adults, not clinical thresholds.
"""
# (age, median VO2max) — men and women tabulated separately because the
# distributions differ by roughly 68 ml/kg/min at every age.
VO2_MEDIAN = {
"male": [(25, 44.0), (35, 41.0), (45, 37.0), (55, 33.0), (65, 29.0)],
"female": [(25, 37.0), (35, 34.0), (45, 31.0), (55, 27.0), (65, 24.0)],
}
RHR_REFERENCE = 60.0 # bpm
RHR_YEARS_PER_10BPM = 2.0
RHR_CAP = 5.0
BMI_LOW, BMI_HIGH = 18.5, 24.9
BMI_YEARS_PER_UNIT = 0.5
BMI_CAP = 5.0
MAX_DEVIATION = 20.0 # years either side of chronological age
AGE_FLOOR, AGE_CEILING = 20.0, 85.0
# Rendered verbatim in 设置 → 评分依据. Kept here, next to the constants it
# describes, so the two cannot drift apart.
BASIS = {
"title": "身体年龄的算法",
"summary": (
"由你的 VO₂max、静息心率、BMI 按公开人群参考值推算,"
"不是 Garmin 的 Fitness Age也不是医学评估。"
),
"steps": [
{
"name": "基准VO₂max 对应年龄",
"detail": "找出你的 VO₂max 相当于同性别人群哪个年龄的中位水平,"
"在参考表上线性插值;高于最年轻一档时按 20 岁计,"
"参考表再往上说明不了更多。",
"source": "ACSM / Cooper Institute 心肺适能人群常模50 百分位)",
},
{
"name": "静息心率修正",
"detail": f"{RHR_REFERENCE:.0f} bpm 为参照,"
f"每高 10 bpm +{RHR_YEARS_PER_10BPM:.0f} 岁,"
f"每低 10 bpm {RHR_YEARS_PER_10BPM:.0f} 岁,"
f"最多 ±{RHR_CAP:.0f} 岁。",
"source": "静息心率与心肺适能、全因死亡率的流行病学关联",
},
{
"name": "BMI 修正",
"detail": f"BMI 在 {BMI_LOW}~{BMI_HIGH} 之间不修正;"
f"每偏离 1 +{BMI_YEARS_PER_UNIT} 岁,最多 +{BMI_CAP:.0f} 岁。",
"source": "WHO 成人 BMI 分类",
},
{
"name": "收敛",
"detail": f"结果限制在实际年龄 ±{MAX_DEVIATION:.0f} 岁以内,"
f"并落在 {AGE_FLOOR:.0f}~{AGE_CEILING:.0f} 岁区间。",
"source": "参考表边界外的外推不可靠",
},
],
"caveat": "仅供长期趋势参考,不能用于诊断。有健康疑问请咨询医生。",
}
def _interpolate_age(vo2, table):
"""The age whose median VO2max equals `vo2`.
Above the youngest reference row the answer is simply "fitter than the
median 25-year-old", and the table cannot say more: extrapolating its slope
there gives absurdities (VO2max 48 reads as an eleven-year-old), so the
result floors instead.
"""
first_age, first_vo2 = table[0]
last_age, last_vo2 = table[-1]
if vo2 >= first_vo2:
return AGE_FLOOR
if vo2 <= last_vo2:
slope = (last_age - table[-2][0]) / (last_vo2 - table[-2][1])
return last_age + (vo2 - last_vo2) * slope
for (age_a, vo2_a), (age_b, vo2_b) in zip(table, table[1:]):
if vo2_b <= vo2 <= vo2_a:
share = (vo2_a - vo2) / (vo2_a - vo2_b)
return age_a + share * (age_b - age_a)
return last_age
def estimate(*, age, sex, vo2max, resting_hr=None, bmi=None):
"""Body age plus the arithmetic that produced it.
Returns None when the inputs cannot support an estimate, so the caller can
tell the user what is missing instead of showing a fabricated number.
"""
missing = []
if age is None:
missing.append("出生日期")
if sex not in VO2_MEDIAN:
missing.append("性别")
if not vo2max:
missing.append("VO₂max需要一次户外跑步或骑行才会生成")
if missing:
return {"value": None, "missing": missing, "basis": BASIS}
table = VO2_MEDIAN[sex]
base = _interpolate_age(float(vo2max), table)
steps = [{
"label": "VO₂max 基准",
"input": f"{float(vo2max):.0f} ml/kg/min",
"years": round(base, 1),
"kind": "base",
}]
total = base
if resting_hr:
delta = (float(resting_hr) - RHR_REFERENCE) / 10.0 * RHR_YEARS_PER_10BPM
delta = max(-RHR_CAP, min(RHR_CAP, delta))
total += delta
steps.append({
"label": "静息心率",
"input": f"{float(resting_hr):.0f} bpm",
"years": round(delta, 1),
"kind": "adjust",
})
if bmi:
value = float(bmi)
if value < BMI_LOW:
off = BMI_LOW - value
elif value > BMI_HIGH:
off = value - BMI_HIGH
else:
off = 0.0
delta = min(BMI_CAP, off * BMI_YEARS_PER_UNIT)
total += delta
steps.append({
"label": "BMI",
"input": f"{value:.1f}",
"years": round(delta, 1),
"kind": "adjust",
})
chronological = float(age)
clamped = max(chronological - MAX_DEVIATION,
min(chronological + MAX_DEVIATION, total))
clamped = max(AGE_FLOOR, min(AGE_CEILING, clamped))
value = int(round(clamped))
return {
"value": value,
"chronologicalAge": int(chronological),
"delta": value - int(chronological),
"steps": steps,
"clamped": abs(clamped - total) > 0.05,
"missing": [],
"basis": BASIS,
}