- 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)
112 lines
5.2 KiB
Python
112 lines
5.2 KiB
Python
#!/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()
|