Files
school-meal/backend/meals/management/commands/calibrate_nutrition.py
T

235 lines
11 KiB
Python
Raw 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.
"""按中国食物成分表校准菜品营养成分。
原理:解析每道菜配料明细中的食材与克数,乘以对应食材营养密度(每 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}'))