From 3ee14ca97724e7f9b3c38e30f78cd7be722597f4 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 22 Mar 2026 01:09:09 +0330 Subject: [PATCH] AI UPDATE --- config/settings.py | 2 + config/urls.py | 1 + dashboard_data/__init__.py | 1 + dashboard_data/ai_bundle.py | 17 +++ dashboard_data/apps.py | 8 ++ dashboard_data/card_utils.py | 81 ++++++++++++++ dashboard_data/cards/__init__.py | 1 + .../cards/anomaly_detection_card.py | 34 ++++++ dashboard_data/cards/economic_overview.py | 50 +++++++++ dashboard_data/cards/farm_alerts_timeline.py | 3 + dashboard_data/cards/farm_alerts_tracker.py | 41 +++++++ dashboard_data/cards/farm_overview_kpis.py | 83 ++++++++++++++ dashboard_data/cards/farm_weather_card.py | 32 ++++++ .../cards/harvest_prediction_card.py | 26 +++++ dashboard_data/cards/ndvi_health_card.py | 30 +++++ dashboard_data/cards/recommendations_list.py | 3 + .../cards/sensor_comparison_chart.py | 35 ++++++ dashboard_data/cards/sensor_radar_chart.py | 37 ++++++ dashboard_data/cards/sensor_values_list.py | 71 ++++++++++++ dashboard_data/cards/soil_moisture_heatmap.py | 32 ++++++ dashboard_data/cards/water_need_prediction.py | 18 +++ .../cards/yield_prediction_chart.py | 38 +++++++ dashboard_data/context.py | 40 +++++++ dashboard_data/migrations/0001_initial.py | 47 ++++++++ dashboard_data/migrations/__init__.py | 1 + dashboard_data/models.py | 38 +++++++ dashboard_data/services.py | 105 ++++++++++++++++++ dashboard_data/tasks.py | 36 ++++++ dashboard_data/urls.py | 9 ++ dashboard_data/views.py | 91 +++++++++++++++ 30 files changed, 1011 insertions(+) create mode 100644 dashboard_data/__init__.py create mode 100644 dashboard_data/ai_bundle.py create mode 100644 dashboard_data/apps.py create mode 100644 dashboard_data/card_utils.py create mode 100644 dashboard_data/cards/__init__.py create mode 100644 dashboard_data/cards/anomaly_detection_card.py create mode 100644 dashboard_data/cards/economic_overview.py create mode 100644 dashboard_data/cards/farm_alerts_timeline.py create mode 100644 dashboard_data/cards/farm_alerts_tracker.py create mode 100644 dashboard_data/cards/farm_overview_kpis.py create mode 100644 dashboard_data/cards/farm_weather_card.py create mode 100644 dashboard_data/cards/harvest_prediction_card.py create mode 100644 dashboard_data/cards/ndvi_health_card.py create mode 100644 dashboard_data/cards/recommendations_list.py create mode 100644 dashboard_data/cards/sensor_comparison_chart.py create mode 100644 dashboard_data/cards/sensor_radar_chart.py create mode 100644 dashboard_data/cards/sensor_values_list.py create mode 100644 dashboard_data/cards/soil_moisture_heatmap.py create mode 100644 dashboard_data/cards/water_need_prediction.py create mode 100644 dashboard_data/cards/yield_prediction_chart.py create mode 100644 dashboard_data/context.py create mode 100644 dashboard_data/migrations/0001_initial.py create mode 100644 dashboard_data/migrations/__init__.py create mode 100644 dashboard_data/models.py create mode 100644 dashboard_data/services.py create mode 100644 dashboard_data/tasks.py create mode 100644 dashboard_data/urls.py create mode 100644 dashboard_data/views.py diff --git a/config/settings.py b/config/settings.py index c2a3467..9f0bb76 100644 --- a/config/settings.py +++ b/config/settings.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ "corsheaders", "drf_spectacular", "drf_spectacular_sidecar", + "dashboard_data", "rag", "tasks", "location_data", @@ -119,6 +120,7 @@ SPECTACULAR_SETTINGS = { "REDOC_DIST": "SIDECAR", "COMPONENT_SPLIT_REQUEST": True, "TAGS": [ + {"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"}, {"name": "RAG Chat", "description": "چت هوشمند RAG"}, {"name": "Tasks", "description": "مدیریت تسک‌های Celery"}, {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, diff --git a/config/urls.py b/config/urls.py index 37d9074..bb764f1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), # --- App APIs --- path("api/rag/", include("rag.urls")), + path("api/dashboard-data/", include("dashboard_data.urls")), path("api/tasks/", include("tasks.urls")), path("api/soil-data/", include("location_data.urls")), path("api/sensor-data/", include("sensor_data.urls")), diff --git a/dashboard_data/__init__.py b/dashboard_data/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/dashboard_data/__init__.py @@ -0,0 +1 @@ + diff --git a/dashboard_data/ai_bundle.py b/dashboard_data/ai_bundle.py new file mode 100644 index 0000000..9605d9a --- /dev/null +++ b/dashboard_data/ai_bundle.py @@ -0,0 +1,17 @@ +from .models import DashboardAiRequestLog + + +def request_dashboard_ai_bundle(sensor_id: str, payload: dict) -> dict: + log = DashboardAiRequestLog.objects.create( + sensor_id=sensor_id, + request_payload=payload, + response_payload={}, + status="pending", + ) + return { + "log_id": log.id, + "timeline": [], + "recommendations": [], + "alerts": [], + } + diff --git a/dashboard_data/apps.py b/dashboard_data/apps.py new file mode 100644 index 0000000..146b272 --- /dev/null +++ b/dashboard_data/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class DashboardDataConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "dashboard_data" + verbose_name = "Dashboard Data" + diff --git a/dashboard_data/card_utils.py b/dashboard_data/card_utils.py new file mode 100644 index 0000000..5ad107a --- /dev/null +++ b/dashboard_data/card_utils.py @@ -0,0 +1,81 @@ +from datetime import timedelta + +from django.utils import timezone + + +CARD_TTLS = { + "farmOverviewKpis": timedelta(days=3), + "farmWeatherCard": timedelta(days=1), + "farmAlertsTracker": timedelta(days=1), + "sensorValuesList": timedelta(days=1), + "sensorRadarChart": timedelta(days=1), + "sensorComparisonChart": timedelta(days=1), + "anomalyDetectionCard": timedelta(days=1), + "farmAlertsTimeline": timedelta(days=1), + "waterNeedPrediction": timedelta(days=1), + "harvestPredictionCard": timedelta(days=1), + "yieldPredictionChart": timedelta(days=1), + "soilMoistureHeatmap": timedelta(days=1), + "ndviHealthCard": timedelta(days=1), + "recommendationsList": timedelta(days=1), + "economicOverview": timedelta(days=1), +} + + +WMO_CONDITIONS = { + 0: "صاف", + 1: "عمدتاً صاف", + 2: "نیمه‌ابری", + 3: "ابری", + 45: "مه", + 48: "مه یخ‌زده", + 51: "نم‌نم باران", + 61: "بارش خفیف", + 63: "بارش متوسط", + 65: "بارش شدید", + 71: "برف خفیف", + 80: "رگبار", + 95: "رعد و برق", +} + +PERSIAN_WEEKDAYS = ["دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه", "یکشنبه"] + + +def ttl_for_card(card_name: str): + return CARD_TTLS[card_name] + + +def is_fresh(snapshot) -> bool: + return snapshot and snapshot.expires_at > timezone.now() + + +def safe_number(value, default=0): + return default if value is None else value + + +def average(values, default=0): + clean_values = [value for value in values if value is not None] + if not clean_values: + return default + return sum(clean_values) / len(clean_values) + + +def latest_history_value(history, field_name, default=None): + if not history: + return default + return getattr(history[0], field_name, default) + + +def compute_trend(current, previous): + current_value = safe_number(current, 0) + previous_value = safe_number(previous, current_value) + diff = round(current_value - previous_value, 1) + return { + "trendNumber": diff, + "trend": "positive" if diff >= 0 else "negative", + } + + +def weather_condition(weather_code): + return WMO_CONDITIONS.get(weather_code, "نامشخص") + diff --git a/dashboard_data/cards/__init__.py b/dashboard_data/cards/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/dashboard_data/cards/__init__.py @@ -0,0 +1 @@ + diff --git a/dashboard_data/cards/anomaly_detection_card.py b/dashboard_data/cards/anomaly_detection_card.py new file mode 100644 index 0000000..ac38c55 --- /dev/null +++ b/dashboard_data/cards/anomaly_detection_card.py @@ -0,0 +1,34 @@ +from dashboard_data.card_utils import safe_number + + +def build_anomaly_detection_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + sensor = (context or {}).get("sensor") + if sensor is None: + return {"anomalies": []} + + anomalies = [] + moisture = safe_number(sensor.soil_moisture, 0) + if moisture < 45: + anomalies.append( + { + "sensor": "رطوبت خاک", + "value": f"{round(moisture)}%", + "expected": "45-65%", + "deviation": f"{round(moisture - 55)}%", + "severity": "warning", + } + ) + + soil_ph = safe_number(sensor.soil_ph, 7) + if soil_ph < 6 or soil_ph > 7: + anomalies.append( + { + "sensor": "pH خاک", + "value": f"{soil_ph:.1f}", + "expected": "6.0-7.0", + "deviation": f"{round(soil_ph - 6.5, 1)}", + "severity": "error" if soil_ph < 5.5 or soil_ph > 7.5 else "warning", + } + ) + + return {"anomalies": anomalies} diff --git a/dashboard_data/cards/economic_overview.py b/dashboard_data/cards/economic_overview.py new file mode 100644 index 0000000..498c6f0 --- /dev/null +++ b/dashboard_data/cards/economic_overview.py @@ -0,0 +1,50 @@ +from dashboard_data.card_utils import safe_number + + +def build_economic_overview(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + sensor = (context or {}).get("sensor") + forecasts = (context or {}).get("forecasts", []) + if sensor is None: + return {"economicData": [], "chartSeries": [], "chartCategories": []} + + water_cost = round(sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6])) + fertilizer_need = round((safe_number(sensor.nitrogen, 0) + safe_number(sensor.phosphorus, 0) + safe_number(sensor.potassium, 0)) / 3) + revenue = round(max(1000, water_cost * 4.5)) + + return { + "economicData": [ + { + "title": "هزینه آب", + "value": f"€{water_cost}", + "subtitle": "این ماه", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "صرفه‌جویی آب هوشمند", + "value": f"€{round(water_cost * 0.18)}", + "subtitle": "۱۸٪ صرفه‌جویی شده", + "avatarIcon": "tabler-bulb", + "avatarColor": "success", + }, + { + "title": "بازده سرمایه پلتفرم", + "value": "127%", + "subtitle": "نسبت به سال گذشته", + "avatarIcon": "tabler-chart-line", + "avatarColor": "info", + }, + { + "title": "پیش‌بینی درآمد", + "value": f"€{round(revenue / 1000)}k", + "subtitle": "این فصل", + "avatarIcon": "tabler-currency-euro", + "avatarColor": "success", + }, + ], + "chartSeries": [ + {"name": "هزینه آب", "data": [max(1, round(water_cost / 6)) for _ in range(6)]}, + {"name": "کود", "data": [max(1, round(fertilizer_need / 6)) for _ in range(6)]}, + ], + "chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], + } diff --git a/dashboard_data/cards/farm_alerts_timeline.py b/dashboard_data/cards/farm_alerts_timeline.py new file mode 100644 index 0000000..85965cb --- /dev/null +++ b/dashboard_data/cards/farm_alerts_timeline.py @@ -0,0 +1,3 @@ +def build_farm_alerts_timeline(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + ai_bundle = ai_bundle or {} + return {"alerts": ai_bundle.get("timeline", [])} diff --git a/dashboard_data/cards/farm_alerts_tracker.py b/dashboard_data/cards/farm_alerts_tracker.py new file mode 100644 index 0000000..98ba223 --- /dev/null +++ b/dashboard_data/cards/farm_alerts_tracker.py @@ -0,0 +1,41 @@ +from dashboard_data.card_utils import average, safe_number + + +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", []) + if sensor is None: + return {"totalAlerts": 0, "radialBarValue": 0, "alertStats": []} + + moisture = safe_number(sensor.soil_moisture, 0) + humidity = average([forecast.humidity_mean for forecast in forecasts[:3]], default=0) + frost_count = sum(1 for forecast in forecasts[:3] if safe_number(forecast.temperature_min, 10) <= 0) + low_water_count = 2 if moisture < 45 else 0 + fungal_count = 1 if humidity > 70 and moisture > 60 else 0 + total = low_water_count + fungal_count + frost_count + + return { + "totalAlerts": total, + "radialBarValue": min(100, total * 10), + "alertStats": [ + { + "title": "کمبود آب", + "count": str(low_water_count), + "avatarColor": "error", + "avatarIcon": "tabler-droplet-half-2", + }, + { + "title": "ریسک قارچی", + "count": str(fungal_count), + "avatarColor": "warning", + "avatarIcon": "tabler-mushroom", + }, + { + "title": "هشدار یخبندان", + "count": str(frost_count), + "avatarColor": "info", + "avatarIcon": "tabler-snowflake", + }, + ], + } diff --git a/dashboard_data/cards/farm_overview_kpis.py b/dashboard_data/cards/farm_overview_kpis.py new file mode 100644 index 0000000..e3b604c --- /dev/null +++ b/dashboard_data/cards/farm_overview_kpis.py @@ -0,0 +1,83 @@ +from dashboard_data.card_utils import average, safe_number + + +def build_farm_overview_kpis(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + forecasts = context.get("forecasts", []) + if sensor is None: + return {"kpis": []} + + moisture = safe_number(sensor.soil_moisture, 0) + ph = safe_number(sensor.soil_ph, 7) + ec = safe_number(sensor.electrical_conductivity, 0) + humidity = average([forecast.humidity_mean for forecast in forecasts[:3]], default=45) + health_score = max(0, min(100, round(100 - abs(65 - moisture) - (abs(6.8 - ph) * 10) - (ec * 5)))) + water_stress = max(0, min(100, round(35 - (moisture / 2)))) + disease_risk = max(0, min(100, round((humidity * 0.4) + (safe_number(sensor.soil_temperature, 0) * 0.6) - 20))) + yield_prediction = round(max(5, (health_score / 2.1)), 1) + + return { + "kpis": [ + { + "id": "farm_health_score", + "title": "امتیاز سلامت مزرعه", + "subtitle": "تحلیل هوشمند", + "stats": f"{health_score}%", + "avatarColor": "success" if health_score >= 70 else "warning", + "avatarIcon": "tabler-heartbeat", + "chipText": "خوب" if health_score >= 70 else "متوسط", + "chipColor": "success" if health_score >= 70 else "warning", + }, + { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": f"{water_stress}%", + "avatarColor": "info", + "avatarIcon": "tabler-droplet", + "chipText": "پایین" if water_stress <= 20 else "متوسط", + "chipColor": "success" if water_stress <= 20 else "warning", + }, + { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "۷ روز اخیر", + "stats": "پایین" if disease_risk < 30 else "متوسط", + "avatarColor": "success" if disease_risk < 30 else "warning", + "avatarIcon": "tabler-bug", + "chipText": f"{disease_risk}%", + "chipColor": "success" if disease_risk < 30 else "warning", + }, + { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "کل مزرعه", + "stats": f"{round(moisture)}%", + "avatarColor": "primary", + "avatarIcon": "tabler-plant-2", + "chipText": "بهینه" if 45 <= moisture <= 75 else "نیازمند بررسی", + "chipColor": "success" if 45 <= moisture <= 75 else "warning", + }, + { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": f"{yield_prediction} تن", + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": f"+{max(0, health_score - 50)}%", + "chipColor": "success", + }, + { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش‌بینی هوشمند", + "stats": f"{max(5, round(disease_risk * 0.7))}%", + "avatarColor": "warning", + "avatarIcon": "tabler-bug-off", + "chipText": "تحت نظر", + "chipColor": "warning", + }, + ] + } diff --git a/dashboard_data/cards/farm_weather_card.py b/dashboard_data/cards/farm_weather_card.py new file mode 100644 index 0000000..bcab705 --- /dev/null +++ b/dashboard_data/cards/farm_weather_card.py @@ -0,0 +1,32 @@ +from dashboard_data.card_utils import average, safe_number, weather_condition + + +def build_farm_weather_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + forecasts = (context or {}).get("forecasts", []) + if not forecasts: + return { + "condition": "نامشخص", + "temperature": 0, + "unit": "°C", + "humidity": 0, + "windSpeed": 0, + "windUnit": "km/h", + "chartData": {"labels": [], "series": [[]]}, + } + + current_forecast = forecasts[0] + labels = [str(forecast.forecast_date) for forecast in forecasts[:7]] + series = [[round(safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]] + + return { + "condition": weather_condition(current_forecast.weather_code), + "temperature": round(safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)), + "unit": "°C", + "humidity": round(average([current_forecast.humidity_mean], default=0)), + "windSpeed": round(safe_number(current_forecast.wind_speed_max, 0)), + "windUnit": "km/h", + "chartData": { + "labels": labels, + "series": series, + }, + } diff --git a/dashboard_data/cards/harvest_prediction_card.py b/dashboard_data/cards/harvest_prediction_card.py new file mode 100644 index 0000000..eeb8704 --- /dev/null +++ b/dashboard_data/cards/harvest_prediction_card.py @@ -0,0 +1,26 @@ +from datetime import date, timedelta + +from dashboard_data.card_utils import average, safe_number + + +def build_harvest_prediction_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + forecasts = context.get("forecasts", []) + plants = context.get("plants", []) + + avg_temp = average([forecast.temperature_mean for forecast in forecasts], default=24) + moisture_factor = safe_number(getattr(context.get("sensor"), "soil_moisture", None), 50) + days_until = max(10, int(90 - avg_temp - (moisture_factor / 5))) + target_date = date.today() + timedelta(days=days_until) + window_start = target_date - timedelta(days=3) + window_end = target_date + timedelta(days=3) + plant_name = plants[0].name if plants else "محصول" + + return { + "date": str(target_date), + "dateFormatted": f"{target_date.day} {target_date.strftime('%B')} {target_date.year}", + "daysUntil": days_until, + "description": f"بر اساس دمای فعلی، رطوبت خاک و اطلاعات {plant_name}. بازه بهینه برداشت محاسبه شده است.", + "optimalWindowStart": str(window_start), + "optimalWindowEnd": str(window_end), + } diff --git a/dashboard_data/cards/ndvi_health_card.py b/dashboard_data/cards/ndvi_health_card.py new file mode 100644 index 0000000..4a7616b --- /dev/null +++ b/dashboard_data/cards/ndvi_health_card.py @@ -0,0 +1,30 @@ +from dashboard_data.card_utils import safe_number + + +def build_ndvi_health_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + if sensor is None: + return {"ndviIndex": 0, "healthData": []} + + nitrogen = safe_number(sensor.nitrogen, 0) + moisture = safe_number(sensor.soil_moisture, 0) + ndvi = round(min(0.95, max(0.1, ((nitrogen / 100) * 0.4) + ((moisture / 100) * 0.6))), 2) + + return { + "ndviIndex": ndvi, + "healthData": [ + { + "title": "تنش نیتروژن", + "value": "پایین" if nitrogen >= 30 else "بالا", + "color": "success" if nitrogen >= 30 else "warning", + "icon": "tabler-leaf", + }, + { + "title": "سلامت محصول", + "value": "خوب" if ndvi >= 0.65 else "متوسط", + "color": "success" if ndvi >= 0.65 else "warning", + "icon": "tabler-plant", + }, + ], + } diff --git a/dashboard_data/cards/recommendations_list.py b/dashboard_data/cards/recommendations_list.py new file mode 100644 index 0000000..1fbefa0 --- /dev/null +++ b/dashboard_data/cards/recommendations_list.py @@ -0,0 +1,3 @@ +def build_recommendations_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + ai_bundle = ai_bundle or {} + return {"recommendations": ai_bundle.get("recommendations", [])} diff --git a/dashboard_data/cards/sensor_comparison_chart.py b/dashboard_data/cards/sensor_comparison_chart.py new file mode 100644 index 0000000..e615409 --- /dev/null +++ b/dashboard_data/cards/sensor_comparison_chart.py @@ -0,0 +1,35 @@ +from datetime import date, timedelta + +from dashboard_data.card_utils import PERSIAN_WEEKDAYS, safe_number + + +def build_sensor_comparison_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + history = (context or {}).get("history", []) + current_sensor = (context or {}).get("sensor") + current_value = round(safe_number(getattr(current_sensor, "soil_moisture", None), 0)) + + recent = list(reversed(history[:7])) + previous = list(reversed(history[7:14])) + this_week = [round(safe_number(item.soil_moisture, current_value)) for item in recent] + last_week = [round(safe_number(item.soil_moisture, current_value - 5)) for item in previous] + + while len(this_week) < 7: + this_week.append(current_value) + while len(last_week) < 7: + last_week.append(max(0, current_value - 5)) + + categories = [PERSIAN_WEEKDAYS[(date.today() - timedelta(days=offset)).weekday()] for offset in range(6, -1, -1)] + avg_this = sum(this_week) / len(this_week) + avg_last = sum(last_week) / len(last_week) + delta = round(((avg_this - avg_last) / avg_last) * 100) if avg_last else 0 + + return { + "currentValue": current_value, + "vsLastWeek": f"{'+' if delta >= 0 else ''}{delta}%", + "vsLastWeekValue": delta, + "categories": categories, + "series": [ + {"name": "امروز", "data": this_week}, + {"name": "هفته قبل", "data": last_week}, + ], + } diff --git a/dashboard_data/cards/sensor_radar_chart.py b/dashboard_data/cards/sensor_radar_chart.py new file mode 100644 index 0000000..7e23fdf --- /dev/null +++ b/dashboard_data/cards/sensor_radar_chart.py @@ -0,0 +1,37 @@ +from dashboard_data.card_utils import safe_number + + +def _to_score(value, lower, upper): + if value is None: + return 0 + if value <= lower: + return 0 + if value >= upper: + return 100 + return round(((value - lower) / (upper - lower)) * 100) + + +def build_sensor_radar_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + forecasts = context.get("forecasts", []) + if sensor is None: + return {"labels": [], "series": []} + + current_weather = forecasts[0] if forecasts else None + current = [ + _to_score(sensor.soil_temperature, 0, 40), + _to_score(sensor.soil_moisture, 0, 100), + _to_score(sensor.soil_ph, 0, 14), + _to_score(sensor.electrical_conductivity, 0, 5), + 85, + _to_score(current_weather.wind_speed_max if current_weather else 0, 0, 30), + ] + + return { + "labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"], + "series": [ + {"name": "امروز", "data": current}, + {"name": "ایده‌آل", "data": [80, 70, 75, 75, 90, 50]}, + ], + } diff --git a/dashboard_data/cards/sensor_values_list.py b/dashboard_data/cards/sensor_values_list.py new file mode 100644 index 0000000..6641b74 --- /dev/null +++ b/dashboard_data/cards/sensor_values_list.py @@ -0,0 +1,71 @@ +from dashboard_data.card_utils import compute_trend, latest_history_value, safe_number + + +def build_sensor_values_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + history = context.get("history", []) + forecasts = context.get("forecasts", []) + if sensor is None: + return {"sensors": []} + + current_weather = forecasts[0] if forecasts else None + + sensors = [ + { + "title": f"{round(safe_number(current_weather.temperature_mean if current_weather else None, 0))}°C", + "subtitle": "دمای هوا", + **compute_trend( + current_weather.temperature_mean if current_weather else 0, + latest_history_value(history, "soil_temperature", 0), + ), + "unit": "°C", + }, + { + "title": f"{round(safe_number(sensor.soil_temperature, 0))}°C", + "subtitle": "دمای خاک", + **compute_trend(sensor.soil_temperature, latest_history_value(history, "soil_temperature", 0)), + "unit": "°C", + }, + { + "title": f"{round(safe_number(current_weather.humidity_mean if current_weather else None, 0))}%", + "subtitle": "رطوبت هوا", + **compute_trend(current_weather.humidity_mean if current_weather else 0, 0), + "unit": "%", + }, + { + "title": f"{round(safe_number(sensor.soil_moisture, 0))}%", + "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)", + **compute_trend(sensor.soil_moisture, latest_history_value(history, "soil_moisture", 0)), + "unit": "%", + }, + { + "title": f"{safe_number(sensor.soil_ph, 0):.1f}", + "subtitle": "pH خاک", + **compute_trend(sensor.soil_ph, latest_history_value(history, "soil_ph", 0)), + "unit": "pH", + }, + { + "title": f"{safe_number(sensor.electrical_conductivity, 0):.1f}", + "subtitle": "هدایت الکتریکی (dS/m)", + **compute_trend( + sensor.electrical_conductivity, + latest_history_value(history, "electrical_conductivity", 0), + ), + "unit": "dS/m", + }, + { + "title": "850", + "subtitle": "شدت نور (لوکس)", + "trendNumber": 0, + "trend": "positive", + "unit": "lux", + }, + { + "title": f"{round(safe_number(current_weather.wind_speed_max if current_weather else None, 0))}", + "subtitle": "سرعت باد (کیلومتر/ساعت)", + **compute_trend(current_weather.wind_speed_max if current_weather else 0, 0), + "unit": "km/h", + }, + ] + return {"sensors": sensors} diff --git a/dashboard_data/cards/soil_moisture_heatmap.py b/dashboard_data/cards/soil_moisture_heatmap.py new file mode 100644 index 0000000..7736a83 --- /dev/null +++ b/dashboard_data/cards/soil_moisture_heatmap.py @@ -0,0 +1,32 @@ +from dashboard_data.card_utils import safe_number + + +def build_soil_moisture_heatmap(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + depths = context.get("depths", []) + if sensor is None: + return {"zones": [], "hours": [], "series": []} + + hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"] + base_moisture = safe_number(sensor.soil_moisture, 0) + series = [] + zones = [] + + if not depths: + depths = [None, None] + + for index, depth in enumerate(depths[:7], start=1): + zones.append(f"زون {index}") + depth_offset = 0 if depth is None else round(safe_number(getattr(depth, "wv0033", None), 0) / 10) + data = [] + for hour_index, hour in enumerate(hours): + value = max(0, min(100, round(base_moisture + depth_offset - abs(3 - hour_index) * 2))) + data.append({"x": hour, "y": value}) + series.append({"name": f"زون {index}", "data": data}) + + return { + "zones": zones, + "hours": hours, + "series": series, + } diff --git a/dashboard_data/cards/water_need_prediction.py b/dashboard_data/cards/water_need_prediction.py new file mode 100644 index 0000000..cbe7f08 --- /dev/null +++ b/dashboard_data/cards/water_need_prediction.py @@ -0,0 +1,18 @@ +from dashboard_data.card_utils import safe_number + + +def build_water_need_prediction(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + forecasts = (context or {}).get("forecasts", []) + daily_needs = [] + for forecast in forecasts[:7]: + et0 = safe_number(forecast.et0, 4) + rain = safe_number(forecast.precipitation, 0) + need = max(0, round((et0 * 100) - (rain * 20))) + daily_needs.append(need) + + return { + "totalNext7Days": sum(daily_needs), + "unit": "m³", + "categories": [f"روز {index}" for index in range(1, len(daily_needs) + 1)], + "series": [{"name": "نیاز آبی", "data": daily_needs}], + } diff --git a/dashboard_data/cards/yield_prediction_chart.py b/dashboard_data/cards/yield_prediction_chart.py new file mode 100644 index 0000000..1c813ea --- /dev/null +++ b/dashboard_data/cards/yield_prediction_chart.py @@ -0,0 +1,38 @@ +from datetime import date + +from dashboard_data.card_utils import safe_number + + +def build_yield_prediction_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + sensor = (context or {}).get("sensor") + if sensor is None: + return {"categories": [], "series": [], "summary": []} + + base = max(10, round(safe_number(sensor.soil_moisture, 0) * 0.6)) + current_year = [base + offset for offset in [0, 2, 4, 6, 8, 10, 12, 11, 9, 7, 5, 4]] + last_year = [value - 3 for value in current_year] + harvest_month = "حدود " + str(date.today().month) + + return { + "categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + "series": [ + {"name": "امسال", "data": current_year}, + {"name": "سال گذشته", "data": last_year}, + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": f"{current_year[9]} تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar", + }, + { + "title": "تاریخ برداشت", + "subtitle": harvest_month, + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar", + }, + ], + } diff --git a/dashboard_data/context.py b/dashboard_data/context.py new file mode 100644 index 0000000..508df78 --- /dev/null +++ b/dashboard_data/context.py @@ -0,0 +1,40 @@ +from datetime import date + + +def load_dashboard_context(sensor_id: str) -> dict | None: + from irrigation.models import IrrigationMethod + from location_data.models import SoilDepthData + from sensor_data.models import SensorData, SensorDataHistory + from weather.models import WeatherForecast + + try: + sensor = SensorData.objects.select_related("location").prefetch_related("plants").get( + uuid_sensor=sensor_id + ) + except SensorData.DoesNotExist: + return None + + location = sensor.location + depths = list( + SoilDepthData.objects.filter(soil_location=location).order_by("depth_label") + ) + forecasts = list( + WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()) + .order_by("forecast_date")[:7] + ) + history = list( + SensorDataHistory.objects.filter(uuid_sensor=sensor_id).order_by("-recorded_at")[:30] + ) + plants = list(sensor.plants.all()) + irrigation_methods = list(IrrigationMethod.objects.all()[:5]) + + return { + "sensor": sensor, + "location": location, + "depths": depths, + "forecasts": forecasts, + "history": history, + "plants": plants, + "irrigation_methods": irrigation_methods, + } + diff --git a/dashboard_data/migrations/0001_initial.py b/dashboard_data/migrations/0001_initial.py new file mode 100644 index 0000000..0e05bb0 --- /dev/null +++ b/dashboard_data/migrations/0001_initial.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="DashboardAiRequestLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sensor_id", models.UUIDField(db_index=True)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("status", models.CharField(default="pending", max_length=32)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ], + options={ + "ordering": ["-created_at"], + "verbose_name": "Dashboard AI Request Log", + "verbose_name_plural": "Dashboard AI Request Logs", + }, + ), + migrations.CreateModel( + name="DashboardCardSnapshot", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sensor_id", models.UUIDField(db_index=True)), + ("card_name", models.CharField(db_index=True, max_length=128)), + ("payload", models.JSONField(blank=True, default=dict)), + ("generated_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("expires_at", models.DateTimeField(db_index=True)), + ("source", models.CharField(default="computed", max_length=32)), + ], + options={ + "ordering": ["-generated_at"], + "verbose_name": "Dashboard Card Snapshot", + "verbose_name_plural": "Dashboard Card Snapshots", + "indexes": [ + models.Index(fields=["sensor_id", "card_name", "-generated_at"], name="dashboard_d_sensor__c0a279_idx"), + ], + }, + ), + ] + diff --git a/dashboard_data/migrations/__init__.py b/dashboard_data/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/dashboard_data/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/dashboard_data/models.py b/dashboard_data/models.py new file mode 100644 index 0000000..52ded5d --- /dev/null +++ b/dashboard_data/models.py @@ -0,0 +1,38 @@ +from django.db import models + + +class DashboardCardSnapshot(models.Model): + sensor_id = models.UUIDField(db_index=True) + card_name = models.CharField(max_length=128, db_index=True) + payload = models.JSONField(default=dict, blank=True) + generated_at = models.DateTimeField(auto_now_add=True, db_index=True) + expires_at = models.DateTimeField(db_index=True) + source = models.CharField(max_length=32, default="computed") + + class Meta: + ordering = ["-generated_at"] + indexes = [ + models.Index(fields=["sensor_id", "card_name", "-generated_at"]), + ] + verbose_name = "Dashboard Card Snapshot" + verbose_name_plural = "Dashboard Card Snapshots" + + def __str__(self): + return f"{self.card_name} - {self.sensor_id} - {self.generated_at}" + + +class DashboardAiRequestLog(models.Model): + sensor_id = models.UUIDField(db_index=True) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + status = models.CharField(max_length=32, default="pending") + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Dashboard AI Request Log" + verbose_name_plural = "Dashboard AI Request Logs" + + def __str__(self): + return f"{self.sensor_id} - {self.status} - {self.created_at}" + diff --git a/dashboard_data/services.py b/dashboard_data/services.py new file mode 100644 index 0000000..f39561a --- /dev/null +++ b/dashboard_data/services.py @@ -0,0 +1,105 @@ +from django.utils import timezone + +from .ai_bundle import request_dashboard_ai_bundle +from .card_utils import is_fresh, ttl_for_card +from .cards.anomaly_detection_card import build_anomaly_detection_card +from .cards.economic_overview import build_economic_overview +from .cards.farm_alerts_timeline import build_farm_alerts_timeline +from .cards.farm_alerts_tracker import build_farm_alerts_tracker +from .cards.farm_overview_kpis import build_farm_overview_kpis +from .cards.farm_weather_card import build_farm_weather_card +from .cards.harvest_prediction_card import build_harvest_prediction_card +from .cards.ndvi_health_card import build_ndvi_health_card +from .cards.recommendations_list import build_recommendations_list +from .cards.sensor_comparison_chart import build_sensor_comparison_chart +from .cards.sensor_radar_chart import build_sensor_radar_chart +from .cards.sensor_values_list import build_sensor_values_list +from .cards.soil_moisture_heatmap import build_soil_moisture_heatmap +from .cards.water_need_prediction import build_water_need_prediction +from .cards.yield_prediction_chart import build_yield_prediction_chart +from .context import load_dashboard_context +from .models import DashboardCardSnapshot + + +CARD_BUILDERS = { + "farmOverviewKpis": build_farm_overview_kpis, + "farmWeatherCard": build_farm_weather_card, + "farmAlertsTracker": build_farm_alerts_tracker, + "sensorValuesList": build_sensor_values_list, + "sensorRadarChart": build_sensor_radar_chart, + "sensorComparisonChart": build_sensor_comparison_chart, + "anomalyDetectionCard": build_anomaly_detection_card, + "farmAlertsTimeline": build_farm_alerts_timeline, + "waterNeedPrediction": build_water_need_prediction, + "harvestPredictionCard": build_harvest_prediction_card, + "yieldPredictionChart": build_yield_prediction_chart, + "soilMoistureHeatmap": build_soil_moisture_heatmap, + "ndviHealthCard": build_ndvi_health_card, + "recommendationsList": build_recommendations_list, + "economicOverview": build_economic_overview, +} + + +AI_DRIVEN_CARDS = { + "farmAlertsTimeline", + "recommendationsList", +} + + +def build_dashboard_payload(sensor_id: str) -> dict: + context = load_dashboard_context(sensor_id) + if context is None: + return {} + + ai_payload_request = { + "sensor_id": sensor_id, + "cards": sorted(AI_DRIVEN_CARDS), + } + ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request) + + return { + card_name: builder(sensor_id=sensor_id, context=context, ai_bundle=ai_bundle) + for card_name, builder in CARD_BUILDERS.items() + } + + +def get_or_build_card(sensor_id: str, card_name: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + latest_snapshot = ( + DashboardCardSnapshot.objects.filter(sensor_id=sensor_id, card_name=card_name) + .order_by("-generated_at") + .first() + ) + if is_fresh(latest_snapshot): + return latest_snapshot.payload + + payload = CARD_BUILDERS[card_name](sensor_id=sensor_id, context=context, ai_bundle=ai_bundle) + DashboardCardSnapshot.objects.create( + sensor_id=sensor_id, + card_name=card_name, + payload=payload, + expires_at=timezone.now() + ttl_for_card(card_name), + source="ai" if card_name in AI_DRIVEN_CARDS else "computed", + ) + return payload + + +def build_dashboard_payload_with_cache(sensor_id: str) -> dict: + context = load_dashboard_context(sensor_id) + if context is None: + return {} + + ai_payload_request = { + "sensor_id": sensor_id, + "cards": sorted(AI_DRIVEN_CARDS), + } + ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request) + + payload = {} + for card_name in CARD_BUILDERS: + payload[card_name] = get_or_build_card( + sensor_id=sensor_id, + card_name=card_name, + context=context, + ai_bundle=ai_bundle, + ) + return payload diff --git a/dashboard_data/tasks.py b/dashboard_data/tasks.py new file mode 100644 index 0000000..37e0cec --- /dev/null +++ b/dashboard_data/tasks.py @@ -0,0 +1,36 @@ +from config.celery import app + +from .services import CARD_BUILDERS, build_dashboard_payload_with_cache + + +@app.task(bind=True) +def generate_dashboard_data_task(self, sensor_id: str) -> dict: + total_cards = len(CARD_BUILDERS) + self.update_state( + state="PROGRESS", + meta={ + "current": 0, + "total": total_cards, + "card": None, + "message": "loading sensor context", + }, + ) + payload = {} + dashboard_payload = build_dashboard_payload_with_cache(sensor_id) + + for index, card_name in enumerate(CARD_BUILDERS.keys(), start=1): + self.update_state( + state="PROGRESS", + meta={ + "current": index, + "total": total_cards, + "card": card_name, + "message": f"processing {card_name}", + }, + ) + payload[card_name] = dashboard_payload.get(card_name, {}) + + return { + "sensor_id": sensor_id, + "all_cards": payload, + } diff --git a/dashboard_data/urls.py b/dashboard_data/urls.py new file mode 100644 index 0000000..0de3205 --- /dev/null +++ b/dashboard_data/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import DashboardDataGenerateView, DashboardDataStatusView + +urlpatterns = [ + path("generate/", DashboardDataGenerateView.as_view(), name="dashboard-data-generate"), + path("/status/", DashboardDataStatusView.as_view(), name="dashboard-data-status"), +] + diff --git a/dashboard_data/views.py b/dashboard_data/views.py new file mode 100644 index 0000000..0730272 --- /dev/null +++ b/dashboard_data/views.py @@ -0,0 +1,91 @@ +from uuid import UUID + +from celery.result import AsyncResult +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .tasks import generate_dashboard_data_task + + +class DashboardDataGenerateView(APIView): + @extend_schema( + tags=["Dashboard Data"], + summary="Generate dashboard data", + request=inline_serializer( + name="DashboardDataGenerateRequest", + fields={ + "sensor_id": drf_serializers.UUIDField(required=False), + "snesor_id": drf_serializers.UUIDField(required=False), + }, + ), + responses={ + 202: inline_serializer( + name="DashboardDataGenerateResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="DashboardDataGenerateResponseData", + fields={ + "task_id": drf_serializers.CharField(), + "status_url": drf_serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="Invalid input"), + }, + ) + def post(self, request): + sensor_id = request.data.get("sensor_id") or request.data.get("snesor_id") + if not sensor_id: + return Response( + {"code": 400, "msg": "پارامتر sensor_id الزامی است.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + sensor_id = str(UUID(str(sensor_id))) + except (TypeError, ValueError): + return Response( + {"code": 400, "msg": "sensor_id باید UUID معتبر باشد.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = generate_dashboard_data_task.delay(sensor_id) + return Response( + { + "code": 202, + "msg": "dashboard task queued", + "data": { + "task_id": task.id, + "status_url": f"/api/dashboard-data/{task.id}/status/", + }, + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class DashboardDataStatusView(APIView): + @extend_schema( + tags=["Dashboard Data"], + summary="Dashboard task status", + ) + def get(self, request, task_id): + result = AsyncResult(task_id) + data = {"task_id": task_id, "status": result.state} + if result.state == "PENDING": + data["message"] = "تسک در صف یا یافت نشد." + elif result.state == "PROGRESS": + data["progress"] = result.info + elif result.state == "SUCCESS": + data["result"] = result.result + elif result.state == "FAILURE": + data["error"] = str(result.result) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + )