@@ -0,0 +1,354 @@
|
||||
"""Пороги и классификация отклонений 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"
|
||||
Reference in New Issue
Block a user