373 lines
12 KiB
Python
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
|
|
|