Files
site/WESP_REL/recipe_calculator.py
2026-07-17 12:57:18 +03:00

458 lines
19 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
# -*- coding: utf-8 -*-
"""
Модуль для расчетов рецептов кормления
"""
from typing import List, Dict, Any, Optional, Tuple
import os
import json
import logging
logger = logging.getLogger(__name__)
def _calc_debug_enabled() -> bool:
"""
Включает подробный трейс расчетов только по флагу окружения.
Удобно для диагностики: set CALC_DEBUG=1
"""
return str(os.getenv("CALC_DEBUG", "")).strip().lower() in {"1", "true", "yes", "on"}
def _calc_log(msg: str, **data) -> None:
if not _calc_debug_enabled():
return
try:
if data:
logger.info("[CALC] %s | %s", msg, json.dumps(data, ensure_ascii=False, default=str))
else:
logger.info("[CALC] %s", msg)
except Exception:
# никогда не ломаем расчет из-за логов
pass
def round_to_step5(value: float) -> float:
"""
Округляет значение до кратного 5 кг
Args:
value: Значение для округления
Returns:
Округленное значение, кратное 5
"""
return round(value / 5) * 5
def calculate_ingredients(
ingredients: List[Dict[str, Any]],
heads_count: int,
trip_percent: float,
component_dry_matter_map: Optional[Dict[str, float]] = None
) -> List[Dict[str, float]]:
"""
Рассчитывает веса и сухое вещество для ингредиентов
Args:
ingredients: Список ингредиентов с полями weightPerHead, dryMatter, component_id
heads_count: Количество голов
trip_percent: Процент на рейс (0-100)
component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально)
Returns:
Список словарей с рассчитанными значениями: totalWeight, tripWeight, dryMatterPerHead, weightPerHead
"""
calculated_ingredients = []
_calc_log(
"calculate_ingredients:start",
heads_count=heads_count,
trip_percent=trip_percent,
ingredient_count=len(ingredients),
)
for idx, ing in enumerate(ingredients, 1):
weight_per_head_raw = ing.get('weightPerHead', 0)
weight_per_head = float(weight_per_head_raw)
dry_matter_percent = float(ing.get('dryMatter', 0))
component_id = ing.get('component_id')
_calc_log(
"calculate_ingredients:weightPerHead:input",
idx=idx,
component_id=component_id,
weightPerHead_raw=weight_per_head_raw,
weightPerHead_raw_type=type(weight_per_head_raw).__name__,
weightPerHead_float=weight_per_head,
)
# Получаем dry_matter из компонента, если не указан и есть component_id
dm_source = "payload"
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
if component_id in component_dry_matter_map:
dry_matter_percent = component_dry_matter_map[component_id]
dm_source = "component_map"
# Расчеты
# weight = weightPerHead * headsCount
weight = weight_per_head * heads_count
# tripWeight = totalWeight * (tripPercent / 100)
trip_weight = weight * (trip_percent / 100)
# dryMatterPerHead = weightPerHead * (dryMatterPercent / 100)
dry_matter_per_head = weight_per_head * (dry_matter_percent / 100)
# Округляем до 2 знаков после запятой, но защищаем от обнуления очень маленьких значений
# Если значение очень маленькое (< 0.01), округляем до 3 знаков для сохранения точности
if weight_per_head > 0 and weight_per_head < 0.01:
rounded_wph = round(weight_per_head, 3)
else:
rounded_wph = round(weight_per_head, 2)
# СВ/гол до 4 знаков, чтобы при обратном расчёте (режим замок СВ) Вес/гол сохранялся до сотых
if dry_matter_per_head > 0 and dry_matter_per_head < 0.01:
rounded_dm_per_head = round(dry_matter_per_head, 4)
else:
rounded_dm_per_head = round(dry_matter_per_head, 4)
_calc_log(
"ingredient:calc",
idx=idx,
component_id=component_id,
dm_source=dm_source,
input={
"weightPerHead": weight_per_head,
"dryMatterPercent": dry_matter_percent,
"headsCount": heads_count,
"tripPercent": trip_percent,
},
formula={
"totalWeight": "weightPerHead * headsCount",
"tripWeight": "totalWeight * (tripPercent/100)",
"dryMatterPerHead": "weightPerHead * (dryMatterPercent/100)",
},
result={
"totalWeight": round(weight, 2),
"tripWeight": round(trip_weight, 2),
"dryMatterPerHead": rounded_dm_per_head,
"weightPerHead": rounded_wph,
},
)
_calc_log(
"calculate_ingredients:weightPerHead:output",
idx=idx,
component_id=component_id,
weightPerHead_before_round=weight_per_head,
rounded_wph=rounded_wph,
rounded_wph_type=type(rounded_wph).__name__,
)
calculated_ingredients.append({
'totalWeight': round(weight, 2),
'tripWeight': round(trip_weight, 2),
'dryMatterPerHead': rounded_dm_per_head,
'weightPerHead': rounded_wph
})
_calc_log("calculate_ingredients:end", ingredient_count=len(calculated_ingredients))
return calculated_ingredients
def calculate_totals(
calculated_ingredients: List[Dict[str, float]],
ingredients: List[Dict[str, Any]]
) -> Dict[str, float]:
"""
Рассчитывает общие итоги по ингредиентам
Args:
calculated_ingredients: Результаты расчета ингредиентов
ingredients: Исходные данные ингредиентов (используется как fallback)
Returns:
Словарь с итогами: totalWeight, totalTripWeight, totalDryMatterPerHead, totalWeightPerHead
"""
total_weight = 0
total_trip_weight = 0
total_dry_matter_per_head = 0
total_weight_per_head = 0
_calc_log("calculate_totals:start", ingredient_count=len(calculated_ingredients))
for i, calc in enumerate(calculated_ingredients):
total_weight += calc['totalWeight']
total_trip_weight += calc['tripWeight']
total_dry_matter_per_head += calc['dryMatterPerHead']
# weightPerHead теперь всегда есть в calculated_ingredients
if 'weightPerHead' in calc:
total_weight_per_head += calc['weightPerHead']
elif i < len(ingredients):
# Fallback на случай, если weightPerHead отсутствует
total_weight_per_head += float(ingredients[i].get('weightPerHead', 0))
totals = {
'totalWeight': round(total_weight, 2),
'totalTripWeight': round(total_trip_weight, 2),
'totalDryMatterPerHead': round(total_dry_matter_per_head, 2),
'totalWeightPerHead': round(total_weight_per_head, 2)
}
_calc_log("calculate_totals:end", totals=totals)
return totals
def calculate_unloading_groups(
unloading_groups: List[Dict[str, Any]],
total_trip_weight: float,
heads_count: int
) -> tuple[List[Dict[str, float]], Dict[str, Any]]:
"""
Рассчитывает веса для групп выгрузки
Args:
unloading_groups: Список групп выгрузки с полями distributionType, value
total_trip_weight: Общий вес на рейс
heads_count: Количество голов
Returns:
Кортеж: (список рассчитанных групп, итоги групп)
"""
calculated_groups = []
total_percent = 0
total_heads = 0
total_weight_kg = 0 # Суммируем неокругленные значения
for group in unloading_groups:
group_type = group.get('distributionType', 'percent')
value = float(group.get('value', 0))
calculated_weight = 0
if group_type == 'percent' and value > 0:
# Рассчитываем через процент от итогового веса
calculated_weight = (total_trip_weight * value) / 100
elif group_type == 'heads' and value > 0 and heads_count > 0:
# Рассчитываем через количество голов
calculated_weight = (total_trip_weight / heads_count) * value
# Округляем до кратного 5 для отображения в группе
calculated_weight_rounded = round_to_step5(calculated_weight)
calculated_groups.append({
'calculatedWeight': calculated_weight_rounded
})
if group_type == 'percent':
total_percent += value
else:
total_heads += value
# Суммируем неокругленные значения для итога
total_weight_kg += calculated_weight
# Округляем итог согласно формуле: W_groups_total_rounded = round(W_groups_total / 5) × 5
unloading_totals = {
'totalPercent': round(total_percent, 1),
'totalHeads': int(total_heads),
'totalWeightKg': round_to_step5(total_weight_kg) # Округляем итог
}
return calculated_groups, unloading_totals
def calculate_recipe(
ingredients: List[Dict[str, Any]],
heads_count: int,
trip_percent: float,
unloading_groups: Optional[List[Dict[str, Any]]] = None,
component_dry_matter_map: Optional[Dict[str, float]] = None,
calculate_from_dry_matter: bool = False
) -> Dict[str, Any]:
"""
Главная функция для расчета всего рецепта
Args:
ingredients: Список ингредиентов
heads_count: Количество голов
trip_percent: Процент на рейс
unloading_groups: Список групп выгрузки (опционально)
component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально)
calculate_from_dry_matter: Если True, рассчитывает от сухого вещества (обратный расчет)
Returns:
Словарь с результатами расчетов
"""
_calc_log(
"calculate_recipe:start",
heads_count=heads_count,
trip_percent=trip_percent,
calculate_from_dry_matter=calculate_from_dry_matter,
ingredient_count=len(ingredients or []),
unloading_group_count=len(unloading_groups or []),
)
if unloading_groups is None:
unloading_groups = []
# Расчеты для ингредиентов
if calculate_from_dry_matter:
# Обратный расчет от сухого вещества
calculated_ingredients = calculate_ingredients_from_dry_matter(
ingredients, heads_count, trip_percent, component_dry_matter_map
)
else:
# Обычный расчет от веса на голову
calculated_ingredients = calculate_ingredients(
ingredients, heads_count, trip_percent, component_dry_matter_map
)
# Общие итоги
totals = calculate_totals(calculated_ingredients, ingredients)
# Расчеты для групп выгрузки
calculated_groups, unloading_totals = calculate_unloading_groups(
unloading_groups, totals['totalTripWeight'], heads_count
)
result = {
'ingredients': calculated_ingredients,
'totals': totals,
'unloadingGroups': calculated_groups,
'unloadingTotals': unloading_totals
}
_calc_log("calculate_recipe:end", totals=totals, unloadingTotals=unloading_totals)
return result
def calculate_ingredients_from_dry_matter(
ingredients: List[Dict[str, Any]],
heads_count: int,
trip_percent: float,
component_dry_matter_map: Optional[Dict[str, float]] = None
) -> List[Dict[str, float]]:
"""
Рассчитывает веса от сухого вещества (обратный расчет)
Args:
ingredients: Список ингредиентов с полями dryMatterPerHead, dryMatter, component_id
heads_count: Количество голов
trip_percent: Процент на рейс (0-100)
component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально)
Returns:
Список словарей с рассчитанными значениями: totalWeight, tripWeight, weightPerHead, dryMatterPerHead
"""
calculated_ingredients = []
_calc_log(
"calculate_ingredients_from_dry_matter:start",
heads_count=heads_count,
trip_percent=trip_percent,
ingredient_count=len(ingredients),
)
for idx, ing in enumerate(ingredients, 1):
dry_matter_per_head_raw = ing.get('dryMatterPerHead', 0)
dry_matter_per_head = float(dry_matter_per_head_raw)
dry_matter_percent = float(ing.get('dryMatter', 0))
component_id = ing.get('component_id')
_calc_log(
"calculate_ingredients_from_dry_matter:input",
idx=idx,
component_id=component_id,
dryMatterPerHead_raw=dry_matter_per_head_raw,
dryMatterPercent=dry_matter_percent,
)
# Получаем dry_matter из компонента, если не указан и есть component_id
dm_source = "payload"
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
if component_id in component_dry_matter_map:
dry_matter_percent = component_dry_matter_map[component_id]
dm_source = "component_map"
# Обратные расчеты от сухого вещества
# Используем более точную проверку с учетом погрешности округления
EPSILON = 1e-6 # Порог для сравнения с нулем
if dry_matter_percent > EPSILON:
# weightPerHead = dryMatterPerHead / (dryMatterPercent / 100)
# Или: weightPerHead = (dryMatterPerHead * 100) / dryMatterPercent
weight_per_head = (dry_matter_per_head * 100) / dry_matter_percent
# ЗАЩИТА: если результат получился очень маленьким (< 0.01),
# но dry_matter_per_head был значимым, сохраняем минимум 0.01
# Это предотвращает обнуление из-за округления
if weight_per_head < 0.01 and dry_matter_per_head >= 0.001:
weight_per_head = 0.01
else:
# ЗАЩИТА: если dry_matter_percent равен 0, но есть dry_matter_per_head,
# не обнуляем веса - это может быть ошибка данных
# В этом случае возвращаем 0, но логируем предупреждение
if dry_matter_per_head > EPSILON:
_calc_log(
"ingredient:warning_zero_dm_percent",
idx=idx,
component_id=component_id,
dry_matter_per_head=dry_matter_per_head,
message="dry_matter_percent равен 0, но dry_matter_per_head > 0 - веса обнулены"
)
weight_per_head = 0
# Расчеты
# totalWeight = weightPerHead * headsCount
weight = weight_per_head * heads_count
# tripWeight = totalWeight * (tripPercent / 100)
trip_weight = weight * (trip_percent / 100)
_calc_log(
"ingredient:inverse_calc",
idx=idx,
component_id=component_id,
dm_source=dm_source,
input={
"dryMatterPerHead": dry_matter_per_head,
"dryMatterPercent": dry_matter_percent,
"headsCount": heads_count,
"tripPercent": trip_percent,
},
formula={
"weightPerHead": "(dryMatterPerHead * 100) / dryMatterPercent",
"totalWeight": "weightPerHead * headsCount",
"tripWeight": "totalWeight * (tripPercent/100)",
},
result={
"weightPerHead": round(weight_per_head, 2),
"totalWeight": round(weight, 2),
"tripWeight": round(trip_weight, 2),
"dryMatterPerHead": round(dry_matter_per_head, 2),
},
)
# Округляем до 2 знаков после запятой, но для очень маленьких значений используем 3 знака
# чтобы избежать обнуления из-за округления
if weight_per_head > 0 and weight_per_head < 0.01:
rounded_wph = round(weight_per_head, 3)
else:
rounded_wph = round(weight_per_head, 2)
_calc_log(
"calculate_ingredients_from_dry_matter:weightPerHead:output",
idx=idx,
component_id=component_id,
formula="(dryMatterPerHead*100)/dryMatterPercent",
weightPerHead_before_round=weight_per_head,
rounded_wph=rounded_wph,
rounded_wph_type=type(rounded_wph).__name__,
)
calculated_ingredients.append({
'totalWeight': round(weight, 2),
'tripWeight': round(trip_weight, 2),
'weightPerHead': rounded_wph,
'dryMatterPerHead': round(dry_matter_per_head, 4) # до 4 знаков, чтобы при обратном расчёте Вес/гол сохранялся до сотых
})
_calc_log("calculate_ingredients_from_dry_matter:end", ingredient_count=len(calculated_ingredients))
return calculated_ingredients