diet-guard/diet_guard/_body_report.py
Krzysztof kuhy Rudnicki bda76b495d
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Ideal weight: drop Broca family and Hamwi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:54:38 +01:00

212 lines
7.7 KiB
Python

"""Render a :class:`~diet_guard._body_view.BodyView` as aligned text blocks.
One renderer for both surfaces: the CLI prints the blocks, the gate's Body
tab shows each in a monospace panel. Numbers go through ``half_up`` so they
match the phone to the digit.
"""
from __future__ import annotations
from datetime import date
import textwrap
from typing import TYPE_CHECKING
from diet_guard._activity_kcal import (
FORMULAS,
METHODS_NOTE,
session_kcal,
)
from diet_guard._body_calc import BMI_PRIME_NOTE, TREFETHEN_NOTE, half_up
from diet_guard._body_energy import KCAL_PER_KG, MIN_TARGET_KCAL, activity_label
from diet_guard._body_steps import STEP_BASELINE
from diet_guard._body_store import age_on
if TYPE_CHECKING:
from diet_guard._body_energy import Goal
from diet_guard._body_view import BodyView
from diet_guard._week_plan import WeekPlan
_NAME_W = 24
_COL_W = 15
_SESSIONS_SHOWN = 10
_WRAP = 88
_NEEDS = "Set birth date, height, sex and a weight to see the numbers."
def _wrap(text: str, indent: str = "") -> list[str]:
"""Explanatory prose broken at :data:`_WRAP` columns.
One unwrapped note once widened the gate's left panel enough to push the
whole right column off-screen.
"""
return textwrap.wrap(text, _WRAP, initial_indent=indent, subsequent_indent=indent)
def _num(value: float, digits: int = 0) -> str:
"""``value`` rounded half-up to ``digits`` decimals."""
return f"{half_up(value, digits):.{digits}f}"
def profile_lines(view: BodyView, today: date) -> list[str]:
"""Who the numbers are for, and what is still missing."""
prof = view.profile
birth = "birth date not set"
if prof.birth is not None:
age = age_on(date.fromisoformat(prof.birth), today)
birth = f"born {prof.birth} ({age} y)"
height = f"{_num(prof.height_cm)} cm" if prof.height_cm else "height not set"
sex = {"m": "male", "f": "female"}.get(prof.sex or "", "sex not set")
newest = max(view.weights) if view.weights else None
weight = (
f"{_num(view.weights[newest], 1)} kg on {newest} "
f"({len(view.weights)} weigh-ins)"
if newest
else "no weight logged yet"
)
return [f"Profile: {birth}, {height}, {sex}", f"Weight: {weight}"]
def bodyfat_lines(view: BodyView) -> list[str]:
"""The newest body-fat reading and what it switches on."""
if not view.body_fats:
return ["Body fat: not logged (log one to use Katch-McArdle)."]
day = max(view.body_fats)
return [
(
f"Body fat: {_num(view.body_fats[day], 1)}% on {day} "
f"({len(view.body_fats)} readings) -- targets use Katch-McArdle."
)
]
def bmi_lines(view: BodyView) -> list[str]:
"""BMI with plain-language readings, and the healthy weight range."""
if view.numbers is None:
return [_NEEDS]
bmi = view.numbers.bmi
low, high = view.numbers.healthy
return [
f"BMI {_num(bmi.bmi, 1)} ({bmi.category})",
(
f"Healthy weight for your height (BMI 18.5-24.9): "
f"{_num(low, 1)}-{_num(high, 1)} kg"
),
f"BMI Prime {_num(bmi.prime, 2)} Trefethen {_num(bmi.trefethen, 1)}",
*_wrap(BMI_PRIME_NOTE, " "),
*_wrap(TREFETHEN_NOTE, " "),
]
def ideal_lines(view: BodyView) -> list[str]:
"""Every ideal-weight formula, then their average and range."""
if view.numbers is None:
return [_NEEDS]
lines = ["Ideal weight:"]
for formula in view.numbers.ideal:
value = (
"n/a (< 152 cm)"
if formula.value is None
else f"{_num(formula.value, 1)} kg"
)
lines.append(f" {formula.name:<{_NAME_W}}{value}")
summary = view.numbers.ideal_summary
if summary is not None:
avg, low, high = summary
lines.append(f" {'Average of all':<{_NAME_W}}{_num(avg, 1)} kg")
lines.append(f" {'Ideal range':<{_NAME_W}}{_num(low, 1)}-{_num(high, 1)} kg")
return lines
def bmr_lines(view: BodyView) -> list[str]:
"""Resting metabolism by formula; body-fat ones wait for a reading."""
if view.numbers is None:
return [_NEEDS]
lines = ["Resting metabolism (kcal/day):"]
for f in view.numbers.bmr:
value = "n/a -- log a body-fat %" if f.value is None else _num(f.value)
lines.append(f" {f.name:<{_NAME_W + 8}}{value}")
return lines
def goal_sentence(goal: Goal) -> str:
"""``To lose 0.5 kg / week on Moderate`` -- the goal as words."""
activity = activity_label(goal.activity)
if goal.direction == "maintain":
return f"To maintain on {activity}"
amount = f"{goal.amount:g} {goal.weight_unit} / {goal.time_unit}"
return f"To {goal.direction} {amount} on {activity}"
def target_lines(view: BodyView) -> list[str]:
"""The top line: what to eat for the chosen goal, and how it was got."""
if view.numbers is None:
return [_NEEDS]
target = view.numbers.target
if target.kcal is None or target.tdee is None:
return [
(
f"{goal_sentence(view.goal)}: no workouts published yet "
"(the PC publishes them on sync)."
)
]
lines = [f"{goal_sentence(view.goal)} eat {target.kcal} kcal a day."]
if target.below_floor:
lines.append(f" That is below the {MIN_TARGET_KCAL} kcal floor.")
lines.append(
f" Maintenance {_num(target.tdee)} kcal ({target.bmr_name} resting burn "
f"x activity); 1 kg of fat = {KCAL_PER_KG:g} kcal."
)
lines.extend(_wrap("Informational only -- the budget is never changed here.", " "))
return lines
def activity_lines(view: BodyView) -> list[str]:
"""Measured exercise averages and the newest published workouts."""
if view.activity_stamp is None and not view.steps:
return ["No workouts published yet (the PC publishes them on sync)."]
stamp = (view.activity_stamp or "")[:16].replace("T", " ") or "not yet"
lines = [f"Workouts published {stamp}"]
exercise = view.exercise
if exercise is not None:
means = " ".join(
f"{name} {_num(kcal)}" for name, kcal in exercise.means.items()
)
lines.append(f"Last {exercise.days} full days, net exercise kcal/day: {means}")
if exercise is not None and view.steps:
newest = max(view.steps)
lines.append(
f"Steps outside workouts: {view.steps[newest]} on {newest}; above "
f"{STEP_BASELINE}/day they add {_num(exercise.steps_kcal)} kcal/day "
"(included above)."
)
weight = view.weight
for session in view.sessions[-_SESSIONS_SHOWN:][::-1]:
km = f" {_num(session.km, 1)} km" if session.km else ""
kcal = ""
if weight is not None:
result = session_kcal(session, weight)
kcal = " " + " ".join(
f"{name[0]}{_num(result.kcal[name])}" for name in FORMULAS
)
lines.append(
f" {session.day} {session.kind:<8}{_num(session.minutes):>4} min{km}{kcal}"
)
lines.extend(_wrap(f"(M = MET, A = ACSM, P = Per-km) {METHODS_NOTE}"))
return lines
def plan_lines(plan: WeekPlan) -> list[str]:
"""The week plan, one line per day."""
lines = [f"Week plan for an average of {plan.target_avg} kcal/day:"]
for day in plan.days:
note = {"unlogged": " (no log - type it?)"}.get(day.source, "")
lines.append(f" {day.weekday} {day.day} {day.kcal:>5} {day.source}{note}")
if plan.per_remaining is None:
lines.append(
f"Every day is known: the week averages {_num(plan.week_avg)} kcal."
)
else:
warn = f" -- below the {MIN_TARGET_KCAL} floor!" if plan.below_floor else ""
lines.append(f"Eat {plan.per_remaining} kcal on each remaining day{warn}")
return lines