""" 身体年龄 (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, then damped towards their real age — see VO2_DAMPING for why that damping has to exist. 2. Resting-heart-rate adjustment, relative to a 60 bpm reference. 3. BMI adjustment, relative to the healthy 18.5–24.9 band. 4. Clamped to within MAX_DEVIATION years of chronological age. 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 6–8 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 # Individual VO2max varies far more between people (SD ~7 ml/kg/min) than it # declines with age (~0.35 ml/kg/min per year), so a raw "what age is this # VO2max the median for" answer swings enormously: a VO2max of 46 at 34 reads # as 20, because it genuinely is the median for a 20-year-old. Published # fitness-age calculators all compress that swing; this one does too, by an # explicit factor that is shown to the user rather than buried. VO2_DAMPING = 0.5 MAX_DEVIATION = 12.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 相当于同性别人群哪个年龄的中位水平," f"在参考表上线性插值,再按 {VO2_DAMPING:.0%} 的阻尼" "向实际年龄收拢。" "不收拢的话,个体差异会淹没年龄效应——人与人之间的 " "VO₂max 标准差约 7 ml/kg/min,而年龄每年只带来约 0.35 " "的衰减,于是稍微能练的人都会算出 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`. Both ends continue along the nearest segment's slope. A hard floor here would put everyone above the youngest row at exactly the same age, which is a cliff precisely where the app's users sit; the damping applied to the result afterwards is what keeps the extrapolation from running away. """ first_age, first_vo2 = table[0] last_age, last_vo2 = table[-1] if vo2 >= first_vo2: slope = (table[1][0] - first_age) / (table[1][1] - first_vo2) return first_age + (vo2 - first_vo2) * slope 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] chronological = float(age) raw = _interpolate_age(float(vo2max), table) # Pull the raw figure back towards the user's real age by the damping # factor. Reported alongside the raw value so the compression is visible. base = chronological + (raw - chronological) * VO2_DAMPING steps = [{ "label": "VO₂max 基准", "input": f"{float(vo2max):.0f} ml/kg/min", "years": round(base, 1), "raw": round(raw, 1), "damping": VO2_DAMPING, "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", }) 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, }