Files
2026-07-17 12:57:18 +03:00

355 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Пороги и классификация отклонений feed_quality."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from app.services.feed_quality.settings_store import get_feed_quality_settings
EVENT_OVERLOAD = "OVERLOAD"
EVENT_UNDERLOAD = "UNDERLOAD"
EVENT_LOADING_TIME = "LOADING_TIME"
EVENT_LOADING_FAST = "LOADING_FAST"
EVENT_MIX_TIME = "MIX_TIME"
EVENT_LEFT_IN_MIXER = "LEFT_IN_MIXER"
TIME_ISSUE_FAST = "fast"
TIME_ISSUE_SLOW = "slow"
SEVERITY_INFO = "info"
SEVERITY_WARNING = "warning"
SEVERITY_ERROR = "error"
@dataclass(frozen=True)
class AlertCandidate:
event_type: str
severity: str
detail: str
component_name: Optional[str] = None
group_name: Optional[str] = None
deviation_kg: Optional[float] = None
deviation_pct: Optional[float] = None
cost_deviation_rub: Optional[float] = None
unloading_report_id: Optional[str] = None
def deviation_pct(dev_kg: float, target_kg: float) -> Optional[float]:
if target_kg <= 0:
return None
return (dev_kg / target_kg) * 100.0
def _settings() -> Dict[str, Any]:
return get_feed_quality_settings()
def _severity_from_pct(abs_pct: float, *, warning_pct: float, critical_pct: float) -> Optional[str]:
if abs_pct < warning_pct:
return None
if abs_pct >= critical_pct:
return SEVERITY_ERROR
return SEVERITY_WARNING
def _max_severity(a: str, b: str) -> str:
rank = {SEVERITY_WARNING: 1, SEVERITY_ERROR: 2}
return a if rank.get(a, 0) >= rank.get(b, 0) else b
@dataclass(frozen=True)
class ComponentTimeIssue:
kind: str
severity: str
duration_sec: float
detail: str
deviation_sec: float
def classify_loading_component(
*,
component_name: str,
target_kg: float,
actual_kg: float,
price_rub_per_kg: float = 0.0,
) -> List[AlertCandidate]:
cfg = _settings().get("loading") or {}
if not cfg.get("enabled", True):
return []
if target_kg <= 0:
return []
dev_kg = actual_kg - target_kg
pct = deviation_pct(dev_kg, target_kg)
if pct is None:
return []
severity = _severity_from_pct(
abs(pct),
warning_pct=float(cfg.get("warning_pct", 10.0)),
critical_pct=float(cfg.get("critical_pct", 15.0)),
)
if severity is None:
return []
if dev_kg > 0:
event_type = EVENT_OVERLOAD
sign = "+"
else:
event_type = EVENT_UNDERLOAD
sign = ""
cost = max(0.0, dev_kg) * max(0.0, price_rub_per_kg)
detail = (
f"Компонент «{component_name}»: план {target_kg:.1f} кг, факт {actual_kg:.1f} кг, "
f"отклонение {sign}{dev_kg:.1f} кг ({sign}{pct:.1f}%)"
)
return [
AlertCandidate(
event_type=event_type,
severity=severity,
detail=detail,
component_name=component_name,
deviation_kg=dev_kg,
deviation_pct=pct,
cost_deviation_rub=cost if cost > 0 else None,
)
]
def classify_component_loading_time(
*,
component_name: str,
duration_sec: Optional[float],
) -> Optional[ComponentTimeIssue]:
"""Слишком быстро (< min) или слишком долго (> max)."""
cfg = _settings().get("loading") or {}
if not cfg.get("enabled", True):
return None
if duration_sec is None:
return None
duration = float(duration_sec)
if duration < 0:
return None
warn_min = int(cfg.get("warning_min_sec", 3))
crit_min = int(cfg.get("critical_min_sec", 1))
warn_max = int(cfg.get("warning_max_sec", 180))
crit_max = int(cfg.get("critical_max_sec", 600))
if crit_min > 0 and duration < crit_min:
return ComponentTimeIssue(
kind=TIME_ISSUE_FAST,
severity=SEVERITY_ERROR,
duration_sec=duration,
detail=(
f"загрузка {duration:.1f} с — быстрее критичного порога {crit_min} с"
),
deviation_sec=crit_min - duration,
)
if warn_min > 0 and duration < warn_min:
return ComponentTimeIssue(
kind=TIME_ISSUE_FAST,
severity=SEVERITY_WARNING,
duration_sec=duration,
detail=(
f"загрузка {duration:.1f} с — быстрее порога предупреждения {warn_min} с"
),
deviation_sec=warn_min - duration,
)
if crit_max > 0 and duration > crit_max:
return ComponentTimeIssue(
kind=TIME_ISSUE_SLOW,
severity=SEVERITY_ERROR,
duration_sec=duration,
detail=(
f"загрузка {duration:.1f} с — дольше критичного порога {crit_max} с"
),
deviation_sec=duration - crit_max,
)
if warn_max > 0 and duration > warn_max:
return ComponentTimeIssue(
kind=TIME_ISSUE_SLOW,
severity=SEVERITY_WARNING,
duration_sec=duration,
detail=(
f"загрузка {duration:.1f} с — дольше порога предупреждения {warn_max} с"
),
deviation_sec=duration - warn_max,
)
return None
def _standalone_time_alert(
*,
component_name: str,
time_issue: ComponentTimeIssue,
) -> AlertCandidate:
event_type = (
EVENT_LOADING_FAST if time_issue.kind == TIME_ISSUE_FAST else EVENT_LOADING_TIME
)
return AlertCandidate(
event_type=event_type,
severity=time_issue.severity,
detail=f"Компонент «{component_name}»: {time_issue.detail}",
component_name=component_name,
deviation_kg=time_issue.deviation_sec,
deviation_pct=None,
)
def merge_component_loading_alerts(
*,
component_name: str,
weight_alert: Optional[AlertCandidate],
time_issue: Optional[ComponentTimeIssue],
) -> List[AlertCandidate]:
"""Вес + время — одна карточка; только время — отдельная (быстро или долго)."""
if weight_alert and time_issue:
merged_detail = f"{weight_alert.detail}; {time_issue.detail}"
return [
AlertCandidate(
event_type=weight_alert.event_type,
severity=_max_severity(weight_alert.severity, time_issue.severity),
detail=merged_detail,
component_name=component_name,
deviation_kg=weight_alert.deviation_kg,
deviation_pct=weight_alert.deviation_pct,
cost_deviation_rub=weight_alert.cost_deviation_rub,
)
]
if weight_alert:
return [weight_alert]
if time_issue:
return [_standalone_time_alert(component_name=component_name, time_issue=time_issue)]
return []
def classify_mix_time(
*,
target_sec: Optional[int],
actual_sec: Optional[int],
) -> List[AlertCandidate]:
cfg = _settings().get("mix_time") or {}
if not cfg.get("enabled", True):
return []
if target_sec is None or actual_sec is None:
return []
diff = int(actual_sec) - int(target_sec)
abs_diff = abs(diff)
warn = int(cfg.get("warning_delta_sec", 30))
crit = int(cfg.get("critical_delta_sec", 60))
if abs_diff < warn:
return []
severity = SEVERITY_ERROR if abs_diff >= crit else SEVERITY_WARNING
detail = (
f"Время смешивания: план {target_sec} с, факт {actual_sec} с{diff:+d} с)"
)
return [
AlertCandidate(
event_type=EVENT_MIX_TIME,
severity=severity,
detail=detail,
deviation_kg=float(diff),
deviation_pct=None,
)
]
def classify_unloading_group(
*,
group_name: str,
target_kg: float,
unloaded_kg: float,
) -> List[AlertCandidate]:
cfg = _settings().get("unloading") or {}
if not cfg.get("enabled", True):
return []
if target_kg <= 0:
return []
dev_kg = unloaded_kg - target_kg
pct = deviation_pct(dev_kg, target_kg)
if pct is None:
return []
severity = _severity_from_pct(
abs(pct),
warning_pct=float(cfg.get("warning_pct", 5.0)),
critical_pct=float(cfg.get("critical_pct", 10.0)),
)
if severity is None:
return []
if dev_kg > 0:
event_type = EVENT_OVERLOAD
sign = "+"
else:
event_type = EVENT_UNDERLOAD
sign = ""
detail = (
f"Группа «{group_name}»: план {target_kg:.1f} кг, выгружено {unloaded_kg:.1f} кг, "
f"отклонение {sign}{dev_kg:.1f} кг ({sign}{pct:.1f}%)"
)
return [
AlertCandidate(
event_type=event_type,
severity=severity,
detail=detail,
group_name=group_name,
deviation_kg=dev_kg,
deviation_pct=pct,
unloading_report_id=None,
)
]
def classify_left_in_mixer(
*,
remaining_kg: float,
total_kg: float,
unloading_report_id: Optional[str] = None,
) -> List[AlertCandidate]:
cfg = _settings().get("left_in_mixer") or {}
if not cfg.get("enabled", True):
return []
warn_kg = float(cfg.get("warning_min_kg", 5.0))
warn_pct = float(cfg.get("warning_min_pct", 2.0))
crit_kg = float(cfg.get("critical_min_kg", 15.0))
pct_of_total = deviation_pct(remaining_kg, total_kg) if total_kg > 0 else None
if remaining_kg >= crit_kg:
severity = SEVERITY_ERROR
elif remaining_kg >= warn_kg or (
pct_of_total is not None and pct_of_total >= warn_pct
):
severity = SEVERITY_WARNING
else:
return []
pct_text = f" ({pct_of_total:.1f}% от партии)" if pct_of_total is not None else ""
detail = f"В миксере осталось {remaining_kg:.1f} кг после выгрузки{pct_text}"
return [
AlertCandidate(
event_type=EVENT_LEFT_IN_MIXER,
severity=severity,
detail=detail,
deviation_kg=remaining_kg,
deviation_pct=pct_of_total,
unloading_report_id=unloading_report_id,
)
]
def settings_section_for_alert(
event_type: str, *, component_name: Optional[str], group_name: Optional[str]
) -> str:
if event_type == EVENT_MIX_TIME:
return "mix_time"
if event_type == EVENT_LEFT_IN_MIXER:
return "left_in_mixer"
if group_name and event_type in (EVENT_OVERLOAD, EVENT_UNDERLOAD):
return "unloading"
if event_type in (
EVENT_OVERLOAD,
EVENT_UNDERLOAD,
EVENT_LOADING_TIME,
EVENT_LOADING_FAST,
):
return "loading"
return "loading"