AI UPDATE

This commit is contained in:
2026-03-22 03:08:27 +03:30
parent 3ee14ca977
commit d977a583c6
37 changed files with 3525 additions and 263 deletions
+55
View File
@@ -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"
+6 -4
View File
@@ -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", ""),
}
+872
View File
@@ -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 = "بر اساس دمای فعلی، رطوبت خاک و اطلاعات <plant_name>. بازه بهینه برداشت محاسبه شده است."
```
اگر گیاهی وجود نداشته باشد:
```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 ساده استفاده می‌کنند.
+251 -23
View File
@@ -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,
}
+4 -1
View File
@@ -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", {}),
}
+550 -31
View File
@@ -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],
}
+174 -8
View File
@@ -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",
+30 -14
View File
@@ -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,
}
+64 -16
View File
@@ -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",
},
],
}
+4 -1
View File
@@ -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", {}),
}
+107 -17
View File
@@ -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: "داده معتبر در دسترس نیست",
},
}
+122 -19
View File
@@ -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},
],
}
+191 -22
View File
@@ -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: "داده معتبر در دسترس نیست",
},
}
+32 -12
View File
@@ -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": "",
"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,
}
@@ -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"),
],
},
),
]
+29
View File
@@ -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}"
+155
View File
@@ -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", {}),
},
)
+75 -8
View File
@@ -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 = {}
+2 -1
View File
@@ -24,7 +24,8 @@ class FertilizationRecommendView(APIView):
description=(
"داده‌های سنسور و گیاه را دریافت کرده و یک تسک Celery "
"برای تولید توصیه کودهی در صف قرار می‌دهد. "
"اطلاعات گیاه از جدول Plant بارگذاری می‌شود."
"اطلاعات گیاه از جدول Plant بارگذاری می‌شود. "
"محاسبات مربوط به نیاز آبی در این endpoint انجام نمی‌شود و مستقل از توصیه کودهی است."
),
request=FertilizationRecommendRequestSerializer,
responses={
+194
View File
@@ -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
]
+2 -1
View File
@@ -45,7 +45,8 @@ class IrrigationRecommendView(APIView):
description=(
"داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery "
"برای تولید توصیه آبیاری در صف قرار می‌دهد. "
"اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود."
"اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود. "
"محاسبات ET₀ و ETc با مدل FAO-56 در بک‌اند انجام می‌شود و مدل زبانی فقط توضیح برنامه آبیاری را تولید می‌کند."
),
request=IrrigationRecommendRequestSerializer,
responses={
@@ -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}}'
),
),
),
]
@@ -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": [...]} باشد.'
),
),
),
]
+16
View File
@@ -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)
+107
View File
@@ -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,
)
@@ -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}}'
),
),
),
]
@@ -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}}'
),
),
),
]
@@ -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}'
),
),
),
]
+26
View File
@@ -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)
+2 -2
View File
@@ -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)
+66 -25
View File
@@ -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,
+64 -9
View File
@@ -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", {}),
)
+22 -4
View File
@@ -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,
)
+19 -6
View File
@@ -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)
+73 -9
View File
@@ -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
+12 -14
View File
@@ -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
]
)
)
+46 -16
View File
@@ -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}]"