Files
hermes f7c9b78dc1 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)
2026-08-29 08:17:43 +01:00

112 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()