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