From d977a583c60a5dc00e735ebd62259a4c74df7a8e Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 22 Mar 2026 03:08:27 +0330 Subject: [PATCH] AI UPDATE --- config/rag_config.yaml | 55 ++ dashboard_data/ai_bundle.py | 10 +- dashboard_data/cards/CARD_FORMULAS.md | 872 ++++++++++++++++++ .../cards/anomaly_detection_card.py | 274 +++++- dashboard_data/cards/farm_alerts_timeline.py | 5 +- dashboard_data/cards/farm_alerts_tracker.py | 581 +++++++++++- dashboard_data/cards/farm_overview_kpis.py | 182 +++- .../cards/harvest_prediction_card.py | 44 +- dashboard_data/cards/ndvi_health_card.py | 80 +- dashboard_data/cards/recommendations_list.py | 5 +- .../cards/sensor_comparison_chart.py | 124 ++- dashboard_data/cards/sensor_radar_chart.py | 141 ++- dashboard_data/cards/soil_moisture_heatmap.py | 213 ++++- dashboard_data/cards/water_need_prediction.py | 44 +- .../migrations/0002_ndvi_observation.py | 36 + dashboard_data/models.py | 29 + dashboard_data/remote_sensing.py | 155 ++++ dashboard_data/services.py | 83 +- fertilization/views.py | 3 +- irrigation/evapotranspiration.py | 194 ++++ irrigation/views.py | 3 +- .../0002_soillocation_ideal_sensor_profile.py | 23 + .../0004_soillocation_farm_boundary.py | 23 + location_data/models.py | 16 + plant/gdd.py | 107 +++ plant/migrations/0002_plant_health_profile.py | 23 + .../0003_plant_irrigation_profile.py | 24 + plant/migrations/0004_plant_growth_profile.py | 24 + plant/models.py | 26 + rag/api_provider.py | 4 +- rag/chat.py | 91 +- rag/config.py | 73 +- rag/retrieve.py | 26 +- rag/services/fertilization.py | 25 +- rag/services/irrigation.py | 82 +- rag/vector_store.py | 26 +- rag/views.py | 62 +- 37 files changed, 3525 insertions(+), 263 deletions(-) create mode 100644 dashboard_data/cards/CARD_FORMULAS.md create mode 100644 dashboard_data/migrations/0002_ndvi_observation.py create mode 100644 dashboard_data/remote_sensing.py create mode 100644 irrigation/evapotranspiration.py create mode 100644 location_data/migrations/0002_soillocation_ideal_sensor_profile.py create mode 100644 location_data/migrations/0004_soillocation_farm_boundary.py create mode 100644 plant/gdd.py create mode 100644 plant/migrations/0002_plant_health_profile.py create mode 100644 plant/migrations/0003_plant_irrigation_profile.py create mode 100644 plant/migrations/0004_plant_growth_profile.py diff --git a/config/rag_config.yaml b/config/rag_config.yaml index a24108f..fca5e5a 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -23,6 +23,7 @@ chunking: # تنظیمات مدل چت (LLM) — Avalai llm: + provider: "gapgpt" model: "gpt-4o" base_url: "https://api.gapgpt.app/v1" api_key_env: "GAPGPT_API_KEY" @@ -45,3 +46,57 @@ knowledge_bases: path: "config/knowledge_base/fertilization" tone_file: "config/tones/fertilization_tone.txt" description: "پایگاه دانش توصیه کودهی" + +services: + support_bot: + knowledge_base: "chat" + tone_file: "config/tones/chat_tone.txt" + use_user_embeddings: false + description: "سرویس پشتیبانی عمومی" + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" + system_prompt: "You are a friendly support assistant. Answer clearly and helpfully." + + chat: + knowledge_base: "chat" + tone_file: "config/tones/chat_tone.txt" + use_user_embeddings: true + description: "چت عمومی با داده‌های کاربر" + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" + + irrigation: + knowledge_base: "irrigation" + tone_file: "config/tones/irrigation_tone.txt" + use_user_embeddings: true + description: "سرویس توصیه آبیاری" + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" + + fertilization: + knowledge_base: "fertilization" + tone_file: "config/tones/fertilization_tone.txt" + use_user_embeddings: true + description: "سرویس توصیه کودهی" + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" diff --git a/dashboard_data/ai_bundle.py b/dashboard_data/ai_bundle.py index 9605d9a..9ce1f85 100644 --- a/dashboard_data/ai_bundle.py +++ b/dashboard_data/ai_bundle.py @@ -8,10 +8,12 @@ def request_dashboard_ai_bundle(sensor_id: str, payload: dict) -> dict: response_payload={}, status="pending", ) + response_payload = log.response_payload or {} return { "log_id": log.id, - "timeline": [], - "recommendations": [], - "alerts": [], + "timeline": response_payload.get("timeline", []), + "recommendations": response_payload.get("recommendations", []), + "alerts": response_payload.get("alerts", []), + "structured_context": payload.get("structured_context", {}), + "system_prompt": payload.get("system_prompt", ""), } - diff --git a/dashboard_data/cards/CARD_FORMULAS.md b/dashboard_data/cards/CARD_FORMULAS.md new file mode 100644 index 0000000..2205cd5 --- /dev/null +++ b/dashboard_data/cards/CARD_FORMULAS.md @@ -0,0 +1,872 @@ +# مستند فرمول‌های `dashboard_data/cards` + +این فایل توضیح می‌دهد هر کارت در `dashboard_data/cards` چطور داده‌های خروجی خود را محاسبه می‌کند، از چه فیلدهایی استفاده می‌کند، و چه fallbackهایی دارد. + +## منبع داده‌های مشترک + +کانتکست بیشتر کارت‌ها از `dashboard_data/context.py` می‌آید: + +- `sensor`: رکورد اصلی سنسور +- `location`: لوکیشن سنسور +- `depths`: داده‌های عمق خاک از `SoilDepthData` +- `forecasts`: حداکثر ۷ پیش‌بینی آب‌وهوا از امروز به بعد +- `history`: حداکثر ۳۰ رکورد تاریخچه سنسور، مرتب‌شده از جدید به قدیم +- `plants`: گیاه‌های متصل به سنسور +- `irrigation_methods`: حداکثر ۵ روش آبیاری + +## توابع کمکی مشترک + +این توابع در `dashboard_data/card_utils.py` استفاده می‌شوند: + +- `safe_number(value, default=0)`: اگر مقدار `None` باشد، `default` برمی‌گرداند. +- `average(values, default=0)`: میانگین مقادیر غیر `None` را می‌دهد؛ اگر هیچ مقداری نبود `default` برمی‌گرداند. +- `latest_history_value(history, field_name, default=None)`: مقدار `field_name` را از جدیدترین رکورد history می‌گیرد. +- `compute_trend(current, previous)`: + - `diff = round(current_value - previous_value, 1)` + - `trend = "positive"` اگر `diff >= 0`، وگرنه `"negative"` + +--- + +## 1) `farm_overview_kpis.py` + +تابع سازنده: `build_farm_overview_kpis` + +### ورودی‌های اصلی + +- `sensor.soil_moisture` → `moisture` +- `sensor.soil_ph` → `ph` +- `sensor.electrical_conductivity` → `ec` +- `sensor.soil_temperature` +- میانگین `forecast.humidity_mean` برای ۳ پیش‌بینی اول → `humidity` + +### فرمول‌ها + +#### 1. امتیاز سلامت مزرعه (`farm_health_score`) + +```text +health_score = clamp( + round( + 100 + - abs(65 - moisture) + - (abs(6.8 - ph) * 10) + - (ec * 5) + ), + 0, + 100 +) +``` + +توضیح: +- رطوبت ایده‌آل ۶۵٪ فرض شده. +- pH ایده‌آل ۶.۸ فرض شده. +- EC بالاتر، امتیاز را کم می‌کند. + +آستانه‌های نمایش: +- اگر `health_score >= 70` → وضعیت `خوب` و رنگ `success` +- در غیر این صورت → `متوسط` و رنگ `warning` + +#### 2. شاخص تنش آبی (`water_stress_index`) + +```text +water_stress = clamp(round(35 - (moisture / 2)), 0, 100) +``` + +توضیح: +- هرچه رطوبت خاک بیشتر شود، تنش آبی کمتر می‌شود. + +آستانه نمایش: +- اگر `water_stress <= 20` → `پایین` +- در غیر این صورت → `متوسط` + +#### 3. ریسک بیماری (`disease_risk`) + +```text +disease_risk = clamp( + round((humidity * 0.4) + (soil_temperature * 0.6) - 20), + 0, + 100 +) +``` + +توضیح: +- دمای خاک وزن ۶۰٪ دارد. +- رطوبت هوا وزن ۴۰٪ دارد. + +آستانه نمایش: +- اگر `disease_risk < 30` → `پایین` +- در غیر این صورت → `متوسط` + +#### 4. میانگین رطوبت خاک (`avg_soil_moisture`) + +```text +avg_soil_moisture = round(moisture) +``` + +نکته: +- اینجا عملاً فقط از `sensor.soil_moisture` فعلی استفاده می‌شود و واقعاً میانگین چند سنسور یا چند ناحیه محاسبه نمی‌شود. + +آستانه نمایش: +- اگر `45 <= moisture <= 75` → `بهینه` +- در غیر این صورت → `نیازمند بررسی` + +#### 5. پیش‌بینی عملکرد (`yield_prediction`) + +```text +yield_prediction = round(max(5, health_score / 2.1), 1) +``` + +فرمول متن chip: + +```text +yield_chip = "+" + str(max(0, health_score - 50)) + "%" +``` + +#### 6. ریسک آفات (`pest_risk`) + +```text +pest_risk = max(5, round(disease_risk * 0.7)) +``` + +نکته: +- ریسک آفات به‌صورت مستقیم از ۷۰٪ ریسک بیماری ساخته شده. + +--- + +## 2) `farm_weather_card.py` + +تابع سازنده: `build_farm_weather_card` + +### منطق کلی + +اگر `forecasts` خالی باشد: +- `condition = "نامشخص"` +- `temperature = 0` +- `humidity = 0` +- `windSpeed = 0` +- `chartData.labels = []` +- `chartData.series = [[]]` + +در غیر این صورت: + +### فرمول‌ها + +#### 1. وضعیت آب‌وهوا + +```text +condition = weather_condition(current_forecast.weather_code) +``` + +که `weather_code` با جدول `WMO_CONDITIONS` به متن فارسی تبدیل می‌شود. + +#### 2. دما + +```text +temperature = round( + safe_number(current_forecast.temperature_mean, current_forecast.temperature_max) +) +``` + +یعنی: +- اول `temperature_mean` +- اگر `None` بود، `temperature_max` + +#### 3. رطوبت + +```text +humidity = round(average([current_forecast.humidity_mean], default=0)) +``` + +نکته: +- چون فقط یک مقدار داخل `average` قرار می‌گیرد، عملاً همان `humidity_mean` فعلی است. + +#### 4. سرعت باد + +```text +windSpeed = round(safe_number(current_forecast.wind_speed_max, 0)) +``` + +#### 5. نمودار دما + +برای ۷ روز اول: + +```text +labels = [str(forecast.forecast_date) for forecast in forecasts[:7]] +series = [[round(safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]] +``` + +--- + +## 3) `farm_alerts_tracker.py` + +تابع سازنده: `build_farm_alerts_tracker` + +### ورودی‌ها + +- `sensor.soil_moisture` → `moisture` +- میانگین `humidity_mean` برای ۳ forecast اول → `humidity` +- `temperature_min` برای ۳ forecast اول + +### فرمول‌ها + +#### 1. هشدار کمبود آب + +```text +low_water_count = 2 if moisture < 45 else 0 +``` + +#### 2. هشدار ریسک قارچی + +```text +fungal_count = 1 if (humidity > 70 and moisture > 60) else 0 +``` + +#### 3. هشدار یخبندان + +```text +frost_count = count( + forecast for first 3 forecasts + if temperature_min <= 0 +) +``` + +در کد: + +```text +frost_count = sum( + 1 for forecast in forecasts[:3] + if safe_number(forecast.temperature_min, 10) <= 0 +) +``` + +#### 4. مجموع هشدارها + +```text +totalAlerts = low_water_count + fungal_count + frost_count +``` + +#### 5. مقدار radial bar + +```text +radialBarValue = min(100, totalAlerts * 10) +``` + +--- + +## 4) `sensor_values_list.py` + +تابع سازنده: `build_sensor_values_list` + +این کارت برای هر آیتم، مقدار فعلی و trend را می‌سازد. + +### فرمول trend + +برای هر سنسور که از `compute_trend` استفاده می‌کند: + +```text +trendNumber = round(current - previous, 1) +trend = "positive" if trendNumber >= 0 else "negative" +``` + +### آیتم‌ها + +#### 1. دمای هوا + +```text +title = round(current_weather.temperature_mean or 0) + "°C" +previous = latest_history_value(history, "soil_temperature", 0) +``` + +نکته مهم: +- trend دمای هوا با `soil_temperature` از history مقایسه می‌شود، نه با history دمای هوا. + +#### 2. دمای خاک + +```text +current = sensor.soil_temperature +previous = latest_history_value(history, "soil_temperature", 0) +``` + +#### 3. رطوبت هوا + +```text +current = current_weather.humidity_mean or 0 +previous = 0 +``` + +نکته: +- همیشه نسبت به صفر trend می‌گیرد، نه history. + +#### 4. رطوبت خاک + +```text +current = sensor.soil_moisture +previous = latest_history_value(history, "soil_moisture", 0) +``` + +#### 5. pH خاک + +```text +current = sensor.soil_ph +previous = latest_history_value(history, "soil_ph", 0) +``` + +#### 6. هدایت الکتریکی + +```text +current = sensor.electrical_conductivity +previous = latest_history_value(history, "electrical_conductivity", 0) +``` + +#### 7. شدت نور + +```text +title = "850" +trendNumber = 0 +trend = "positive" +``` + +نکته: +- این مقدار کاملاً ثابت (hard-coded) است و فرمولی ندارد. + +#### 8. سرعت باد + +```text +current = current_weather.wind_speed_max or 0 +previous = 0 +``` + +نکته: +- trend سرعت باد هم نسبت به صفر محاسبه می‌شود. + +--- + +## 5) `sensor_radar_chart.py` + +تابع سازنده: `build_sensor_radar_chart` + +### تابع نرمال‌سازی + +```text +to_score(value, lower, upper): + if value is None -> 0 + if value <= lower -> 0 + if value >= upper -> 100 + else -> round(((value - lower) / (upper - lower)) * 100) +``` + +### سری «امروز» + +به‌ترتیب: + +```text +soil_temperature_score = to_score(sensor.soil_temperature, 0, 40) +soil_moisture_score = to_score(sensor.soil_moisture, 0, 100) +soil_ph_score = to_score(sensor.soil_ph, 0, 14) +ec_score = to_score(sensor.electrical_conductivity, 0, 5) +light_score = 85 +wind_score = to_score(current_weather.wind_speed_max if current_weather else 0, 0, 30) +``` + +خروجی: + +```text +series[0].data = [ + soil_temperature_score, + soil_moisture_score, + soil_ph_score, + ec_score, + 85, + wind_score +] +``` + +### سری «ایده‌آل» + +```text +[80, 70, 75, 75, 90, 50] +``` + +نکته: +- این مقادیر ثابت هستند و از دیتابیس محاسبه نمی‌شوند. + +--- + +## 6) `sensor_comparison_chart.py` + +تابع سازنده: `build_sensor_comparison_chart` + +### ورودی‌ها + +- `current_sensor.soil_moisture` → `current_value` +- `history[:7]` → داده‌های هفته جاری +- `history[7:14]` → داده‌های هفته قبل + +### فرمول‌ها + +#### 1. مقدار فعلی + +```text +currentValue = round(sensor.soil_moisture) +``` + +#### 2. سری هفته جاری + +```text +recent = reversed(history[:7]) +this_week = [round(item.soil_moisture or current_value) for item in recent] +``` + +اگر کمتر از ۷ مقدار باشد: + +```text +while len(this_week) < 7: + this_week.append(current_value) +``` + +#### 3. سری هفته قبل + +```text +previous = reversed(history[7:14]) +last_week = [round(item.soil_moisture or (current_value - 5)) for item in previous] +``` + +اگر کمتر از ۷ مقدار باشد: + +```text +while len(last_week) < 7: + last_week.append(max(0, current_value - 5)) +``` + +#### 4. درصد تغییر نسبت به هفته قبل + +```text +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 +``` + +نمایش متن: + +```text +vsLastWeek = f"{'+' if delta >= 0 else ''}{delta}%" +vsLastWeekValue = delta +``` + +#### 5. دسته‌بندی روزها + +روزهای ۷ روز اخیر با نام فارسی weekday ساخته می‌شوند: + +```text +categories = [ + PERSIAN_WEEKDAYS[(today - offset_days).weekday()] + for offset_days in range(6, -1, -1) +] +``` + +--- + +## 7) `anomaly_detection_card.py` + +تابع سازنده: `build_anomaly_detection_card` + +این کارت anomalyها را فقط برای دو شاخص تولید می‌کند: رطوبت خاک و pH خاک. + +### 1. anomaly رطوبت خاک + +فقط وقتی ساخته می‌شود که: + +```text +moisture < 45 +``` + +ساختار خروجی: + +```text +value = round(moisture) + "%" +expected = "45-65%" +deviation = round(moisture - 55) + "%" +severity = "warning" +``` + +نکته: +- deviation نسبت به نقطه مرجع ۵۵٪ محاسبه می‌شود، نه مرز ۴۵٪. + +### 2. anomaly pH خاک + +فقط وقتی ساخته می‌شود که: + +```text +soil_ph < 6 or soil_ph > 7 +``` + +ساختار خروجی: + +```text +value = format(soil_ph, ".1f") +expected = "6.0-7.0" +deviation = round(soil_ph - 6.5, 1) +severity = "error" if (soil_ph < 5.5 or soil_ph > 7.5) else "warning" +``` + +--- + +## 8) `farm_alerts_timeline.py` + +تابع سازنده: `build_farm_alerts_timeline` + +### منطق + +این کارت هیچ فرمول داخلی ندارد و داده را مستقیماً از `ai_bundle` برمی‌دارد: + +```text +alerts = ai_bundle.get("timeline", []) +``` + +--- + +## 9) `water_need_prediction.py` + +تابع سازنده: `build_water_need_prediction` + +برای ۷ forecast اول: + +### فرمول نیاز آبی روزانه + +```text +et0 = safe_number(forecast.et0, 4) +rain = safe_number(forecast.precipitation, 0) +need = max(0, round((et0 * 100) - (rain * 20))) +``` + +توضیح: +- `ET0` در ۱۰۰ ضرب می‌شود. +- بارش در ۲۰ ضرب و از آن کم می‌شود. +- مقدار منفی به صفر clamp می‌شود. + +### خروجی نهایی + +```text +daily_needs = [need for first 7 forecasts] +totalNext7Days = sum(daily_needs) +categories = ["روز 1", "روز 2", ...] +series = [{"name": "نیاز آبی", "data": daily_needs}] +``` + +واحد خروجی: + +```text +unit = "m³" +``` + +--- + +## 10) `harvest_prediction_card.py` + +تابع سازنده: `build_harvest_prediction_card` + +### ورودی‌ها + +- میانگین `temperature_mean` تمام forecastها → `avg_temp` +- `sensor.soil_moisture` → `moisture_factor` +- نام اولین گیاه → `plant_name` + +### فرمول‌ها + +#### 1. میانگین دما + +```text +avg_temp = average([forecast.temperature_mean for forecast in forecasts], default=24) +``` + +#### 2. فاکتور رطوبت + +```text +moisture_factor = sensor.soil_moisture if available else 50 +``` + +#### 3. روز باقی‌مانده تا برداشت + +```text +days_until = max(10, int(90 - avg_temp - (moisture_factor / 5))) +``` + +توضیح: +- هرچه دمای متوسط بیشتر باشد، `days_until` کمتر می‌شود. +- هرچه رطوبت خاک بیشتر باشد، `days_until` کمتر می‌شود. +- حداقل ۱۰ روز است. + +#### 4. تاریخ برداشت و بازه بهینه + +```text +target_date = today + days_until +optimalWindowStart = target_date - 3 days +optimalWindowEnd = target_date + 3 days +``` + +#### 5. توضیح متنی + +اگر گیاه وجود داشته باشد: + +```text +description = "بر اساس دمای فعلی، رطوبت خاک و اطلاعات . بازه بهینه برداشت محاسبه شده است." +``` + +اگر گیاهی وجود نداشته باشد: + +```text +plant_name = "محصول" +``` + +--- + +## 11) `yield_prediction_chart.py` + +تابع سازنده: `build_yield_prediction_chart` + +### فرمول‌ها + +#### 1. مقدار پایه + +```text +base = max(10, round(sensor.soil_moisture * 0.6)) +``` + +#### 2. سری سال جاری + +```text +current_year = [ + base + 0, + base + 2, + base + 4, + base + 6, + base + 8, + base + 10, + base + 12, + base + 11, + base + 9, + base + 7, + base + 5, + base + 4 +] +``` + +#### 3. سری سال گذشته + +```text +last_year = [value - 3 for value in current_year] +``` + +#### 4. خلاصه کارت + +عملکرد پیش‌بینی‌شده: + +```text +summary[0].amount = current_year[9] + " تن" +``` + +یعنی مقدار ماه دهم لیست (اندیس ۹). + +تاریخ برداشت: + +```text +harvest_month = "حدود " + str(today.month) +summary[1].amount = "+8%" +``` + +نکته: +- `+8%` مقدار ثابت است و از فرمول نیامده. + +--- + +## 12) `soil_moisture_heatmap.py` + +تابع سازنده: `build_soil_moisture_heatmap` + +### ورودی‌ها + +- `sensor.soil_moisture` → `base_moisture` +- `depth.wv0033` برای هر لایه عمق خاک + +### منطق اولیه + +```text +hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"] +``` + +اگر `depths` خالی باشد: + +```text +depths = [None, None] +``` + +### فرمول zone offset + +برای هر depth: + +```text +depth_offset = 0 if depth is None else round(depth.wv0033 / 10) +``` + +### فرمول هر خانه heatmap + +برای هر zone و هر ساعت: + +```text +value = clamp( + round(base_moisture + depth_offset - abs(3 - hour_index) * 2), + 0, + 100 +) +``` + +توضیح: +- `hour_index = 3` مرکز نمودار است و بیشترین مقدار را می‌دهد. +- هرچه از مرکز دورتر شویم، به ازای هر پله ۲ واحد کم می‌شود. + +ساختار خروجی هر نقطه: + +```text +{"x": hour, "y": value} +``` + +--- + +## 13) `ndvi_health_card.py` + +تابع سازنده: `build_ndvi_health_card` + +### ورودی‌ها + +- `sensor.nitrogen` → `nitrogen` +- `sensor.soil_moisture` → `moisture` + +### فرمول NDVI + +```text +ndvi = round( + clamp(((nitrogen / 100) * 0.4) + ((moisture / 100) * 0.6), 0.1, 0.95), + 2 +) +``` + +توضیح: +- نیتروژن ۴۰٪ وزن دارد. +- رطوبت خاک ۶۰٪ وزن دارد. +- خروجی بین `0.1` و `0.95` محدود می‌شود. + +### وضعیت‌های متنی + +#### 1. تنش نیتروژن + +```text +"پایین" if nitrogen >= 30 else "بالا" +``` + +#### 2. سلامت محصول + +```text +"خوب" if ndvi >= 0.65 else "متوسط" +``` + +--- + +## 14) `recommendations_list.py` + +تابع سازنده: `build_recommendations_list` + +### منطق + +این کارت فرمول داخلی ندارد: + +```text +recommendations = ai_bundle.get("recommendations", []) +``` + +--- + +## 15) `economic_overview.py` + +تابع سازنده: `build_economic_overview` + +### ورودی‌ها + +- `forecast.et0` برای ۶ forecast اول +- `sensor.nitrogen` +- `sensor.phosphorus` +- `sensor.potassium` + +### فرمول‌ها + +#### 1. هزینه آب + +```text +water_cost = round(sum(max(0, forecast.et0 * 20) for first 6 forecasts)) +``` + +در کد با fallback: + +```text +water_cost = round( + sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6]) +) +``` + +#### 2. نیاز کودی + +```text +fertilizer_need = round((nitrogen + phosphorus + potassium) / 3) +``` + +با fallback صفر برای هر کدام. + +#### 3. پیش‌بینی درآمد + +```text +revenue = round(max(1000, water_cost * 4.5)) +``` + +#### 4. صرفه‌جویی آب هوشمند + +```text +smart_saving = round(water_cost * 0.18) +``` + +### chartSeries + +#### سری هزینه آب + +```text +water_series = [max(1, round(water_cost / 6)) for _ in range(6)] +``` + +#### سری کود + +```text +fertilizer_series = [max(1, round(fertilizer_need / 6)) for _ in range(6)] +``` + +نکته: +- هر دو سری، ۶ مقدار تکراری یکسان تولید می‌کنند و روند ماهانه واقعی ندارند. + +--- + +## کارت‌های بدون فرمول محاسباتی + +این کارت‌ها فقط داده را از `ai_bundle` می‌خوانند: + +- `farm_alerts_timeline.py` +- `recommendations_list.py` + +--- + +## نکات مهم برای تیم + +- چند اسم کارت با واقعیت محاسبه‌شان دقیقاً منطبق نیست؛ مثلاً `avg_soil_moisture` واقعاً average چند منبع نیست. +- بعضی trendها نسبت به history درستِ همان شاخص محاسبه نمی‌شوند؛ مخصوصاً در `sensor_values_list.py`. +- چند مقدار hard-coded هستند، مثل: + - `light_score = 85` + - `sensor_values_list` برای نور = `850` + - `yield_prediction_chart` برای برداشت = `+8%` + - سری ایده‌آل در `sensor_radar_chart` +- چند کارت به‌جای مدل تحلیلی واقعی، از فرمول‌های heuristic ساده استفاده می‌کنند. + diff --git a/dashboard_data/cards/anomaly_detection_card.py b/dashboard_data/cards/anomaly_detection_card.py index ac38c55..eac508a 100644 --- a/dashboard_data/cards/anomaly_detection_card.py +++ b/dashboard_data/cards/anomaly_detection_card.py @@ -1,34 +1,262 @@ -from dashboard_data.card_utils import safe_number +from __future__ import annotations + +from math import sqrt +from statistics import mean +from typing import Any + + +METRIC_CONFIG = { + "soil_moisture": { + "label": "رطوبت خاک", + "unit": "%", + "source": "history", + "current_field": "soil_moisture", + }, + "soil_temperature": { + "label": "دمای خاک", + "unit": "°C", + "source": "history", + "current_field": "soil_temperature", + }, + "humidity": { + "label": "رطوبت هوا", + "unit": "%", + "source": "forecast", + "forecast_field": "humidity_mean", + }, + "soil_ph": { + "label": "pH خاک", + "unit": "pH", + "source": "history", + "current_field": "soil_ph", + }, + "electrical_conductivity": { + "label": "هدایت الکتریکی", + "unit": "dS/m", + "source": "history", + "current_field": "electrical_conductivity", + }, +} + +METHOD_PRIORITY = {"IQR": 2, "Z_SCORE": 1} + + +def _percentile(sorted_values: list[float], percentile: float) -> float: + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * percentile + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = index - lower + return sorted_values[lower] + ((sorted_values[upper] - sorted_values[lower]) * fraction) + + +def _population_std(values: list[float]) -> float: + if len(values) < 2: + return 0.0 + center = mean(values) + variance = sum((value - center) ** 2 for value in values) / len(values) + return sqrt(variance) + + +def _severity_from_score(score: float) -> str: + absolute = abs(score) + if absolute >= 3.5: + return "critical" + if absolute >= 2.5: + return "high" + if absolute >= 1.5: + return "medium" + return "low" + + +def _history_series(history: list[Any], field_name: str) -> tuple[list[float], str | None, float | None]: + values: list[float] = [] + latest_timestamp = None + latest_value = None + + for item in history: + value = getattr(item, field_name, None) + if value is None: + continue + numeric = float(value) + values.append(numeric) + if latest_timestamp is None: + recorded_at = getattr(item, "recorded_at", None) + latest_timestamp = recorded_at.isoformat() if recorded_at is not None else None + latest_value = numeric + + return list(reversed(values)), latest_timestamp, latest_value + + +def _forecast_series(forecasts: list[Any], field_name: str) -> tuple[list[float], str | None, float | None]: + values: list[float] = [] + latest_timestamp = None + latest_value = None + + for forecast in forecasts[:7]: + value = getattr(forecast, field_name, None) + if value is None: + continue + numeric = float(value) + values.append(numeric) + if latest_timestamp is None: + forecast_date = getattr(forecast, "forecast_date", None) + latest_timestamp = forecast_date.isoformat() if forecast_date is not None else None + latest_value = numeric + + return values, latest_timestamp, latest_value + + +def _detect_with_z_score(values: list[float], observed_value: float) -> dict[str, Any] | None: + if len(values) < 5: + return None + center = mean(values) + std = _population_std(values) + if std == 0: + return None + score = (observed_value - center) / std + if abs(score) < 2.0: + return None + return { + "anomaly_method": "Z_SCORE", + "deviation_score": round(score, 3), + "expected_range": [round(center - (2 * std), 2), round(center + (2 * std), 2)], + "severity": _severity_from_score(score), + } + + +def _detect_with_iqr(values: list[float], observed_value: float) -> dict[str, Any] | None: + if len(values) < 5: + return None + sorted_values = sorted(values) + q1 = _percentile(sorted_values, 0.25) + q3 = _percentile(sorted_values, 0.75) + iqr = q3 - q1 + if iqr == 0: + return None + lower = q1 - (1.5 * iqr) + upper = q3 + (1.5 * iqr) + if lower <= observed_value <= upper: + return None + + if observed_value < lower: + score = (observed_value - lower) / iqr + else: + score = (observed_value - upper) / iqr + + return { + "anomaly_method": "IQR", + "deviation_score": round(score, 3), + "expected_range": [round(lower, 2), round(upper, 2)], + "severity": _severity_from_score(score), + } + + +def _select_detection_result(results: list[dict[str, Any]]) -> dict[str, Any] | None: + if not results: + return None + return sorted( + results, + key=lambda item: (METHOD_PRIORITY[item["anomaly_method"]], abs(item["deviation_score"])), + reverse=True, + )[0] + + +def _build_contextual_interpretation(anomalies: list[dict[str, Any]], ai_bundle: dict | None = None) -> dict[str, Any]: + ai_bundle = ai_bundle or {} + ai_payload = ai_bundle.get("anomalyDetectionCard", {}) if isinstance(ai_bundle, dict) else {} + if isinstance(ai_payload, dict) and all(ai_payload.get(key) for key in ("explanation", "likely_cause", "recommended_action")): + return { + "explanation": ai_payload["explanation"], + "likely_cause": ai_payload["likely_cause"], + "recommended_action": ai_payload["recommended_action"], + } + + metric_types = {item["metric_type"] for item in anomalies} + if {"soil_temperature", "soil_moisture"} <= metric_types: + return { + "explanation": "هم‌زمانی ناهنجاری دمای خاک و رطوبت خاک نشان می‌دهد تنش ترکیبی در ناحیه ریشه در حال شکل‌گیری است.", + "likely_cause": "احتمالاً الگوی آبیاری، موج گرما یا افت ناگهانی ظرفیت نگهداشت رطوبت خاک عامل اصلی است.", + "recommended_action": "زمان‌بندی آبیاری و وضعیت زهکشی/تبخیر بررسی و قرائت‌های سنسور در ۲۴ ساعت آینده دوباره پایش شود.", + } + if "electrical_conductivity" in metric_types and "soil_moisture" in metric_types: + return { + "explanation": "هم‌زمانی ناهنجاری EC و رطوبت می‌تواند نشان‌دهنده فشار شوری یا تجمع نمک در بستر باشد.", + "likely_cause": "کیفیت آب آبیاری، کوددهی اخیر یا کاهش شست‌وشوی خاک می‌تواند عامل این الگو باشد.", + "recommended_action": "EC آب و برنامه کوددهی بازبینی و در صورت نیاز شست‌وشوی کنترل‌شده خاک بررسی شود.", + } + if anomalies: + top = anomalies[0] + return { + "explanation": f"در شاخص {top['label']} یک ناهنجاری آماری با روش {top['anomaly_method']} شناسایی شده است.", + "likely_cause": "این رخداد می‌تواند ناشی از تغییر ناگهانی شرایط محیطی، خطای فرایندی یا نیاز به کالیبراسیون سنسور باشد.", + "recommended_action": "روند همان شاخص و داده‌های پیرامونی بازبینی و در صورت تداوم، اقدام اصلاحی مزرعه اجرا شود.", + } + return { + "explanation": "ناهنجاری آماری معناداری در داده‌های اخیر شناسایی نشد.", + "likely_cause": "داده‌های فعلی با الگوی تاریخی سازگار هستند.", + "recommended_action": "پایش عادی ادامه یابد.", + } def build_anomaly_detection_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: - sensor = (context or {}).get("sensor") + context = context or {} + sensor = context.get("sensor") + history = context.get("history", []) + forecasts = context.get("forecasts", []) if sensor is None: - return {"anomalies": []} + return {"anomalies": [], "interpretation": None} + + anomalies: list[dict[str, Any]] = [] + + for metric_type, config in METRIC_CONFIG.items(): + if config["source"] == "history": + values, timestamp, observed_value = _history_series(history, config["current_field"]) + current_value = getattr(sensor, config["current_field"], None) + if current_value is not None: + observed_value = float(current_value) + timestamp = getattr(sensor, "updated_at", None) + timestamp = timestamp.isoformat() if timestamp is not None else timestamp + else: + values, timestamp, observed_value = _forecast_series(forecasts, config["forecast_field"]) + + if observed_value is None or len(values) < 5: + continue + + detection = _select_detection_result( + [ + result + for result in ( + _detect_with_z_score(values, observed_value), + _detect_with_iqr(values, observed_value), + ) + if result is not None + ] + ) + if detection is None: + continue - 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", + "metric_type": metric_type, + "label": config["label"], + "timestamp": timestamp, + "observed_value": round(observed_value, 2), + "expected_range": detection["expected_range"], + "deviation_score": detection["deviation_score"], + "anomaly_method": detection["anomaly_method"], + "severity": detection["severity"], + "unit": config["unit"], } ) - 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", - } - ) + anomalies.sort(key=lambda item: abs(item["deviation_score"]), reverse=True) + interpretation = _build_contextual_interpretation(anomalies, ai_bundle=ai_bundle) - return {"anomalies": anomalies} + return { + "anomalies": anomalies, + "interpretation": interpretation, + } diff --git a/dashboard_data/cards/farm_alerts_timeline.py b/dashboard_data/cards/farm_alerts_timeline.py index 85965cb..d2fd225 100644 --- a/dashboard_data/cards/farm_alerts_timeline.py +++ b/dashboard_data/cards/farm_alerts_timeline.py @@ -1,3 +1,6 @@ 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", [])} + return { + "alerts": ai_bundle.get("timeline", []), + "structuredContext": ai_bundle.get("structured_context", {}), + } diff --git a/dashboard_data/cards/farm_alerts_tracker.py b/dashboard_data/cards/farm_alerts_tracker.py index 98ba223..8d7e92e 100644 --- a/dashboard_data/cards/farm_alerts_tracker.py +++ b/dashboard_data/cards/farm_alerts_tracker.py @@ -1,41 +1,560 @@ -from dashboard_data.card_utils import average, safe_number +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", []) - if sensor is None: - return {"totalAlerts": 0, "radialBarValue": 0, "alertStats": []} + history = context.get("history", []) - 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 + 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": 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", - }, - ], + "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], } diff --git a/dashboard_data/cards/farm_overview_kpis.py b/dashboard_data/cards/farm_overview_kpis.py index e3b604c..13224c8 100644 --- a/dashboard_data/cards/farm_overview_kpis.py +++ b/dashboard_data/cards/farm_overview_kpis.py @@ -1,6 +1,160 @@ +from __future__ import annotations + +from typing import Any + from dashboard_data.card_utils import average, safe_number +DEFAULT_HEALTH_PROFILE = { + "moisture": {"ideal_value": 65.0, "min_range": 45.0, "max_range": 75.0, "weight": 0.45}, + "ph": {"ideal_value": 6.6, "min_range": 6.0, "max_range": 7.5, "weight": 0.30}, + "ec": {"ideal_value": 1.2, "min_range": 0.2, "max_range": 3.0, "weight": 0.25}, +} + +METRIC_SPECS = { + "moisture": { + "sensor_field": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + }, + "ph": { + "sensor_field": "soil_ph", + "label": "pH خاک", + "unit": "pH", + }, + "ec": { + "sensor_field": "electrical_conductivity", + "label": "هدایت الکتریکی", + "unit": "dS/m", + }, +} + + +def _normalize_metric(value: float, ideal_value: float, min_range: float, max_range: float) -> float: + if max_range <= min_range: + return 0.0 + if value <= min_range or value >= max_range: + return 0.0 + if value == ideal_value: + return 1.0 + if value < ideal_value: + span = ideal_value - min_range + if span <= 0: + return 0.0 + return max(0.0, min(1.0, (value - min_range) / span)) + span = max_range - ideal_value + if span <= 0: + return 0.0 + return max(0.0, min(1.0, (max_range - value) / span)) + + +def _resolve_plant_profile(context: dict[str, Any]) -> tuple[dict[str, dict[str, float]], str]: + plants = context.get("plants", []) + for plant in plants: + profile = getattr(plant, "health_profile", None) or {} + if profile: + merged = { + metric: { + **DEFAULT_HEALTH_PROFILE.get(metric, {}), + **profile.get(metric, {}), + } + for metric in set(DEFAULT_HEALTH_PROFILE) | set(profile) + } + return merged, getattr(plant, "name", "گیاه") + return DEFAULT_HEALTH_PROFILE, (plants[0].name if plants else "پروفایل پیش‌فرض") + + +def _compute_health_score(sensor: Any, profile: dict[str, dict[str, float]]) -> tuple[int, list[dict[str, Any]]]: + weighted_sum = 0.0 + total_weight = 0.0 + components: list[dict[str, Any]] = [] + + for metric_type, config in profile.items(): + spec = METRIC_SPECS.get(metric_type) + if spec is None: + continue + + sensor_value = getattr(sensor, spec["sensor_field"], None) + if sensor_value is None: + continue + + current_value = float(safe_number(sensor_value, 0)) + ideal_value = float(config.get("ideal_value", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("ideal_value", 0))) + min_range = float(config.get("min_range", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("min_range", 0))) + max_range = float(config.get("max_range", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("max_range", 0))) + weight = float(config.get("weight", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("weight", 0))) + if weight <= 0: + continue + + normalized_value = _normalize_metric( + value=current_value, + ideal_value=ideal_value, + min_range=min_range, + max_range=max_range, + ) + weighted_sum += weight * normalized_value + total_weight += weight + components.append( + { + "metricType": metric_type, + "label": spec["label"], + "unit": spec["unit"], + "currentValue": round(current_value, 2), + "idealValue": round(ideal_value, 2), + "minRange": round(min_range, 2), + "maxRange": round(max_range, 2), + "weight": round(weight, 3), + "normalizedValue": round(normalized_value, 4), + "weightedContribution": round(weight * normalized_value, 4), + } + ) + + if total_weight <= 0: + return 0, components + + score = round((weighted_sum / total_weight) * 100) + return max(0, min(100, score)), components + + +def _health_language(health_score: int, ai_bundle: dict | None = None) -> dict[str, str]: + ai_bundle = ai_bundle or {} + ai_health = ai_bundle.get("farmOverviewKpis", {}) if isinstance(ai_bundle, dict) else {} + short_chip_text = ai_health.get("short_chip_text") + action_hint = ai_health.get("action_hint") + explanation = ai_health.get("explanation") + + if isinstance(short_chip_text, str) and short_chip_text.strip() and isinstance(action_hint, str) and action_hint.strip() and isinstance(explanation, str) and explanation.strip(): + return { + "short_chip_text": short_chip_text.strip(), + "action_hint": action_hint.strip(), + "explanation": explanation.strip(), + } + + if health_score >= 85: + return { + "short_chip_text": "بسیار خوب", + "action_hint": "برنامه فعلی پایش و نگهداری حفظ شود.", + "explanation": "شاخص سلامت مزرعه به محدوده بسیار خوب رسیده و بیشتر پارامترهای کلیدی نزدیک به پروفایل ایده‌آل گیاه هستند.", + } + if health_score >= 70: + return { + "short_chip_text": "پایدار", + "action_hint": "تنظیمات فعلی حفظ و فقط شاخص‌های مرزی پایش شوند.", + "explanation": "سلامت مزرعه در محدوده قابل قبول است، اما برخی پارامترها هنوز با مقدار ایده‌آل فاصله دارند.", + } + if health_score >= 50: + return { + "short_chip_text": "نیازمند تنظیم", + "action_hint": "پارامترهای دور از محدوده ایده‌آل در اولویت اصلاح قرار گیرند.", + "explanation": "امتیاز سلامت نشان می‌دهد بخشی از شرایط محیطی از پروفایل مطلوب گیاه فاصله گرفته و باید تنظیم شود.", + } + return { + "short_chip_text": "تنش بالا", + "action_hint": "اصلاح فوری رطوبت، تغذیه یا شوری بر اساس اجزای امتیاز انجام شود.", + "explanation": "سلامت مزرعه در محدوده ضعیف قرار دارد و چند شاخص اصلی خارج از بازه قابل قبول گیاه هستند.", + } + + 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") @@ -8,26 +162,36 @@ def build_farm_overview_kpis(sensor_id: str, context: dict | None = None, ai_bun if sensor is None: return {"kpis": []} + profile, profile_source = _resolve_plant_profile(context) + health_score, health_components = _compute_health_score(sensor, profile) + health_language = _health_language(health_score, ai_bundle=ai_bundle) + 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) + primary_gap = min(health_components, key=lambda item: item["normalizedValue"], default=None) return { "kpis": [ { "id": "farm_health_score", "title": "امتیاز سلامت مزرعه", - "subtitle": "تحلیل هوشمند", + "subtitle": f"پروفایل {profile_source}", "stats": f"{health_score}%", - "avatarColor": "success" if health_score >= 70 else "warning", + "avatarColor": "success" if health_score >= 70 else "warning" if health_score >= 50 else "error", "avatarIcon": "tabler-heartbeat", - "chipText": "خوب" if health_score >= 70 else "متوسط", - "chipColor": "success" if health_score >= 70 else "warning", + "chipText": health_language["short_chip_text"], + "chipColor": "success" if health_score >= 70 else "warning" if health_score >= 50 else "error", + "actionHint": health_language["action_hint"], + "explanation": health_language["explanation"], + "healthScoreDetails": { + "method": "normalized_weighted_average", + "profileSource": profile_source, + "components": health_components, + }, }, { "id": "water_stress_index", @@ -66,8 +230,10 @@ def build_farm_overview_kpis(sensor_id: str, context: dict | None = None, ai_bun "stats": f"{yield_prediction} تن", "avatarColor": "secondary", "avatarIcon": "tabler-chart-bar", - "chipText": f"+{max(0, health_score - 50)}%", - "chipColor": "success", + "chipText": ( + primary_gap["label"] if primary_gap else "پایدار" + ), + "chipColor": "warning" if primary_gap and primary_gap["normalizedValue"] < 0.6 else "success", }, { "id": "pest_risk", diff --git a/dashboard_data/cards/harvest_prediction_card.py b/dashboard_data/cards/harvest_prediction_card.py index eeb8704..7c06037 100644 --- a/dashboard_data/cards/harvest_prediction_card.py +++ b/dashboard_data/cards/harvest_prediction_card.py @@ -1,6 +1,22 @@ -from datetime import date, timedelta +from __future__ import annotations -from dashboard_data.card_utils import average, safe_number +from datetime import date + +from plant.gdd import predict_harvest_from_forecasts + + +def _harvest_language(prediction: dict, plant_name: str, ai_bundle: dict | None = None) -> str: + ai_bundle = ai_bundle or {} + ai_payload = ai_bundle.get("harvestPredictionCard", {}) if isinstance(ai_bundle, dict) else {} + description = ai_payload.get("description") + if isinstance(description, str) and description.strip(): + return description.strip() + + return ( + f"برای {plant_name}، رشد تجمعی بر اساس مدل GDD محاسبه شده است. " + f"تا امروز {prediction['current_cumulative_gdd']} واحد-روز رشد ثبت شده و " + f"برای رسیدن به بلوغ حدود {prediction['remaining_gdd']} واحد-روز دیگر نیاز است." + ) def build_harvest_prediction_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: @@ -8,19 +24,19 @@ def build_harvest_prediction_card(sensor_id: str, context: dict | None = None, a 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 "محصول" + plant = plants[0] if plants else None + plant_name = plant.name if plant else "محصول" + prediction = predict_harvest_from_forecasts(forecasts=forecasts, plant=plant).__dict__ + target_date = date.fromisoformat(prediction["predicted_harvest_date"]) + window_start = date.fromisoformat(prediction["predicted_harvest_window"]["start"]) + window_end = date.fromisoformat(prediction["predicted_harvest_window"]["end"]) return { - "date": str(target_date), + "date": prediction["predicted_harvest_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), + "daysUntil": prediction["estimated_days_to_harvest"], + "description": _harvest_language(prediction, plant_name=plant_name, ai_bundle=ai_bundle), + "optimalWindowStart": window_start.isoformat(), + "optimalWindowEnd": window_end.isoformat(), + "gddDetails": prediction, } diff --git a/dashboard_data/cards/ndvi_health_card.py b/dashboard_data/cards/ndvi_health_card.py index 4a7616b..c3d0f27 100644 --- a/dashboard_data/cards/ndvi_health_card.py +++ b/dashboard_data/cards/ndvi_health_card.py @@ -1,30 +1,78 @@ -from dashboard_data.card_utils import safe_number +from __future__ import annotations + +from dashboard_data.remote_sensing import fetch_or_get_ndvi_observation + + +def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str: + ai_bundle = ai_bundle or {} + ai_payload = ai_bundle.get("ndviHealthCard", {}) if isinstance(ai_bundle, dict) else {} + explanation = ai_payload.get("explanation") + if isinstance(explanation, str) and explanation.strip(): + return explanation.strip() + return ( + f"میانگین NDVI مزرعه {observation.mean_ndvi} ثبت شده و کلاس سلامت پوشش گیاهی " + f"در وضعیت {observation.vegetation_health_class} قرار دارد." + ) 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": []} + location = context.get("location") + if location is None: + return { + "mean_ndvi": None, + "ndvi_map": {}, + "vegetation_health_class": None, + "observation_date": None, + "satellite_source": None, + "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) + observation = fetch_or_get_ndvi_observation(location) + if observation is None: + return { + "mean_ndvi": None, + "ndvi_map": {}, + "vegetation_health_class": "Unavailable", + "observation_date": None, + "satellite_source": None, + "healthData": [ + { + "title": "وضعیت NDVI", + "value": "داده ماهواره‌ای موجود نیست", + "color": "warning", + "icon": "tabler-satellite-off", + }, + ], + } + mean_value = round(observation.mean_ndvi, 2) + vegetation_class = observation.vegetation_health_class return { - "ndviIndex": ndvi, + "ndviIndex": mean_value, + "mean_ndvi": mean_value, + "ndvi_map": observation.ndvi_map, + "vegetation_health_class": vegetation_class, + "observation_date": observation.observation_date.isoformat(), + "satellite_source": observation.satellite_source, "healthData": [ { - "title": "تنش نیتروژن", - "value": "پایین" if nitrogen >= 30 else "بالا", - "color": "success" if nitrogen >= 30 else "warning", - "icon": "tabler-leaf", + "title": "سلامت پوشش گیاهی", + "value": vegetation_class, + "color": "success" if mean_value > 0.6 else "warning" if mean_value >= 0.4 else "error", + "icon": "tabler-plant", }, { - "title": "سلامت محصول", - "value": "خوب" if ndvi >= 0.65 else "متوسط", - "color": "success" if ndvi >= 0.65 else "warning", - "icon": "tabler-plant", + "title": "تاریخ مشاهده", + "value": observation.observation_date.isoformat(), + "color": "info", + "icon": "tabler-calendar", + }, + { + "title": "تفسیر", + "value": _ndvi_explanation(observation, ai_bundle=ai_bundle), + "color": "primary", + "icon": "tabler-message-2", }, ], } diff --git a/dashboard_data/cards/recommendations_list.py b/dashboard_data/cards/recommendations_list.py index 1fbefa0..2cbf2f4 100644 --- a/dashboard_data/cards/recommendations_list.py +++ b/dashboard_data/cards/recommendations_list.py @@ -1,3 +1,6 @@ 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", [])} + return { + "recommendations": ai_bundle.get("recommendations", []), + "structuredContext": ai_bundle.get("structured_context", {}), + } diff --git a/dashboard_data/cards/sensor_comparison_chart.py b/dashboard_data/cards/sensor_comparison_chart.py index e615409..768b20b 100644 --- a/dashboard_data/cards/sensor_comparison_chart.py +++ b/dashboard_data/cards/sensor_comparison_chart.py @@ -1,35 +1,125 @@ -from datetime import date, timedelta +from __future__ import annotations -from dashboard_data.card_utils import PERSIAN_WEEKDAYS, safe_number +from datetime import date, timedelta +from typing import Any + +from dashboard_data.card_utils import PERSIAN_WEEKDAYS + + +INTERPOLATION_LIMIT = 3 +QUALITY_REAL = "REAL" +QUALITY_INTERPOLATED = "INTERPOLATED" +QUALITY_MISSING = "MISSING" + + +def _day_categories() -> list[date]: + return [date.today() - timedelta(days=offset) for offset in range(6, -1, -1)] + + +def _day_label(day: date) -> str: + return PERSIAN_WEEKDAYS[day.weekday()] + + +def _history_value_map(history: list[Any], field_name: str) -> dict[date, float | None]: + value_map: dict[date, float | None] = {} + for item in history: + timestamp = getattr(item, "recorded_at", None) + if timestamp is None: + continue + day = timestamp.date() + if day in value_map: + continue + value = getattr(item, field_name, None) + value_map[day] = float(value) if value is not None else None + return value_map + + +def _apply_linear_interpolation(points: list[dict[str, Any]], limit: int = INTERPOLATION_LIMIT) -> list[dict[str, Any]]: + output = [dict(point) for point in points] + known_indexes = [index for index, point in enumerate(output) if point["value"] is not None] + + for start_index, end_index in zip(known_indexes, known_indexes[1:]): + gap = end_index - start_index - 1 + if gap <= 0 or gap > limit: + continue + + start_value = output[start_index]["value"] + end_value = output[end_index]["value"] + if start_value is None or end_value is None: + continue + + step = (end_value - start_value) / (gap + 1) + for offset in range(1, gap + 1): + target_index = start_index + offset + output[target_index]["value"] = round(start_value + (step * offset), 2) + output[target_index]["quality_flag"] = QUALITY_INTERPOLATED + + return output + + +def _build_week_points( + history: list[Any], + field_name: str, + day_offset_start: int, +) -> list[dict[str, Any]]: + days = [date.today() - timedelta(days=offset) for offset in range(day_offset_start + 6, day_offset_start - 1, -1)] + value_map = _history_value_map(history, field_name) + raw_points = [ + { + "timestamp": day.isoformat(), + "value": round(value_map[day], 2) if day in value_map and value_map[day] is not None else None, + "quality_flag": QUALITY_REAL if day in value_map and value_map[day] is not None else QUALITY_MISSING, + } + for day in days + ] + return _apply_linear_interpolation(raw_points) + + +def _average_known(points: list[dict[str, Any]]) -> float | None: + values = [point["value"] for point in points if point["value"] is not None] + if not values: + return None + return sum(values) / len(values) 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)) + current_value = getattr(current_sensor, "soil_moisture", None) if current_sensor is not None else None + current_value = round(float(current_value), 2) if current_value is not None else None - 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] + this_week_points = _build_week_points(history, "soil_moisture", day_offset_start=0) + last_week_points = _build_week_points(history, "soil_moisture", day_offset_start=7) - while len(this_week) < 7: - this_week.append(current_value) - while len(last_week) < 7: - last_week.append(max(0, current_value - 5)) + this_week_avg = _average_known(this_week_points) + last_week_avg = _average_known(last_week_points) + delta = 0 + if this_week_avg is not None and last_week_avg not in (None, 0): + delta = round(((this_week_avg - last_week_avg) / last_week_avg) * 100) - 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 + categories = [_day_label(day) for day in _day_categories()] return { "currentValue": current_value, + "currentValueQuality": QUALITY_REAL if current_value is not None else QUALITY_MISSING, "vsLastWeek": f"{'+' if delta >= 0 else ''}{delta}%", "vsLastWeekValue": delta, "categories": categories, "series": [ - {"name": "امروز", "data": this_week}, - {"name": "هفته قبل", "data": last_week}, + { + "name": "هفته جاری", + "data": [point["value"] for point in this_week_points], + "points": this_week_points, + }, + { + "name": "هفته قبل", + "data": [point["value"] for point in last_week_points], + "points": last_week_points, + }, ], + "qualityLegend": { + QUALITY_REAL: "اندازه‌گیری واقعی سنسور", + QUALITY_INTERPOLATED: "برآورد خطی برای شکاف کوتاه داده", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + }, } diff --git a/dashboard_data/cards/sensor_radar_chart.py b/dashboard_data/cards/sensor_radar_chart.py index 7e23fdf..da6f8db 100644 --- a/dashboard_data/cards/sensor_radar_chart.py +++ b/dashboard_data/cards/sensor_radar_chart.py @@ -1,14 +1,92 @@ -from dashboard_data.card_utils import safe_number +from __future__ import annotations + +from typing import Any + +from dashboard_data.card_utils import average, safe_number -def _to_score(value, lower, upper): - if value is None: +DEFAULT_IDEAL_SENSOR_PROFILE = { + "temperature": {"ideal": 24.0, "min": 18.0, "max": 30.0}, + "moisture": {"ideal": 65.0, "min": 45.0, "max": 80.0}, + "ph": {"ideal": 6.5, "min": 6.0, "max": 7.2}, + "ec": {"ideal": 1.4, "min": 1.0, "max": 2.0}, + "humidity": {"ideal": 60.0, "min": 45.0, "max": 75.0}, +} + +METRIC_ORDER = [ + ("temperature", "دما"), + ("moisture", "رطوبت"), + ("ph", "pH"), + ("ec", "هدایت الکتریکی"), + ("humidity", "رطوبت هوا"), +] + + +def _normalize_to_ideal_score(value: float | None, minimum: float, ideal: float, maximum: float) -> int: + if value is None or maximum <= minimum: return 0 - if value <= lower: + if value <= minimum or value >= maximum: return 0 - if value >= upper: + if value == ideal: return 100 - return round(((value - lower) / (upper - lower)) * 100) + if value < ideal: + span = ideal - minimum + if span <= 0: + return 0 + return round(max(0.0, min(1.0, (value - minimum) / span)) * 100) + span = maximum - ideal + if span <= 0: + return 0 + return round(max(0.0, min(1.0, (maximum - value) / span)) * 100) + + +def _resolve_profile(context: dict[str, Any]) -> tuple[dict[str, dict[str, float]], str]: + location = context.get("location") + if location is not None: + location_profile = getattr(location, "ideal_sensor_profile", None) or {} + if location_profile: + merged = { + metric: {**DEFAULT_IDEAL_SENSOR_PROFILE.get(metric, {}), **location_profile.get(metric, {})} + for metric in set(DEFAULT_IDEAL_SENSOR_PROFILE) | set(location_profile) + } + return merged, "location" + + plants = context.get("plants", []) + for plant in plants: + plant_profile = getattr(plant, "health_profile", None) or {} + if plant_profile: + translated: dict[str, dict[str, float]] = {} + for metric in DEFAULT_IDEAL_SENSOR_PROFILE: + metric_data = plant_profile.get(metric) + if not metric_data: + continue + translated[metric] = { + "ideal": float(metric_data.get("ideal_value", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["ideal"])), + "min": float(metric_data.get("min_range", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["min"])), + "max": float(metric_data.get("max_range", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["max"])), + } + merged = { + metric: {**DEFAULT_IDEAL_SENSOR_PROFILE.get(metric, {}), **translated.get(metric, {})} + for metric in DEFAULT_IDEAL_SENSOR_PROFILE + } + return merged, f"plant:{getattr(plant, 'name', 'unknown')}" + + return DEFAULT_IDEAL_SENSOR_PROFILE, "default" + + +def _current_metric_values(sensor: Any, forecasts: list[Any]) -> dict[str, float]: + current_forecast = forecasts[0] if forecasts else None + humidity = average( + [getattr(forecast, "humidity_mean", None) for forecast in forecasts[:3]], + default=safe_number(getattr(current_forecast, "humidity_mean", None), 0), + ) + return { + "temperature": float(safe_number(getattr(sensor, "soil_temperature", None), 0)), + "moisture": float(safe_number(getattr(sensor, "soil_moisture", None), 0)), + "ph": float(safe_number(getattr(sensor, "soil_ph", None), 0)), + "ec": float(safe_number(getattr(sensor, "electrical_conductivity", None), 0)), + "humidity": float(safe_number(humidity, 0)), + } def build_sensor_radar_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: @@ -16,22 +94,47 @@ def build_sensor_radar_chart(sensor_id: str, context: dict | None = None, ai_bun sensor = context.get("sensor") forecasts = context.get("forecasts", []) if sensor is None: - return {"labels": [], "series": []} + return {"labels": [], "series": [], "profileSource": None, "profile": {}} - 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), - ] + profile, profile_source = _resolve_profile(context) + current_values = _current_metric_values(sensor, forecasts) + + labels: list[str] = [] + current_series: list[int] = [] + ideal_series: list[int] = [] + metric_details: list[dict[str, Any]] = [] + + for metric_key, label in METRIC_ORDER: + metric_profile = profile.get(metric_key, DEFAULT_IDEAL_SENSOR_PROFILE.get(metric_key, {})) + minimum = float(metric_profile.get("min", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["min"])) + ideal = float(metric_profile.get("ideal", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["ideal"])) + maximum = float(metric_profile.get("max", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["max"])) + current_value = current_values.get(metric_key) + + labels.append(label) + current_score = _normalize_to_ideal_score(current_value, minimum, ideal, maximum) + current_series.append(current_score) + ideal_series.append(100) + metric_details.append( + { + "metricType": metric_key, + "label": label, + "currentValue": round(current_value, 2) if current_value is not None else None, + "idealValue": round(ideal, 2), + "minRange": round(minimum, 2), + "maxRange": round(maximum, 2), + "currentScore": current_score, + "idealScore": 100, + } + ) return { - "labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"], + "labels": labels, + "profileSource": profile_source, + "profile": profile, + "metricDetails": metric_details, "series": [ - {"name": "امروز", "data": current}, - {"name": "ایده‌آل", "data": [80, 70, 75, 75, 90, 50]}, + {"name": "امروز", "data": current_series}, + {"name": "پروفایل ایده‌آل", "data": ideal_series}, ], } diff --git a/dashboard_data/cards/soil_moisture_heatmap.py b/dashboard_data/cards/soil_moisture_heatmap.py index 7736a83..5ba6d51 100644 --- a/dashboard_data/cards/soil_moisture_heatmap.py +++ b/dashboard_data/cards/soil_moisture_heatmap.py @@ -1,32 +1,201 @@ -from dashboard_data.card_utils import safe_number +from __future__ import annotations + +from math import sqrt +from typing import Any + +from sensor_data.models import SensorData, SensorDataHistory + + +QUALITY_REAL = "REAL" +QUALITY_INTERPOLATED = "INTERPOLATED" +QUALITY_MISSING = "MISSING" +INTERPOLATION_LIMIT = 3 +IDW_POWER = 2 +MAX_GRID_STEPS = 10 + + +def _interpolate_series(points: list[dict[str, Any]], limit: int = INTERPOLATION_LIMIT) -> list[dict[str, Any]]: + output = [dict(point) for point in points] + known_indexes = [index for index, point in enumerate(output) if point["value"] is not None] + + for start_index, end_index in zip(known_indexes, known_indexes[1:]): + gap = end_index - start_index - 1 + if gap <= 0 or gap > limit: + continue + + start_value = output[start_index]["value"] + end_value = output[end_index]["value"] + if start_value is None or end_value is None: + continue + + step = (end_value - start_value) / (gap + 1) + for offset in range(1, gap + 1): + target_index = start_index + offset + output[target_index]["value"] = round(start_value + (step * offset), 2) + output[target_index]["quality_flag"] = QUALITY_INTERPOLATED + + return output + + +def _sensor_time_series(sensor: Any, histories: list[Any]) -> list[dict[str, Any]]: + points = [] + for item in reversed(histories): + points.append( + { + "timestamp": item.recorded_at.isoformat(), + "value": float(item.soil_moisture) if item.soil_moisture is not None else None, + "quality_flag": QUALITY_REAL if item.soil_moisture is not None else QUALITY_MISSING, + } + ) + points.append( + { + "timestamp": sensor.updated_at.isoformat() if getattr(sensor, "updated_at", None) else None, + "value": float(sensor.soil_moisture) if getattr(sensor, "soil_moisture", None) is not None else None, + "quality_flag": QUALITY_REAL if getattr(sensor, "soil_moisture", None) is not None else QUALITY_MISSING, + } + ) + return _interpolate_series(points) + + +def _latest_sensor_measurement(sensor: Any, histories: list[Any]) -> dict[str, Any]: + series = _sensor_time_series(sensor, histories) + latest = series[-1] if series else {"timestamp": None, "value": None, "quality_flag": QUALITY_MISSING} + return { + "sensor_id": str(sensor.uuid_sensor), + "latitude": float(sensor.location.latitude), + "longitude": float(sensor.location.longitude), + "depth": None, + "timestamp": latest["timestamp"], + "soil_moisture_value": latest["value"], + "quality_flag": latest["quality_flag"], + } + + +def _idw_value(lat: float, lon: float, sensor_points: list[dict[str, Any]]) -> float | None: + weighted_sum = 0.0 + weight_total = 0.0 + for point in sensor_points: + value = point["soil_moisture_value"] + if value is None: + continue + distance = sqrt(((lat - point["latitude"]) ** 2) + ((lon - point["longitude"]) ** 2)) + if distance == 0: + return round(float(value), 2) + weight = 1 / (distance**IDW_POWER) + weighted_sum += weight * float(value) + weight_total += weight + if weight_total == 0: + return None + return round(weighted_sum / weight_total, 2) + + +def _grid_axis(min_value: float, max_value: float) -> list[float]: + if min_value == max_value: + return [round(min_value, 6)] + step_count = min(MAX_GRID_STEPS, max(int((max_value - min_value) / 0.0001) + 1, 2)) + step = (max_value - min_value) / (step_count - 1) + return [round(min_value + (step * index), 6) for index in range(step_count)] + + +def _load_sensor_network(current_sensor: Any) -> list[Any]: + plant_ids = list(current_sensor.plants.values_list("id", flat=True)) + queryset = SensorData.objects.select_related("location").prefetch_related("plants") + if plant_ids: + queryset = queryset.filter(plants__id__in=plant_ids).distinct() + return list(queryset) 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": []} + current_sensor = context.get("sensor") + if current_sensor is None: + return { + "timestamp": None, + "grid_resolution": None, + "grid_cells": [], + "sensor_points": [], + "quality_legend": {}, + } - hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"] - base_moisture = safe_number(sensor.soil_moisture, 0) - series = [] - zones = [] + sensors = _load_sensor_network(current_sensor) + sensor_ids = [sensor.uuid_sensor for sensor in sensors] + history_rows = SensorDataHistory.objects.filter(uuid_sensor__in=sensor_ids).order_by("-recorded_at")[:200] + history_map: dict[Any, list[Any]] = {} + for row in history_rows: + history_map.setdefault(row.uuid_sensor, []).append(row) - if not depths: - depths = [None, None] + sensor_points = [ + _latest_sensor_measurement(sensor, history_map.get(sensor.uuid_sensor, [])) + for sensor in sensors + ] + valid_sensor_points = [point for point in sensor_points if point["soil_moisture_value"] is not 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}) + if not valid_sensor_points: + return { + "timestamp": current_sensor.updated_at.isoformat() if getattr(current_sensor, "updated_at", None) else None, + "grid_resolution": None, + "grid_cells": [], + "sensor_points": sensor_points, + "quality_legend": { + QUALITY_REAL: "اندازه‌گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار سنسور با درون‌یابی زمانی کوتاه‌مدت", + QUALITY_MISSING: "داده معتبر برای سنسور موجود نیست", + }, + } + + min_lat = min(point["latitude"] for point in valid_sensor_points) + max_lat = max(point["latitude"] for point in valid_sensor_points) + min_lon = min(point["longitude"] for point in valid_sensor_points) + max_lon = max(point["longitude"] for point in valid_sensor_points) + + lat_axis = _grid_axis(min_lat, max_lat) + lon_axis = _grid_axis(min_lon, max_lon) + + grid_cells = [] + for lat in lat_axis: + for lon in lon_axis: + direct_sensor = next( + ( + point for point in valid_sensor_points + if point["latitude"] == lat and point["longitude"] == lon + ), + None, + ) + if direct_sensor is not None: + moisture_value = direct_sensor["soil_moisture_value"] + quality_flag = direct_sensor["quality_flag"] + elif len(valid_sensor_points) >= 2: + moisture_value = _idw_value(lat, lon, valid_sensor_points) + quality_flag = QUALITY_INTERPOLATED if moisture_value is not None else QUALITY_MISSING + else: + moisture_value = None + quality_flag = QUALITY_MISSING + + grid_cells.append( + { + "lat": lat, + "lon": lon, + "moisture_value": moisture_value, + "quality_flag": quality_flag, + } + ) + + lat_step = round(abs(lat_axis[1] - lat_axis[0]), 6) if len(lat_axis) > 1 else 0.0 + lon_step = round(abs(lon_axis[1] - lon_axis[0]), 6) if len(lon_axis) > 1 else 0.0 return { - "zones": zones, - "hours": hours, - "series": series, + "timestamp": max(point["timestamp"] for point in sensor_points if point["timestamp"]), + "grid_resolution": { + "lat_step": lat_step, + "lon_step": lon_step, + "rows": len(lat_axis), + "cols": len(lon_axis), + }, + "grid_cells": grid_cells, + "sensor_points": sensor_points, + "quality_legend": { + QUALITY_REAL: "اندازه‌گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار برآوردشده با درون‌یابی زمانی/فضایی", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + }, } diff --git a/dashboard_data/cards/water_need_prediction.py b/dashboard_data/cards/water_need_prediction.py index cbe7f08..4a83f9c 100644 --- a/dashboard_data/cards/water_need_prediction.py +++ b/dashboard_data/cards/water_need_prediction.py @@ -1,18 +1,38 @@ -from dashboard_data.card_utils import safe_number +from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile 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) + context = context or {} + forecasts = context.get("forecasts", []) + location = context.get("location") + plants = context.get("plants", []) + irrigation_methods = context.get("irrigation_methods", []) + + if not forecasts or location is None: + return { + "totalNext7Days": 0, + "unit": "mm", + "categories": [], + "series": [], + "dailyBreakdown": [], + } + + plant = plants[0] if plants else None + crop_profile = resolve_crop_profile(plant) + efficiency = getattr(irrigation_methods[0], "water_efficiency_percent", None) if irrigation_methods else None + daily = calculate_forecast_water_needs( + forecasts=forecasts[:7], + latitude_deg=float(location.latitude), + crop_profile=crop_profile, + growth_stage=crop_profile.get("current_stage"), + irrigation_efficiency_percent=efficiency, + ) + daily_requirements = [round(item["gross_irrigation_mm"], 2) for item in daily] return { - "totalNext7Days": sum(daily_needs), - "unit": "m³", - "categories": [f"روز {index}" for index in range(1, len(daily_needs) + 1)], - "series": [{"name": "نیاز آبی", "data": daily_needs}], + "totalNext7Days": round(sum(daily_requirements), 2), + "unit": "mm", + "categories": [f"روز {index}" for index in range(1, len(daily_requirements) + 1)], + "series": [{"name": "نیاز آبی تعدیل‌شده", "data": daily_requirements}], + "dailyBreakdown": daily, } diff --git a/dashboard_data/migrations/0002_ndvi_observation.py b/dashboard_data/migrations/0002_ndvi_observation.py new file mode 100644 index 0000000..798d866 --- /dev/null +++ b/dashboard_data/migrations/0002_ndvi_observation.py @@ -0,0 +1,36 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0004_soillocation_farm_boundary"), + ("dashboard_data", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="NdviObservation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("observation_date", models.DateField(db_index=True)), + ("mean_ndvi", models.FloatField()), + ("ndvi_map", models.JSONField(blank=True, default=dict)), + ("vegetation_health_class", models.CharField(max_length=64)), + ("satellite_source", models.CharField(default="sentinel-2", max_length=64)), + ("cloud_cover", models.FloatField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("location", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="ndvi_observations", to="location_data.soillocation")), + ], + options={ + "ordering": ["-observation_date", "-created_at"], + "verbose_name": "NDVI Observation", + "verbose_name_plural": "NDVI Observations", + "constraints": [ + models.UniqueConstraint(fields=("location", "observation_date", "satellite_source"), name="ndvi_unique_location_date_source"), + ], + }, + ), + ] diff --git a/dashboard_data/models.py b/dashboard_data/models.py index 52ded5d..5a34b0c 100644 --- a/dashboard_data/models.py +++ b/dashboard_data/models.py @@ -36,3 +36,32 @@ class DashboardAiRequestLog(models.Model): def __str__(self): return f"{self.sensor_id} - {self.status} - {self.created_at}" + +class NdviObservation(models.Model): + location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="ndvi_observations", + ) + observation_date = models.DateField(db_index=True) + mean_ndvi = models.FloatField() + ndvi_map = models.JSONField(default=dict, blank=True) + vegetation_health_class = models.CharField(max_length=64) + satellite_source = models.CharField(max_length=64, default="sentinel-2") + cloud_cover = models.FloatField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-observation_date", "-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["location", "observation_date", "satellite_source"], + name="ndvi_unique_location_date_source", + ) + ] + verbose_name = "NDVI Observation" + verbose_name_plural = "NDVI Observations" + + def __str__(self): + return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}" diff --git a/dashboard_data/remote_sensing.py b/dashboard_data/remote_sensing.py new file mode 100644 index 0000000..422f73a --- /dev/null +++ b/dashboard_data/remote_sensing.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + +import requests + +from .models import NdviObservation + + +DEFAULT_SATELLITE_SOURCE = "sentinel-2" +DEFAULT_CLOUD_COVER = 20.0 + + +def classify_ndvi(mean_ndvi: float) -> str: + if mean_ndvi < 0.2: + return "Bare soil" + if mean_ndvi < 0.4: + return "Weak vegetation" + if mean_ndvi < 0.6: + return "Moderate vegetation" + return "Healthy vegetation" + + +def calculate_ndvi(red: float, nir: float) -> float | None: + denominator = nir + red + if denominator == 0: + return None + return round((nir - red) / denominator, 4) + + +def calculate_ndvi_grid(red_band: list[list[float]], nir_band: list[list[float]]) -> list[list[float | None]]: + grid: list[list[float | None]] = [] + for red_row, nir_row in zip(red_band, nir_band): + row: list[float | None] = [] + for red, nir in zip(red_row, nir_row): + row.append(calculate_ndvi(float(red), float(nir))) + grid.append(row) + return grid + + +def mean_ndvi(grid: list[list[float | None]]) -> float: + values = [value for row in grid for value in row if value is not None] + if not values: + return 0.0 + return round(sum(values) / len(values), 4) + + +def _default_bbox(location: Any, delta: float = 0.001) -> list[float]: + lat = float(location.latitude) + lon = float(location.longitude) + return [lon - delta, lat - delta, lon + delta, lat + delta] + + +def _geometry_payload(location: Any) -> dict: + boundary = getattr(location, "farm_boundary", None) or {} + if boundary: + return boundary + return {"bbox": _default_bbox(location)} + + +@dataclass +class SatelliteNdviResult: + observation_date: str + mean_ndvi: float + ndvi_map: list[list[float | None]] + vegetation_health_class: str + satellite_source: str + cloud_cover: float | None + metadata: dict[str, Any] + + +class SentinelCompatibleNdviClient: + def __init__(self) -> None: + self.endpoint = os.environ.get("SATELLITE_NDVI_ENDPOINT") + self.api_key = os.environ.get("SATELLITE_NDVI_API_KEY") + self.source = os.environ.get("SATELLITE_SOURCE", DEFAULT_SATELLITE_SOURCE) + + @property + def is_configured(self) -> bool: + return bool(self.endpoint and self.api_key) + + def fetch_red_nir( + self, + geometry: dict, + date_from: date, + date_to: date, + cloud_cover: float, + ) -> dict[str, Any] | None: + if not self.is_configured: + return None + + response = requests.post( + self.endpoint, + json={ + "geometry": geometry, + "date_from": date_from.isoformat(), + "date_to": date_to.isoformat(), + "cloud_cover_max": cloud_cover, + "source": self.source, + "bands": ["B04", "B08"], + }, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def fetch_or_get_ndvi_observation( + location: Any, + days_back: int = 7, + cloud_cover: float = DEFAULT_CLOUD_COVER, +) -> NdviObservation | None: + observation = location.ndvi_observations.order_by("-observation_date", "-created_at").first() + if observation is not None: + return observation + + client = SentinelCompatibleNdviClient() + payload = client.fetch_red_nir( + geometry=_geometry_payload(location), + date_from=date.today() - timedelta(days=days_back), + date_to=date.today(), + cloud_cover=cloud_cover, + ) + if not payload: + return None + + red_band = payload.get("red_band") or [] + nir_band = payload.get("nir_band") or [] + observation_date = payload.get("observation_date") or date.today().isoformat() + ndvi_grid = calculate_ndvi_grid(red_band=red_band, nir_band=nir_band) + ndvi_mean = mean_ndvi(ndvi_grid) + return NdviObservation.objects.create( + location=location, + observation_date=date.fromisoformat(observation_date), + mean_ndvi=ndvi_mean, + ndvi_map={ + "grid": ndvi_grid, + "red_band_source": "B04", + "nir_band_source": "B08", + }, + vegetation_health_class=classify_ndvi(ndvi_mean), + satellite_source=payload.get("satellite_source", client.source), + cloud_cover=payload.get("cloud_cover"), + metadata={ + "geometry": _geometry_payload(location), + "raw_payload_meta": payload.get("metadata", {}), + }, + ) diff --git a/dashboard_data/services.py b/dashboard_data/services.py index f39561a..c9d671d 100644 --- a/dashboard_data/services.py +++ b/dashboard_data/services.py @@ -46,15 +46,85 @@ AI_DRIVEN_CARDS = { } +def _farm_profile_from_context(context: dict) -> dict: + sensor = context.get("sensor") + location = context.get("location") + plants = context.get("plants", []) + irrigation_methods = context.get("irrigation_methods", []) + + return { + "sensor_id": str(getattr(sensor, "uuid_sensor", "")) if sensor else "", + "crop_type": getattr(plants[0], "name", None) if plants else None, + "region": { + "latitude": float(location.latitude) if location else None, + "longitude": float(location.longitude) if location else None, + }, + "season": timezone.now().date().isoformat(), + "farming_method": getattr(irrigation_methods[0], "name", None) if irrigation_methods else None, + } + + +def _sensor_trends_payload(sensor_id: str, context: dict) -> dict: + chart = build_sensor_comparison_chart(sensor_id=sensor_id, context=context, ai_bundle=None) + return { + "current_value": chart.get("currentValue"), + "current_value_quality": chart.get("currentValueQuality"), + "vs_last_week": chart.get("vsLastWeekValue"), + "series": chart.get("series", []), + } + + +def _alerts_payload(sensor_id: str, context: dict) -> dict: + tracker = build_farm_alerts_tracker(sensor_id=sensor_id, context=context, ai_bundle=None) + return { + "total_alerts": tracker.get("totalAlerts", 0), + "alerts": tracker.get("alerts", []), + "most_critical_issue": tracker.get("mostCriticalIssue"), + "clusters": tracker.get("alertClusters", []), + } + + +def _anomalies_payload(sensor_id: str, context: dict) -> dict: + anomaly_card = build_anomaly_detection_card(sensor_id=sensor_id, context=context, ai_bundle=None) + return { + "anomalies": anomaly_card.get("anomalies", []), + "interpretation_seed": anomaly_card.get("interpretation"), + } + + +def _build_ai_payload_request(sensor_id: str, context: dict) -> dict: + structured_context = { + "farm_profile": _farm_profile_from_context(context), + "detected_alerts": _alerts_payload(sensor_id, context), + "anomaly_events": _anomalies_payload(sensor_id, context), + "sensor_trends": _sensor_trends_payload(sensor_id, context), + "timestamps": { + "generated_at": timezone.now().isoformat(), + }, + } + system_prompt = ( + "You are an agricultural decision-support assistant. " + "Use only the structured data provided. " + "Do not hallucinate sensor values, timestamps, severities, or agronomic events. " + "Generate concise, actionable outputs.\n\n" + "For the timeline, explain what happened, when it happened, and why it matters.\n" + "For recommendations, prioritize by alert severity, time proximity, and potential crop impact.\n" + "Return recommendation objects with: recommendation_title, explanation, suggested_action, urgency_level, related_alert_id (optional)." + ) + return { + "sensor_id": sensor_id, + "cards": sorted(AI_DRIVEN_CARDS), + "system_prompt": system_prompt, + "structured_context": structured_context, + } + + 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_payload_request = _build_ai_payload_request(sensor_id, context) ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request) return { @@ -88,10 +158,7 @@ def build_dashboard_payload_with_cache(sensor_id: str) -> dict: if context is None: return {} - ai_payload_request = { - "sensor_id": sensor_id, - "cards": sorted(AI_DRIVEN_CARDS), - } + ai_payload_request = _build_ai_payload_request(sensor_id, context) ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request) payload = {} diff --git a/fertilization/views.py b/fertilization/views.py index 044d487..77eb57f 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -24,7 +24,8 @@ class FertilizationRecommendView(APIView): description=( "داده‌های سنسور و گیاه را دریافت کرده و یک تسک Celery " "برای تولید توصیه کودهی در صف قرار می‌دهد. " - "اطلاعات گیاه از جدول Plant بارگذاری می‌شود." + "اطلاعات گیاه از جدول Plant بارگذاری می‌شود. " + "محاسبات مربوط به نیاز آبی در این endpoint انجام نمی‌شود و مستقل از توصیه کودهی است." ), request=FertilizationRecommendRequestSerializer, responses={ diff --git a/irrigation/evapotranspiration.py b/irrigation/evapotranspiration.py new file mode 100644 index 0000000..6c87ea7 --- /dev/null +++ b/irrigation/evapotranspiration.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from math import acos, cos, exp, pi, sin, sqrt, tan +from typing import Any + + +DEFAULT_CROP_PROFILE = { + "kc_initial": 0.6, + "kc_mid": 1.05, + "kc_end": 0.8, + "growth_stage_duration": { + "initial": 20, + "mid": 30, + "late": 25, + }, + "current_stage": "mid", +} + +DEFAULT_STAGE_KC = { + "initial": "kc_initial", + "development": "kc_mid", + "mid": "kc_mid", + "late": "kc_end", + "end": "kc_end", +} + + +@dataclass +class DailyWaterNeed: + forecast_date: str + et0_mm: float + etc_mm: float + effective_rainfall_mm: float + net_irrigation_mm: float + gross_irrigation_mm: float + kc: float + irrigation_timing: str + + +def _saturation_vapor_pressure(temperature_c: float) -> float: + return 0.6108 * exp((17.27 * temperature_c) / (temperature_c + 237.3)) + + +def _slope_vapor_pressure_curve(temperature_c: float) -> float: + es = _saturation_vapor_pressure(temperature_c) + return (4098 * es) / ((temperature_c + 237.3) ** 2) + + +def _psychrometric_constant(elevation_m: float = 0.0) -> float: + pressure = 101.3 * (((293.0 - (0.0065 * elevation_m)) / 293.0) ** 5.26) + return 0.000665 * pressure + + +def _extraterrestrial_radiation(day_of_year: int, latitude_deg: float) -> float: + latitude_rad = latitude_deg * pi / 180.0 + dr = 1 + (0.033 * cos((2 * pi / 365) * day_of_year)) + solar_declination = 0.409 * sin(((2 * pi / 365) * day_of_year) - 1.39) + ws = acos(max(-1.0, min(1.0, -tan(latitude_rad) * tan(solar_declination)))) + return ( + (24 * 60 / pi) + * 0.0820 + * dr + * ( + (ws * sin(latitude_rad) * sin(solar_declination)) + + (cos(latitude_rad) * cos(solar_declination) * sin(ws)) + ) + ) + + +def _estimate_net_radiation( + forecast: Any, + latitude_deg: float, + elevation_m: float = 0.0, +) -> float: + day_of_year = forecast.forecast_date.timetuple().tm_yday + ra = _extraterrestrial_radiation(day_of_year, latitude_deg) + temp_max = float(getattr(forecast, "temperature_max", None) or getattr(forecast, "temperature_mean", 25.0)) + temp_min = float(getattr(forecast, "temperature_min", None) or getattr(forecast, "temperature_mean", 15.0)) + rh_mean = float(getattr(forecast, "humidity_mean", None) or 50.0) + + temp_range = max(temp_max - temp_min, 0.1) + rs = 0.16 * sqrt(temp_range) * ra + rso = (0.75 + (2e-5 * elevation_m)) * ra + ea = (rh_mean / 100.0) * _saturation_vapor_pressure((temp_max + temp_min) / 2.0) + rns = (1 - 0.23) * rs + rs_rso_ratio = min(rs / rso, 1.0) if rso else 0.0 + rnl = 4.903e-9 * ( + (((temp_max + 273.16) ** 4) + ((temp_min + 273.16) ** 4)) / 2 + ) * (0.34 - (0.14 * sqrt(max(ea, 0.0)))) * ((1.35 * rs_rso_ratio) - 0.35) + return max(rns - rnl, 0.0) + + +def calculate_daily_et0(forecast: Any, latitude_deg: float, elevation_m: float = 0.0) -> float: + temp_mean = float(getattr(forecast, "temperature_mean", None) or 20.0) + temp_max = float(getattr(forecast, "temperature_max", None) or temp_mean + 3.0) + temp_min = float(getattr(forecast, "temperature_min", None) or temp_mean - 3.0) + wind_speed_kmh = float(getattr(forecast, "wind_speed_max", None) or 7.2) + wind_speed_ms = wind_speed_kmh / 3.6 + rh_mean = float(getattr(forecast, "humidity_mean", None) or 50.0) + + delta = _slope_vapor_pressure_curve(temp_mean) + gamma = _psychrometric_constant(elevation_m) + rn = _estimate_net_radiation(forecast, latitude_deg=latitude_deg, elevation_m=elevation_m) + es = (_saturation_vapor_pressure(temp_max) + _saturation_vapor_pressure(temp_min)) / 2.0 + ea = (rh_mean / 100.0) * _saturation_vapor_pressure(temp_mean) + g = 0.0 + + numerator = (0.408 * delta * (rn - g)) + (gamma * (900.0 / (temp_mean + 273.0)) * wind_speed_ms * (es - ea)) + denominator = delta + (gamma * (1 + (0.34 * wind_speed_ms))) + if denominator == 0: + return 0.0 + return round(max(numerator / denominator, 0.0), 3) + + +def resolve_crop_profile(plant: Any | None, growth_stage: str | None = None) -> dict: + profile = getattr(plant, "irrigation_profile", None) or {} + merged = {**DEFAULT_CROP_PROFILE, **profile} + durations = {**DEFAULT_CROP_PROFILE["growth_stage_duration"], **profile.get("growth_stage_duration", {})} + merged["growth_stage_duration"] = durations + merged["current_stage"] = (growth_stage or profile.get("current_stage") or DEFAULT_CROP_PROFILE["current_stage"]).lower() + return merged + + +def resolve_kc(profile: dict, growth_stage: str | None = None) -> float: + stage = (growth_stage or profile.get("current_stage") or "mid").lower() + kc_key = DEFAULT_STAGE_KC.get(stage, "kc_mid") + return float(profile.get(kc_key, DEFAULT_CROP_PROFILE[kc_key])) + + +def effective_rainfall(precipitation_mm: float, etc_mm: float) -> float: + if precipitation_mm <= 0: + return 0.0 + return round(min(precipitation_mm * 0.8, etc_mm), 3) + + +def recommend_irrigation_timing(forecast: Any) -> str: + temp_mean = float(getattr(forecast, "temperature_mean", None) or 20.0) + wind_speed = float(getattr(forecast, "wind_speed_max", None) or 0.0) + if temp_mean >= 30 or wind_speed >= 20: + return "اوایل صبح" + if temp_mean <= 18: + return "اواخر صبح" + return "اوایل صبح یا نزدیک غروب" + + +def calculate_daily_water_need( + forecast: Any, + latitude_deg: float, + crop_profile: dict, + growth_stage: str | None = None, + irrigation_efficiency_percent: float | None = None, + elevation_m: float = 0.0, +) -> DailyWaterNeed: + et0_mm = calculate_daily_et0(forecast, latitude_deg=latitude_deg, elevation_m=elevation_m) + kc = resolve_kc(crop_profile, growth_stage=growth_stage) + etc_mm = round(kc * et0_mm, 3) + rainfall_mm = float(getattr(forecast, "precipitation", None) or 0.0) + effective_rain_mm = effective_rainfall(rainfall_mm, etc_mm) + net_irrigation_mm = round(max(etc_mm - effective_rain_mm, 0.0), 3) + efficiency = max((irrigation_efficiency_percent or 100.0) / 100.0, 0.01) + gross_irrigation_mm = round(net_irrigation_mm / efficiency, 3) + return DailyWaterNeed( + forecast_date=forecast.forecast_date.isoformat() if isinstance(forecast.forecast_date, date) else str(forecast.forecast_date), + et0_mm=et0_mm, + etc_mm=etc_mm, + effective_rainfall_mm=effective_rain_mm, + net_irrigation_mm=net_irrigation_mm, + gross_irrigation_mm=gross_irrigation_mm, + kc=round(kc, 3), + irrigation_timing=recommend_irrigation_timing(forecast), + ) + + +def calculate_forecast_water_needs( + forecasts: list[Any], + latitude_deg: float, + crop_profile: dict, + growth_stage: str | None = None, + irrigation_efficiency_percent: float | None = None, + elevation_m: float = 0.0, +) -> list[dict]: + return [ + calculate_daily_water_need( + forecast=forecast, + latitude_deg=latitude_deg, + crop_profile=crop_profile, + growth_stage=growth_stage, + irrigation_efficiency_percent=irrigation_efficiency_percent, + elevation_m=elevation_m, + ).__dict__ + for forecast in forecasts + ] diff --git a/irrigation/views.py b/irrigation/views.py index 2c7488f..3aa31f4 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -45,7 +45,8 @@ class IrrigationRecommendView(APIView): description=( "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery " "برای تولید توصیه آبیاری در صف قرار می‌دهد. " - "اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود." + "اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود. " + "محاسبات ET₀ و ETc با مدل FAO-56 در بک‌اند انجام می‌شود و مدل زبانی فقط توضیح برنامه آبیاری را تولید می‌کند." ), request=IrrigationRecommendRequestSerializer, responses={ diff --git a/location_data/migrations/0002_soillocation_ideal_sensor_profile.py b/location_data/migrations/0002_soillocation_ideal_sensor_profile.py new file mode 100644 index 0000000..f301c51 --- /dev/null +++ b/location_data/migrations/0002_soillocation_ideal_sensor_profile.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="soillocation", + name="ideal_sensor_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل ایده‌آل سنسورها برای این مزرعه/لوکیشن. " + 'نمونه: {"moisture": {"ideal": 0.65, "min": 0.50, "max": 0.80}}' + ), + ), + ), + ] diff --git a/location_data/migrations/0004_soillocation_farm_boundary.py b/location_data/migrations/0004_soillocation_farm_boundary.py new file mode 100644 index 0000000..7c11a31 --- /dev/null +++ b/location_data/migrations/0004_soillocation_farm_boundary.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0003_rename_app_label"), + ] + + operations = [ + migrations.AddField( + model_name="soillocation", + name="farm_boundary", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "مرز مزرعه برای درخواست‌های سنجش‌ازدور. " + 'می‌تواند GeoJSON polygon یا bbox مثل {"type": "Polygon", "coordinates": [...]} باشد.' + ), + ), + ), + ] diff --git a/location_data/models.py b/location_data/models.py index b2e6aac..be47c82 100644 --- a/location_data/models.py +++ b/location_data/models.py @@ -24,6 +24,22 @@ class SoilLocation(models.Model): blank=True, help_text="شناسه تسک Celery در حال پردازش", ) + ideal_sensor_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل ایده‌آل سنسورها برای این مزرعه/لوکیشن. " + 'نمونه: {"moisture": {"ideal": 0.65, "min": 0.50, "max": 0.80}}' + ), + ) + farm_boundary = models.JSONField( + default=dict, + blank=True, + help_text=( + "مرز مزرعه برای درخواست‌های سنجش‌ازدور. " + 'می‌تواند GeoJSON polygon یا bbox مثل {"type": "Polygon", "coordinates": [...]} باشد.' + ), + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/plant/gdd.py b/plant/gdd.py new file mode 100644 index 0000000..4649cfd --- /dev/null +++ b/plant/gdd.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + + +DEFAULT_GROWTH_PROFILE = { + "base_temperature": 10.0, + "required_gdd_for_maturity": 1200.0, + "stage_thresholds": { + "flowering": 500.0, + "fruiting": 850.0, + }, + "current_cumulative_gdd": 0.0, +} + + +@dataclass +class HarvestPrediction: + current_cumulative_gdd: float + required_gdd_for_maturity: float + remaining_gdd: float + estimated_days_to_harvest: int + predicted_harvest_date: str + predicted_harvest_window: dict[str, str] + daily_gdd_forecast: list[dict[str, float | str]] + active_stage: str | None + + +def resolve_growth_profile(plant: Any | None) -> dict: + profile = getattr(plant, "growth_profile", None) or {} + stage_thresholds = { + **DEFAULT_GROWTH_PROFILE["stage_thresholds"], + **profile.get("stage_thresholds", {}), + } + return { + **DEFAULT_GROWTH_PROFILE, + **profile, + "stage_thresholds": stage_thresholds, + } + + +def calculate_daily_gdd(tmax: float, tmin: float, tbase: float) -> float: + mean_temp = (tmax + tmin) / 2.0 + return round(max(mean_temp - tbase, 0.0), 3) + + +def determine_active_stage(current_cumulative_gdd: float, stage_thresholds: dict[str, float]) -> str | None: + active_stage = None + for stage, threshold in sorted(stage_thresholds.items(), key=lambda item: item[1]): + if current_cumulative_gdd >= float(threshold): + active_stage = stage + return active_stage + + +def predict_harvest_from_forecasts( + forecasts: list[Any], + plant: Any | None, +) -> HarvestPrediction: + profile = resolve_growth_profile(plant) + base_temperature = float(profile.get("base_temperature", DEFAULT_GROWTH_PROFILE["base_temperature"])) + required_gdd = float(profile.get("required_gdd_for_maturity", DEFAULT_GROWTH_PROFILE["required_gdd_for_maturity"])) + current_cumulative_gdd = float(profile.get("current_cumulative_gdd", DEFAULT_GROWTH_PROFILE["current_cumulative_gdd"])) + + cumulative_gdd = current_cumulative_gdd + daily_forecast: list[dict[str, float | str]] = [] + estimated_date = forecasts[-1].forecast_date if forecasts else date.today() + + for forecast in forecasts: + tmax = float(getattr(forecast, "temperature_max", None) or getattr(forecast, "temperature_mean", 0.0)) + tmin = float(getattr(forecast, "temperature_min", None) or getattr(forecast, "temperature_mean", 0.0)) + daily_gdd = calculate_daily_gdd(tmax=tmax, tmin=tmin, tbase=base_temperature) + cumulative_gdd += daily_gdd + daily_forecast.append( + { + "date": forecast.forecast_date.isoformat(), + "gdd": daily_gdd, + "cumulative_gdd": round(cumulative_gdd, 3), + } + ) + if cumulative_gdd >= required_gdd: + estimated_date = forecast.forecast_date + break + else: + remaining_gdd_after_forecast = max(required_gdd - cumulative_gdd, 0.0) + avg_gdd = sum(item["gdd"] for item in daily_forecast) / len(daily_forecast) if daily_forecast else 0.0 + extra_days = int(remaining_gdd_after_forecast / avg_gdd) + (1 if avg_gdd > 0 and remaining_gdd_after_forecast > 0 else 0) + estimated_date = estimated_date + timedelta(days=max(extra_days, 0)) + + remaining_gdd = max(required_gdd - current_cumulative_gdd, 0.0) + estimated_days = max((estimated_date - date.today()).days, 0) + active_stage = determine_active_stage(current_cumulative_gdd, profile.get("stage_thresholds", {})) + + return HarvestPrediction( + current_cumulative_gdd=round(current_cumulative_gdd, 3), + required_gdd_for_maturity=round(required_gdd, 3), + remaining_gdd=round(remaining_gdd, 3), + estimated_days_to_harvest=estimated_days, + predicted_harvest_date=estimated_date.isoformat(), + predicted_harvest_window={ + "start": (estimated_date - timedelta(days=3)).isoformat(), + "end": (estimated_date + timedelta(days=3)).isoformat(), + }, + daily_gdd_forecast=daily_forecast, + active_stage=active_stage, + ) diff --git a/plant/migrations/0002_plant_health_profile.py b/plant/migrations/0002_plant_health_profile.py new file mode 100644 index 0000000..c896745 --- /dev/null +++ b/plant/migrations/0002_plant_health_profile.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="health_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ), + ), + ] diff --git a/plant/migrations/0003_plant_irrigation_profile.py b/plant/migrations/0003_plant_irrigation_profile.py new file mode 100644 index 0000000..b615274 --- /dev/null +++ b/plant/migrations/0003_plant_irrigation_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0002_plant_health_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="irrigation_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + 'نمونه: {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ), + ), + ] diff --git a/plant/migrations/0004_plant_growth_profile.py b/plant/migrations/0004_plant_growth_profile.py new file mode 100644 index 0000000..96245ff --- /dev/null +++ b/plant/migrations/0004_plant_growth_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0003_plant_irrigation_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="growth_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + 'نمونه: {"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ), + ), + ] diff --git a/plant/models.py b/plant/models.py index 31aa900..1f912f4 100644 --- a/plant/models.py +++ b/plant/models.py @@ -52,6 +52,32 @@ class Plant(models.Model): blank=True, help_text="کود مناسب", ) + health_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ) + irrigation_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + 'نمونه: {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ) + growth_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + 'نمونه: {"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/rag/api_provider.py b/rag/api_provider.py index 08b7675..bb288e7 100644 --- a/rag/api_provider.py +++ b/rag/api_provider.py @@ -37,11 +37,11 @@ def get_embedding_client(config: RAGConfig | None = None) -> OpenAI: def get_chat_client(config: RAGConfig | None = None) -> OpenAI: """ ساخت کلاینت OpenAI برای Chat/LLM بر اساس provider فعال. - provider از config.embedding.provider خوانده می‌شود (مشترک بین embedding و chat). + provider از config.llm.provider خوانده می‌شود. """ cfg = config or load_rag_config() llm = cfg.llm - provider = cfg.embedding.provider + provider = llm.provider or cfg.embedding.provider logger.info(provider) diff --git a/rag/chat.py b/rag/chat.py index 5705238..4283673 100644 --- a/rag/chat.py +++ b/rag/chat.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from .config import load_rag_config, RAGConfig +from .config import load_rag_config, RAGConfig, get_service_config, ServiceConfig from .api_provider import get_chat_client from .retrieve import search_with_query from .user_data import build_user_soil_text, build_user_weather_text @@ -43,6 +43,16 @@ def _load_kb_tone(kb_name: str, config: RAGConfig | None = None) -> str: return "" +def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str: + cfg = config or load_rag_config() + if service.tone_file: + base = Path(__file__).resolve().parent.parent + tone_path = base / service.tone_file + if tone_path.exists(): + return tone_path.read_text(encoding="utf-8").strip() + return _load_kb_tone(service.knowledge_base, cfg) + + def _detect_kb_intent(query: str) -> str: """تشخیص ساده نوع پایگاه دانش مورد نیاز از روی متن سوال.""" q = query.lower() @@ -59,10 +69,11 @@ def _detect_kb_intent(query: str) -> str: def build_rag_context( query: str, - sensor_uuid: str, + sensor_uuid: str | None = None, config: RAGConfig | None = None, limit: int = 8, kb_name: str | None = None, + service_id: str | None = None, ) -> str: """ ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG. @@ -76,24 +87,34 @@ def build_rag_context( len(query or ""), ) parts: list[str] = [] + cfg = config or load_rag_config() + service = get_service_config(service_id, cfg) if service_id else None + include_user_embeddings = service.use_user_embeddings if service else True + resolved_kb_name = kb_name or (service.knowledge_base if service else None) - user_soil = build_user_soil_text(sensor_uuid) - if user_soil and user_soil.strip(): - parts.append("[داده‌های فعلی خاک شما]\n" + user_soil.strip()) - logger.debug("Included user soil section sensor_uuid=%s", sensor_uuid) - else: - logger.info("No user soil data found sensor_uuid=%s", sensor_uuid) + if include_user_embeddings and sensor_uuid: + user_soil = build_user_soil_text(sensor_uuid) + if user_soil and user_soil.strip(): + parts.append("[داده‌های فعلی خاک شما]\n" + user_soil.strip()) + logger.debug("Included user soil section sensor_uuid=%s", sensor_uuid) + else: + logger.info("No user soil data found sensor_uuid=%s", sensor_uuid) - weather_text = build_user_weather_text(sensor_uuid) - if weather_text and weather_text.strip(): - parts.append("[پیش‌بینی هواشناسی]\n" + weather_text.strip()) - logger.debug("Included weather section sensor_uuid=%s", sensor_uuid) - else: - logger.info("No weather data found sensor_uuid=%s", sensor_uuid) + weather_text = build_user_weather_text(sensor_uuid) + if weather_text and weather_text.strip(): + parts.append("[پیش‌بینی هواشناسی]\n" + weather_text.strip()) + logger.debug("Included weather section sensor_uuid=%s", sensor_uuid) + else: + logger.info("No weather data found sensor_uuid=%s", sensor_uuid) results = search_with_query( - query, sensor_uuid=sensor_uuid, limit=limit, config=config, - kb_name=kb_name, + query, + sensor_uuid=sensor_uuid, + limit=limit, + config=cfg, + kb_name=resolved_kb_name, + service_id=service_id, + use_user_embeddings=include_user_embeddings, ) if results: logger.info("Retrieved RAG results count=%s sensor_uuid=%s", len(results), sensor_uuid) @@ -109,11 +130,12 @@ def build_rag_context( def chat_rag_stream( query: str, - sensor_uuid: str, + sensor_uuid: str | None = None, config: RAGConfig | None = None, limit: int = 5, system_override: str | None = None, kb_name: str | None = None, + service_id: str | None = None, ): logger.info( "chat_rag_stream started sensor_uuid=%s kb_name=%s limit=%s query_len=%s", @@ -137,24 +159,43 @@ def chat_rag_stream( تک‌تک deltaهای content به‌صورت رشته """ cfg = config or load_rag_config() - client = get_chat_client(cfg) - model = cfg.llm.model - logger.debug("Loaded RAG config with model=%s", model) + resolved_service_id = service_id or kb_name or _detect_kb_intent(query) + service = get_service_config(resolved_service_id, cfg) + service_llm_config = service.llm + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service_llm_config, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service_llm_config.model + logger.debug("Loaded service config service_id=%s model=%s", resolved_service_id, model) - detected_kb = kb_name or _detect_kb_intent(query) - logger.info("Using knowledge base=%s", detected_kb) + detected_kb = kb_name or service.knowledge_base + logger.info("Using knowledge base=%s for service_id=%s", detected_kb, resolved_service_id) context = build_rag_context( - query, sensor_uuid, config=cfg, limit=limit, kb_name=detected_kb, + query, + sensor_uuid, + config=cfg, + limit=limit, + kb_name=detected_kb, + service_id=resolved_service_id, ) logger.debug("Built context length=%s", len(context)) if system_override is not None: system_content = system_override else: - tone = _load_kb_tone(detected_kb, cfg) + tone = _load_service_tone(service, cfg) if not tone: tone = _load_tone(cfg) system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) system_parts.append( "با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. " "برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. " @@ -169,7 +210,7 @@ def chat_rag_stream( {"role": "system", "content": system_content}, {"role": "user", "content": query}, ] - logger.info("Prepared messages for model=%s message=%s", model,messages) + logger.info("Prepared messages for model=%s service_id=%s", model, resolved_service_id) stream = client.chat.completions.create( model=model, diff --git a/rag/config.py b/rag/config.py index 2a5ea47..fb9b19b 100644 --- a/rag/config.py +++ b/rag/config.py @@ -1,5 +1,6 @@ """ -بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider و چند پایگاه دانش +بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider، +چند پایگاه دانش و چند سرویس. """ import os from dataclasses import dataclass, field @@ -36,6 +37,7 @@ class ChunkingConfig: @dataclass class LLMConfig: + provider: str = "gapgpt" model: str = "gpt-4o" base_url: str | None = None api_key_env: str | None = None @@ -50,6 +52,17 @@ class KnowledgeBaseConfig: description: str = "" +@dataclass +class ServiceConfig: + service_id: str + knowledge_base: str + llm: LLMConfig = field(default_factory=LLMConfig) + tone_file: str | None = None + system_prompt: str | None = None + use_user_embeddings: bool = True + description: str = "" + + @dataclass class RAGConfig: embedding: EmbeddingConfig @@ -57,9 +70,31 @@ class RAGConfig: chunking: ChunkingConfig llm: LLMConfig = field(default_factory=LLMConfig) knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict) + services: dict[str, ServiceConfig] = field(default_factory=dict) chromadb: dict[str, Any] = field(default_factory=dict) +def _build_llm_config(data: dict[str, Any] | None, default: LLMConfig | None = None) -> LLMConfig: + llm_data = data or {} + fallback = default or LLMConfig() + return LLMConfig( + provider=llm_data.get("provider", fallback.provider), + model=llm_data.get("model", fallback.model), + base_url=llm_data.get("base_url", fallback.base_url), + api_key_env=llm_data.get("api_key_env", fallback.api_key_env), + avalai_base_url=llm_data.get("avalai_base_url", fallback.avalai_base_url), + avalai_api_key_env=llm_data.get("avalai_api_key_env", fallback.avalai_api_key_env), + ) + + +def get_service_config(service_id: str, config: RAGConfig | None = None) -> ServiceConfig: + cfg = config or load_rag_config() + service = cfg.services.get(service_id) + if service is None: + raise KeyError(f"Unknown service_id: {service_id}") + return service + + def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: """ بارگذاری تنظیمات از YAML و env. @@ -101,14 +136,7 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: overlap_tokens=ch.get("overlap_tokens", 50), ) - llm_data = data.get("llm", {}) - llm = LLMConfig( - model=llm_data.get("model", "gpt-4o"), - base_url=llm_data.get("base_url"), - api_key_env=llm_data.get("api_key_env"), - avalai_base_url=llm_data.get("avalai_base_url"), - avalai_api_key_env=llm_data.get("avalai_api_key_env"), - ) + llm = _build_llm_config(data.get("llm", {})) kb_data = data.get("knowledge_bases", {}) knowledge_bases: dict[str, KnowledgeBaseConfig] = {} @@ -119,11 +147,38 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: description=kb_conf.get("description", ""), ) + services_data = data.get("services", {}) + services: dict[str, ServiceConfig] = {} + for service_id, service_conf in services_data.items(): + kb_name = service_conf.get("knowledge_base", service_id) + kb_conf = knowledge_bases.get(kb_name) + services[service_id] = ServiceConfig( + service_id=service_id, + knowledge_base=kb_name, + llm=_build_llm_config(service_conf.get("llm"), default=llm), + tone_file=service_conf.get("tone_file") or (kb_conf.tone_file if kb_conf else None), + system_prompt=service_conf.get("system_prompt"), + use_user_embeddings=service_conf.get("use_user_embeddings", True), + description=service_conf.get("description", ""), + ) + + if not services: + for kb_name, kb_conf in knowledge_bases.items(): + services[kb_name] = ServiceConfig( + service_id=kb_name, + knowledge_base=kb_name, + llm=llm, + tone_file=kb_conf.tone_file, + use_user_embeddings=True, + description=kb_conf.description, + ) + return RAGConfig( embedding=embedding, qdrant=qdrant, chunking=chunking, llm=llm, knowledge_bases=knowledge_bases, + services=services, chromadb=data.get("chromadb", {}), ) diff --git a/rag/retrieve.py b/rag/retrieve.py index 89a200b..4eb20a2 100644 --- a/rag/retrieve.py +++ b/rag/retrieve.py @@ -1,18 +1,20 @@ """ بازیابی RAG: embed کوئری و جستجو در vector store """ -from .config import load_rag_config, RAGConfig +from .config import load_rag_config, RAGConfig, get_service_config from .embedding import embed_single from .vector_store import QdrantVectorStore def search_with_query( query: str, - sensor_uuid: str, + sensor_uuid: str | None = None, limit: int = 5, score_threshold: float | None = None, config: RAGConfig | None = None, kb_name: str | None = None, + service_id: str | None = None, + use_user_embeddings: bool | None = None, ) -> list[dict]: """ کوئری را embed می‌کند و در vector store جستجو می‌کند. @@ -27,12 +29,28 @@ def search_with_query( لیست نتایج با id, score, text, metadata """ cfg = config or load_rag_config() + service = get_service_config(service_id, cfg) if service_id else None + resolved_kb_name = kb_name or (service.knowledge_base if service else None) + include_user_embeddings = ( + use_user_embeddings + if use_user_embeddings is not None + else (service.use_user_embeddings if service else True) + ) + + sensor_filters = ["__global__"] + if include_user_embeddings and sensor_uuid: + sensor_filters.insert(0, sensor_uuid) + + kb_filters = [resolved_kb_name] if resolved_kb_name else [] + if include_user_embeddings: + kb_filters.append("__all__") + query_vector = embed_single(query, config=cfg) store = QdrantVectorStore(config=cfg) return store.search( query_vector=query_vector, limit=limit, score_threshold=score_threshold, - sensor_uuid=sensor_uuid, - kb_name=kb_name, + sensor_uuids=sensor_filters, + kb_names=kb_filters, ) diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index dc7155e..7f32406 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -6,13 +6,14 @@ import json import logging from rag.api_provider import get_chat_client -from rag.chat import build_rag_context, _load_kb_tone -from rag.config import load_rag_config, RAGConfig +from rag.chat import build_rag_context, _load_service_tone +from rag.config import load_rag_config, RAGConfig, get_service_config from rag.user_data import build_plant_text logger = logging.getLogger(__name__) KB_NAME = "fertilization" +SERVICE_ID = "fertilization" DEFAULT_FERTILIZATION_PROMPT = ( "بر اساس داده‌های خاک (NPK، pH)، مشخصات گیاه، مرحله رشد و پایگاه دانش کودهی، " @@ -56,13 +57,23 @@ def get_fertilization_recommendation( dict با کلیدهای fertilizer_needed, fertilizer_type, amount_kg_per_hectare, reason, npk_status, raw_response """ cfg = config or load_rag_config() - client = get_chat_client(cfg) - model = cfg.llm.model + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service.llm.model user_query = query or "توصیه کودهی برای مزرعه من چیست؟" context = build_rag_context( - user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, + user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID, ) extra_parts: list[str] = [] @@ -73,8 +84,10 @@ def get_fertilization_recommendation( if extra_parts: context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") - tone = _load_kb_tone(KB_NAME, cfg) + tone = _load_service_tone(service, cfg) system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) system_parts.append(DEFAULT_FERTILIZATION_PROMPT) if context: system_parts.append("\n\n" + context) diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 08c4686..0e062fc 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -5,18 +5,22 @@ import json import logging +from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile, resolve_kc +from sensor_data.models import SensorData from rag.api_provider import get_chat_client -from rag.chat import build_rag_context, _load_kb_tone -from rag.config import load_rag_config, RAGConfig +from rag.chat import build_rag_context, _load_service_tone +from rag.config import load_rag_config, RAGConfig, get_service_config from rag.user_data import build_plant_text, build_irrigation_method_text +from weather.models import WeatherForecast logger = logging.getLogger(__name__) KB_NAME = "irrigation" +SERVICE_ID = "irrigation" DEFAULT_IRRIGATION_PROMPT = ( - "بر اساس داده‌های خاک، هواشناسی، مشخصات گیاه، روش آبیاری و پایگاه دانش آبیاری، " - "یک توصیه آبیاری دقیق بده. " + "بر اساس محاسبات نهایی تبخیر-تعرق و نیاز آبی که در ورودی آمده، " + "یک برنامه آبیاری قابل‌فهم برای کشاورز تولید کن. " "پاسخ حتماً به فرمت JSON با ساختار زیر باشد:\n" '{\n' ' "plan": {\n' @@ -28,7 +32,7 @@ DEFAULT_IRRIGATION_PROMPT = ( ' }\n' '}\n' "فقط JSON خروجی بده، بدون توضیح اضافی. " - "مقادیر عددی را بر اساس شرایط واقعی محاسبه کن." + "از انجام هرگونه محاسبه عددی جدید خودداری کن و فقط از داده‌های ساختاریافته ورودی استفاده کن." ) @@ -58,13 +62,52 @@ def get_irrigation_recommendation( dict با کلیدهای irrigation_needed, amount_mm, reason, next_check_date, raw_response """ cfg = config or load_rag_config() - client = get_chat_client(cfg) - model = cfg.llm.model + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service.llm.model user_query = query or "توصیه آبیاری برای مزرعه من چیست؟" + sensor = SensorData.objects.select_related("location").prefetch_related("plants").filter(uuid_sensor=sensor_uuid).first() + plant = None + if sensor is not None and plant_name: + plant = sensor.plants.filter(name=plant_name).first() + elif sensor is not None: + plant = sensor.plants.first() + crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage) + active_kc = resolve_kc(crop_profile, growth_stage=growth_stage) + forecasts = [] + daily_water_needs = [] + if sensor is not None: + forecasts = list( + WeatherForecast.objects.filter(location=sensor.location, forecast_date__isnull=False) + .order_by("forecast_date")[:7] + ) + efficiency_percent = None + if irrigation_method_name: + from irrigation.models import IrrigationMethod + + method = IrrigationMethod.objects.filter(name=irrigation_method_name).first() + efficiency_percent = getattr(method, "water_efficiency_percent", None) if method else None + daily_water_needs = calculate_forecast_water_needs( + forecasts=forecasts, + latitude_deg=float(sensor.location.latitude), + crop_profile=crop_profile, + growth_stage=growth_stage, + irrigation_efficiency_percent=efficiency_percent, + ) + context = build_rag_context( - user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, + user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID, ) extra_parts: list[str] = [] @@ -76,11 +119,27 @@ def get_irrigation_recommendation( method_text = build_irrigation_method_text(irrigation_method_name) if method_text: extra_parts.append("[روش آبیاری انتخابی]\n" + method_text) + if daily_water_needs: + total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2) + schedule_lines = [ + f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, " + f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, " + f"زمان پیشنهادی={item['irrigation_timing']}" + for item in daily_water_needs + ] + extra_parts.append( + "[خروجی قطعی محاسبات FAO-56]\n" + f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n" + f"Kc مورد استفاده: {active_kc}\n" + + "\n".join(schedule_lines) + ) if extra_parts: context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") - tone = _load_kb_tone(KB_NAME, cfg) + tone = _load_service_tone(service, cfg) system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) system_parts.append(DEFAULT_IRRIGATION_PROMPT) if context: system_parts.append("\n\n" + context) @@ -120,4 +179,9 @@ def get_irrigation_recommendation( } result["raw_response"] = raw + result["water_balance"] = { + "daily": daily_water_needs, + "crop_profile": crop_profile, + "active_kc": active_kc, + } return result diff --git a/rag/vector_store.py b/rag/vector_store.py index ddcbc53..a329a08 100644 --- a/rag/vector_store.py +++ b/rag/vector_store.py @@ -97,6 +97,8 @@ class QdrantVectorStore: score_threshold: float | None = None, sensor_uuid: str | None = None, kb_name: str | None = None, + sensor_uuids: list[str] | None = None, + kb_names: list[str] | None = None, ) -> list[dict]: """ جستجوی شباهت بر اساس query vector. @@ -107,34 +109,30 @@ class QdrantVectorStore: """ must_conditions = [] - if sensor_uuid: + sensor_values = [value for value in (sensor_uuids or ([sensor_uuid] if sensor_uuid else [])) if value] + if sensor_values: must_conditions.append( qmodels.Filter( should=[ qmodels.FieldCondition( key="sensor_uuid", - match=qmodels.MatchValue(value=sensor_uuid), - ), - qmodels.FieldCondition( - key="sensor_uuid", - match=qmodels.MatchValue(value="__global__"), - ), + match=qmodels.MatchValue(value=value), + ) + for value in sensor_values ] ) ) - if kb_name: + kb_values = [value for value in (kb_names or ([kb_name] if kb_name else [])) if value] + if kb_values: must_conditions.append( qmodels.Filter( should=[ qmodels.FieldCondition( key="kb_name", - match=qmodels.MatchValue(value=kb_name), - ), - qmodels.FieldCondition( - key="kb_name", - match=qmodels.MatchValue(value="__all__"), - ), + match=qmodels.MatchValue(value=value), + ) + for value in kb_values ] ) ) diff --git a/rag/views.py b/rag/views.py index 9d149ee..ad4ec0a 100644 --- a/rag/views.py +++ b/rag/views.py @@ -24,8 +24,8 @@ logger = logging.getLogger(__name__) class ChatView(APIView): """ چت RAG با استریم. - POST با {"message": "متن سوال", "sensor_uuid": "uuid-سنسور"} - sensor_uuid اجباری — هر کاربر فقط به دیتای خودش دسترسی دارد. + POST با {"service_id": "...", "query": "متن سوال", "user_id": "شناسه کاربر"} + service_id اجباری است. user_id فقط برای سرویس‌هایی که user embeddings دارند اجباری می‌شود. """ @extend_schema( @@ -35,8 +35,11 @@ class ChatView(APIView): request=inline_serializer( name="ChatRequest", fields={ - "message": drf_serializers.CharField(help_text="متن سوال کاربر"), - "sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور"), + "service_id": drf_serializers.CharField(help_text="شناسه سرویس"), + "query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"), + "message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"), + "user_id": drf_serializers.CharField(required=False, help_text="شناسه کاربر"), + "sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد user_id"), }, ), responses={ @@ -50,19 +53,25 @@ class ChatView(APIView): examples=[ OpenApiExample( "نمونه درخواست", - value={"message": "وضعیت خاک من چطوره؟", "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + value={ + "service_id": "support_bot", + "user_id": "12345", + "query": "How do I reset my password?", + }, request_only=True, ), ], ) def post(self, request: Request): + from .config import load_rag_config, get_service_config + data = request.data if request.method == "POST" else request.query_params - message = data.get("message") - sensor_uuid = data.get("sensor_uuid") - logging.info("jhh") + service_id = data.get("service_id") + message = data.get("query", data.get("message")) + user_id = data.get("user_id", data.get("sensor_uuid")) if not message or not isinstance(message, str): return Response( - {"code": 400, "msg": "پارامتر message الزامی است."}, + {"code": 400, "msg": "پارامتر query الزامی است."}, status=status.HTTP_400_BAD_REQUEST, ) message = str(message).strip() @@ -71,22 +80,43 @@ class ChatView(APIView): {"code": 400, "msg": "پیام نباید خالی باشد."}, status=status.HTTP_400_BAD_REQUEST, ) - if not sensor_uuid or not isinstance(sensor_uuid, str): + if not service_id or not isinstance(service_id, str): return Response( - {"code": 400, "msg": "پارامتر sensor_uuid الزامی است."}, + {"code": 400, "msg": "پارامتر service_id الزامی است."}, status=status.HTTP_400_BAD_REQUEST, ) - sensor_uuid = str(sensor_uuid).strip() - if not sensor_uuid: + service_id = str(service_id).strip() + if not service_id: return Response( - {"code": 400, "msg": "sensor_uuid نباید خالی باشد."}, + {"code": 400, "msg": "service_id نباید خالی باشد."}, + status=status.HTTP_400_BAD_REQUEST, + ) + cfg = load_rag_config() + try: + service = get_service_config(service_id, cfg) + except KeyError: + return Response( + {"code": 400, "msg": f"service_id نامعتبر است: {service_id}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if user_id is not None: + user_id = str(user_id).strip() + if not user_id: + user_id = None + if service.use_user_embeddings and not user_id: + return Response( + {"code": 400, "msg": "برای این service_id، پارامتر user_id الزامی است."}, status=status.HTTP_400_BAD_REQUEST, ) - def generate(): try: - for chunk in chat_rag_stream(message, sensor_uuid=sensor_uuid): + for chunk in chat_rag_stream( + message, + sensor_uuid=user_id, + service_id=service_id, + config=cfg, + ): yield chunk except Exception as e: yield f"\n[خطا: {e}]"