feat: Update nutrition metrics and energy balance calculations
- Introduced a single TDEE calculation based on current weight, replacing the fixed 2500 kcal value. - Updated `get_energy_balance_data` to use daily totals for intake calculations and improved energy balance logic. - Enhanced `get_nutrition_average_data` to calculate averages over calendar days instead of raw log entries. - Adjusted placeholder resolution to ensure consistent metadata usage across requests. - Fixed issues in the charts router to reflect the new energy balance logic and TDEE calculations. These changes improve the accuracy of nutritional assessments and streamline data handling in the application.
This commit is contained in:
parent
549c31431e
commit
61a5bb39ae
|
|
@ -5,7 +5,9 @@ Folgende Ergebnisse des Tests:
|
|||
- In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben.
|
||||
- Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren.
|
||||
- Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um.
|
||||
- Die Workflows werden aktuell nicht in Analyse und den verfügbaren KI-Asuwertungen angezeigt. ggf. weil wir sie aktuell noch keinem Bereich zuordnen können. Diesen könnten wir ggf. über die Start-Node im Workflow konfigurieren.
|
||||
- Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen
|
||||
- Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!)
|
||||
- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen
|
||||
- Exportieren aller KI-Prompts/Templates/Workflows im Admin --> KI-Prompts führt zu einem "internal Server Error", Importieren konnte daraufhin nicht getestet werden
|
||||
- Das duplizieren von Workflows funktioniert nicht
|
||||
-
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -105,6 +105,16 @@ frontend/src/
|
|||
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
|
||||
- **Follow-ups:** **Gitea #71** – Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
|
||||
|
||||
### Updates (11.04.2026 - Placeholder Phase A)
|
||||
|
||||
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (48 Keys) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
|
||||
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
|
||||
|
||||
### Updates (11.04.2026 - Ernährung: eine TDEE-/Tageslogik)
|
||||
|
||||
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz = **aktuelles Gewicht × 32,5 kcal/kg** (`estimate_tdee_kcal_from_latest_weight`); `get_energy_balance_data` und `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen** (nicht Rohzeilen). Makro-Durchschnitte über **Tagesmittel**; `protein_adequacy_28d`, `macro_consistency_score`, `get_protein_adequacy_data`, `get_macro_consistency_data` auf **Kalendertag** umgestellt. Entfernt: festes **2500 kcal** in `get_energy_balance_data`.
|
||||
- **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**.
|
||||
|
||||
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
||||
|
||||
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,28 @@ from datetime import datetime, timedelta, date
|
|||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
|
||||
# Single TDEE rule for placeholders, charts, and warnings (kcal/day = kg * factor).
|
||||
# Replaces legacy fixed 2500 kcal so all consumers stay aligned.
|
||||
TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5
|
||||
|
||||
|
||||
def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]:
|
||||
"""
|
||||
Estimated TDEE (kcal/day) from latest body weight.
|
||||
Returns None if no weight on record.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT weight FROM weight_log
|
||||
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or row["weight"] is None:
|
||||
return None
|
||||
return float(row["weight"]) * TDEE_KCAL_PER_KG_BODYWEIGHT
|
||||
|
||||
|
||||
def get_nutrition_average_data(
|
||||
profile_id: str,
|
||||
|
|
@ -56,20 +78,29 @@ def get_nutrition_average_data(
|
|||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Mean over calendar days (per-day sums), not over raw log rows.
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
AVG(kcal) as kcal_avg,
|
||||
AVG(protein_g) as protein_avg,
|
||||
AVG(carbs_g) as carbs_avg,
|
||||
AVG(fat_g) as fat_avg,
|
||||
COUNT(*) as data_points
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff)
|
||||
AVG(daily_kcal) AS kcal_avg,
|
||||
AVG(daily_protein) AS protein_avg,
|
||||
AVG(daily_carbs) AS carbs_avg,
|
||||
AVG(daily_fat) AS fat_avg,
|
||||
COUNT(*)::int AS day_count
|
||||
FROM (
|
||||
SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS daily_kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS daily_protein,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
) AS daily""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['data_points'] == 0:
|
||||
if not row or row["day_count"] == 0:
|
||||
return {
|
||||
"kcal_avg": 0.0,
|
||||
"protein_avg": 0.0,
|
||||
|
|
@ -80,7 +111,7 @@ def get_nutrition_average_data(
|
|||
"days_analyzed": days
|
||||
}
|
||||
|
||||
data_points = row['data_points']
|
||||
data_points = row["day_count"]
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
|
||||
return {
|
||||
|
|
@ -190,79 +221,73 @@ def get_energy_balance_data(
|
|||
days: int = 7
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate energy balance (intake - estimated expenditure).
|
||||
Energy balance (intake - estimated expenditure), kcal/day.
|
||||
|
||||
Note: This is a simplified calculation.
|
||||
For accurate TDEE, use profile-based calculations.
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
days: Analysis window (default 7)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"energy_balance": float, # kcal/day (negative = deficit)
|
||||
"avg_intake": float,
|
||||
"estimated_tdee": float,
|
||||
"status": str, # "deficit" | "surplus" | "maintenance"
|
||||
"confidence": str,
|
||||
"days_analyzed": int,
|
||||
"data_points": int
|
||||
}
|
||||
Intake: mean of daily total kcal (sum per calendar day).
|
||||
TDEE: latest weight (kg) * TDEE_KCAL_PER_KG_BODYWEIGHT (same rule as placeholders).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Get average intake
|
||||
cur.execute(
|
||||
"""SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt
|
||||
"""SELECT date, SUM(kcal)::float AS daily_kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""",
|
||||
(profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['cnt'] == 0:
|
||||
return {
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": 0.0,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": 0
|
||||
}
|
||||
|
||||
avg_intake = safe_float(row['avg_kcal'])
|
||||
data_points = row['cnt']
|
||||
|
||||
# Simple TDEE estimation (this should be improved with profile data)
|
||||
# For now, use a rough estimate: 2500 kcal for average adult
|
||||
estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity)
|
||||
|
||||
energy_balance = avg_intake - estimated_tdee
|
||||
|
||||
# Determine status
|
||||
if energy_balance < -200:
|
||||
status = "deficit"
|
||||
elif energy_balance > 200:
|
||||
status = "surplus"
|
||||
else:
|
||||
status = "maintenance"
|
||||
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
daily_rows = cur.fetchall()
|
||||
|
||||
if not daily_rows:
|
||||
return {
|
||||
"energy_balance": energy_balance,
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": 0.0,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": 0,
|
||||
}
|
||||
|
||||
daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows]
|
||||
avg_intake = sum(daily_totals) / len(daily_totals)
|
||||
data_points = len(daily_totals)
|
||||
|
||||
estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
||||
if estimated_tdee is None:
|
||||
return {
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": avg_intake,
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": data_points
|
||||
}
|
||||
|
||||
energy_balance = avg_intake - estimated_tdee
|
||||
|
||||
if energy_balance < -200:
|
||||
status = "deficit"
|
||||
elif energy_balance > 200:
|
||||
status = "surplus"
|
||||
else:
|
||||
status = "maintenance"
|
||||
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
|
||||
return {
|
||||
"energy_balance": energy_balance,
|
||||
"avg_intake": avg_intake,
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
"days_analyzed": days,
|
||||
"data_points": data_points
|
||||
}
|
||||
|
||||
|
||||
def get_protein_adequacy_data(
|
||||
profile_id: str,
|
||||
|
|
@ -291,7 +316,6 @@ def get_protein_adequacy_data(
|
|||
"confidence": str
|
||||
}
|
||||
"""
|
||||
# Get protein targets
|
||||
targets = get_protein_targets_data(profile_id)
|
||||
|
||||
with get_db() as conn:
|
||||
|
|
@ -299,60 +323,55 @@ def get_protein_adequacy_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
AVG(protein_g) as avg_protein,
|
||||
COUNT(*) as cnt,
|
||||
SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target
|
||||
"""SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""",
|
||||
(targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['cnt'] == 0:
|
||||
return {
|
||||
"adequacy_score": 0,
|
||||
"avg_protein_g": 0.0,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": 0.0,
|
||||
"days_in_target": 0,
|
||||
"days_with_data": 0,
|
||||
"confidence": "insufficient"
|
||||
}
|
||||
|
||||
avg_protein = safe_float(row['avg_protein'])
|
||||
days_with_data = row['cnt']
|
||||
days_in_target = row['days_in_target']
|
||||
|
||||
protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0
|
||||
|
||||
# Calculate adequacy score
|
||||
# 100 = always in target range
|
||||
# Scale based on percentage of days in target + average relative to target
|
||||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||||
|
||||
# Bonus/penalty for average protein level
|
||||
target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2
|
||||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||||
|
||||
# Weighted score: 70% target days, 30% average level
|
||||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||||
adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100
|
||||
|
||||
confidence = calculate_confidence(days_with_data, days, "general")
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0:
|
||||
return {
|
||||
"adequacy_score": adequacy_score,
|
||||
"avg_protein_g": avg_protein,
|
||||
"adequacy_score": 0,
|
||||
"avg_protein_g": 0.0,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": protein_g_per_kg,
|
||||
"days_in_target": days_in_target,
|
||||
"days_with_data": days_with_data,
|
||||
"confidence": confidence
|
||||
"protein_g_per_kg": 0.0,
|
||||
"days_in_target": 0,
|
||||
"days_with_data": 0,
|
||||
"confidence": "insufficient"
|
||||
}
|
||||
|
||||
daily_totals = [safe_float(r["daily_protein"]) for r in rows]
|
||||
days_with_data = len(daily_totals)
|
||||
low = targets["protein_target_low"]
|
||||
high = targets["protein_target_high"]
|
||||
days_in_target = sum(1 for d in daily_totals if low <= d <= high)
|
||||
|
||||
avg_protein = sum(daily_totals) / days_with_data
|
||||
protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0
|
||||
|
||||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||||
target_mid = (low + high) / 2
|
||||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||||
|
||||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||||
adequacy_score = max(0, min(100, adequacy_score))
|
||||
|
||||
confidence = calculate_confidence(days_with_data, days, "general")
|
||||
|
||||
return {
|
||||
"adequacy_score": adequacy_score,
|
||||
"avg_protein_g": avg_protein,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": protein_g_per_kg,
|
||||
"days_in_target": days_in_target,
|
||||
"days_with_data": days_with_data,
|
||||
"confidence": confidence
|
||||
}
|
||||
|
||||
|
||||
def get_macro_consistency_data(
|
||||
profile_id: str,
|
||||
|
|
@ -387,16 +406,18 @@ def get_macro_consistency_data(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
protein_g, carbs_g, fat_g, kcal
|
||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s
|
||||
AND date >= %s
|
||||
AND protein_g IS NOT NULL
|
||||
AND carbs_g IS NOT NULL
|
||||
AND fat_g IS NOT NULL
|
||||
AND kcal > 0
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||||
AND COALESCE(SUM(protein_g), 0) > 0
|
||||
AND COALESCE(SUM(carbs_g), 0) > 0
|
||||
AND COALESCE(SUM(fat_g), 0) > 0""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -413,7 +434,6 @@ def get_macro_consistency_data(
|
|||
"data_points": len(rows)
|
||||
}
|
||||
|
||||
# Calculate macro percentages for each day
|
||||
import statistics
|
||||
|
||||
protein_pcts = []
|
||||
|
|
@ -425,7 +445,6 @@ def get_macro_consistency_data(
|
|||
if total_kcal == 0:
|
||||
continue
|
||||
|
||||
# Convert grams to kcal (protein=4, carbs=4, fat=9)
|
||||
protein_kcal = safe_float(row['protein_g']) * 4
|
||||
carbs_kcal = safe_float(row['carbs_g']) * 4
|
||||
fat_kcal = safe_float(row['fat_g']) * 9
|
||||
|
|
@ -491,50 +510,15 @@ def get_macro_consistency_data(
|
|||
|
||||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
||||
"""
|
||||
Calculate 7-day average energy balance (kcal/day)
|
||||
Positive = surplus, Negative = deficit
|
||||
|
||||
Migration from Phase 0b:
|
||||
Used by placeholders that need single balance value
|
||||
7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||
ORDER BY date DESC
|
||||
""", (profile_id,))
|
||||
|
||||
calories = [row['kcal'] for row in cur.fetchall()]
|
||||
|
||||
if len(calories) < 4: # Need at least 4 days
|
||||
return None
|
||||
|
||||
avg_intake = float(sum(calories) / len(calories))
|
||||
|
||||
# Get estimated TDEE (simplified - could use Harris-Benedict)
|
||||
# For now, use weight-based estimate
|
||||
cur.execute("""
|
||||
SELECT weight
|
||||
FROM weight_log
|
||||
WHERE profile_id = %s
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
""", (profile_id,))
|
||||
|
||||
weight_row = cur.fetchone()
|
||||
if not weight_row:
|
||||
return None
|
||||
|
||||
# Simple TDEE estimate: bodyweight (kg) × 30-35
|
||||
# TODO: Improve with activity level, age, gender
|
||||
estimated_tdee = float(weight_row['weight']) * 32.5
|
||||
|
||||
balance = avg_intake - estimated_tdee
|
||||
|
||||
return round(balance, 0)
|
||||
data = get_energy_balance_data(profile_id, 7)
|
||||
if data["data_points"] < 4:
|
||||
return None
|
||||
tdee = data.get("estimated_tdee") or 0
|
||||
if tdee <= 0:
|
||||
return None
|
||||
return round(float(data["energy_balance"]), 0)
|
||||
|
||||
|
||||
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
||||
|
|
@ -654,15 +638,14 @@ def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, t
|
|||
|
||||
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||||
"""
|
||||
Protein adequacy score 0-100 (last 28 days)
|
||||
Based on consistency and target achievement
|
||||
Protein adequacy score 0-100 (last 28 days).
|
||||
Uses per-calendar-day total protein vs. average weight in the window (g/kg per day).
|
||||
"""
|
||||
import statistics
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get average weight (28d)
|
||||
cur.execute("""
|
||||
SELECT AVG(weight) as avg_weight
|
||||
FROM weight_log
|
||||
|
|
@ -676,38 +659,29 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
|||
|
||||
weight = float(weight_row['avg_weight'])
|
||||
|
||||
# Get protein intake (28d)
|
||||
cur.execute("""
|
||||
SELECT protein_g
|
||||
SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||
AND protein_g IS NOT NULL
|
||||
GROUP BY date
|
||||
""", (profile_id,))
|
||||
|
||||
protein_values = [float(row['protein_g']) for row in cur.fetchall()]
|
||||
daily_totals = [float(row['daily_protein']) for row in cur.fetchall()]
|
||||
|
||||
if len(protein_values) < 18: # 60% coverage
|
||||
if len(daily_totals) < 18:
|
||||
return None
|
||||
|
||||
# Calculate metrics
|
||||
protein_per_kg_values = [p / weight for p in protein_values]
|
||||
protein_per_kg_values = [p / weight for p in daily_totals]
|
||||
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
||||
|
||||
# Target range: 1.6-2.2 g/kg for active individuals
|
||||
target_mid = 1.9
|
||||
|
||||
# Score based on distance from target
|
||||
if 1.6 <= avg_protein_per_kg <= 2.2:
|
||||
base_score = 100
|
||||
elif avg_protein_per_kg < 1.6:
|
||||
# Below target
|
||||
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
||||
else:
|
||||
# Above target (less penalty)
|
||||
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
||||
|
||||
# Consistency bonus/penalty
|
||||
std_dev = statistics.stdev(protein_per_kg_values)
|
||||
if std_dev < 0.3:
|
||||
consistency_bonus = 10
|
||||
|
|
@ -723,20 +697,24 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
|||
|
||||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||
"""
|
||||
Macro consistency score 0-100 (last 28 days)
|
||||
Lower variability = higher score
|
||||
Macro consistency score 0-100 (last 28 days).
|
||||
CV of daily totals (kcal and macros), not raw log rows.
|
||||
"""
|
||||
import statistics
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT kcal, protein_g, fat_g, carbs_g
|
||||
SELECT
|
||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||||
COALESCE(SUM(fat_g), 0)::float AS df,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS dc
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||
AND kcal IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
GROUP BY date
|
||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||||
""", (profile_id,))
|
||||
|
||||
data = cur.fetchall()
|
||||
|
|
@ -744,9 +722,7 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
if len(data) < 18:
|
||||
return None
|
||||
|
||||
# Calculate coefficient of variation for each macro
|
||||
def cv(values):
|
||||
"""Coefficient of variation (std_dev / mean)"""
|
||||
if not values or len(values) < 2:
|
||||
return None
|
||||
mean = sum(values) / len(values)
|
||||
|
|
@ -755,10 +731,10 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
std_dev = statistics.stdev(values)
|
||||
return std_dev / mean
|
||||
|
||||
calories_cv = cv([d['kcal'] for d in data])
|
||||
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']])
|
||||
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']])
|
||||
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']])
|
||||
calories_cv = cv([d['dk'] for d in data])
|
||||
protein_cv = cv([d['dp'] for d in data if d['dp']])
|
||||
fat_cv = cv([d['df'] for d in data if d['df']])
|
||||
carbs_cv = cv([d['dc'] for d in data if d['dc']])
|
||||
|
||||
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
||||
|
||||
|
|
@ -767,9 +743,6 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
|
||||
avg_cv = sum(cv_values) / len(cv_values)
|
||||
|
||||
# Score: lower CV = higher score
|
||||
# CV < 0.2 = excellent consistency
|
||||
# CV > 0.5 = poor consistency
|
||||
if avg_cv < 0.2:
|
||||
score = 100
|
||||
elif avg_cv < 0.3:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ from slowapi.errors import RateLimitExceeded
|
|||
|
||||
from db import init_db
|
||||
|
||||
# Placeholder registry: load all register_placeholder() side-effects before any request
|
||||
# so get_placeholder_catalog() and exports see consistent metadata (see Phase A plan).
|
||||
import placeholder_registrations # noqa: F401
|
||||
|
||||
# Import routers
|
||||
from routers import auth, profiles, weight, circumference, caliper
|
||||
from routers import activity, nutrition, photos, insights, prompts
|
||||
|
|
|
|||
|
|
@ -1133,7 +1133,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
|||
|
||||
# --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) ---
|
||||
'{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid),
|
||||
'{{top_goal_progress_pct}}': lambda pid: _safe_str('top_goal_progress_pct', pid),
|
||||
'{{top_goal_progress_pct}}': lambda pid: _safe_int('top_goal_progress_pct', pid),
|
||||
'{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid),
|
||||
'{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid),
|
||||
'{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ from data_layer.nutrition_metrics import (
|
|||
get_nutrition_average_data,
|
||||
get_protein_targets_data,
|
||||
get_protein_adequacy_data,
|
||||
get_macro_consistency_data
|
||||
get_macro_consistency_data,
|
||||
get_energy_balance_data,
|
||||
)
|
||||
from data_layer.activity_metrics import (
|
||||
get_activity_summary_data,
|
||||
|
|
@ -346,17 +347,20 @@ def get_energy_balance_chart(
|
|||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
balance_meta = get_energy_balance_data(profile_id, days)
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, kcal
|
||||
"""SELECT date, SUM(kcal)::float AS kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -374,7 +378,21 @@ def get_energy_balance_chart(
|
|||
}
|
||||
}
|
||||
|
||||
# Prepare data
|
||||
estimated_tdee = balance_meta.get("estimated_tdee") or 0
|
||||
if estimated_tdee <= 0:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows),
|
||||
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)"
|
||||
}
|
||||
}
|
||||
|
||||
labels = []
|
||||
daily_values = []
|
||||
avg_7d = []
|
||||
|
|
@ -384,23 +402,19 @@ def get_energy_balance_chart(
|
|||
labels.append(row['date'].isoformat())
|
||||
daily_values.append(safe_float(row['kcal']))
|
||||
|
||||
# 7d rolling average
|
||||
start_7d = max(0, i - 6)
|
||||
window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)]
|
||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||
|
||||
# 14d rolling average
|
||||
start_14d = max(0, i - 13)
|
||||
window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)]
|
||||
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
|
||||
|
||||
# Calculate TDEE (estimated, should come from profile)
|
||||
# TODO: Calculate from profile (weight, height, age, activity level)
|
||||
estimated_tdee = 2500.0
|
||||
|
||||
# Calculate deficit/surplus
|
||||
avg_intake = sum(daily_values) / len(daily_values) if daily_values else 0
|
||||
energy_balance = avg_intake - estimated_tdee
|
||||
avg_intake = float(balance_meta.get("avg_intake") or (sum(daily_values) / len(daily_values) if daily_values else 0))
|
||||
energy_balance = float(balance_meta.get("energy_balance") or (avg_intake - estimated_tdee))
|
||||
balance_status = balance_meta.get("status") or (
|
||||
"deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance"
|
||||
)
|
||||
|
||||
datasets = [
|
||||
{
|
||||
|
|
@ -443,8 +457,7 @@ def get_energy_balance_chart(
|
|||
}
|
||||
]
|
||||
|
||||
from data_layer.utils import calculate_confidence
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
confidence = balance_meta.get("confidence") or "low"
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
|
|
@ -458,7 +471,7 @@ def get_energy_balance_chart(
|
|||
"avg_kcal": round(avg_intake, 1),
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"energy_balance": round(energy_balance, 1),
|
||||
"balance_status": "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance",
|
||||
"balance_status": balance_status,
|
||||
"first_date": rows[0]['date'],
|
||||
"last_date": rows[-1]['date']
|
||||
})
|
||||
|
|
@ -587,11 +600,12 @@ def get_protein_adequacy_chart(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, protein_g
|
||||
"""SELECT date, SUM(protein_g)::float AS protein_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -687,7 +701,6 @@ def get_protein_adequacy_chart(
|
|||
from data_layer.utils import calculate_confidence
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
|
||||
# Count days in target
|
||||
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
||||
|
||||
return {
|
||||
|
|
@ -978,16 +991,23 @@ def get_nutrition_adherence_score(
|
|||
|
||||
# Get nutrition data
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as cnt,
|
||||
AVG(kcal) as avg_kcal,
|
||||
STDDEV(kcal) as std_kcal,
|
||||
AVG(protein_g) as avg_protein,
|
||||
AVG(carbs_g) as avg_carbs,
|
||||
AVG(fat_g) as avg_fat
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
AND kcal IS NOT NULL""",
|
||||
(profile_id, cutoff)
|
||||
"""WITH daily AS (
|
||||
SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS dc,
|
||||
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
)
|
||||
SELECT COUNT(*)::int AS cnt,
|
||||
AVG(dk) AS avg_kcal,
|
||||
STDDEV(dk) AS std_kcal,
|
||||
AVG(dp) AS avg_protein,
|
||||
AVG(dc) AS avg_carbs,
|
||||
AVG(df) AS avg_fat
|
||||
FROM daily""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
stats = cur.fetchone()
|
||||
|
||||
|
|
|
|||
65
backend/test_export.py
Normal file
65
backend/test_export.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test export-all endpoint"""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from datetime import datetime
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
print("Testing export-all logic...")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug")
|
||||
prompts = [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
print(f"Found {len(prompts)} prompts")
|
||||
|
||||
# Convert to export format (clean up DB-specific fields)
|
||||
export_data = []
|
||||
for idx, p in enumerate(prompts):
|
||||
print(f"\nProcessing prompt {idx+1}: {p.get('slug')}")
|
||||
try:
|
||||
export_item = {
|
||||
'slug': p['slug'],
|
||||
'name': p['name'],
|
||||
'display_name': p.get('display_name'),
|
||||
'description': p.get('description'),
|
||||
'type': p.get('type', 'pipeline'),
|
||||
'category': p.get('category', 'ganzheitlich'),
|
||||
'template': p.get('template'),
|
||||
'stages': p.get('stages'),
|
||||
'output_format': p.get('output_format', 'text'),
|
||||
'output_schema': p.get('output_schema'),
|
||||
'question_augmentations': p.get('question_augmentations'),
|
||||
'graph_data': p.get('graph_data'),
|
||||
'active': p.get('active', True),
|
||||
'sort_order': p.get('sort_order', 0)
|
||||
}
|
||||
export_data.append(export_item)
|
||||
print(f" ✓ OK")
|
||||
except Exception as e:
|
||||
print(f" ✗ ERROR: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
print(f"\n\nSuccessfully processed {len(export_data)} prompts")
|
||||
|
||||
# Try to create the response dict
|
||||
try:
|
||||
result = {
|
||||
'export_date': datetime.now().isoformat(),
|
||||
'count': len(export_data),
|
||||
'prompts': export_data
|
||||
}
|
||||
print("✓ Result dict created successfully")
|
||||
|
||||
# Try JSON serialization
|
||||
import json
|
||||
json_str = json.dumps(result)
|
||||
print(f"✓ JSON serialization OK, length: {len(json_str)}")
|
||||
except Exception as e:
|
||||
print(f"✗ ERROR creating result: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Loading…
Reference in New Issue
Block a user