@@ -0,0 +1,458 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user