File size: 8,576 Bytes
f75c5b2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | """nutrition_engine/usda_client.py — USDA FDC API client with local cache + fallback DB."""
from __future__ import annotations
import json, time
from pathlib import Path
from typing import Dict, Optional, Any
import requests
from utils.config import config, NutritionConfig
from utils.logger import logger
USDA_NUTRIENT_ID_MAP = {
1008:"calories", 1004:"total_fat", 1258:"saturated_fat",
1003:"protein", 1005:"carbohydrates", 2000:"sugar", 1079:"fiber", 1093:"sodium",
}
NUTRIENT_NAME_MAP = {
"energy":"calories","total lipid":"total_fat","fatty acids, total saturated":"saturated_fat",
"protein":"protein","carbohydrate":"carbohydrates","sugars, total":"sugar",
"fiber, total dietary":"fiber","sodium":"sodium",
}
FALLBACK_NUTRITION_DB: Dict[str, Dict[str, float]] = {
"butter": {"calories":717,"total_fat":81.1,"saturated_fat":51.4,"protein":0.85,"carbohydrates":0.06,"sugar":0.06,"fiber":0.0,"sodium":714},
"chicken": {"calories":239,"total_fat":13.6,"saturated_fat":3.8, "protein":27.3,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":82},
"olive oil": {"calories":884,"total_fat":100.0,"saturated_fat":13.8,"protein":0.0,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":2},
"flour": {"calories":364,"total_fat":1.0, "saturated_fat":0.16,"protein":10.3,"carbohydrates":76.3,"sugar":0.27,"fiber":2.7,"sodium":2},
"sugar": {"calories":387,"total_fat":0.0, "saturated_fat":0.0, "protein":0.0, "carbohydrates":99.98,"sugar":99.8,"fiber":0.0,"sodium":1},
"heavy cream": {"calories":345,"total_fat":37.0, "saturated_fat":23.0,"protein":2.1, "carbohydrates":2.8, "sugar":2.8, "fiber":0.0,"sodium":38},
"egg": {"calories":143,"total_fat":9.5, "saturated_fat":3.1, "protein":12.6,"carbohydrates":0.72,"sugar":0.37,"fiber":0.0,"sodium":142},
"milk": {"calories":61, "total_fat":3.3, "saturated_fat":1.9, "protein":3.2, "carbohydrates":4.8, "sugar":5.0, "fiber":0.0,"sodium":44},
"cheese": {"calories":402,"total_fat":33.1, "saturated_fat":20.8,"protein":25.0,"carbohydrates":1.3, "sugar":0.5, "fiber":0.0,"sodium":621},
"salt": {"calories":0, "total_fat":0.0, "saturated_fat":0.0, "protein":0.0, "carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":38758},
"garlic": {"calories":149,"total_fat":0.5, "saturated_fat":0.09,"protein":6.4, "carbohydrates":33.1,"sugar":1.0, "fiber":2.1,"sodium":17},
"onion": {"calories":40, "total_fat":0.1, "saturated_fat":0.04,"protein":1.1, "carbohydrates":9.3, "sugar":4.2, "fiber":1.7,"sodium":4},
"tomato": {"calories":18, "total_fat":0.2, "saturated_fat":0.03,"protein":0.88,"carbohydrates":3.9, "sugar":2.6, "fiber":1.2,"sodium":5},
"spinach": {"calories":23, "total_fat":0.4, "saturated_fat":0.06,"protein":2.9, "carbohydrates":3.6, "sugar":0.42,"fiber":2.2,"sodium":79},
"broccoli": {"calories":34, "total_fat":0.4, "saturated_fat":0.04,"protein":2.8, "carbohydrates":6.6, "sugar":1.7, "fiber":2.6,"sodium":33},
"salmon": {"calories":208,"total_fat":13.4, "saturated_fat":3.1, "protein":20.4,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":59},
"rice": {"calories":130,"total_fat":0.3, "saturated_fat":0.08,"protein":2.7, "carbohydrates":28.2,"sugar":0.05,"fiber":0.4,"sodium":1},
"oats": {"calories":389,"total_fat":6.9, "saturated_fat":1.2, "protein":16.9,"carbohydrates":66.3,"sugar":0.99,"fiber":10.6,"sodium":2},
"bacon": {"calories":541,"total_fat":45.0, "saturated_fat":15.1,"protein":37.0,"carbohydrates":1.4, "sugar":0.0, "fiber":0.0,"sodium":1717},
"avocado": {"calories":160,"total_fat":14.7, "saturated_fat":2.1, "protein":2.0, "carbohydrates":8.5, "sugar":0.66,"fiber":6.7,"sodium":7},
"lentil": {"calories":116,"total_fat":0.4, "saturated_fat":0.05,"protein":9.0, "carbohydrates":20.1,"sugar":1.8, "fiber":7.9,"sodium":2},
"oil": {"calories":884,"total_fat":100.0,"saturated_fat":14.0,"protein":0.0, "carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":0},
"cream": {"calories":345,"total_fat":37.0, "saturated_fat":23.0,"protein":2.1, "carbohydrates":2.8, "sugar":2.8, "fiber":0.0,"sodium":38},
"pasta": {"calories":371,"total_fat":1.5, "saturated_fat":0.28,"protein":13.0,"carbohydrates":75.0,"sugar":0.56,"fiber":3.2,"sodium":6},
"spaghetti": {"calories":371,"total_fat":1.5, "saturated_fat":0.28,"protein":13.0,"carbohydrates":75.0,"sugar":0.56,"fiber":3.2,"sodium":6},
"carrot": {"calories":41, "total_fat":0.24, "saturated_fat":0.04,"protein":0.93,"carbohydrates":9.6, "sugar":4.7, "fiber":2.8,"sodium":69},
"celery": {"calories":16, "total_fat":0.17, "saturated_fat":0.04,"protein":0.69,"carbohydrates":3.0, "sugar":1.8, "fiber":1.6,"sodium":80},
"potato": {"calories":77, "total_fat":0.09, "saturated_fat":0.02,"protein":2.0, "carbohydrates":17.0,"sugar":0.78,"fiber":2.2,"sodium":6},
"parmesan": {"calories":431,"total_fat":29.0, "saturated_fat":18.6,"protein":38.0,"carbohydrates":3.2, "sugar":0.0, "fiber":0.0,"sodium":1529},
"brown rice": {"calories":216,"total_fat":1.8, "saturated_fat":0.36,"protein":5.0, "carbohydrates":45.0,"sugar":0.7, "fiber":3.5,"sodium":10},
}
class NutritionCache:
def __init__(self, cache_file: Path):
self.cache_file = cache_file
self._data: Dict[str, Any] = {}
self._load()
def _load(self):
if self.cache_file.exists():
try:
with open(self.cache_file) as f:
self._data = json.load(f)
except Exception:
self._data = {}
def _save(self):
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.cache_file, "w") as f:
json.dump(self._data, f)
def get(self, key: str) -> Optional[Dict]:
return self._data.get(key.lower().strip())
def set(self, key: str, value: Dict):
self._data[key.lower().strip()] = value
self._save()
def __contains__(self, key: str) -> bool:
return key.lower().strip() in self._data
class USDAClient:
def __init__(self, cfg: NutritionConfig = None):
self.cfg = cfg or config.nutrition
self._cache = NutritionCache(self.cfg.cache_file) if self.cfg.use_cache else None
self._last_req = 0.0
def get_nutrition(self, food_name: str) -> Dict[str, float]:
food_name = food_name.strip().lower()
if self._cache and food_name in self._cache:
return self._cache.get(food_name)
try:
result = self._fetch(food_name)
except Exception as e:
logger.warning(f"USDA fallback for '{food_name}': {e}")
result = self._fallback(food_name)
if self._cache:
self._cache.set(food_name, result)
return result
def _rate_limit(self):
elapsed = time.time() - self._last_req
if elapsed < 0.35:
time.sleep(0.35 - elapsed)
self._last_req = time.time()
def _fetch(self, food_name: str) -> Dict[str, float]:
self._rate_limit()
resp = requests.get(
f"{self.cfg.usda_base_url}/foods/search",
params={"query": food_name, "api_key": self.cfg.usda_api_key,
"pageSize": 5, "dataType": "Foundation,SR Legacy"},
timeout=8,
)
resp.raise_for_status()
foods = resp.json().get("foods", [])
if not foods:
return self._fallback(food_name)
return self._parse(foods[0])
def _parse(self, food_data: Dict) -> Dict[str, float]:
result = {k: 0.0 for k in self.cfg.nutrient_keys}
for n in food_data.get("foodNutrients", []):
nid = n.get("nutrientId", 0)
if nid in USDA_NUTRIENT_ID_MAP:
result[USDA_NUTRIENT_ID_MAP[nid]] = float(n.get("value", 0))
continue
name = n.get("nutrientName", "").lower()
for sub, key in NUTRIENT_NAME_MAP.items():
if sub in name:
result[key] = float(n.get("value", 0))
break
return result
def _fallback(self, food_name: str) -> Dict[str, float]:
for key in FALLBACK_NUTRITION_DB:
if key in food_name or food_name in key:
return FALLBACK_NUTRITION_DB[key]
return {"calories":150,"total_fat":5,"saturated_fat":1.5,"protein":5,
"carbohydrates":20,"sugar":3,"fiber":2,"sodium":100}
|