235 lines
11 KiB
Python
235 lines
11 KiB
Python
"""按中国食物成分表校准菜品营养成分。
|
||
|
||
原理:解析每道菜配料明细中的食材与克数,乘以对应食材营养密度(每 100g),
|
||
再按烹饪方式(炒/红烧/油炸/蒸煮)修正油脂与糖的用量,得到更符合实际的值。
|
||
用法:python manage.py calibrate_nutrition [--apply] [--export-seed]
|
||
"""
|
||
|
||
import re
|
||
|
||
from django.core.management.base import BaseCommand
|
||
|
||
from meals.models import Dish
|
||
|
||
# 每 100g 可食部:蛋白质g, 脂肪g, 热量kcal(参考中国食物成分表/常见值)
|
||
NUTRITION = {
|
||
'五花肉': (9.5, 35.0, 400), '猪五花': (9.5, 35.0, 400),
|
||
'梅花肉': (20.0, 8.0, 150), '里脊肉': (20.3, 6.2, 143), '瘦肉': (20.3, 6.2, 143),
|
||
'肉丝': (20.0, 7.0, 150), '肉片': (20.0, 8.0, 155), '肉糜': (18.0, 12.0, 180),
|
||
'肉馅': (18.0, 12.0, 180), '肉末': (18.0, 12.0, 180),
|
||
'猪大排': (12.6, 12.6, 168), '大排': (12.6, 12.6, 168),
|
||
'肋排': (12.8, 14.3, 185), '排骨': (12.8, 14.3, 185), '小排': (12.8, 14.3, 185),
|
||
'汤骨': (15.0, 15.0, 220), '牛腩': (20.0, 8.0, 150), '牛肉': (20.2, 4.2, 125),
|
||
'羊肉': (19.0, 14.1, 203), '鸡胸': (24.0, 1.5, 118), '鸡丁': (19.0, 6.0, 140),
|
||
'鸡块': (19.0, 8.0, 150), '鸡肉': (19.3, 9.4, 167), '鸡腿': (14.0, 9.0, 150),
|
||
'鸡翅': (17.4, 11.8, 194), '烤翅': (19.0, 16.0, 240), '鸭腿': (16.0, 18.0, 230),
|
||
'鸭块': (15.5, 19.7, 240), '鸭肉': (15.5, 19.7, 240),
|
||
'鱼片': (17.0, 4.0, 110), '鱼块': (16.6, 5.2, 113), '草鱼': (16.6, 5.2, 113),
|
||
'鲈鱼': (18.6, 3.4, 105), '鲫鱼': (17.1, 2.7, 108),
|
||
'基围虾': (18.6, 0.8, 93), '虾仁': (18.6, 0.8, 93), '虾皮': (30.7, 2.2, 153),
|
||
'鸡蛋': (13.3, 8.8, 144), '咸蛋黄': (16.0, 30.0, 330), '皮蛋': (14.2, 10.7, 171),
|
||
'豆腐': (8.1, 3.7, 81), '素鸡': (16.5, 12.5, 192), '烤麸': (22.5, 0.7, 121),
|
||
'百叶结': (24.5, 9.0, 205), '油面筋': (17.1, 17.6, 244), '油方': (17.1, 17.6, 244),
|
||
'腐竹': (44.6, 21.7, 459), '粉丝': (0.8, 0.2, 338), '花生米': (24.1, 44.4, 574),
|
||
'花生': (24.1, 44.4, 574), '贡丸': (12.0, 12.0, 200), '红肠': (12.0, 15.0, 220),
|
||
'烤肠': (10.0, 20.0, 230),
|
||
'土豆': (2.0, 0.2, 77), '红薯': (1.1, 0.2, 90), '南瓜': (0.7, 0.1, 23),
|
||
'山药': (1.9, 0.2, 57), '玉米棒': (4.0, 1.2, 112), '玉米': (4.0, 1.2, 112),
|
||
'冬瓜': (0.4, 0.2, 12), '西葫芦': (0.8, 0.2, 19), '丝瓜': (1.0, 0.2, 20),
|
||
'番茄': (0.9, 0.2, 20), '西红柿': (0.9, 0.2, 20), '茄子': (1.1, 0.2, 23),
|
||
'青椒': (1.0, 0.2, 22), '辣椒': (1.3, 0.4, 32), '干辣椒': (15.0, 12.0, 212),
|
||
'彩椒': (1.0, 0.2, 22), '大白菜': (1.5, 0.1, 17), '白菜': (1.5, 0.1, 17),
|
||
'卷心菜': (1.5, 0.2, 22), '牛心菜': (1.5, 0.2, 22), '包菜': (1.5, 0.2, 24),
|
||
'娃娃菜': (1.7, 0.3, 15), '青菜': (1.7, 0.3, 15), '菠菜': (2.6, 0.3, 28),
|
||
'空心菜': (2.2, 0.3, 20), '菜心': (2.8, 0.5, 22), '生菜': (1.3, 0.3, 15),
|
||
'杭白菜': (1.8, 0.4, 18), '茼蒿菜': (1.9, 0.3, 24), '茼蒿': (1.9, 0.3, 24),
|
||
'莴笋': (1.0, 0.1, 15), '芦笋': (2.6, 0.2, 22), '西兰花': (4.1, 0.6, 36),
|
||
'莲藕': (1.9, 0.2, 73), '藕片': (1.9, 0.2, 73), '藕丁': (1.9, 0.2, 73),
|
||
'茭白': (1.2, 0.2, 26), '蚕豆': (8.8, 0.4, 104), '荷兰豆': (2.5, 0.3, 30),
|
||
'四季豆': (2.0, 0.4, 31), '豆芽': (2.1, 0.1, 18), '香菇': (2.2, 0.3, 26),
|
||
'菌菇': (2.5, 0.3, 25), '木耳': (1.5, 0.2, 27), '紫菜': (26.7, 1.1, 250),
|
||
'海带': (1.2, 0.1, 13), '黄瓜': (0.8, 0.2, 16), '洋葱': (1.1, 0.2, 40),
|
||
'大葱': (1.7, 0.3, 30), '韭菜': (2.4, 0.4, 26), '胡萝卜': (1.0, 0.2, 39),
|
||
'马蹄': (1.2, 0.2, 61), '荸荠': (1.2, 0.2, 61), '酸菜': (1.2, 0.2, 18),
|
||
'蒜苗': (2.1, 0.4, 37), '豆豉': (24.0, 8.0, 220), '葱油': (0.0, 100.0, 900),
|
||
'笋丝': (2.6, 0.2, 25), '笋': (2.6, 0.2, 25), '芹菜': (1.2, 0.2, 22),
|
||
'红枣': (3.2, 0.5, 276), '剁椒': (2.0, 1.0, 40), '荸荠': (1.2, 0.2, 61),
|
||
'米饭': (2.6, 0.3, 116), '黑米饭': (3.2, 0.6, 122), '麦片饭': (4.0, 1.2, 130),
|
||
'杂粮饭': (3.5, 0.8, 125), '炒面': (4.5, 6.5, 175), '豆沙包': (8.5, 3.0, 220),
|
||
'奶黄包': (7.0, 4.0, 200), '小笼包': (9.0, 5.0, 220), '花卷': (7.0, 1.0, 220),
|
||
'馒头': (7.0, 1.0, 220),
|
||
}
|
||
|
||
# 非克单位 → 每单位可食部克数
|
||
UNIT_GRAMS = {
|
||
'只': {'鸡翅': 30, '烤翅': 35, '鸡蛋': 50, '蛋': 50, '基围虾': 10, '虾': 10,
|
||
'鸡腿': 100, '鸭腿': 100, '油面筋': 20, '油方': 20, '小笼包': 25},
|
||
'根': {'烤肠': 50, '火腿肠': 50},
|
||
'块': {'大排': 70, '排骨': 100, '鱼块': 100, '鸡块': 35, '素鸡': 50, '豆腐': 100},
|
||
}
|
||
UNIT_DEFAULT = {'只': 30, '根': 50, '块': 80}
|
||
|
||
# 烹饪方式修正(额外油/糖,每份):加 蛋白质g、脂肪g、热量kcal
|
||
COOK_OIL = [
|
||
(('油炸', '炸'), (0.0, 12.0, 108.0)),
|
||
(('地三鲜', '水煮', '回锅'), (0.0, 8.0, 72.0)),
|
||
(('红烧', '糖醋', '蜜汁', '拔丝', '可乐', '鱼香', '酱爆', '酱鸭', '东坡', '焖', '油焖'), (0.0, 5.0, 80.0)),
|
||
(('炒', '爆', '干煸', '蒜香', '烤', '焗'), (0.0, 5.0, 45.0)),
|
||
(('蒸', '炖', '煮', '白灼', '凉拌', '汤', '清炒', '蒜蓉'), (0.0, 2.0, 18.0)),
|
||
(('糖拌',), (0.0, 0.0, 45.0)),
|
||
(('塞肉',), (4.5, 3.0, 45.0)),
|
||
]
|
||
|
||
# 类型合理范围 clamp:(protein, fat, kcal)
|
||
CLAMP = {
|
||
'meat': ((4.0, 20.0), (2.0, 28.0), (90.0, 340.0)),
|
||
'veg': ((1.0, 10.0), (0.5, 12.0), (35.0, 170.0)),
|
||
'soup': ((1.5, 7.0), (1.0, 7.0), (30.0, 100.0)),
|
||
'staple': ((2.0, 9.0), (0.5, 10.0), (70.0, 320.0)),
|
||
}
|
||
|
||
|
||
def _parse_items(text):
|
||
"""解析 '食材12g食材34g' / '食材2只' → [(食材名, 克数)]"""
|
||
items = []
|
||
for m in re.finditer(r'([\u4e00-\u9fa5]{1,10}?)(\d+\.?\d*)g', text):
|
||
items.append((m.group(1), float(m.group(2))))
|
||
for m in re.finditer(r'([\u4e00-\u9fa5]{1,10}?)(\d+)(?:-(\d+))?(只|根|块)', text):
|
||
name, unit = m.group(1), m.group(4)
|
||
n = int(m.group(3) or m.group(2))
|
||
grams = UNIT_GRAMS.get(unit, {}).get(name)
|
||
if grams is None:
|
||
grams = next((v for k, v in UNIT_GRAMS.get(unit, {}).items() if k in name), UNIT_DEFAULT[unit])
|
||
items.append((name, grams * n))
|
||
return items
|
||
|
||
|
||
def _food_nutrition(name):
|
||
for key in sorted(NUTRITION, key=len, reverse=True):
|
||
if key in name:
|
||
return NUTRITION[key]
|
||
return None
|
||
|
||
|
||
def _cook_fix(name):
|
||
for keywords, fix in COOK_OIL:
|
||
for k in keywords:
|
||
if k in name:
|
||
return fix
|
||
return (0.0, 3.0, 27.0)
|
||
|
||
|
||
def _calc(name, dish_type, text):
|
||
items = _parse_items(text)
|
||
protein = fat = kcal = 0.0
|
||
matched = 0
|
||
for food, grams in items:
|
||
n = _food_nutrition(food)
|
||
if n:
|
||
matched += 1
|
||
p, f, k = n
|
||
protein += p * grams / 100
|
||
fat += f * grams / 100
|
||
kcal += k * grams / 100
|
||
if not matched:
|
||
return None
|
||
if dish_type != 'staple':
|
||
fix = _cook_fix(name)
|
||
fat += fix[1]
|
||
kcal += fix[2]
|
||
(p_min, p_max), (f_min, f_max), (k_min, k_max) = CLAMP[dish_type]
|
||
protein = min(max(protein, p_min), p_max)
|
||
fat = min(max(fat, f_min), f_max)
|
||
kcal = min(max(round(kcal), int(k_min)), int(k_max))
|
||
return round(protein, 1), round(fat, 1), int(kcal)
|
||
|
||
|
||
class Command(BaseCommand):
|
||
help = '按食物成分表校准菜品营养成分(--apply 写入数据库,--export-seed 重新生成 seed_dishes.py)'
|
||
|
||
def add_arguments(self, parser):
|
||
parser.add_argument('--apply', action='store_true', help='写入数据库(默认仅预览)')
|
||
parser.add_argument('--export-seed', action='store_true', help='重新生成 seed_dishes.py')
|
||
|
||
def handle(self, *args, **options):
|
||
apply_changes = options['apply']
|
||
updated, unchanged, skipped = [], [], []
|
||
rows = []
|
||
for dish in Dish.objects.filter(is_active=True).order_by('dish_type', 'cuisine', 'id'):
|
||
result = _calc(dish.name, dish.dish_type, dish.ingredient_detail or '')
|
||
if result is None:
|
||
skipped.append(dish.name)
|
||
continue
|
||
p, f, k = result
|
||
old = (float(dish.protein), float(dish.fat), int(dish.calorie))
|
||
rows.append((dish, old, result))
|
||
if abs(old[0] - p) < 0.05 and abs(old[1] - f) < 0.05 and old[2] == k:
|
||
unchanged.append(dish.name)
|
||
continue
|
||
updated.append(dish.name)
|
||
self.stdout.write(
|
||
f'{dish.name}: 蛋白 {old[0]}→{p} | 脂肪 {old[1]}→{f} | 热量 {old[2]}→{k}'
|
||
)
|
||
|
||
self.stdout.write(self.style.WARNING(f'\n将更新 {len(updated)} 道,不变 {len(unchanged)} 道,无法解析 {len(skipped)} 道'))
|
||
if skipped:
|
||
self.stdout.write('无法解析(保留原值): ' + '、'.join(skipped))
|
||
|
||
if apply_changes:
|
||
for dish, _, (p, f, k) in rows:
|
||
dish.protein, dish.fat, dish.calorie = p, f, k
|
||
dish.save(update_fields=['protein', 'fat', 'calorie'])
|
||
self.stdout.write(self.style.SUCCESS('已写入数据库'))
|
||
|
||
if options['export_seed']:
|
||
self._export_seed(rows, skipped)
|
||
|
||
def _export_seed(self, rows, skipped):
|
||
path = __file__.replace('calibrate_nutrition.py', 'seed_dishes.py')
|
||
grouped = {}
|
||
for dish, _, (p, f, k) in rows:
|
||
grouped.setdefault(dish.dish_type, []).append(dish)
|
||
lines = []
|
||
lines.append('from django.core.management.base import BaseCommand')
|
||
lines.append('')
|
||
lines.append('from meals.models import Dish')
|
||
lines.append('')
|
||
lines.append('# (菜名, 菜系, 配料明细, 蛋白质g, 脂肪g, 热量kcal)')
|
||
lines.append('SAMPLE_DISHES = {')
|
||
for t in ('meat', 'veg', 'soup', 'staple'):
|
||
dishes = [d for d in grouped.get(t, []) if d.name not in skipped]
|
||
lines.append(f" '{t}': [")
|
||
for d in dishes:
|
||
lines.append(
|
||
f" ('{d.name}', '{d.cuisine}', '{d.ingredient_detail}', {d.protein}, {d.fat}, {d.calorie}),"
|
||
)
|
||
lines.append(' ],')
|
||
lines.append('}')
|
||
tail = '''
|
||
|
||
class Command(BaseCommand):
|
||
help = '写入示例菜品数据(含菜系)'
|
||
|
||
def handle(self, *args, **options):
|
||
count = 0
|
||
for dish_type, items in SAMPLE_DISHES.items():
|
||
for name, cuisine, ingredient, protein, fat, calorie in items:
|
||
_, created = Dish.objects.update_or_create(
|
||
name=name,
|
||
defaults={
|
||
'dish_type': dish_type,
|
||
'cuisine': cuisine,
|
||
'ingredient_detail': ingredient,
|
||
'protein': protein,
|
||
'fat': fat,
|
||
'calorie': calorie,
|
||
},
|
||
)
|
||
if created:
|
||
count += 1
|
||
self.stdout.write(self.style.SUCCESS(f'示例菜品已就绪(新增 {count} 道,库中共 {Dish.objects.count()} 道)'))
|
||
'''
|
||
with open(path, 'w', encoding='utf-8') as f:
|
||
f.write('\n'.join(lines) + tail)
|
||
self.stdout.write(self.style.SUCCESS(f'seed_dishes.py 已重新生成:{path}'))
|