Add per-meal calorie cap (1000 kcal) + kcal/protein display

- SKILL.md RATIONAL PORTIONS: add ~1000 kcal/meal cap; trim starch/cheese/halloumi/lentil
  (not protein) if over; use scripts/calc_meal.py to check
- scripts/calc_meal.py: estimate protein/kcal/fibre/satfat for a meal's items, flag
  over_kcal (>1000) + per-item max_per_meal breaches (unit-aware); gates daily sends
- scripts/verify_portions.py: now also flags KCAL CAP violations via calc_meal
- references/calories.md: per-portion kcal/macro estimate table (source of truth)
- references/email-template.md + meal-history.md: show Est. kcal + Protein per card/entry
- daily cron prompt: compute + record kcal/protein_g on each entry, show kcal in email,
  enforce 1000 kcal cap before send (pushed via cronjob update)
This commit is contained in:
hermes
2026-08-29 08:17:43 +01:00
parent d4c5a8f658
commit f7c9b78dc1
5 changed files with 132 additions and 1 deletions

111
scripts/calc_meal.py Normal file
View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Estimate protein (g), calories (kcal), fibre (g) and saturated fat (g) for a meal
from its items, and check the RATIONAL PORTIONS caps (kcal cap + per-item protein
max_per_meal). Reads references/calories.md-style numbers inline (kept in sync with
that file). Usage:
python3 calc_meal.py '[{"name":"Eggs","qty":4,"unit":"count"}, ...]'
Prints JSON: {protein_g, kcal, fibre_g, satfat_g, over_kcal, over_item}.
Exit non-zero if over_kcal (so it can gate sends in the daily job).
"""
import json, sys, os
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# kcal per logged portion. For g/ml-unit items the value is PER GRAM (× qty).
# For count/can/portion items the value is the whole item. Keep in sync with
# references/calories.md (which shows dairy per 100g for humans — divide by 100 here).
KCAL = {
"Eggs": 70, "Chicken breast": 165, "Tinned tuna in veg oil": 190,
"Tinned tuna in spring water": 130, "Tinned green lentils": 230,
"Greek yogurt (0%)": 0.5, "Cottage cheese": 0.95, "Cheese": 4.0, "Halloumi": 3.2,
"Whey protein powder": 4, "Milk (semi)": 0.5,
"Dried red lentils": 3.5, "Baked beans": 200, "Chickpeas": 180, "Tofu (firm)": 1.4,
"Edamame (frozen)": 1.2, "Wholemeal pasta": 350, "Microwave rice": 220,
"Scottish oats": 3.7,
"Broccoli": 35, "Carrots": 0.41, "Courgette": 20, "Sweet potatoes": 130,
"Aubergine": 40, "Red cabbage": 0.04, "Cauliflower": 30, "Leeks": 55, "Mushrooms": 0.23,
"Lettuce": 15, "Salad tomatoes": 15, "Orange bell pepper": 30, "Small white onions": 15,
"Frozen mixed veg": 0.73, "Peeled plum tomatoes": 90,
"Cumberland sausages": 55, "Lamb mince": 2.5,
}
PROTEIN_G = {
"Eggs": 6, "Chicken breast": 33, "Tinned tuna in veg oil": 25,
"Tinned tuna in spring water": 25, "Tinned green lentils": 18,
"Greek yogurt (0%)": 0.10, "Cottage cheese": 0.12, "Cheese": 0.25, "Halloumi": 0.19,
"Whey protein powder": 0.8, "Milk (semi)": 3.4,
"Dried red lentils": 0.25, "Baked beans": 18, "Chickpeas": 8, "Tofu (firm)": 13,
"Edamame (frozen)": 11, "Wholemeal pasta": 7, "Microwave rice": 3, "Scottish oats": 11,
"Broccoli": 3, "Carrots": 0.01, "Courgette": 1, "Sweet potatoes": 2, "Aubergine": 1,
"Red cabbage": 0.01, "Cauliflower": 3, "Leeks": 1, "Mushrooms": 0.03, "Lettuce": 1,
"Salad tomatoes": 1, "Orange bell pepper": 1, "Small white onions": 1,
"Frozen mixed veg": 0.03, "Peeled plum tomatoes": 2,
"Cumberland sausages": 11, "Lamb mince": 18,
}
FIBRE_G = {
"Dried red lentils": 0.11, "Baked beans": 14, "Chickpeas": 14, "Tinned green lentils": 14,
"Tofu (firm)": 0.004, "Edamame (frozen)": 0.1, "Wholemeal pasta": 5, "Scottish oats": 0.1,
"Broccoli": 3, "Carrots": 0.03, "Courgette": 1, "Sweet potatoes": 4, "Aubergine": 3,
"Red cabbage": 0.04, "Cauliflower": 3, "Leeks": 3, "Mushrooms": 0.02, "Lettuce": 1,
"Salad tomatoes": 1, "Orange bell pepper": 2, "Small white onions": 1,
"Frozen mixed veg": 0.04, "Peeled plum tomatoes": 2, "Cumberland sausages": 0, "Lamb mince": 0,
}
SATFAT_G = {
"Eggs": 6, "Cheese": 0.21, "Halloumi": 0.26, "Cumberland sausages": 18, "Lamb mince": 9,
"Tinned tuna in veg oil": 3, "Chicken breast": 1, "Whey protein powder": 0,
}
KCAL_CAP = 1000
GRAMS_PER_UNIT = {
"g": 1, "ml": 1, "kg": 1000, "count": 1, "can": 1,
"small block": 120, "block": 120, "serving": 1, "portion": 1,
"pack": 1, "jar": 1, "tub": 1, "bulb": 1, "bottle": 1, "loaf": 1,
}
def estimate(items, inv):
tot = {"protein_g": 0.0, "kcal": 0.0, "fibre_g": 0.0, "satfat_g": 0.0}
over_kcal = False
over_item = []
for it in items:
name, qty, unit = it["name"], it["qty"], it.get("unit", "count")
# kcal
kp = KCAL.get(name, 0)
k = kp * (qty if unit not in ("g", "ml") else qty)
# protein
pp = PROTEIN_G.get(name, 0)
p = pp * (qty if unit not in ("g", "ml") else qty)
# fibre
fp = FIBRE_G.get(name, 0)
f = fp * (qty if unit not in ("g", "ml") else qty)
# satfat
sp = SATFAT_G.get(name, 0)
s = sp * (qty if unit not in ("g", "ml") else qty)
tot["kcal"] += k; tot["protein_g"] += p; tot["fibre_g"] += f; tot["satfat_g"] += s
# per-item max_per_meal (protein) check — convert both sides to grams
inv_item = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
cap = inv_item.get("max_per_meal") if inv_item else None
if cap is not None:
cap_unit = inv_item.get("unit", unit)
used_g = qty * GRAMS_PER_UNIT.get(unit, 1)
cap_g = cap * GRAMS_PER_UNIT.get(cap_unit, 1)
if used_g > cap_g + 1e-6:
over_item.append(f"{name} {qty}{unit} > cap {cap}{cap_unit}")
if tot["kcal"] > KCAL_CAP + 1e-6:
over_kcal = True
for key in tot:
tot[key] = round(tot[key], 1)
return {**tot, "over_kcal": over_kcal, "over_item": over_item}
def main():
items = json.loads(sys.argv[1]) if len(sys.argv) > 1 else []
inv = json.load(open(os.path.join(SKILL_DIR, "inventory.json")))
res = estimate(items, inv)
print(json.dumps(res))
if res["over_kcal"] or res["over_item"]:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -121,6 +121,19 @@ def main():
f"{it['name']} {it['qty']} is >=70% of its cap and supplies "
f"{pg:.0f}/{total:.0f} g ({pg/total*100:.0f}%) of meal protein "
f"— not a balanced plate")
# 3) calorie cap check (uses calc_meal helper if available)
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"calc_meal", os.path.join(SKILL_DIR, "scripts", "calc_meal.py"))
cm = importlib.util.module_from_spec(spec); spec.loader.exec_module(cm)
res = cm.estimate(meal_items, inv)
if res["over_kcal"]:
violations.append(
f"KCAL CAP {e['date']} {e['meal']} '{e['name']}': "
f"~{res['kcal']:.0f} kcal exceeds 1000 kcal/meal")
except Exception:
pass
if violations:
print("FAIL: rational-portion violations:")
for v in violations: