Files
Ai/dashboard_data/cards/farm_alerts_tracker.py
T
2026-03-22 03:08:27 +03:30

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],
}