Files
site/apps/api/app/modules/zootech/recipe_calculator.py
T

373 lines
12 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Модуль для расчетов рецептов кормления.
Перенесен из корневого recipe_calculator.py в пакет app.services
без изменения алгоритмов.
"""
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 кг
"""
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]]:
"""
Рассчитывает веса и сухое вещество для ингредиентов
"""
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 = weight_per_head * heads_count
trip_weight = weight * (trip_percent / 100)
dry_matter_per_head = weight_per_head * (dry_matter_percent / 100)
if 0 < weight_per_head < 0.01:
rounded_wph = round(weight_per_head, 3)
else:
rounded_wph = round(weight_per_head, 2)
if 0 < 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]:
"""
Рассчитывает общие итоги по ингредиентам
"""
total_weight = 0.0
total_trip_weight = 0.0
total_dry_matter_per_head = 0.0
total_weight_per_head = 0.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"]
if "weightPerHead" in calc:
total_weight_per_head += calc["weightPerHead"]
elif i < len(ingredients):
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]]:
"""
Рассчитывает веса для групп выгрузки.
"""
calculated_groups: List[Dict[str, float]] = []
total_percent = 0.0
total_heads = 0.0
total_weight_kg = 0.0
for group in unloading_groups:
group_type = group.get("distributionType", "percent")
value = float(group.get("value", 0))
calculated_weight = 0.0
if group_type == "percent" and value > 0:
calculated_weight = (total_trip_weight * value) / 100.0
elif group_type == "heads" and value > 0 and heads_count > 0:
calculated_weight = (total_trip_weight / heads_count) * value
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
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]:
"""
Главная функция для расчета всего рецепта.
"""
_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: Dict[str, Any] = {
"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]]:
"""
Рассчитывает веса от сухого вещества (обратный расчет).
"""
calculated_ingredients: List[Dict[str, float]] = []
_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,
)
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:
weight_per_head = (dry_matter_per_head * 100.0) / dry_matter_percent
if weight_per_head < 0.01 and dry_matter_per_head >= 0.001:
weight_per_head = 0.01
else:
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.0
weight = weight_per_head * heads_count
trip_weight = weight * (trip_percent / 100.0)
_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,
},
)
calculated_ingredients.append(
{
"totalWeight": round(weight, 2),
"tripWeight": round(trip_weight, 2),
"weightPerHead": round(weight_per_head, 2),
"dryMatterPerHead": round(dry_matter_per_head, 4),
}
)
_calc_log(
"calculate_ingredients_from_dry_matter:end",
ingredient_count=len(calculated_ingredients),
)
return calculated_ingredients