561 lines
25 KiB
Python
561 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from django.utils import timezone
|
|
|
|
from dashboard_data.card_utils import safe_number
|
|
|
|
|
|
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
SEVERITY_UI = {
|
|
"low": {"avatarColor": "info", "chipColor": "info"},
|
|
"medium": {"avatarColor": "warning", "chipColor": "warning"},
|
|
"high": {"avatarColor": "error", "chipColor": "error"},
|
|
"critical": {"avatarColor": "error", "chipColor": "error"},
|
|
}
|
|
METRIC_META = {
|
|
"moisture": {
|
|
"title": "تنش رطوبتی",
|
|
"icon": "tabler-droplet-half-2",
|
|
"unit": "%",
|
|
"domain": "water_balance",
|
|
"threshold": 45.0,
|
|
"danger_span": 20.0,
|
|
"direction": "below",
|
|
},
|
|
"temperature": {
|
|
"title": "تنش دمایی",
|
|
"icon": "tabler-snowflake",
|
|
"unit": "°C",
|
|
"domain": "temperature_stress",
|
|
"threshold": 0.0,
|
|
"danger_span": 8.0,
|
|
"direction": "below",
|
|
},
|
|
"ph": {
|
|
"title": "عدم تعادل pH",
|
|
"icon": "tabler-flask",
|
|
"unit": "pH",
|
|
"domain": "root_chemistry",
|
|
"threshold_low": 6.0,
|
|
"threshold_high": 7.5,
|
|
"danger_span": 1.5,
|
|
},
|
|
"ec": {
|
|
"title": "شوری / EC بالا",
|
|
"icon": "tabler-bolt",
|
|
"unit": "dS/m",
|
|
"domain": "root_chemistry",
|
|
"threshold": 3.0,
|
|
"danger_span": 2.0,
|
|
"direction": "above",
|
|
},
|
|
"fungal_risk": {
|
|
"title": "ریسک قارچی",
|
|
"icon": "tabler-mushroom",
|
|
"unit": "%",
|
|
"domain": "disease_pressure",
|
|
"threshold": 70.0,
|
|
"danger_span": 20.0,
|
|
"direction": "above",
|
|
},
|
|
}
|
|
|
|
SUMMARY_TEMPLATES = {
|
|
"moisture": {
|
|
"low": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد.",
|
|
"medium": "رطوبت خاک پایینتر از محدوده مطلوب است و برنامه آبیاری باید بازبینی شود.",
|
|
"high": "تنش آبی قابلتوجه شناسایی شده و مزرعه به اقدام آبیاری سریع نیاز دارد.",
|
|
"critical": "کمبود شدید رطوبت فعال است و خطر افت رشد یا آسیب ریشه بالا رفته است.",
|
|
},
|
|
"temperature": {
|
|
"low": "دمای پایین ثبت شده و باید روند شبانه پایش شود.",
|
|
"medium": "ریسک سرمازدگی ایجاد شده و اقدامات محافظتی باید آماده شود.",
|
|
"high": "سرما به محدوده پرخطر رسیده و حفاظت دمایی باید در اولویت باشد.",
|
|
"critical": "یخبندان بحرانی پیشبینی یا مشاهده شده و اقدام فوری حفاظتی لازم است.",
|
|
},
|
|
"ph": {
|
|
"low": "pH از محدوده مطلوب فاصله گرفته و نیاز به بررسی اصلاحی دارد.",
|
|
"medium": "عدم تعادل pH میتواند جذب عناصر را مختل کند و باید اصلاح شود.",
|
|
"high": "انحراف pH شدید است و ریسک اختلال تغذیه گیاه بالا رفته است.",
|
|
"critical": "pH در وضعیت بحرانی قرار دارد و مداخله سریع برای جلوگیری از تنش تغذیهای لازم است.",
|
|
},
|
|
"ec": {
|
|
"low": "EC بالاتر از حد مرجع است و باید روند شوری پیگیری شود.",
|
|
"medium": "شوری خاک میتواند رشد را محدود کند و نیاز به تعدیل دارد.",
|
|
"high": "EC بالا به سطح پرخطر رسیده و مدیریت شوری باید انجام شود.",
|
|
"critical": "شوری بحرانی فعال است و احتمال آسیب ریشه و افت جذب آب بسیار بالاست.",
|
|
},
|
|
"fungal_risk": {
|
|
"low": "شرایط اولیه برای فشار بیماری قارچی مشاهده شده است.",
|
|
"medium": "رطوبت و خیسماندگی بستر، ریسک بیماری قارچی را افزایش داده است.",
|
|
"high": "فشار بیماری قارچی بالا است و عملیات پیشگیرانه باید در اولویت قرار گیرد.",
|
|
"critical": "الگوی بسیار پرخطر بیماری قارچی فعال است و اقدام فوری محافظتی لازم است.",
|
|
},
|
|
}
|
|
|
|
ACTION_TEMPLATES = {
|
|
"moisture": {
|
|
"low": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.",
|
|
"medium": "یک نوبت آبیاری اصلاحی برنامهریزی و افت رطوبت در عمقهای مختلف پایش شود.",
|
|
"high": "آبیاری جبرانی کوتاهمدت اجرا و راندمان روش آبیاری بازبینی شود.",
|
|
"critical": "آبیاری اضطراری، بررسی انسداد سامانه و پایش مجدد سنسور فوراً انجام شود.",
|
|
},
|
|
"temperature": {
|
|
"low": "پوشش یا برنامه محافظتی شبانه آماده نگه داشته شود.",
|
|
"medium": "زمانبندی آبیاری و پوشش حفاظتی برای ساعات سرد تنظیم شود.",
|
|
"high": "اقدامات ضدیخبندان مانند آبیاری حفاظتی یا پوشش فوری اجرا شود.",
|
|
"critical": "پروتکل کامل حفاظت سرما فوراً فعال و وضعیت مزرعه در چند ساعت بعدی بازبینی شود.",
|
|
},
|
|
"ph": {
|
|
"low": "نمونهبرداری تکمیلی انجام و روند pH برای چند قرائت بعدی کنترل شود.",
|
|
"medium": "برنامه اصلاح pH با توجه به نوع خاک و کود مصرفی بازتنظیم شود.",
|
|
"high": "اصلاحکننده مناسب خاک در اولویت قرار گیرد و تغذیه گیاه بازبینی شود.",
|
|
"critical": "مداخله اصلاحی فوری برای pH انجام و مصرف نهادههای تشدیدکننده متوقف شود.",
|
|
},
|
|
"ec": {
|
|
"low": "منبع آب و روند EC در روزهای آینده کنترل شود.",
|
|
"medium": "شستوشوی محدود خاک یا اصلاح برنامه کوددهی بررسی شود.",
|
|
"high": "کاهش بار نمکی، بازبینی کوددهی و ارزیابی زهکشی در اولویت قرار گیرد.",
|
|
"critical": "اقدام فوری برای کاهش شوری و توقف نهادههای شورکننده انجام شود.",
|
|
},
|
|
"fungal_risk": {
|
|
"low": "تهویه و رطوبت بستر پایش شود و نشانههای اولیه بیماری بررسی گردد.",
|
|
"medium": "فاصله آبیاری و تهویه مزرعه تنظیم و بازدید بیماری انجام شود.",
|
|
"high": "اقدامات پیشگیرانه بیماری و کاهش رطوبت ماندگار فوراً اجرا شود.",
|
|
"critical": "پروتکل فوری مدیریت بیماری فعال و مزرعه از نظر آلودگی کانونی بررسی شود.",
|
|
},
|
|
}
|
|
|
|
EXPLANATION_TEMPLATES = {
|
|
"moisture": {
|
|
"low": "رطوبت فعلی {current_value}{unit} به زیر آستانه {threshold_value}{unit} رسیده است و این وضعیت {duration_text} ادامه داشته است.",
|
|
"medium": "رطوبت خاک {current_value}{unit} است؛ فاصله از آستانه {threshold_value}{unit} و تداوم {duration_text} نشاندهنده تنش آبی است.",
|
|
"high": "رطوبت خاک در {current_value}{unit} ثبت شده که بهطور معنیدار پایینتر از آستانه {threshold_value}{unit} است و {duration_text} پایدار مانده است.",
|
|
"critical": "رطوبت خاک به {current_value}{unit} سقوط کرده و با عبور شدید از آستانه {threshold_value}{unit}، {duration_text} در وضعیت بحرانی باقی مانده است.",
|
|
},
|
|
"temperature": {
|
|
"low": "دما به {current_value}{unit} رسیده که از حد هشدار {threshold_value}{unit} پایینتر است و {duration_text} تداوم داشته است.",
|
|
"medium": "دمای ثبتشده {current_value}{unit} کمتر از آستانه {threshold_value}{unit} است و تداوم {duration_text} ریسک تنش سرما را بالا برده است.",
|
|
"high": "افت دما تا {current_value}{unit} همراه با ماندگاری {duration_text} شرایط پرخطر سرما را ایجاد کرده است.",
|
|
"critical": "دمای {current_value}{unit} با ماندگاری {duration_text} نشان میدهد مزرعه در معرض یخبندان بحرانی قرار دارد.",
|
|
},
|
|
"ph": {
|
|
"low": "pH فعلی {current_value}{unit} از محدوده مرجع {threshold_value} خارج شده و این انحراف {duration_text} ادامه داشته است.",
|
|
"medium": "انحراف pH تا {current_value}{unit} نسبت به حد مجاز {threshold_value} همراه با تداوم {duration_text} میتواند جذب عناصر را مختل کند.",
|
|
"high": "pH {current_value}{unit} با فاصله زیاد از محدوده مرجع و پایداری {duration_text} یک تنش شیمیایی مهم ایجاد کرده است.",
|
|
"critical": "وضعیت بحرانی pH در سطح {current_value}{unit} و با تداوم {duration_text} نیاز به اصلاح فوری دارد.",
|
|
},
|
|
"ec": {
|
|
"low": "EC فعلی {current_value}{unit} از آستانه {threshold_value}{unit} عبور کرده و {duration_text} پایدار مانده است.",
|
|
"medium": "EC برابر {current_value}{unit} است؛ عبور از حد {threshold_value}{unit} با ماندگاری {duration_text} فشار شوری را افزایش داده است.",
|
|
"high": "شوری ثبتشده در {current_value}{unit} با تداوم {duration_text} به سطح پرخطر رسیده است.",
|
|
"critical": "EC در {current_value}{unit} و با پایداری {duration_text} نشاندهنده شوری بحرانی خاک است.",
|
|
},
|
|
"fungal_risk": {
|
|
"low": "رطوبت هوا و خاک شرایط اولیه فشار قارچی را ایجاد کرده و این الگو {duration_text} ادامه داشته است.",
|
|
"medium": "ترکیب رطوبت {current_value}{unit} و ماندگاری {duration_text} از آستانه {threshold_value}{unit} عبور کرده و ریسک قارچی را بالا برده است.",
|
|
"high": "شرایط مرطوب پایدار در {current_value}{unit} و تداوم {duration_text} فشار قارچی جدی ایجاد کرده است.",
|
|
"critical": "ماندگاری طولانی شرایط بسیار مرطوب ({current_value}{unit}) در برابر حد {threshold_value}{unit} نشاندهنده ریسک بحرانی بیماری قارچی است.",
|
|
},
|
|
}
|
|
|
|
CLUSTER_TITLES = {
|
|
"water_balance": "تعادل آب",
|
|
"temperature_stress": "تنش دمایی",
|
|
"root_chemistry": "شیمی ناحیه ریشه",
|
|
"disease_pressure": "فشار بیماری",
|
|
}
|
|
|
|
|
|
def _now() -> datetime:
|
|
return timezone.now()
|
|
|
|
|
|
def _timestamp_for(obj: Any, fallback: datetime) -> datetime:
|
|
for attr in ("recorded_at", "updated_at", "created_at", "forecast_date"):
|
|
value = getattr(obj, attr, None)
|
|
if value is not None:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
return datetime.combine(value, datetime.min.time(), tzinfo=fallback.tzinfo)
|
|
return fallback
|
|
|
|
|
|
def _format_timestamp(value: datetime) -> str:
|
|
if timezone.is_naive(value):
|
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
|
return value.isoformat()
|
|
|
|
|
|
def _format_duration(hours: float) -> str:
|
|
rounded = max(1, round(hours))
|
|
if rounded >= 24:
|
|
days = rounded // 24
|
|
rem_hours = rounded % 24
|
|
if rem_hours == 0:
|
|
return f"{days} روز"
|
|
return f"{days} روز و {rem_hours} ساعت"
|
|
return f"{rounded} ساعت"
|
|
|
|
|
|
def _severity_from_score(score: float) -> str:
|
|
if score >= 0.85:
|
|
return "critical"
|
|
if score >= 0.55:
|
|
return "high"
|
|
if score >= 0.3:
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _build_severity(distance_ratio: float, duration_hours: float) -> str:
|
|
duration_ratio = min(duration_hours / 72.0, 1.0)
|
|
score = min((distance_ratio * 0.7) + (duration_ratio * 0.3), 1.0)
|
|
return _severity_from_score(score)
|
|
|
|
|
|
def _collect_active_history_duration(
|
|
current_value: float,
|
|
history: list[Any],
|
|
field_name: str,
|
|
threshold: float,
|
|
direction: str,
|
|
fallback_timestamp: datetime,
|
|
) -> tuple[float, datetime]:
|
|
if direction == "below":
|
|
is_violating = lambda value: value < threshold
|
|
else:
|
|
is_violating = lambda value: value > threshold
|
|
|
|
if not is_violating(current_value):
|
|
return 0.0, fallback_timestamp
|
|
|
|
violating_times = [fallback_timestamp]
|
|
for item in history:
|
|
value = getattr(item, field_name, None)
|
|
if value is None:
|
|
break
|
|
if not is_violating(value):
|
|
break
|
|
violating_times.append(_timestamp_for(item, fallback_timestamp))
|
|
|
|
oldest_violation = min(violating_times)
|
|
duration_hours = max((_now() - oldest_violation).total_seconds() / 3600, 1.0)
|
|
return duration_hours, oldest_violation
|
|
|
|
|
|
def _make_alert(
|
|
metric_type: str,
|
|
current_value: float,
|
|
threshold_value: float | str,
|
|
severity: str,
|
|
duration_hours: float,
|
|
timestamp: datetime,
|
|
sensor_id: str,
|
|
zone_id: str | None = None,
|
|
direction: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
meta = METRIC_META[metric_type]
|
|
unit = meta["unit"]
|
|
threshold_display = threshold_value
|
|
if isinstance(threshold_value, float):
|
|
threshold_display = round(threshold_value, 2)
|
|
|
|
explanation = EXPLANATION_TEMPLATES[metric_type][severity].format(
|
|
current_value=round(current_value, 2),
|
|
threshold_value=threshold_display,
|
|
unit=unit,
|
|
duration_text=_format_duration(duration_hours),
|
|
)
|
|
return {
|
|
"metric_type": metric_type,
|
|
"title": meta["title"],
|
|
"current_value": round(current_value, 2),
|
|
"threshold_value": threshold_display,
|
|
"severity": severity,
|
|
"duration_hours": round(duration_hours, 1),
|
|
"duration": _format_duration(duration_hours),
|
|
"timestamp": _format_timestamp(timestamp),
|
|
"sensor_id": sensor_id,
|
|
"zone_id": zone_id,
|
|
"domain": meta["domain"],
|
|
"direction": direction,
|
|
"unit": unit,
|
|
"icon": meta["icon"],
|
|
"summary": SUMMARY_TEMPLATES[metric_type][severity],
|
|
"recommended_action": ACTION_TEMPLATES[metric_type][severity],
|
|
"explanation": explanation,
|
|
"metadata": metadata or {},
|
|
}
|
|
|
|
|
|
def _detect_moisture_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
|
|
current_value = safe_number(getattr(sensor, "soil_moisture", None), 0)
|
|
meta = METRIC_META["moisture"]
|
|
threshold = meta["threshold"]
|
|
if current_value >= threshold:
|
|
return []
|
|
|
|
timestamp = _timestamp_for(sensor, _now())
|
|
duration_hours, started_at = _collect_active_history_duration(
|
|
current_value=current_value,
|
|
history=history,
|
|
field_name="soil_moisture",
|
|
threshold=threshold,
|
|
direction=meta["direction"],
|
|
fallback_timestamp=timestamp,
|
|
)
|
|
distance_ratio = min((threshold - current_value) / meta["danger_span"], 1.0)
|
|
severity = _build_severity(distance_ratio, duration_hours)
|
|
return [
|
|
_make_alert(
|
|
metric_type="moisture",
|
|
current_value=current_value,
|
|
threshold_value=threshold,
|
|
severity=severity,
|
|
duration_hours=duration_hours,
|
|
timestamp=started_at,
|
|
sensor_id=sensor_id,
|
|
direction=meta["direction"],
|
|
)
|
|
]
|
|
|
|
|
|
def _detect_ph_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
|
|
current_value = safe_number(getattr(sensor, "soil_ph", None), 7)
|
|
meta = METRIC_META["ph"]
|
|
low = meta["threshold_low"]
|
|
high = meta["threshold_high"]
|
|
if low <= current_value <= high:
|
|
return []
|
|
|
|
direction = "below" if current_value < low else "above"
|
|
threshold = low if direction == "below" else high
|
|
timestamp = _timestamp_for(sensor, _now())
|
|
duration_hours, started_at = _collect_active_history_duration(
|
|
current_value=current_value,
|
|
history=history,
|
|
field_name="soil_ph",
|
|
threshold=threshold,
|
|
direction=direction,
|
|
fallback_timestamp=timestamp,
|
|
)
|
|
distance_ratio = min(abs(current_value - threshold) / meta["danger_span"], 1.0)
|
|
severity = _build_severity(distance_ratio, duration_hours)
|
|
threshold_display = f"{low}-{high}"
|
|
return [
|
|
_make_alert(
|
|
metric_type="ph",
|
|
current_value=current_value,
|
|
threshold_value=threshold_display,
|
|
severity=severity,
|
|
duration_hours=duration_hours,
|
|
timestamp=started_at,
|
|
sensor_id=sensor_id,
|
|
direction=direction,
|
|
metadata={"boundary_threshold": threshold},
|
|
)
|
|
]
|
|
|
|
|
|
def _detect_ec_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
|
|
current_value = safe_number(getattr(sensor, "electrical_conductivity", None), 0)
|
|
meta = METRIC_META["ec"]
|
|
threshold = meta["threshold"]
|
|
if current_value <= threshold:
|
|
return []
|
|
|
|
timestamp = _timestamp_for(sensor, _now())
|
|
duration_hours, started_at = _collect_active_history_duration(
|
|
current_value=current_value,
|
|
history=history,
|
|
field_name="electrical_conductivity",
|
|
threshold=threshold,
|
|
direction=meta["direction"],
|
|
fallback_timestamp=timestamp,
|
|
)
|
|
distance_ratio = min((current_value - threshold) / meta["danger_span"], 1.0)
|
|
severity = _build_severity(distance_ratio, duration_hours)
|
|
return [
|
|
_make_alert(
|
|
metric_type="ec",
|
|
current_value=current_value,
|
|
threshold_value=threshold,
|
|
severity=severity,
|
|
duration_hours=duration_hours,
|
|
timestamp=started_at,
|
|
sensor_id=sensor_id,
|
|
direction=meta["direction"],
|
|
)
|
|
]
|
|
|
|
|
|
def _detect_frost_alert(forecasts: list[Any], sensor_id: str) -> list[dict[str, Any]]:
|
|
violating = [forecast for forecast in forecasts[:3] if safe_number(getattr(forecast, "temperature_min", None), 10) < 0]
|
|
if not violating:
|
|
return []
|
|
|
|
first = violating[0]
|
|
coldest = min(safe_number(getattr(item, "temperature_min", None), 0) for item in violating)
|
|
duration_hours = max(len(violating) * 24.0, 24.0)
|
|
meta = METRIC_META["temperature"]
|
|
distance_ratio = min((meta["threshold"] - coldest) / meta["danger_span"], 1.0)
|
|
severity = _build_severity(distance_ratio, duration_hours)
|
|
timestamp = _timestamp_for(first, _now())
|
|
return [
|
|
_make_alert(
|
|
metric_type="temperature",
|
|
current_value=coldest,
|
|
threshold_value=meta["threshold"],
|
|
severity=severity,
|
|
duration_hours=duration_hours,
|
|
timestamp=timestamp,
|
|
sensor_id=sensor_id,
|
|
direction=meta["direction"],
|
|
metadata={"forecast_days_impacted": len(violating)},
|
|
)
|
|
]
|
|
|
|
|
|
def _detect_fungal_risk(sensor: Any, forecasts: list[Any], history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
|
|
humidity_values = [safe_number(getattr(forecast, "humidity_mean", None), None) for forecast in forecasts[:3]]
|
|
humidity_values = [value for value in humidity_values if value is not None]
|
|
if not humidity_values:
|
|
return []
|
|
|
|
humidity = sum(humidity_values) / len(humidity_values)
|
|
moisture = safe_number(getattr(sensor, "soil_moisture", None), 0)
|
|
meta = METRIC_META["fungal_risk"]
|
|
threshold = meta["threshold"]
|
|
if humidity <= threshold or moisture <= 60:
|
|
return []
|
|
|
|
timestamp = _timestamp_for(sensor, _now())
|
|
duration_hours, started_at = _collect_active_history_duration(
|
|
current_value=moisture,
|
|
history=history,
|
|
field_name="soil_moisture",
|
|
threshold=60.0,
|
|
direction="above",
|
|
fallback_timestamp=timestamp,
|
|
)
|
|
duration_hours = max(duration_hours, len(forecasts[:3]) * 12.0)
|
|
humidity_ratio = min((humidity - threshold) / meta["danger_span"], 1.0)
|
|
moisture_ratio = min((moisture - 60.0) / 20.0, 1.0)
|
|
severity = _build_severity((humidity_ratio * 0.6) + (moisture_ratio * 0.4), duration_hours)
|
|
return [
|
|
_make_alert(
|
|
metric_type="fungal_risk",
|
|
current_value=humidity,
|
|
threshold_value=threshold,
|
|
severity=severity,
|
|
duration_hours=duration_hours,
|
|
timestamp=started_at,
|
|
sensor_id=sensor_id,
|
|
direction=meta["direction"],
|
|
metadata={"soil_moisture": round(moisture, 2)},
|
|
)
|
|
]
|
|
|
|
|
|
def _sort_alerts(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return sorted(
|
|
alerts,
|
|
key=lambda alert: (
|
|
SEVERITY_ORDER[alert["severity"]],
|
|
alert["duration_hours"],
|
|
abs(float(alert["current_value"])) if isinstance(alert["current_value"], (int, float)) else 0,
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
def _build_clusters(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for alert in alerts:
|
|
grouped[alert["domain"]].append(alert)
|
|
|
|
clusters: list[dict[str, Any]] = []
|
|
for domain, items in grouped.items():
|
|
ordered = _sort_alerts(items)
|
|
top = ordered[0]
|
|
clusters.append(
|
|
{
|
|
"domain": domain,
|
|
"title": CLUSTER_TITLES.get(domain, domain),
|
|
"alert_count": len(items),
|
|
"highest_severity": top["severity"],
|
|
"primary_metric": top["metric_type"],
|
|
"summary": top["summary"],
|
|
"alert_ids": [f"{item['metric_type']}:{item['timestamp']}" for item in ordered],
|
|
}
|
|
)
|
|
return sorted(clusters, key=lambda cluster: SEVERITY_ORDER[cluster["highest_severity"]], reverse=True)
|
|
|
|
|
|
def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
stats: list[dict[str, Any]] = []
|
|
for metric_type, meta in METRIC_META.items():
|
|
matches = [alert for alert in alerts if alert["metric_type"] == metric_type]
|
|
if not matches:
|
|
continue
|
|
top = _sort_alerts(matches)[0]
|
|
ui = SEVERITY_UI[top["severity"]]
|
|
stats.append(
|
|
{
|
|
"title": meta["title"],
|
|
"count": str(len(matches)),
|
|
"avatarColor": ui["avatarColor"],
|
|
"avatarIcon": meta["icon"],
|
|
"severity": top["severity"],
|
|
"topSummary": top["summary"],
|
|
}
|
|
)
|
|
return stats
|
|
|
|
|
|
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
|
context = context or {}
|
|
sensor = context.get("sensor")
|
|
forecasts = context.get("forecasts", [])
|
|
history = context.get("history", [])
|
|
|
|
if sensor is None:
|
|
return {
|
|
"totalAlerts": 0,
|
|
"alerts": [],
|
|
"alertStats": [],
|
|
"alertClusters": [],
|
|
"mostCriticalIssue": None,
|
|
"prioritizedAlertSummaries": [],
|
|
"recommendedOperationalActions": [],
|
|
"humanReadableExplanations": [],
|
|
}
|
|
|
|
alerts = []
|
|
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
|
|
alerts.extend(_detect_ph_alert(sensor, history, sensor_id))
|
|
alerts.extend(_detect_ec_alert(sensor, history, sensor_id))
|
|
alerts.extend(_detect_frost_alert(forecasts, sensor_id))
|
|
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id))
|
|
|
|
ordered_alerts = _sort_alerts(alerts)
|
|
clusters = _build_clusters(ordered_alerts)
|
|
top_alert = ordered_alerts[0] if ordered_alerts else None
|
|
|
|
return {
|
|
"totalAlerts": len(ordered_alerts),
|
|
"alerts": ordered_alerts,
|
|
"alertStats": _build_alert_stats(ordered_alerts),
|
|
"alertClusters": clusters,
|
|
"mostCriticalIssue": top_alert,
|
|
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
|
|
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
|
|
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
|
|
}
|