458 lines
19 KiB
Python
458 lines
19 KiB
Python
#!/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 |