smart tdee
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
from sqlalchemy import inspect, text
|
||||
import logging
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import engine
|
||||
from app.db.models import FitnessProfile
|
||||
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TDEE_V2_BACKFILL = "fitness_tdee_v2_backfill"
|
||||
MACROS_GKG_BACKFILL = "fitness_macros_gkg_v1"
|
||||
|
||||
|
||||
def _table_exists(table: str) -> bool:
|
||||
return table in inspect(engine).get_table_names()
|
||||
|
||||
|
||||
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||
@@ -14,6 +28,113 @@ def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||
conn.execute(text(ddl))
|
||||
|
||||
|
||||
def _ensure_schema_migrations_table() -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
|
||||
"name TEXT PRIMARY KEY, "
|
||||
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_applied(name: str) -> bool:
|
||||
_ensure_schema_migrations_table()
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT 1 FROM _schema_migrations WHERE name = :name"),
|
||||
{"name": name},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _mark_migration_applied(name: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("INSERT INTO _schema_migrations (name) VALUES (:name)"),
|
||||
{"name": name},
|
||||
)
|
||||
|
||||
|
||||
def _profile_targets(row: FitnessProfile) -> dict[str, float]:
|
||||
neat = row.neat_base_kcal if row.neat_base_kcal is not None else DEFAULT_NEAT_KCAL
|
||||
return compute_targets(
|
||||
{
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"goal": row.goal,
|
||||
"neat_base_kcal": neat,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def backfill_tdee_targets(*, force: bool = False) -> int:
|
||||
"""Recalculate stored calorie/macro targets for all profiles (PAL → BMR+NEAT)."""
|
||||
if not _table_exists("fitness_profiles"):
|
||||
return 0
|
||||
_ensure_schema_migrations_table()
|
||||
if not force and _migration_applied(TDEE_V2_BACKFILL):
|
||||
return 0
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE fitness_profiles "
|
||||
"SET neat_base_kcal = :neat "
|
||||
"WHERE neat_base_kcal IS NULL"
|
||||
),
|
||||
{"neat": DEFAULT_NEAT_KCAL},
|
||||
)
|
||||
|
||||
updated = 0
|
||||
with Session(engine) as db:
|
||||
rows = db.scalars(select(FitnessProfile)).all()
|
||||
for row in rows:
|
||||
if row.neat_base_kcal is None:
|
||||
row.neat_base_kcal = DEFAULT_NEAT_KCAL
|
||||
targets = _profile_targets(row)
|
||||
row.calorie_target = targets["calorie_target"]
|
||||
row.protein_g = targets["protein_g"]
|
||||
row.fat_g = targets["fat_g"]
|
||||
row.carbs_g = targets["carbs_g"]
|
||||
row.water_l = targets["water_l"]
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
if not force or not _migration_applied(TDEE_V2_BACKFILL):
|
||||
_mark_migration_applied(TDEE_V2_BACKFILL)
|
||||
|
||||
logger.info("TDEE v2 backfill: recalculated %s fitness profile(s)", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def backfill_macros_gkg(*, force: bool = False) -> int:
|
||||
"""Recalculate stored BJU from weight (protein/fat g/kg, carbs = remainder)."""
|
||||
if not _table_exists("fitness_profiles"):
|
||||
return 0
|
||||
_ensure_schema_migrations_table()
|
||||
if not force and _migration_applied(MACROS_GKG_BACKFILL):
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
with Session(engine) as db:
|
||||
rows = db.scalars(select(FitnessProfile)).all()
|
||||
for row in rows:
|
||||
macros = macro_targets(row.calorie_target, row.weight_kg, row.goal)
|
||||
row.protein_g = macros["protein_g"]
|
||||
row.fat_g = macros["fat_g"]
|
||||
row.carbs_g = macros["carbs_g"]
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
_mark_migration_applied(MACROS_GKG_BACKFILL)
|
||||
logger.info("Macros g/kg backfill: updated %s fitness profile(s)", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def run_fitness_migrations() -> None:
|
||||
inspector = inspect(engine)
|
||||
|
||||
@@ -28,6 +149,11 @@ def run_fitness_migrations() -> None:
|
||||
"baseline_workout_kcal",
|
||||
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"fitness_profiles",
|
||||
"neat_base_kcal",
|
||||
"ALTER TABLE fitness_profiles ADD COLUMN neat_base_kcal FLOAT DEFAULT 200.0",
|
||||
)
|
||||
|
||||
if "workout_logs" in inspector.get_table_names():
|
||||
_add_column_if_missing(
|
||||
@@ -92,3 +218,6 @@ def run_fitness_migrations() -> None:
|
||||
"ffmi",
|
||||
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
|
||||
)
|
||||
|
||||
backfill_tdee_targets()
|
||||
backfill_macros_gkg()
|
||||
|
||||
@@ -179,6 +179,7 @@ class FitnessProfile(Base):
|
||||
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
neat_base_kcal: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
Reference in New Issue
Block a user