Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+458
View File
@@ -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