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