AI UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, "نامشخص")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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}
|
||||
@@ -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": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
|
||||
}
|
||||
@@ -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", [])}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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", [])}
|
||||
@@ -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},
|
||||
],
|
||||
}
|
||||
@@ -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]},
|
||||
],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}],
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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("<str:task_id>/status/", DashboardDataStatusView.as_view(), name="dashboard-data-status"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user