UPDATE
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
# Dashboard Card Sources
|
||||||
|
|
||||||
|
این فایل مشخص میکند هر کارت داشبورد در بکاند از کدام app، service و endpoint تغذیه میشود.
|
||||||
|
|
||||||
|
## مسیر اصلی داشبورد
|
||||||
|
|
||||||
|
- `GET /api/farm-dashboard/?farm_uuid=...`
|
||||||
|
- تجمیع نهایی کارتها در `dashboard/services.py` انجام میشود.
|
||||||
|
- این سرویس از app های مختلف داده را جمع میکند و response نهایی داشبورد را میسازد.
|
||||||
|
|
||||||
|
## مپ ردیفها و کارتها
|
||||||
|
|
||||||
|
### `overviewKpis`
|
||||||
|
|
||||||
|
این ردیف در نهایت در `dashboard/services.py` ساخته میشود، ولی هر KPI از app خودش میآید:
|
||||||
|
|
||||||
|
| Card / KPI ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `farm_health_score` | امتیاز سلامت مزرعه | `crop_health` | `get_crop_health_summary_data` | `GET /api/crop-health/summary/` |
|
||||||
|
| `water_stress_index` | شاخص تنش آبی | `WATER` | `get_water_stress_index_data` | `GET /api/water/stress-index/` |
|
||||||
|
| `avg_soil_moisture` | میانگین رطوبت خاک | `soil` | `get_avg_soil_moisture_data` | `GET /api/soil/avg-moisture/` |
|
||||||
|
| `disease_risk` | ریسک بیماری | `pest_detection` | `get_risk_summary_data` | `GET /api/pest-detection/risk-summary/` |
|
||||||
|
| `yield_prediction` | پیشبینی عملکرد | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
|
||||||
|
| `pest_risk` | ریسک آفات | `pest_detection` | `get_risk_summary_data` | `GET /api/pest-detection/risk-summary/` |
|
||||||
|
|
||||||
|
### `weatherAlerts`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `farmWeatherCard` | کارت آبوهوا | `WATER` | `get_farm_weather_card_data` | `GET /api/water/card/` |
|
||||||
|
| `farmAlertsTracker` | خلاصه هشدارها | `farm_alerts` | `get_alert_tracker_data` | هنوز endpoint مستقل service-based ندارد؛ در داشبورد از service داخلی استفاده میشود |
|
||||||
|
|
||||||
|
### `sensorMonitoring`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `sensorValuesList` | لیست مقادیر سنسورها | فعلا `dashboard` | فعلا مستقیم از `dashboard/mock_data.py` | endpoint مستقل ندارد |
|
||||||
|
|
||||||
|
### `sensorCharts`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `sensorRadarChart` | نمودار راداری سنسورها | `soil` | `get_sensor_radar_chart_data` | `GET /api/soil/sensor-radar-chart/` |
|
||||||
|
| `sensorComparisonChart` | مقایسه با هفته قبل | `soil` | `get_sensor_comparison_chart_data` | `GET /api/soil/sensor-comparison-chart/` |
|
||||||
|
|
||||||
|
### `alertsWater`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `anomalyDetectionCard` | ناهنجاری سنسورها/خاک | `soil` | `get_anomaly_detection_card_data` | `GET /api/soil/anomalies/` |
|
||||||
|
| `farmAlertsTimeline` | تایملاین هشدارها | `farm_alerts` | `get_alert_timeline_data` | هنوز endpoint مستقل service-based ندارد؛ در داشبورد از service داخلی استفاده میشود |
|
||||||
|
| `waterNeedPrediction` | نیاز آبی 7 روز آینده | `WATER` | `get_water_need_prediction_data` | `GET /api/water/need-prediction/` |
|
||||||
|
|
||||||
|
### `predictions`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `harvestPredictionCard` | پیشبینی برداشت | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
|
||||||
|
| `yieldPredictionChart` | نمودار پیشبینی عملکرد | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
|
||||||
|
|
||||||
|
### `soilHeatmap`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `soilMoistureHeatmap` | نقشه حرارتی رطوبت خاک | `soil` | `get_soil_moisture_heatmap_data` | `GET /api/soil/moisture-heatmap/` |
|
||||||
|
|
||||||
|
### `ndviRecommendations`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ndviHealthCard` | کارت سلامت NDVI | `crop_health` | `get_crop_health_summary_data` | `GET /api/crop-health/summary/` |
|
||||||
|
| `recommendationsList` | لیست پیشنهادها | ترکیبی | ترکیب در `dashboard/services.py` | endpoint مستقل نهایی ندارد |
|
||||||
|
|
||||||
|
منابع `recommendationsList`:
|
||||||
|
|
||||||
|
- `farm_alerts` از `get_recommendations_list_data`
|
||||||
|
- `irrigation_recommendation` از `get_irrigation_dashboard_recommendation`
|
||||||
|
- `fertilization_recommendation` از `get_fertilization_dashboard_recommendation`
|
||||||
|
- `yield_harvest` برای آیتم بازه برداشت
|
||||||
|
|
||||||
|
### `economic`
|
||||||
|
|
||||||
|
| Card ID | عنوان | منبع اصلی | service | endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `economicOverview` | نمای اقتصادی | `economic_overview` | `get_economic_overview_data` | `GET /api/economic-overview/summary/` |
|
||||||
|
|
||||||
|
## endpoint های summary جدید برای app ها
|
||||||
|
|
||||||
|
برای استفاده راحتتر در فرانت، این app ها الان منبع واضح برای کارتهای داشبورد دارند:
|
||||||
|
|
||||||
|
- `crop_health`
|
||||||
|
- `GET /api/crop-health/summary/`
|
||||||
|
- `WATER`
|
||||||
|
- `GET /api/water/card/`
|
||||||
|
- `GET /api/water/need-prediction/`
|
||||||
|
- `GET /api/water/stress-index/`
|
||||||
|
- `GET /api/water/summary/`
|
||||||
|
- `soil`
|
||||||
|
- `GET /api/soil/avg-moisture/`
|
||||||
|
- `GET /api/soil/sensor-radar-chart/`
|
||||||
|
- `GET /api/soil/sensor-comparison-chart/`
|
||||||
|
- `GET /api/soil/anomalies/`
|
||||||
|
- `GET /api/soil/moisture-heatmap/`
|
||||||
|
- `GET /api/soil/summary/`
|
||||||
|
- `yield_harvest`
|
||||||
|
- `GET /api/yield-harvest/summary/`
|
||||||
|
- `economic_overview`
|
||||||
|
- `GET /api/economic-overview/summary/`
|
||||||
|
|
||||||
|
## وضعیت فعلی کارتها
|
||||||
|
|
||||||
|
### کاملا service-based
|
||||||
|
|
||||||
|
- `farmOverviewKpis` به صورت ترکیبی
|
||||||
|
- `farmWeatherCard`
|
||||||
|
- `farmAlertsTracker`
|
||||||
|
- `sensorRadarChart`
|
||||||
|
- `sensorComparisonChart`
|
||||||
|
- `anomalyDetectionCard`
|
||||||
|
- `farmAlertsTimeline`
|
||||||
|
- `waterNeedPrediction`
|
||||||
|
- `harvestPredictionCard`
|
||||||
|
- `yieldPredictionChart`
|
||||||
|
- `soilMoistureHeatmap`
|
||||||
|
- `ndviHealthCard`
|
||||||
|
- `recommendationsList`
|
||||||
|
- `economicOverview`
|
||||||
|
|
||||||
|
### هنوز مستقیم از mock داشبورد
|
||||||
|
|
||||||
|
- `sensorValuesList`
|
||||||
|
|
||||||
|
## پیشنهاد برای فرانت
|
||||||
|
|
||||||
|
- اگر صفحه مستقل برای هر domain دارید، برای هر صفحه از endpoint خود همان app استفاده کنید.
|
||||||
|
- اگر صفحه داشبورد اصلی دارید، فقط از `GET /api/farm-dashboard/?farm_uuid=...` استفاده کنید.
|
||||||
|
- اگر لازم است هر کارت به صفحه جزئیات خودش لینک شود، بهترین mapping فعلی این است:
|
||||||
|
- Weather + Water cards -> `WATER`
|
||||||
|
- NDVI + Farm Health -> `crop_health`
|
||||||
|
- Soil cards -> `soil`
|
||||||
|
- Yield + Harvest -> `yield_harvest`
|
||||||
|
- Alerts -> `farm_alerts`
|
||||||
|
- Economy -> `economic_overview`
|
||||||
|
- Pest/Disease risk -> `pest_detection`
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# Frontend Pages & APIs Guide
|
||||||
|
|
||||||
|
این فایل برای تیم فرانت نوشته شده تا بدانند:
|
||||||
|
|
||||||
|
- چه app های جدیدی به بکاند اضافه شدهاند
|
||||||
|
- چه API های جدیدی اضافه شدهاند
|
||||||
|
- چه تغییراتی نسبت به commit قبلی و تغییرات فعلی انجام شده
|
||||||
|
- سیستم شبیهسازی گیاه چگونه به ساختار `yield_harvest` منتقل شده
|
||||||
|
- هر card داشبورد باید به کدام app و endpoint وصل شود
|
||||||
|
- هر card علاوه بر داشبورد باید در صفحه domain خودش هم نمایش داده شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه تغییرات مهم
|
||||||
|
|
||||||
|
### تغییرات مهم commit قبلی و فعلی
|
||||||
|
|
||||||
|
در تغییرات اخیر، backend از حالت monolithic dashboard mock به سمت app-based domain APIs رفته است.
|
||||||
|
|
||||||
|
یعنی به جای اینکه همه چیز فقط در `dashboard` یا فقط از AI بیاید:
|
||||||
|
|
||||||
|
- هر بخش domain اصلی app خودش را دارد
|
||||||
|
- برای چند domain جدید، API مستقل ساخته شده
|
||||||
|
- داشبورد از service های app های مختلف داده را assemble میکند
|
||||||
|
|
||||||
|
### مهمترین تغییر معماری
|
||||||
|
|
||||||
|
سیستم `plant_simulator` دیگر app مستقل نیست و منطق آن به `yield_harvest` منتقل شده است.
|
||||||
|
|
||||||
|
این یعنی:
|
||||||
|
|
||||||
|
- فولدر `plant_simulator` حذف شده
|
||||||
|
- route های شبیهساز هنوز در `api/plant-simulator/` موجودند
|
||||||
|
- اما implementation آنها داخل `yield_harvest` نگهداری میشود
|
||||||
|
|
||||||
|
پس برای فرانت:
|
||||||
|
|
||||||
|
- صفحه Plant Simulator هنوز میتواند باقی بماند
|
||||||
|
- ولی از نظر backend ownership، این بخش زیرمجموعه `yield_harvest` محسوب میشود
|
||||||
|
- بهتر است در فرانت هم Plant Simulator به عنوان صفحه/تب وابسته به `Yield & Harvest` دیده شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## app های جدید یا تغییر یافته
|
||||||
|
|
||||||
|
### 1) `crop_health`
|
||||||
|
|
||||||
|
app جدید برای:
|
||||||
|
|
||||||
|
- `ndviHealthCard`
|
||||||
|
- `farm_health_score`
|
||||||
|
|
||||||
|
هدف:
|
||||||
|
|
||||||
|
- داشتن صفحه مستقل برای سلامت محصول / NDVI
|
||||||
|
- جدا کردن health-related KPIها از `dashboard`
|
||||||
|
|
||||||
|
### 2) `WATER`
|
||||||
|
|
||||||
|
این app جایگزین ساختار قبلی `weather_forecast` شده است.
|
||||||
|
|
||||||
|
الان app `WATER` مسئول این بخشهاست:
|
||||||
|
|
||||||
|
- `farmWeatherCard`
|
||||||
|
- `waterNeedPrediction`
|
||||||
|
- `water_stress_index`
|
||||||
|
|
||||||
|
هدف:
|
||||||
|
|
||||||
|
- ادغام weather + water نیاز + water stress در یک domain
|
||||||
|
- ساخت صفحه مستقل برای آب و هوا و وضعیت آب
|
||||||
|
|
||||||
|
### 3) `soil`
|
||||||
|
|
||||||
|
app جدید برای:
|
||||||
|
|
||||||
|
- `avg_soil_moisture`
|
||||||
|
- `sensorRadarChart`
|
||||||
|
- `sensorComparisonChart`
|
||||||
|
- `anomalyDetectionCard`
|
||||||
|
- `soilMoistureHeatmap`
|
||||||
|
|
||||||
|
هدف:
|
||||||
|
|
||||||
|
- جدا کردن card های مرتبط با خاک و سنسور از `dashboard`
|
||||||
|
- فراهم کردن API مستقل برای صفحه Soil / Sensor Insights
|
||||||
|
|
||||||
|
### 4) `yield_harvest`
|
||||||
|
|
||||||
|
این app قبلا وجود داشت، اما نقش آن گستردهتر شده:
|
||||||
|
|
||||||
|
- `yieldPredictionChart`
|
||||||
|
- `harvestPredictionCard`
|
||||||
|
- `yield_prediction` KPI
|
||||||
|
- منطق `plant_simulator` هم به این app منتقل شده
|
||||||
|
|
||||||
|
### 5) `dashboard`
|
||||||
|
|
||||||
|
`dashboard` الان دیگر منبع اصلی data نیست.
|
||||||
|
|
||||||
|
وظیفه جدید آن:
|
||||||
|
|
||||||
|
- گرفتن data از service های app های مختلف
|
||||||
|
- ساخت response نهایی برای صفحه داشبورد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API های جدید و فعال
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
- `GET /api/farm-dashboard/?farm_uuid=...`
|
||||||
|
- `GET /api/farm-dashboard-config/?farm_uuid=...`
|
||||||
|
- `PATCH /api/farm-dashboard-config/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- فقط برای صفحه Dashboard اصلی
|
||||||
|
- در فرانت برای داشبورد overview از این endpoint استفاده شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crop Health APIs
|
||||||
|
|
||||||
|
- `GET /api/crop-health/summary/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه مستقل Crop Health
|
||||||
|
- کارتهای:
|
||||||
|
- `farm_health_score`
|
||||||
|
- `ndviHealthCard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WATER APIs
|
||||||
|
|
||||||
|
- `GET /api/water/card/`
|
||||||
|
- `GET /api/water/need-prediction/`
|
||||||
|
- `GET /api/water/stress-index/`
|
||||||
|
- `GET /api/water/summary/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه مستقل Water / Water & Weather
|
||||||
|
- کارتهای:
|
||||||
|
- `farmWeatherCard`
|
||||||
|
- `waterNeedPrediction`
|
||||||
|
- `water_stress_index`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
|
||||||
|
- route قدیمی `weather_forecast` دیگر برای فرانت target اصلی نیست
|
||||||
|
- فرانت باید به `api/water/...` مهاجرت کند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soil APIs
|
||||||
|
|
||||||
|
- `GET /api/soil/avg-moisture/`
|
||||||
|
- `GET /api/soil/sensor-radar-chart/`
|
||||||
|
- `GET /api/soil/sensor-comparison-chart/`
|
||||||
|
- `GET /api/soil/anomalies/`
|
||||||
|
- `GET /api/soil/moisture-heatmap/`
|
||||||
|
- `GET /api/soil/summary/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه مستقل Soil / Soil Analytics / Sensor Insights
|
||||||
|
- کارتهای:
|
||||||
|
- `avg_soil_moisture`
|
||||||
|
- `sensorRadarChart`
|
||||||
|
- `sensorComparisonChart`
|
||||||
|
- `anomalyDetectionCard`
|
||||||
|
- `soilMoistureHeatmap`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Yield & Harvest APIs
|
||||||
|
|
||||||
|
- `GET /api/yield-harvest/summary/`
|
||||||
|
|
||||||
|
Plant Simulator routes که الان implementationشان زیر `yield_harvest` است:
|
||||||
|
|
||||||
|
- `GET /api/plant-simulator/config/`
|
||||||
|
- `GET /api/plant-simulator/state/`
|
||||||
|
- `POST /api/plant-simulator/start/`
|
||||||
|
- `POST /api/plant-simulator/stop/`
|
||||||
|
- `POST /api/plant-simulator/reset/`
|
||||||
|
- `PATCH /api/plant-simulator/environment/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه مستقل Yield & Harvest
|
||||||
|
- صفحه یا تب Plant Simulator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Economic Overview APIs
|
||||||
|
|
||||||
|
- `GET /api/economic-overview/summary/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه مستقل Economic Overview
|
||||||
|
- کارت `economicOverview`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pest Detection APIs
|
||||||
|
|
||||||
|
- `GET /api/pest-detection/risk-summary/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه Pest / Disease Risk
|
||||||
|
- KPIهای:
|
||||||
|
- `disease_risk`
|
||||||
|
- `pest_risk`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Farm Alerts APIs
|
||||||
|
|
||||||
|
در backend برای dashboard service استفاده میشوند:
|
||||||
|
|
||||||
|
- tracker
|
||||||
|
- timeline
|
||||||
|
- recommendations
|
||||||
|
|
||||||
|
مسیرهای موجود:
|
||||||
|
|
||||||
|
- `GET /api/farm-alerts/tracker/`
|
||||||
|
- `GET /api/farm-alerts/timeline/`
|
||||||
|
- `GET /api/farm-alerts/anomalies/`
|
||||||
|
- `GET /api/farm-alerts/recommendations/`
|
||||||
|
|
||||||
|
کاربرد:
|
||||||
|
|
||||||
|
- صفحه Alerts
|
||||||
|
- برخی recommendationها
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
|
||||||
|
- در ساختار جدید، `anomalyDetectionCard` برای dashboard از `soil` میآید
|
||||||
|
- ولی `farm_alerts` هنوز برای timeline/tracker/recommendations مهم است
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تغییر سیستم Plant Simulator
|
||||||
|
|
||||||
|
### قبل
|
||||||
|
|
||||||
|
- app جداگانهای به اسم `plant_simulator` وجود داشت
|
||||||
|
- config, view, route و mock data همگی در همان app بودند
|
||||||
|
|
||||||
|
### الان
|
||||||
|
|
||||||
|
- app `plant_simulator` حذف شده
|
||||||
|
- route های plant simulator حفظ شدهاند
|
||||||
|
- implementation آنها به `yield_harvest` منتقل شده
|
||||||
|
|
||||||
|
### نتیجه برای فرانت
|
||||||
|
|
||||||
|
- صفحه Plant Simulator حذف نشود
|
||||||
|
- اما در navigation و ownership بهتر است به عنوان بخشی از `Yield & Harvest` دیده شود
|
||||||
|
- اگر صفحه Yield & Harvest دارید، Plant Simulator بهتر است زیر همین domain قرار بگیرد
|
||||||
|
|
||||||
|
### پیشنهاد UI/Navigation
|
||||||
|
|
||||||
|
- `Yield & Harvest`
|
||||||
|
- Overview
|
||||||
|
- Yield Prediction
|
||||||
|
- Harvest Prediction
|
||||||
|
- Plant Simulator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مپ کامل card ها به app ها
|
||||||
|
|
||||||
|
| Card | Dashboard Row | Source App | Preferred Endpoint | Frontend Page |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `farm_health_score` | `overviewKpis` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page |
|
||||||
|
| `water_stress_index` | `overviewKpis` | `WATER` | `GET /api/water/stress-index/` | Water Page |
|
||||||
|
| `avg_soil_moisture` | `overviewKpis` | `soil` | `GET /api/soil/avg-moisture/` | Soil Page |
|
||||||
|
| `disease_risk` | `overviewKpis` | `pest_detection` | `GET /api/pest-detection/risk-summary/` | Pest Risk Page |
|
||||||
|
| `yield_prediction` | `overviewKpis` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
|
||||||
|
| `pest_risk` | `overviewKpis` | `pest_detection` | `GET /api/pest-detection/risk-summary/` | Pest Risk Page |
|
||||||
|
| `farmWeatherCard` | `weatherAlerts` | `WATER` | `GET /api/water/card/` | Water Page |
|
||||||
|
| `farmAlertsTracker` | `weatherAlerts` | `farm_alerts` | `GET /api/farm-alerts/tracker/` | Alerts Page |
|
||||||
|
| `sensorValuesList` | `sensorMonitoring` | فعلا `dashboard` | فعلا فقط dashboard | Sensor Page در آینده |
|
||||||
|
| `sensorRadarChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-radar-chart/` | Soil Page |
|
||||||
|
| `sensorComparisonChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-comparison-chart/` | Soil Page |
|
||||||
|
| `anomalyDetectionCard` | `alertsWater` | `soil` | `GET /api/soil/anomalies/` | Soil Page |
|
||||||
|
| `farmAlertsTimeline` | `alertsWater` | `farm_alerts` | `GET /api/farm-alerts/timeline/` | Alerts Page |
|
||||||
|
| `waterNeedPrediction` | `alertsWater` | `WATER` | `GET /api/water/need-prediction/` | Water Page |
|
||||||
|
| `harvestPredictionCard` | `predictions` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
|
||||||
|
| `yieldPredictionChart` | `predictions` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
|
||||||
|
| `soilMoistureHeatmap` | `soilHeatmap` | `soil` | `GET /api/soil/moisture-heatmap/` | Soil Page |
|
||||||
|
| `ndviHealthCard` | `ndviRecommendations` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page |
|
||||||
|
| `recommendationsList` | `ndviRecommendations` | ترکیبی | dashboard aggregate | Recommendations / Alerts / Domain Pages |
|
||||||
|
| `economicOverview` | `economic` | `economic_overview` | `GET /api/economic-overview/summary/` | Economic Overview Page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## صفحههایی که تیم فرانت باید بسازد
|
||||||
|
|
||||||
|
با توجه به backend فعلی، پیشنهاد میشود این صفحهها حتما در فرانت ساخته شوند:
|
||||||
|
|
||||||
|
### 1) Dashboard Page
|
||||||
|
|
||||||
|
برای overview کلی مزرعه
|
||||||
|
|
||||||
|
endpoint اصلی:
|
||||||
|
|
||||||
|
- `GET /api/farm-dashboard/?farm_uuid=...`
|
||||||
|
|
||||||
|
### 2) Crop Health Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `farm_health_score`
|
||||||
|
- `ndviHealthCard`
|
||||||
|
|
||||||
|
endpoint:
|
||||||
|
|
||||||
|
- `GET /api/crop-health/summary/`
|
||||||
|
|
||||||
|
### 3) Water Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `farmWeatherCard`
|
||||||
|
- `waterNeedPrediction`
|
||||||
|
- `water_stress_index`
|
||||||
|
|
||||||
|
endpoint ها:
|
||||||
|
|
||||||
|
- `GET /api/water/card/`
|
||||||
|
- `GET /api/water/need-prediction/`
|
||||||
|
- `GET /api/water/stress-index/`
|
||||||
|
- یا یکجا `GET /api/water/summary/`
|
||||||
|
|
||||||
|
### 4) Soil Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `avg_soil_moisture`
|
||||||
|
- `sensorRadarChart`
|
||||||
|
- `sensorComparisonChart`
|
||||||
|
- `anomalyDetectionCard`
|
||||||
|
- `soilMoistureHeatmap`
|
||||||
|
|
||||||
|
endpoint ها:
|
||||||
|
|
||||||
|
- `GET /api/soil/avg-moisture/`
|
||||||
|
- `GET /api/soil/sensor-radar-chart/`
|
||||||
|
- `GET /api/soil/sensor-comparison-chart/`
|
||||||
|
- `GET /api/soil/anomalies/`
|
||||||
|
- `GET /api/soil/moisture-heatmap/`
|
||||||
|
- یا یکجا `GET /api/soil/summary/`
|
||||||
|
|
||||||
|
### 5) Yield & Harvest Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `yield_prediction`
|
||||||
|
- `yieldPredictionChart`
|
||||||
|
- `harvestPredictionCard`
|
||||||
|
|
||||||
|
endpoint:
|
||||||
|
|
||||||
|
- `GET /api/yield-harvest/summary/`
|
||||||
|
|
||||||
|
### 6) Plant Simulator Page / Tab
|
||||||
|
|
||||||
|
این صفحه باید باقی بماند، اما domain آن الان زیر `yield_harvest` است.
|
||||||
|
|
||||||
|
endpoint ها:
|
||||||
|
|
||||||
|
- `GET /api/plant-simulator/config/`
|
||||||
|
- `GET /api/plant-simulator/state/`
|
||||||
|
- `POST /api/plant-simulator/start/`
|
||||||
|
- `POST /api/plant-simulator/stop/`
|
||||||
|
- `POST /api/plant-simulator/reset/`
|
||||||
|
- `PATCH /api/plant-simulator/environment/`
|
||||||
|
|
||||||
|
### 7) Alerts Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `farmAlertsTracker`
|
||||||
|
- `farmAlertsTimeline`
|
||||||
|
- recommendationهای alert-based
|
||||||
|
|
||||||
|
endpoint ها:
|
||||||
|
|
||||||
|
- `GET /api/farm-alerts/tracker/`
|
||||||
|
- `GET /api/farm-alerts/timeline/`
|
||||||
|
- `GET /api/farm-alerts/recommendations/`
|
||||||
|
|
||||||
|
### 8) Pest / Disease Risk Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `disease_risk`
|
||||||
|
- `pest_risk`
|
||||||
|
|
||||||
|
endpoint:
|
||||||
|
|
||||||
|
- `GET /api/pest-detection/risk-summary/`
|
||||||
|
|
||||||
|
### 9) Economic Overview Page
|
||||||
|
|
||||||
|
نمایش:
|
||||||
|
|
||||||
|
- `economicOverview`
|
||||||
|
|
||||||
|
endpoint:
|
||||||
|
|
||||||
|
- `GET /api/economic-overview/summary/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قانون مهم برای تیم فرانت
|
||||||
|
|
||||||
|
هر card باید در دو جا قابل استفاده باشد:
|
||||||
|
|
||||||
|
### 1) داخل Dashboard
|
||||||
|
|
||||||
|
برای overview سریع مزرعه
|
||||||
|
|
||||||
|
### 2) داخل صفحه domain خودش
|
||||||
|
|
||||||
|
برای نمایش کاملتر و جزئیتر
|
||||||
|
|
||||||
|
یعنی:
|
||||||
|
|
||||||
|
- `ndviHealthCard` هم در Dashboard باشد هم در Crop Health Page
|
||||||
|
- `waterNeedPrediction` هم در Dashboard باشد هم در Water Page
|
||||||
|
- `soilMoistureHeatmap` هم در Dashboard باشد هم در Soil Page
|
||||||
|
- `yieldPredictionChart` هم در Dashboard باشد هم در Yield & Harvest Page
|
||||||
|
- `economicOverview` هم در Dashboard باشد هم در Economic Page
|
||||||
|
|
||||||
|
این الگو باید برای همه card های domain-based رعایت شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنهاد ساختار صفحات در فرانت
|
||||||
|
|
||||||
|
### پیشنهادی برای منوی اصلی
|
||||||
|
|
||||||
|
- Dashboard
|
||||||
|
- Crop Health
|
||||||
|
- Water
|
||||||
|
- Soil
|
||||||
|
- Yield & Harvest
|
||||||
|
- Plant Simulator
|
||||||
|
- Alerts
|
||||||
|
- Pest & Disease Risk
|
||||||
|
- Economic Overview
|
||||||
|
|
||||||
|
### پیشنهادی برای ارتباط صفحه و API
|
||||||
|
|
||||||
|
- Dashboard -> فقط dashboard aggregate
|
||||||
|
- Domain pages -> endpoint اختصاصی همان app
|
||||||
|
|
||||||
|
این روش باعث میشود:
|
||||||
|
|
||||||
|
- dashboard سریعتر و سادهتر بماند
|
||||||
|
- صفحات domain مستقل توسعهپذیر باشند
|
||||||
|
- coupling بین frontend pages و dashboard کم شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکته مهم درباره sensorValuesList
|
||||||
|
|
||||||
|
در حال حاضر:
|
||||||
|
|
||||||
|
- `sensorValuesList` هنوز app اختصاصی ندارد
|
||||||
|
- هنوز از `dashboard/mock_data.py` میآید
|
||||||
|
|
||||||
|
برای فرانت:
|
||||||
|
|
||||||
|
- موقتا میتواند فقط در Dashboard نشان داده شود
|
||||||
|
- یا یک placeholder page برای Sensor Details ساخته شود
|
||||||
|
|
||||||
|
اما از نظر backend، این card هنوز به app اختصاصی مهاجرت نکرده است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جمعبندی نهایی
|
||||||
|
|
||||||
|
### app های جدید یا تغییر یافته برای فرانت
|
||||||
|
|
||||||
|
- `crop_health`
|
||||||
|
- `WATER`
|
||||||
|
- `soil`
|
||||||
|
- `yield_harvest` با نقش گستردهتر
|
||||||
|
- حذف `plant_simulator` به عنوان app مستقل و انتقال آن به `yield_harvest`
|
||||||
|
|
||||||
|
### چیزی که فرانت باید بداند
|
||||||
|
|
||||||
|
- dashboard دیگر منبع domain data نیست؛ فقط aggregator است
|
||||||
|
- هر card مهم الان باید app/domain خودش را داشته باشد
|
||||||
|
- هر card باید هم در dashboard باشد هم در صفحه مربوط به خودش
|
||||||
|
- Plant Simulator هنوز endpoint دارد، اما از نظر معماری بخشی از `yield_harvest` است
|
||||||
|
|
||||||
|
### بهترین rule برای تیم فرانت
|
||||||
|
|
||||||
|
اگر صفحه overview میخواهید:
|
||||||
|
|
||||||
|
- از `GET /api/farm-dashboard/` استفاده کنید
|
||||||
|
|
||||||
|
اگر صفحه domain میخواهید:
|
||||||
|
|
||||||
|
- از endpoint اختصاصی همان app استفاده کنید
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# راهنمای API فرانت برای سنسورها
|
||||||
|
|
||||||
|
این فایل برای تحویل به فرانت نوشته شده و دو app را بهصورت جداگانه توضیح میدهد:
|
||||||
|
|
||||||
|
1. `sensor_external_api`
|
||||||
|
2. `sensor_7_in_1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# صفحه 1 - `sensor_external_api`
|
||||||
|
|
||||||
|
## اطلاعات app
|
||||||
|
|
||||||
|
- فایل app: `sensor_external_api/apps.py`
|
||||||
|
- AppConfig: `SensorExternalApiConfig`
|
||||||
|
- Django app name: `sensor_external_api`
|
||||||
|
- Base URL: `/api/sensor-external-api/`
|
||||||
|
|
||||||
|
## کاربرد این app
|
||||||
|
|
||||||
|
این app برای دریافت payload خام از سنسور خارجی و همچنین مشاهده لاگهای ثبتشده آن استفاده میشود.
|
||||||
|
|
||||||
|
نکته مهم:
|
||||||
|
|
||||||
|
- endpoint ثبت payload بیشتر برای **خود سنسور / gateway / backend integration** است.
|
||||||
|
- endpoint لاگها برای **فرانت** مناسب است تا آخرین دادههای خام دریافتشده را ببیند.
|
||||||
|
|
||||||
|
## API 1 - ثبت payload از سنسور خارجی
|
||||||
|
|
||||||
|
- Method: `POST`
|
||||||
|
- URL: `/api/sensor-external-api/`
|
||||||
|
- Auth: هدر `X-API-Key`
|
||||||
|
- Permission: عمومی است، ولی فقط با API key معتبر
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: 12345
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 48.5,
|
||||||
|
"soil_temperature": 23.2,
|
||||||
|
"soil_ph": 6.8,
|
||||||
|
"electrical_conductivity": 1.4,
|
||||||
|
"nitrogen": 31,
|
||||||
|
"phosphorus": 16,
|
||||||
|
"potassium": 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### توضیح فیلدها
|
||||||
|
|
||||||
|
- `uuid`: شناسه فیزیکی دستگاه سنسور
|
||||||
|
- `payload`: داده خام ارسالشده توسط دستگاه
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 201,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"title": "Sensor external API request",
|
||||||
|
"message": "Payload received from device 33333333-3333-3333-3333-333333333333."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### خطاهای مهم
|
||||||
|
|
||||||
|
- `401`: API key نامعتبر یا ارسال نشده
|
||||||
|
- `404`: سنسور با این `uuid` پیدا نشده
|
||||||
|
- `503`: سرویس مقصد یا جداول موردنیاز آماده نیستند
|
||||||
|
|
||||||
|
## API 2 - دریافت لاگهای سنسور خارجی
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/sensor-external-api/logs/?farm_uuid=<uuid>`
|
||||||
|
- Auth: JWT کاربر
|
||||||
|
- Permission: `IsAuthenticated`
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
- `farm_uuid` اجباری
|
||||||
|
- `page` اختیاری
|
||||||
|
- `page_size` اختیاری
|
||||||
|
|
||||||
|
### نمونه درخواست
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"count": 2,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"physical_device_uuid": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"farm_sensor": {
|
||||||
|
"uuid": "aaaa1111-1111-1111-1111-111111111111",
|
||||||
|
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"physical_device_uuid": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"name": "Soil Sensor 7-in-1",
|
||||||
|
"sensor_type": "soil_7_in_1",
|
||||||
|
"is_active": true,
|
||||||
|
"specifications": {},
|
||||||
|
"power_source": {},
|
||||||
|
"created_at": "2025-03-24T10:00:00Z",
|
||||||
|
"updated_at": "2025-03-24T10:00:00Z"
|
||||||
|
},
|
||||||
|
"sensor_catalog": {
|
||||||
|
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"code": "sensor-7-in-1",
|
||||||
|
"name": "7 in 1 Soil Sensor",
|
||||||
|
"description": "",
|
||||||
|
"customizable_fields": [],
|
||||||
|
"supported_power_sources": [],
|
||||||
|
"returned_data_fields": [
|
||||||
|
"soil_moisture",
|
||||||
|
"soil_temperature",
|
||||||
|
"soil_ph",
|
||||||
|
"electrical_conductivity",
|
||||||
|
"nitrogen",
|
||||||
|
"phosphorus",
|
||||||
|
"potassium"
|
||||||
|
],
|
||||||
|
"sample_payload": {},
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-03-24T10:00:00Z",
|
||||||
|
"updated_at": "2025-03-24T10:00:00Z"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 48.5,
|
||||||
|
"soil_temperature": 23.2,
|
||||||
|
"soil_ph": 6.8,
|
||||||
|
"electrical_conductivity": 1.4,
|
||||||
|
"nitrogen": 31,
|
||||||
|
"phosphorus": 16,
|
||||||
|
"potassium": 24
|
||||||
|
},
|
||||||
|
"created_at": "2025-03-24T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## کاربرد فرانت
|
||||||
|
|
||||||
|
- برای صفحه history / logs سنسور
|
||||||
|
- برای نمایش raw payload دریافتی از دستگاه
|
||||||
|
- برای debug و بررسی آخرین دادههای ثبتشده
|
||||||
|
|
||||||
|
## نکته فرانت
|
||||||
|
|
||||||
|
برای نمایش کارتهای نهایی و تمیزشده سنسور 7 در 1 بهتر است از app بعدی یعنی `sensor_7_in_1` استفاده شود، نه از payload خام این app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# صفحه 2 - `sensor_7_in_1`
|
||||||
|
|
||||||
|
## اطلاعات app
|
||||||
|
|
||||||
|
- فایل app: `sensor_7_in_1/apps.py`
|
||||||
|
- AppConfig: `Sensor7In1Config`
|
||||||
|
- Django app name: `sensor_7_in_1`
|
||||||
|
- Base URL: `/api/sensor-7-in-1/`
|
||||||
|
- Feature code: `sensor-7-in-1`
|
||||||
|
|
||||||
|
## کاربرد این app
|
||||||
|
|
||||||
|
این app دادههای خام ثبتشده در `sensor_external_api` را میخواند و فقط اطلاعات مربوط به **یک سنسور خاص** یعنی سنسور خاک 7-in-1 را به فرم قابلاستفاده برای UI برمیگرداند.
|
||||||
|
|
||||||
|
بهجای اینکه فرانت خودش از payload خام `soil_moisture`, `soil_ph`, `nitrogen` و ... کارت بسازد، این app خروجی نهایی UI-ready میدهد.
|
||||||
|
|
||||||
|
## API اصلی
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/sensor-7-in-1/summary/?farm_uuid=<uuid>`
|
||||||
|
- Auth: JWT کاربر
|
||||||
|
- Permission: `IsAuthenticated`
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
- `farm_uuid` اجباری
|
||||||
|
|
||||||
|
### نمونه درخواست
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "OK",
|
||||||
|
"data": {
|
||||||
|
"sensor": {
|
||||||
|
"name": "Soil Sensor 7-in-1",
|
||||||
|
"physicalDeviceUuid": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"sensorCatalogCode": "sensor-7-in-1",
|
||||||
|
"updatedAt": "2025-03-24T10:00:00Z"
|
||||||
|
},
|
||||||
|
"sensorValuesList": {
|
||||||
|
"sensor": {
|
||||||
|
"name": "Soil Sensor 7-in-1",
|
||||||
|
"physicalDeviceUuid": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"sensorCatalogCode": "sensor-7-in-1",
|
||||||
|
"updatedAt": "2025-03-24T10:00:00Z"
|
||||||
|
},
|
||||||
|
"sensors": [
|
||||||
|
{
|
||||||
|
"id": "soil_moisture",
|
||||||
|
"title": "48.5%",
|
||||||
|
"subtitle": "رطوبت خاک",
|
||||||
|
"trendNumber": 7.5,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_temperature",
|
||||||
|
"title": "23.2°C",
|
||||||
|
"subtitle": "دمای خاک",
|
||||||
|
"trendNumber": 2.2,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "°C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_ph",
|
||||||
|
"title": "6.8",
|
||||||
|
"subtitle": "pH خاک",
|
||||||
|
"trendNumber": 0.3,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "pH"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"avgSoilMoisture": {
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "سنسور 7 در 1 خاک",
|
||||||
|
"stats": "48.5%",
|
||||||
|
"avatarColor": "warning",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "متوسط",
|
||||||
|
"chipColor": "warning"
|
||||||
|
},
|
||||||
|
"sensorRadarChart": {
|
||||||
|
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "اکنون",
|
||||||
|
"data": [87, 90, 95, 88, 90, 85, 92]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "هدف",
|
||||||
|
"data": [100, 100, 100, 100, 100, 100, 100]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sensorComparisonChart": {
|
||||||
|
"currentValue": 48.5,
|
||||||
|
"vsLastWeek": "+18.3%",
|
||||||
|
"vsLastWeekValue": 18.3,
|
||||||
|
"categories": ["03/24 09:00", "03/24 10:00"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "رطوبت خاک",
|
||||||
|
"data": [41, 48.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "بازه هدف",
|
||||||
|
"data": [55, 55]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"anomalyDetectionCard": {
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": "هدایت الکتریکی",
|
||||||
|
"value": "1.4 dS/m",
|
||||||
|
"expected": "0.8-1.8 dS/m",
|
||||||
|
"deviation": "0",
|
||||||
|
"severity": "success"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"soilMoistureHeatmap": {
|
||||||
|
"zones": ["Soil Sensor 7-in-1"],
|
||||||
|
"hours": ["09:00", "10:00"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "Soil Sensor 7-in-1",
|
||||||
|
"data": [
|
||||||
|
{ "x": "09:00", "y": 41 },
|
||||||
|
{ "x": "10:00", "y": 48.5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## فیلدهای مهم برای فرانت
|
||||||
|
|
||||||
|
### `sensor`
|
||||||
|
|
||||||
|
اطلاعات شناسایی سنسور:
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `physicalDeviceUuid`
|
||||||
|
- `sensorCatalogCode`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
### `sensorValuesList`
|
||||||
|
|
||||||
|
برای لیست کارتهای عددی سنسور:
|
||||||
|
|
||||||
|
- رطوبت خاک
|
||||||
|
- دمای خاک
|
||||||
|
- pH خاک
|
||||||
|
- هدایت الکتریکی
|
||||||
|
- نیتروژن
|
||||||
|
- فسفر
|
||||||
|
- پتاسیم
|
||||||
|
|
||||||
|
### `avgSoilMoisture`
|
||||||
|
|
||||||
|
برای KPI رطوبت خاک در dashboard
|
||||||
|
|
||||||
|
### `sensorRadarChart`
|
||||||
|
|
||||||
|
برای نمودار radar وضعیت شاخصهای سنسور
|
||||||
|
|
||||||
|
### `sensorComparisonChart`
|
||||||
|
|
||||||
|
برای نمودار روند رطوبت خاک نسبت به دادههای قبلی
|
||||||
|
|
||||||
|
### `anomalyDetectionCard`
|
||||||
|
|
||||||
|
برای نمایش مقادیر خارج از بازه نرمال
|
||||||
|
|
||||||
|
### `soilMoistureHeatmap`
|
||||||
|
|
||||||
|
برای heatmap یا نمودار تاریخچه رطوبت خاک
|
||||||
|
|
||||||
|
## رفتار این app
|
||||||
|
|
||||||
|
- فقط داده سنسوری را که مشخصات 7-in-1 داشته باشد برمیگرداند
|
||||||
|
- دادهها را از لاگهای `sensor_external_api` میخواند
|
||||||
|
- اگر داده واقعی پیدا نشود، fallback mock برمیگرداند
|
||||||
|
- این app همان منبعی است که dashboard برای کارتهای سنسوری از آن استفاده میکند
|
||||||
|
|
||||||
|
## پیشنهاد استفاده در فرانت
|
||||||
|
|
||||||
|
- اگر هدف نمایش **کارتهای نهایی سنسور** است، از `sensor_7_in_1` استفاده کنید
|
||||||
|
- اگر هدف نمایش **لاگ خام payload** یا history ورودی دستگاه است، از `sensor_external_api/logs/` استفاده کنید
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"crop_zoning": "crop_zoning",
|
"crop_zoning": "crop_zoning",
|
||||||
"plant_simulator": "plant_simulator",
|
"plant_simulator": "plant_simulator",
|
||||||
"pest_detection": "pest_detection",
|
"pest_detection": "pest_detection",
|
||||||
|
"sensor_7_in_1": "sensor-7-in-1",
|
||||||
"irrigation_recommendation": "irrigation_recommendation",
|
"irrigation_recommendation": "irrigation_recommendation",
|
||||||
"fertilization_recommendation": "fertilization_recommendation",
|
"fertilization_recommendation": "fertilization_recommendation",
|
||||||
"farm_ai_assistant": "farm_ai_assistant",
|
"farm_ai_assistant": "farm_ai_assistant",
|
||||||
|
|||||||
+4
-2
@@ -31,10 +31,12 @@ INSTALLED_APPS = [
|
|||||||
"access_control.apps.AccessControlConfig",
|
"access_control.apps.AccessControlConfig",
|
||||||
"sensor_catalog.apps.SensorCatalogConfig",
|
"sensor_catalog.apps.SensorCatalogConfig",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"crop_health.apps.CropHealthConfig",
|
||||||
|
"soil.apps.SoilConfig",
|
||||||
"crop_zoning",
|
"crop_zoning",
|
||||||
"plant_simulator",
|
|
||||||
"pest_detection",
|
"pest_detection",
|
||||||
"weather_forecast.apps.WeatherForecastConfig",
|
"sensor_7_in_1.apps.Sensor7In1Config",
|
||||||
|
"water.apps.WaterConfig",
|
||||||
"irrigation_recommendation",
|
"irrigation_recommendation",
|
||||||
"yield_harvest.apps.YieldHarvestConfig",
|
"yield_harvest.apps.YieldHarvestConfig",
|
||||||
"economic_overview.apps.EconomicOverviewConfig",
|
"economic_overview.apps.EconomicOverviewConfig",
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||||
|
from yield_harvest.urls import plant_simulator_urlpatterns
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
@@ -14,11 +15,14 @@ urlpatterns = [
|
|||||||
path("api/sensor-catalog/", include("sensor_catalog.urls")),
|
path("api/sensor-catalog/", include("sensor_catalog.urls")),
|
||||||
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
|
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
|
||||||
path("api/farm-dashboard/", include("dashboard.urls")),
|
path("api/farm-dashboard/", include("dashboard.urls")),
|
||||||
|
path("api/crop-health/", include("crop_health.urls")),
|
||||||
|
path("api/soil/", include("soil.urls")),
|
||||||
path("api/crop-zoning/", include("crop_zoning.urls")),
|
path("api/crop-zoning/", include("crop_zoning.urls")),
|
||||||
path("api/plant-simulator/", include("plant_simulator.urls")),
|
path("api/plant-simulator/", include(plant_simulator_urlpatterns)),
|
||||||
path("api/pest-detection/", include("pest_detection.urls")),
|
path("api/pest-detection/", include("pest_detection.urls")),
|
||||||
|
path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")),
|
||||||
path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")),
|
path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")),
|
||||||
path("api/weather-forecast/", include("weather_forecast.urls")),
|
path("api/water/", include("water.urls")),
|
||||||
path("api/yield-harvest/", include("yield_harvest.urls")),
|
path("api/yield-harvest/", include("yield_harvest.urls")),
|
||||||
path("api/economic-overview/", include("economic_overview.urls")),
|
path("api/economic-overview/", include("economic_overview.urls")),
|
||||||
path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")),
|
path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")),
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CropHealthConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "crop_health"
|
||||||
|
verbose_name = "Crop Health"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FARM_HEALTH_SCORE = {
|
||||||
|
"id": "farm_health_score",
|
||||||
|
"title": "امتیاز سلامت مزرعه",
|
||||||
|
"subtitle": "تحلیل هوشمند",
|
||||||
|
"stats": "87%",
|
||||||
|
"avatarColor": "success",
|
||||||
|
"avatarIcon": "tabler-heartbeat",
|
||||||
|
"chipText": "خوب",
|
||||||
|
"chipColor": "success",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NDVI_HEALTH_CARD = {
|
||||||
|
"ndviIndex": 0.78,
|
||||||
|
"healthData": [
|
||||||
|
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
|
||||||
|
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class HealthDataItemSerializer(serializers.Serializer):
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
value = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
color = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
icon = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NdviHealthCardSerializer(serializers.Serializer):
|
||||||
|
ndviIndex = serializers.FloatField(required=False)
|
||||||
|
healthData = HealthDataItemSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FarmHealthScoreSerializer(serializers.Serializer):
|
||||||
|
id = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
subtitle = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
stats = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarIcon = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipText = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CropHealthSummarySerializer(serializers.Serializer):
|
||||||
|
ndviHealthCard = NdviHealthCardSerializer(required=False)
|
||||||
|
farmHealthScore = FarmHealthScoreSerializer(required=False)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import FARM_HEALTH_SCORE, NDVI_HEALTH_CARD
|
||||||
|
|
||||||
|
|
||||||
|
def get_crop_health_summary_data(farm=None):
|
||||||
|
return {
|
||||||
|
"ndviHealthCard": deepcopy(NDVI_HEALTH_CARD),
|
||||||
|
"farmHealthScore": deepcopy(FARM_HEALTH_SCORE),
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import CropHealthSummaryView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
|
||||||
|
from config.swagger import status_response
|
||||||
|
from .serializers import CropHealthSummarySerializer
|
||||||
|
from .services import get_crop_health_summary_data
|
||||||
|
|
||||||
|
|
||||||
|
class CropHealthSummaryView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Crop Health"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm for crop health data.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_crop_health_summary_data()},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
+5
-117
@@ -66,26 +66,6 @@ def reset_config():
|
|||||||
# 4.1 farmOverviewKpis
|
# 4.1 farmOverviewKpis
|
||||||
FARM_OVERVIEW_KPIS = {
|
FARM_OVERVIEW_KPIS = {
|
||||||
"kpis": [
|
"kpis": [
|
||||||
{
|
|
||||||
"id": "farm_health_score",
|
|
||||||
"title": "امتیاز سلامت مزرعه",
|
|
||||||
"subtitle": "تحلیل هوشمند",
|
|
||||||
"stats": "87%",
|
|
||||||
"avatarColor": "success",
|
|
||||||
"avatarIcon": "tabler-heartbeat",
|
|
||||||
"chipText": "خوب",
|
|
||||||
"chipColor": "success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "water_stress_index",
|
|
||||||
"title": "شاخص تنش آبی",
|
|
||||||
"subtitle": "فعلی",
|
|
||||||
"stats": "12%",
|
|
||||||
"avatarColor": "info",
|
|
||||||
"avatarIcon": "tabler-droplet",
|
|
||||||
"chipText": "پایین",
|
|
||||||
"chipColor": "success",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "disease_risk",
|
"id": "disease_risk",
|
||||||
"title": "ریسک بیماری",
|
"title": "ریسک بیماری",
|
||||||
@@ -96,16 +76,6 @@ FARM_OVERVIEW_KPIS = {
|
|||||||
"chipText": "5%",
|
"chipText": "5%",
|
||||||
"chipColor": "success",
|
"chipColor": "success",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "avg_soil_moisture",
|
|
||||||
"title": "میانگین رطوبت خاک",
|
|
||||||
"subtitle": "کل مزرعه",
|
|
||||||
"stats": "65%",
|
|
||||||
"avatarColor": "primary",
|
|
||||||
"avatarIcon": "tabler-plant-2",
|
|
||||||
"chipText": "بهینه",
|
|
||||||
"chipColor": "success",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "yield_prediction",
|
"id": "yield_prediction",
|
||||||
"title": "پیشبینی عملکرد",
|
"title": "پیشبینی عملکرد",
|
||||||
@@ -231,47 +201,6 @@ SENSOR_VALUES_LIST = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4.5 sensorRadarChart
|
|
||||||
SENSOR_RADAR_CHART = {
|
|
||||||
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
|
|
||||||
"series": [
|
|
||||||
{"name": "امروز", "data": [75, 65, 80, 70, 85, 60]},
|
|
||||||
{"name": "ایدهآل", "data": [80, 70, 75, 75, 90, 50]},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4.6 sensorComparisonChart
|
|
||||||
SENSOR_COMPARISON_CHART = {
|
|
||||||
"currentValue": 48,
|
|
||||||
"vsLastWeek": "+5%",
|
|
||||||
"vsLastWeekValue": 5,
|
|
||||||
"categories": ["دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه", "یکشنبه"],
|
|
||||||
"series": [
|
|
||||||
{"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]},
|
|
||||||
{"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4.7 anomalyDetectionCard
|
|
||||||
ANOMALY_DETECTION_CARD = {
|
|
||||||
"anomalies": [
|
|
||||||
{
|
|
||||||
"sensor": "رطوبت خاک زون ۳",
|
|
||||||
"value": "38%",
|
|
||||||
"expected": "45-65%",
|
|
||||||
"deviation": "-12%",
|
|
||||||
"severity": "warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sensor": "pH بخش ۲",
|
|
||||||
"value": "5.2",
|
|
||||||
"expected": "6.0-7.0",
|
|
||||||
"deviation": "-0.8",
|
|
||||||
"severity": "error",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4.8 farmAlertsTimeline
|
# 4.8 farmAlertsTimeline
|
||||||
FARM_ALERTS_TIMELINE = {
|
FARM_ALERTS_TIMELINE = {
|
||||||
"alerts": [
|
"alerts": [
|
||||||
@@ -345,47 +274,6 @@ YIELD_PREDICTION_CHART = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4.12 soilMoistureHeatmap
|
|
||||||
SOIL_MOISTURE_HEATMAP = {
|
|
||||||
"zones": ["زون ۱", "زون ۲", "زون ۳", "زون ۴", "زون ۵", "زون ۶", "زون ۷"],
|
|
||||||
"hours": ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"],
|
|
||||||
"series": [
|
|
||||||
{
|
|
||||||
"name": "زون ۱",
|
|
||||||
"data": [
|
|
||||||
{"x": "۶ ص", "y": 52},
|
|
||||||
{"x": "۸ ص", "y": 48},
|
|
||||||
{"x": "۱۰ ص", "y": 55},
|
|
||||||
{"x": "۱۲ ظ", "y": 60},
|
|
||||||
{"x": "۱۴ ع", "y": 58},
|
|
||||||
{"x": "۱۶ ع", "y": 54},
|
|
||||||
{"x": "۱۸ ع", "y": 50},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "زون ۲",
|
|
||||||
"data": [
|
|
||||||
{"x": "۶ ص", "y": 45},
|
|
||||||
{"x": "۸ ص", "y": 42},
|
|
||||||
{"x": "۱۰ ص", "y": 48},
|
|
||||||
{"x": "۱۲ ظ", "y": 52},
|
|
||||||
{"x": "۱۴ ع", "y": 50},
|
|
||||||
{"x": "۱۶ ع", "y": 47},
|
|
||||||
{"x": "۱۸ ع", "y": 44},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4.13 ndviHealthCard
|
|
||||||
NDVI_HEALTH_CARD = {
|
|
||||||
"ndviIndex": 0.78,
|
|
||||||
"healthData": [
|
|
||||||
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
|
|
||||||
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4.14 recommendationsList
|
# 4.14 recommendationsList
|
||||||
RECOMMENDATIONS_LIST = {
|
RECOMMENDATIONS_LIST = {
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
@@ -461,15 +349,15 @@ ALL_CARDS = {
|
|||||||
"farmWeatherCard": FARM_WEATHER_CARD, # هروز
|
"farmWeatherCard": FARM_WEATHER_CARD, # هروز
|
||||||
"farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز
|
"farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز
|
||||||
"sensorValuesList": SENSOR_VALUES_LIST,#هروز
|
"sensorValuesList": SENSOR_VALUES_LIST,#هروز
|
||||||
"sensorRadarChart": SENSOR_RADAR_CHART,
|
"sensorRadarChart": {},
|
||||||
"sensorComparisonChart": SENSOR_COMPARISON_CHART,
|
"sensorComparisonChart": {},
|
||||||
"anomalyDetectionCard": ANOMALY_DETECTION_CARD,
|
"anomalyDetectionCard": {},
|
||||||
"farmAlertsTimeline": FARM_ALERTS_TIMELINE,
|
"farmAlertsTimeline": FARM_ALERTS_TIMELINE,
|
||||||
"waterNeedPrediction": WATER_NEED_PREDICTION,
|
"waterNeedPrediction": WATER_NEED_PREDICTION,
|
||||||
"harvestPredictionCard": HARVEST_PREDICTION_CARD,
|
"harvestPredictionCard": HARVEST_PREDICTION_CARD,
|
||||||
"yieldPredictionChart": YIELD_PREDICTION_CHART,
|
"yieldPredictionChart": YIELD_PREDICTION_CHART,
|
||||||
"soilMoistureHeatmap": SOIL_MOISTURE_HEATMAP,
|
"soilMoistureHeatmap": {},
|
||||||
"ndviHealthCard": NDVI_HEALTH_CARD,
|
"ndviHealthCard": {},
|
||||||
"recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه
|
"recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه
|
||||||
"economicOverview": ECONOMIC_OVERVIEW,
|
"economicOverview": ECONOMIC_OVERVIEW,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from water.services import (
|
||||||
|
get_farm_weather_card_data,
|
||||||
|
get_water_need_prediction_data,
|
||||||
|
get_water_stress_index_data,
|
||||||
|
)
|
||||||
|
from crop_health.services import get_crop_health_summary_data
|
||||||
|
from economic_overview.services import get_economic_overview_data
|
||||||
|
from farm_alerts.services import (
|
||||||
|
get_alert_timeline_data,
|
||||||
|
get_alert_tracker_data,
|
||||||
|
get_recommendations_list_data,
|
||||||
|
)
|
||||||
|
from fertilization_recommendation.services import get_fertilization_dashboard_recommendation
|
||||||
|
from irrigation_recommendation.services import get_irrigation_dashboard_recommendation
|
||||||
|
from pest_detection.services import get_risk_summary_data
|
||||||
|
from sensor_7_in_1.services import (
|
||||||
|
get_sensor_7_in_1_summary_data,
|
||||||
|
)
|
||||||
|
from yield_harvest.services import get_yield_harvest_summary_data
|
||||||
|
|
||||||
|
from .mock_data import ALL_CARDS
|
||||||
|
|
||||||
|
|
||||||
|
def _update_kpi(card_lookup, card_data):
|
||||||
|
if not card_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
card_id = card_data.get("id")
|
||||||
|
if not card_id or card_id not in card_lookup:
|
||||||
|
return
|
||||||
|
|
||||||
|
details = card_data.get("details")
|
||||||
|
clean_data = {key: value for key, value in card_data.items() if key != "details"}
|
||||||
|
card_lookup[card_id].update(clean_data)
|
||||||
|
if details is not None:
|
||||||
|
card_lookup[card_id]["details"] = details
|
||||||
|
|
||||||
|
|
||||||
|
def _build_overview_kpis(base_cards, crop_health_summary, water_stress_index, avg_soil_moisture, risk_summary, yield_summary):
|
||||||
|
kpis = [crop_health_summary["farmHealthScore"], water_stress_index, avg_soil_moisture, *deepcopy(base_cards["kpis"])]
|
||||||
|
card_lookup = {item["id"]: item for item in kpis}
|
||||||
|
|
||||||
|
_update_kpi(card_lookup, water_stress_index)
|
||||||
|
_update_kpi(card_lookup, avg_soil_moisture)
|
||||||
|
_update_kpi(card_lookup, risk_summary.get("disease_risk", {}))
|
||||||
|
_update_kpi(card_lookup, risk_summary.get("pest_risk", {}))
|
||||||
|
_update_kpi(card_lookup, yield_summary.get("yield_prediction_card", {}))
|
||||||
|
|
||||||
|
return {"kpis": kpis}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_recommendations_list(farm, fallback_data, harvest_card):
|
||||||
|
recommendations = []
|
||||||
|
recommendations.extend(get_recommendations_list_data(farm).get("recommendations", []))
|
||||||
|
recommendations.append(get_irrigation_dashboard_recommendation(farm))
|
||||||
|
recommendations.append(get_fertilization_dashboard_recommendation(farm))
|
||||||
|
|
||||||
|
if harvest_card:
|
||||||
|
recommendations.append(
|
||||||
|
{
|
||||||
|
"title": f"بازه برداشت: {harvest_card.get('optimalWindowStart', '')} تا {harvest_card.get('optimalWindowEnd', '')}",
|
||||||
|
"subtitle": harvest_card.get("description", ""),
|
||||||
|
"avatarIcon": "tabler-calendar-event",
|
||||||
|
"avatarColor": "info",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
deduped = []
|
||||||
|
seen_titles = set()
|
||||||
|
for item in recommendations:
|
||||||
|
title = item.get("title")
|
||||||
|
if not title or title in seen_titles:
|
||||||
|
continue
|
||||||
|
seen_titles.add(title)
|
||||||
|
deduped.append(item)
|
||||||
|
|
||||||
|
if deduped:
|
||||||
|
return {"recommendations": deduped[:4]}
|
||||||
|
|
||||||
|
return deepcopy(fallback_data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_dashboard_cards(farm):
|
||||||
|
cards = deepcopy(ALL_CARDS)
|
||||||
|
|
||||||
|
weather_card = get_farm_weather_card_data(farm)
|
||||||
|
crop_health_summary = get_crop_health_summary_data(farm)
|
||||||
|
risk_summary = get_risk_summary_data(farm)
|
||||||
|
yield_summary = get_yield_harvest_summary_data(farm)
|
||||||
|
water_stress_index = get_water_stress_index_data(farm)
|
||||||
|
sensor_summary = get_sensor_7_in_1_summary_data(farm)
|
||||||
|
avg_soil_moisture = sensor_summary["avgSoilMoisture"]
|
||||||
|
|
||||||
|
cards["farmWeatherCard"] = weather_card
|
||||||
|
cards["farmAlertsTracker"] = get_alert_tracker_data(farm)
|
||||||
|
cards["farmAlertsTimeline"] = get_alert_timeline_data(farm)
|
||||||
|
cards["sensorValuesList"] = sensor_summary["sensorValuesList"]
|
||||||
|
cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"]
|
||||||
|
cards["waterNeedPrediction"] = get_water_need_prediction_data(farm)
|
||||||
|
cards["harvestPredictionCard"] = yield_summary["harvest_prediction_card"]
|
||||||
|
cards["yieldPredictionChart"] = yield_summary["yield_prediction_chart"]
|
||||||
|
cards["sensorRadarChart"] = sensor_summary["sensorRadarChart"]
|
||||||
|
cards["sensorComparisonChart"] = sensor_summary["sensorComparisonChart"]
|
||||||
|
cards["soilMoistureHeatmap"] = sensor_summary["soilMoistureHeatmap"]
|
||||||
|
cards["ndviHealthCard"] = crop_health_summary["ndviHealthCard"]
|
||||||
|
cards["economicOverview"] = get_economic_overview_data(farm)
|
||||||
|
cards["farmOverviewKpis"] = _build_overview_kpis(
|
||||||
|
cards["farmOverviewKpis"],
|
||||||
|
crop_health_summary,
|
||||||
|
water_stress_index,
|
||||||
|
avg_soil_moisture,
|
||||||
|
risk_summary,
|
||||||
|
yield_summary,
|
||||||
|
)
|
||||||
|
cards["recommendationsList"] = _build_recommendations_list(
|
||||||
|
farm,
|
||||||
|
cards["recommendationsList"],
|
||||||
|
yield_summary.get("harvest_prediction_card", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cards
|
||||||
+12
-12
@@ -1,5 +1,4 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -136,23 +135,24 @@ class FarmDashboardConfigViewTests(DashboardBaseTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class FarmDashboardCardsViewTests(DashboardBaseTestCase):
|
class FarmDashboardCardsViewTests(DashboardBaseTestCase):
|
||||||
@patch("dashboard.views.external_api_request")
|
def test_get_returns_locally_aggregated_cards(self):
|
||||||
def test_get_forwards_farm_uuid_to_external_api(self, mock_external_api_request):
|
|
||||||
mock_external_api_request.return_value.data = {"status": "success", "data": {}}
|
|
||||||
mock_external_api_request.return_value.status_code = 200
|
|
||||||
|
|
||||||
request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}")
|
request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}")
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
response = FarmDashboardCardsView.as_view()(request)
|
response = FarmDashboardCardsView.as_view()(request)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
mock_external_api_request.assert_called_once_with(
|
self.assertEqual(response.data["code"], 200)
|
||||||
"ai",
|
self.assertEqual(response.data["msg"], "OK")
|
||||||
"/dashboard-data/status",
|
self.assertIn("farmWeatherCard", response.data["data"])
|
||||||
method="GET",
|
self.assertIn("farmAlertsTracker", response.data["data"])
|
||||||
query={"farm_uuid": str(self.farm.farm_uuid)},
|
self.assertIn("yieldPredictionChart", response.data["data"])
|
||||||
)
|
self.assertIn("ndviHealthCard", response.data["data"])
|
||||||
|
self.assertIn("sensorRadarChart", response.data["data"])
|
||||||
|
self.assertIn("soilMoistureHeatmap", response.data["data"])
|
||||||
|
self.assertIn("economicOverview", response.data["data"])
|
||||||
|
self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][0]["id"], "farm_health_score")
|
||||||
|
self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][2]["id"], "avg_soil_moisture")
|
||||||
|
|
||||||
def test_get_requires_farm_uuid(self):
|
def test_get_requires_farm_uuid(self):
|
||||||
request = self.factory.get("/api/farm-dashboard/")
|
request = self.factory.get("/api/farm-dashboard/")
|
||||||
|
|||||||
+5
-8
@@ -10,8 +10,8 @@ from drf_spectacular.types import OpenApiTypes
|
|||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
||||||
|
|
||||||
from config.swagger import code_response
|
from config.swagger import code_response
|
||||||
from external_api_adapter import request as external_api_request
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
from .services import get_farm_dashboard_cards
|
||||||
from .mock_data import DEFAULT_CONFIG
|
from .mock_data import DEFAULT_CONFIG
|
||||||
from .models import FarmDashboardConfig
|
from .models import FarmDashboardConfig
|
||||||
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
|
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
|
||||||
@@ -117,7 +117,7 @@ class FarmDashboardConfigView(FarmAccessMixin, APIView):
|
|||||||
class FarmDashboardCardsView(FarmAccessMixin, APIView):
|
class FarmDashboardCardsView(FarmAccessMixin, APIView):
|
||||||
"""
|
"""
|
||||||
Farm dashboard cards endpoint: GET.
|
Farm dashboard cards endpoint: GET.
|
||||||
Requires farm_uuid and forwards it to the external AI service.
|
Requires farm_uuid and assembles local dashboard services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -125,10 +125,7 @@ class FarmDashboardCardsView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
|
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
|
||||||
adapter_response = external_api_request(
|
return Response(
|
||||||
"ai",
|
{"code": 200, "msg": "OK", "data": get_farm_dashboard_cards(farm)},
|
||||||
"/dashboard-data/status",
|
status=status.HTTP_200_OK,
|
||||||
method="GET",
|
|
||||||
query={"farm_uuid": str(farm.farm_uuid)},
|
|
||||||
)
|
)
|
||||||
return Response(adapter_response.data, status=adapter_response.status_code)
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import ECONOMIC_OVERVIEW
|
||||||
|
from .models import EconomicOverviewLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_economic_overview_data(farm=None):
|
||||||
|
data = deepcopy(ECONOMIC_OVERVIEW)
|
||||||
|
|
||||||
|
if farm is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
log = EconomicOverviewLog.objects.filter(farm=farm).first()
|
||||||
|
if log is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if log.economic_data:
|
||||||
|
data["economicData"] = deepcopy(log.economic_data)
|
||||||
|
if log.chart_series:
|
||||||
|
data["chartSeries"] = deepcopy(log.chart_series)
|
||||||
|
if log.chart_categories:
|
||||||
|
data["chartCategories"] = deepcopy(log.chart_categories)
|
||||||
|
|
||||||
|
return data
|
||||||
+102
-1
@@ -1,7 +1,16 @@
|
|||||||
|
from collections import Counter
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
from notifications.models import FarmNotification
|
from notifications.models import FarmNotification
|
||||||
|
|
||||||
from .models import FarmAlert
|
from .mock_data import (
|
||||||
|
ANOMALY_DETECTION_CARD,
|
||||||
|
ARM_ALERTS_TRACKER,
|
||||||
|
FARM_ALERTS_TIMELINE,
|
||||||
|
RECOMMENDATIONS_LIST,
|
||||||
|
)
|
||||||
|
from .models import AnomalyDetection, FarmAlert, Recommendation
|
||||||
|
|
||||||
|
|
||||||
class AlertService:
|
class AlertService:
|
||||||
@@ -47,3 +56,95 @@ class AlertService:
|
|||||||
level=level_map.get(alert.color, "info"),
|
level=level_map.get(alert.color, "info"),
|
||||||
metadata={"alert_uuid": str(alert.uuid), "color": alert.color},
|
metadata={"alert_uuid": str(alert.uuid), "color": alert.color},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_tracker_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(ARM_ALERTS_TRACKER)
|
||||||
|
|
||||||
|
alerts = list(FarmAlert.objects.filter(farm=farm, is_active=True)[:20])
|
||||||
|
if not alerts:
|
||||||
|
return deepcopy(ARM_ALERTS_TRACKER)
|
||||||
|
|
||||||
|
counts = Counter(alert.title for alert in alerts)
|
||||||
|
alert_stats = []
|
||||||
|
for title, count in counts.most_common(3):
|
||||||
|
sample = next((alert for alert in alerts if alert.title == title), None)
|
||||||
|
alert_stats.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"count": str(count),
|
||||||
|
"avatarColor": sample.color if sample else "info",
|
||||||
|
"avatarIcon": sample.avatar_icon or "tabler-bell",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalAlerts": len(alerts),
|
||||||
|
"radialBarValue": min(len(alerts) * 10, 100),
|
||||||
|
"alertStats": alert_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_timeline_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(FARM_ALERTS_TIMELINE)
|
||||||
|
|
||||||
|
alerts = list(FarmAlert.objects.filter(farm=farm)[:10])
|
||||||
|
if not alerts:
|
||||||
|
return deepcopy(FARM_ALERTS_TIMELINE)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"title": alert.title,
|
||||||
|
"description": alert.description,
|
||||||
|
"time": alert.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"color": alert.color,
|
||||||
|
}
|
||||||
|
for alert in alerts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_anomaly_detection_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
|
||||||
|
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
|
||||||
|
if not anomalies:
|
||||||
|
return deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": anomaly.sensor,
|
||||||
|
"value": anomaly.value,
|
||||||
|
"expected": anomaly.expected,
|
||||||
|
"deviation": anomaly.deviation,
|
||||||
|
"severity": anomaly.severity,
|
||||||
|
}
|
||||||
|
for anomaly in anomalies
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recommendations_list_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(RECOMMENDATIONS_LIST)
|
||||||
|
|
||||||
|
recommendations = list(Recommendation.objects.filter(farm=farm)[:10])
|
||||||
|
if not recommendations:
|
||||||
|
return deepcopy(RECOMMENDATIONS_LIST)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"title": recommendation.title,
|
||||||
|
"subtitle": recommendation.subtitle,
|
||||||
|
"avatarIcon": recommendation.avatar_icon or "tabler-bulb",
|
||||||
|
"avatarColor": recommendation.avatar_color or "info",
|
||||||
|
}
|
||||||
|
for recommendation in recommendations
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ RECOMMEND_RESPONSE_DATA = {
|
|||||||
"reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.",
|
"reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FERTILIZATION_DASHBOARD_RECOMMENDATION = {
|
||||||
|
"title": "کود: 20-20-20 (NPK)",
|
||||||
|
"subtitle": "150 kg/ha، با روش Foliar spray + soil broadcast و هر 14 روز.",
|
||||||
|
"avatarIcon": "tabler-leaf",
|
||||||
|
"avatarColor": "success",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA
|
||||||
|
from .models import FertilizationRecommendationRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_result(response_payload):
|
||||||
|
if not isinstance(response_payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = response_payload.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
result = response_payload.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_result(farm):
|
||||||
|
if farm is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
for request in FertilizationRecommendationRequest.objects.filter(farm=farm):
|
||||||
|
result = _extract_result(request.response_payload)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_fertilization_dashboard_recommendation(farm=None):
|
||||||
|
default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION)
|
||||||
|
result = _get_latest_result(farm)
|
||||||
|
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
|
||||||
|
|
||||||
|
npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)"
|
||||||
|
amount = plan.get("amountPerHectare")
|
||||||
|
method = plan.get("applicationMethod")
|
||||||
|
interval = plan.get("applicationInterval")
|
||||||
|
|
||||||
|
subtitle_parts = [part for part in [amount, method, interval] if part]
|
||||||
|
|
||||||
|
default_item["title"] = f"کود: {npk_ratio}"
|
||||||
|
if subtitle_parts:
|
||||||
|
default_item["subtitle"] = "، ".join(subtitle_parts)
|
||||||
|
|
||||||
|
return default_item
|
||||||
@@ -28,3 +28,17 @@ RECOMMEND_RESPONSE_DATA = {
|
|||||||
"warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.",
|
"warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WATER_NEED_PREDICTION = {
|
||||||
|
"totalNext7Days": 3290,
|
||||||
|
"unit": "m3",
|
||||||
|
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
|
||||||
|
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
IRRIGATION_DASHBOARD_RECOMMENDATION = {
|
||||||
|
"title": "آبیاری: 05:00 - 07:00",
|
||||||
|
"subtitle": "4 نوبت در هفته، 45 دقیقه برای هر نوبت. رطوبت هدف 72%.",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
|
||||||
|
from .models import IrrigationRecommendationRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_result(response_payload):
|
||||||
|
if not isinstance(response_payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = response_payload.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
result = response_payload.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_result(farm):
|
||||||
|
if farm is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
for request in IrrigationRecommendationRequest.objects.filter(farm=farm):
|
||||||
|
result = _extract_result(request.response_payload)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_need_prediction_data(farm=None):
|
||||||
|
default_data = deepcopy(WATER_NEED_PREDICTION)
|
||||||
|
result = _get_latest_result(farm)
|
||||||
|
water_balance = result.get("water_balance", {})
|
||||||
|
daily = water_balance.get("daily", [])
|
||||||
|
|
||||||
|
if not daily:
|
||||||
|
return default_data
|
||||||
|
|
||||||
|
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
|
||||||
|
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalNext7Days": round(sum(series_data), 2),
|
||||||
|
"unit": "mm",
|
||||||
|
"categories": categories,
|
||||||
|
"series": [{"name": "نیاز آبی", "data": series_data}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_irrigation_dashboard_recommendation(farm=None):
|
||||||
|
default_item = deepcopy(IRRIGATION_DASHBOARD_RECOMMENDATION)
|
||||||
|
result = _get_latest_result(farm)
|
||||||
|
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
|
||||||
|
|
||||||
|
best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00"
|
||||||
|
frequency = plan.get("frequencyPerWeek")
|
||||||
|
duration = plan.get("durationMinutes")
|
||||||
|
moisture = plan.get("moistureLevel")
|
||||||
|
warning = plan.get("warning")
|
||||||
|
|
||||||
|
subtitle_parts = []
|
||||||
|
if frequency is not None and duration is not None:
|
||||||
|
subtitle_parts.append(f"{frequency} نوبت در هفته، {duration} دقیقه برای هر نوبت")
|
||||||
|
if moisture is not None:
|
||||||
|
subtitle_parts.append(f"رطوبت هدف {moisture}%")
|
||||||
|
if warning:
|
||||||
|
subtitle_parts.append(str(warning))
|
||||||
|
|
||||||
|
default_item["title"] = f"آبیاری: {best_time}"
|
||||||
|
if subtitle_parts:
|
||||||
|
default_item["subtitle"] = ". ".join(subtitle_parts)
|
||||||
|
|
||||||
|
return default_item
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import RISK_SUMMARY_RESPONSE_DATA
|
||||||
|
|
||||||
|
|
||||||
|
def get_risk_summary_data(farm=None):
|
||||||
|
return deepcopy(RISK_SUMMARY_RESPONSE_DATA)
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class PlantSimulatorConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "plant_simulator"
|
|
||||||
verbose_name = "Plant Simulator"
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
Static mock data for Plant Simulator API.
|
|
||||||
Matches PLANT_SIMULATOR_API.md. No database, no dynamic values.
|
|
||||||
Smooth animation: 51 points (0–10s, ~5 frames per second).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /api/plant-simulator/config (ورود: فقط اسلایدرها)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CONFIG_SLIDERS_ONLY = {
|
|
||||||
"sliders": [
|
|
||||||
{
|
|
||||||
"key": "light",
|
|
||||||
"label": "نور",
|
|
||||||
"min": 0,
|
|
||||||
"max": 100,
|
|
||||||
"step": 5,
|
|
||||||
"unit_type": "percent",
|
|
||||||
"default_value": 75,
|
|
||||||
"icon": "☀️",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "water",
|
|
||||||
"label": "آب",
|
|
||||||
"min": 0,
|
|
||||||
"max": 100,
|
|
||||||
"step": 5,
|
|
||||||
"unit_type": "percent",
|
|
||||||
"default_value": 65,
|
|
||||||
"icon": "💧",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "soil_ph",
|
|
||||||
"label": "pH خاک",
|
|
||||||
"min": 4,
|
|
||||||
"max": 9,
|
|
||||||
"step": 0.5,
|
|
||||||
"unit_type": "number",
|
|
||||||
"unit": "",
|
|
||||||
"default_value": 6.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "growth_speed",
|
|
||||||
"label": "سرعت رشد",
|
|
||||||
"min": 0.5,
|
|
||||||
"max": 5,
|
|
||||||
"step": 0.5,
|
|
||||||
"unit_type": "number",
|
|
||||||
"unit": "×",
|
|
||||||
"default_value": 1.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /api/plant-simulator/start (ثابتها + چارت کانفیگ + plant + progress + chart)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CONSTANTS = {
|
|
||||||
"max_height": 280,
|
|
||||||
"max_leaves": 14,
|
|
||||||
"max_branches": 6,
|
|
||||||
"max_yield": 500,
|
|
||||||
"yield_unit": "g",
|
|
||||||
"yield_rate_unit": "g/s",
|
|
||||||
"height_unit": "px",
|
|
||||||
}
|
|
||||||
|
|
||||||
CHART_CONFIG = {
|
|
||||||
"title": "پیشرفت رشد",
|
|
||||||
"x_axis_label": "زمان (ثانیه)",
|
|
||||||
"series": [
|
|
||||||
{
|
|
||||||
"key": "height",
|
|
||||||
"label": "ارتفاع (px)",
|
|
||||||
"y_axis_id": "yHeight",
|
|
||||||
"min": 0,
|
|
||||||
"max": 280,
|
|
||||||
"unit": "px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "leaves",
|
|
||||||
"label": "تعداد برگ",
|
|
||||||
"y_axis_id": "yLeaf",
|
|
||||||
"min": 0,
|
|
||||||
"max": 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "yield",
|
|
||||||
"label": "محصول (g)",
|
|
||||||
"y_axis_id": "yYield",
|
|
||||||
"min": 0,
|
|
||||||
"max": 500,
|
|
||||||
"unit": "g",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "yield_rate",
|
|
||||||
"label": "نرخ محصول (g/s)",
|
|
||||||
"y_axis_id": "yYieldRate",
|
|
||||||
"min": 0,
|
|
||||||
"unit": "g/s",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 51 نقطه برای انیمیشن نرم (۰ تا ۱۰ ثانیه، هر ~۰٫۲s)
|
|
||||||
_labels = [f"{i * 0.2:.1f}s" for i in range(51)]
|
|
||||||
_height = [round(142 * (i / 50) ** 0.9) for i in range(51)] # رشد کمی شتابدار
|
|
||||||
_leaf = [min(5, int(i / 10)) for i in range(51)] # 0,0..,1,1..,2,...,5
|
|
||||||
_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)] # محصول با شتاب ملایم
|
|
||||||
_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)] # نرخ از ثانیه ~۳
|
|
||||||
|
|
||||||
START_RESPONSE_DATA = {
|
|
||||||
"constants": CONSTANTS,
|
|
||||||
"chart": CHART_CONFIG,
|
|
||||||
"plant": {
|
|
||||||
"height": 142,
|
|
||||||
"leaves_count": 5,
|
|
||||||
"branches_count": 2,
|
|
||||||
"fruits_count": 0,
|
|
||||||
"yield": 12.4,
|
|
||||||
"yield_rate": 0.087,
|
|
||||||
"tick": 520,
|
|
||||||
"is_healthy": True,
|
|
||||||
"can_continue": True,
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"growth_progress": 50,
|
|
||||||
"light_status": 75,
|
|
||||||
"water_status": 65,
|
|
||||||
"yield_progress": 2.5,
|
|
||||||
"yield_current": 12.4,
|
|
||||||
"yield_rate_current": 0.087,
|
|
||||||
},
|
|
||||||
"chart_history": {
|
|
||||||
"labels": _labels,
|
|
||||||
"height_history": _height,
|
|
||||||
"leaf_history": _leaf,
|
|
||||||
"yield_history": _yield,
|
|
||||||
"yield_rate_history": _yield_rate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /api/plant-simulator/state (plant + progress + chart history)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
STATE_RESPONSE_DATA = {
|
|
||||||
"plant": {
|
|
||||||
"height": 142,
|
|
||||||
"leaves_count": 5,
|
|
||||||
"branches_count": 2,
|
|
||||||
"fruits_count": 0,
|
|
||||||
"yield": 12.4,
|
|
||||||
"yield_rate": 0.087,
|
|
||||||
"tick": 520,
|
|
||||||
"is_healthy": True,
|
|
||||||
"can_continue": True,
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"growth_progress": 50,
|
|
||||||
"light_status": 75,
|
|
||||||
"water_status": 65,
|
|
||||||
"yield_progress": 2.5,
|
|
||||||
"yield_current": 12.4,
|
|
||||||
"yield_rate_current": 0.087,
|
|
||||||
},
|
|
||||||
"chart": {
|
|
||||||
"labels": _labels,
|
|
||||||
"height_history": _height,
|
|
||||||
"leaf_history": _leaf,
|
|
||||||
"yield_history": _yield,
|
|
||||||
"yield_rate_history": _yield_rate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
{
|
|
||||||
"info": {
|
|
||||||
"name": "Plant Simulator",
|
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
||||||
"description": "Plant Simulator API. GET config (sliders only), GET state, POST start (body: environment + growth_speed per serializers), stop/reset/environment. Static mock only."
|
|
||||||
},
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Get config (GET)",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/config/",
|
|
||||||
"description": "ورود: فقط اسلایدرها. Returns sliders only (key, label, min, max, step, unit_type, default_value, icon)."
|
|
||||||
},
|
|
||||||
"response": [
|
|
||||||
{
|
|
||||||
"name": "Success",
|
|
||||||
"status": "OK",
|
|
||||||
"code": 200,
|
|
||||||
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"sliders\": [\n {\"key\": \"light\", \"label\": \"نور\", \"min\": 0, \"max\": 100, \"step\": 5, \"unit_type\": \"percent\", \"default_value\": 75, \"icon\": \"☀️\"},\n {\"key\": \"water\", \"label\": \"آب\", \"min\": 0, \"max\": 100, \"step\": 5, \"unit_type\": \"percent\", \"default_value\": 65, \"icon\": \"💧\"},\n {\"key\": \"soil_ph\", \"label\": \"pH خاک\", \"min\": 4, \"max\": 9, \"step\": 0.5, \"unit_type\": \"number\", \"unit\": \"\", \"default_value\": 6.5},\n {\"key\": \"growth_speed\", \"label\": \"سرعت رشد\", \"min\": 0.5, \"max\": 5, \"step\": 0.5, \"unit_type\": \"number\", \"unit\": \"×\", \"default_value\": 1.5}\n ]\n }\n}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get state (GET)",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/state/",
|
|
||||||
"description": "Returns static plant state, progress, and chart history (plant, progress, chart)."
|
|
||||||
},
|
|
||||||
"response": [
|
|
||||||
{
|
|
||||||
"name": "Success",
|
|
||||||
"status": "OK",
|
|
||||||
"code": 200,
|
|
||||||
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"plant\": {\"height\": 142, \"leaves_count\": 5, \"branches_count\": 2, \"fruits_count\": 0, \"yield\": 12.4, \"yield_rate\": 0.087, \"tick\": 520, \"is_healthy\": true, \"can_continue\": true},\n \"progress\": {\"growth_progress\": 50, \"light_status\": 75, \"water_status\": 65, \"yield_progress\": 2.5, \"yield_current\": 12.4, \"yield_rate_current\": 0.087},\n \"chart\": {\n \"labels\": [\"0s\", \"1s\", \"2s\", \"3s\", \"4s\", \"5s\", \"6s\", \"7s\", \"8s\", \"9s\", \"10s\"],\n \"height_history\": [0, 5, 12, 28, 45, 68, 92, 110, 125, 135, 142],\n \"leaf_history\": [0, 0, 1, 2, 3, 4, 4, 5, 5, 5, 5],\n \"yield_history\": [0, 0, 0, 0.1, 0.5, 1.2, 3.2, 5.8, 8.2, 10.1, 12.4],\n \"yield_rate_history\": [0, 0, 0, 0.01, 0.03, 0.05, 0.06, 0.07, 0.08, 0.085, 0.087]\n }\n }\n}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Start simulation (POST)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"environment\": {\n \"light\": 75,\n \"water\": 65,\n \"soil_ph\": 6.5\n },\n \"growth_speed\": 1.5\n}"
|
|
||||||
},
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/start/",
|
|
||||||
"description": "Body per serializers.START_REQUEST_EXAMPLE_STATIC: environment (light, water, soil_ph) + growth_speed. Returns constants, chart, plant, progress, chart_history."
|
|
||||||
},
|
|
||||||
"response": [
|
|
||||||
{
|
|
||||||
"name": "Success",
|
|
||||||
"status": "OK",
|
|
||||||
"code": 200,
|
|
||||||
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"constants\": {\"max_height\": 280, \"max_leaves\": 14, \"max_branches\": 6, \"max_yield\": 500, \"yield_unit\": \"g\", \"yield_rate_unit\": \"g/s\", \"height_unit\": \"px\"},\n \"chart\": {\"title\": \"پیشرفت رشد\", \"x_axis_label\": \"زمان (ثانیه)\", \"series\": [{\"key\": \"height\", \"label\": \"ارتفاع (px)\", \"y_axis_id\": \"yHeight\", \"min\": 0, \"max\": 280, \"unit\": \"px\"}, {\"key\": \"leaves\", \"label\": \"تعداد برگ\", \"y_axis_id\": \"yLeaf\", \"min\": 0, \"max\": 14}, {\"key\": \"yield\", \"label\": \"محصول (g)\", \"y_axis_id\": \"yYield\", \"min\": 0, \"max\": 500, \"unit\": \"g\"}, {\"key\": \"yield_rate\", \"label\": \"نرخ محصول (g/s)\", \"y_axis_id\": \"yYieldRate\", \"min\": 0, \"unit\": \"g/s\"}]},\n \"plant\": {\"height\": 142, \"leaves_count\": 5, \"branches_count\": 2, \"fruits_count\": 0, \"yield\": 12.4, \"yield_rate\": 0.087, \"tick\": 520, \"is_healthy\": true, \"can_continue\": true},\n \"progress\": {\"growth_progress\": 50, \"light_status\": 75, \"water_status\": 65, \"yield_progress\": 2.5, \"yield_current\": 12.4, \"yield_rate_current\": 0.087},\n \"chart_history\": {\"labels\": [\"0.0s\", \"0.2s\", \"0.4s\", \"1.0s\", \"2.0s\", \"5.0s\", \"10.0s\"], \"height_history\": [0, 2, 5, 18, 45, 110, 142], \"leaf_history\": [0, 0, 0, 0, 0, 1, 5], \"yield_history\": [0, 0, 0, 0.2, 1.5, 6.2, 12.4], \"yield_rate_history\": [0, 0, 0, 0.01, 0.03, 0.06, 0.087]}\n }\n}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Stop simulation (POST)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"body": {"mode": "raw", "raw": "{}"},
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/stop/",
|
|
||||||
"description": "Empty body. Returns success only."
|
|
||||||
},
|
|
||||||
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Reset simulation (POST)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"body": {"mode": "raw", "raw": "{}"},
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/reset/",
|
|
||||||
"description": "Empty body. Returns success only."
|
|
||||||
},
|
|
||||||
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Update environment (PATCH)",
|
|
||||||
"request": {
|
|
||||||
"method": "PATCH",
|
|
||||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"environment\": {\n \"light\": 80,\n \"water\": 70,\n \"soil_ph\": 6.5\n },\n \"growth_speed\": 2\n}"
|
|
||||||
},
|
|
||||||
"url": "{{baseUrl}}/api/plant-simulator/environment/",
|
|
||||||
"description": "Body: environment (same keys as sliders) + optional growth_speed. Returns success only."
|
|
||||||
},
|
|
||||||
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"variable": [{"key": "baseUrl", "value": "http://localhost:8000"}]
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""
|
|
||||||
Response serialization for Plant Simulator API.
|
|
||||||
Plain Django; no DRF. Builds response envelope only. All payloads are static mock data.
|
|
||||||
|
|
||||||
Request: on POST /start the client must send a body with keys matching the sliders
|
|
||||||
from config (light, water, soil_ph) plus growth_speed. See START_REQUEST_EXAMPLE.
|
|
||||||
No validation or use of request in response; backend returns static mock only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .mock_data import CONFIG_SLIDERS_ONLY
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /start — بدنهای که کلاینت باید ارسال کند (مطابق اسلایدرهای config)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# کلیدهای environment (همان key هر اسلایدر بهجز growth_speed)
|
|
||||||
START_ENVIRONMENT_KEYS = [
|
|
||||||
item["key"]
|
|
||||||
for item in CONFIG_SLIDERS_ONLY["sliders"]
|
|
||||||
if item["key"] != "growth_speed"
|
|
||||||
]
|
|
||||||
|
|
||||||
# مقدار پیشفرض هر اسلایدر برای ساخت نمونه درخواست
|
|
||||||
def _defaults_from_sliders():
|
|
||||||
return {
|
|
||||||
item["key"]: item["default_value"]
|
|
||||||
for item in CONFIG_SLIDERS_ONLY["sliders"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# نمونه بدنه درخواست start که کلاینت باید ارسال کند
|
|
||||||
START_REQUEST_EXAMPLE = {
|
|
||||||
"environment": {
|
|
||||||
k: v for k, v in _defaults_from_sliders().items() if k != "growth_speed"
|
|
||||||
},
|
|
||||||
"growth_speed": _defaults_from_sliders().get("growth_speed", 1.5),
|
|
||||||
}
|
|
||||||
|
|
||||||
# برای سازگاری: همان ساختار به صورت ثابت (بدون وابستگی به محاسبه)
|
|
||||||
START_REQUEST_EXAMPLE_STATIC = {
|
|
||||||
"environment": {
|
|
||||||
"light": 75,
|
|
||||||
"water": 65,
|
|
||||||
"soil_ph": 6.5,
|
|
||||||
},
|
|
||||||
"growth_speed": 1.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def success_response():
|
|
||||||
"""
|
|
||||||
Response when endpoint does not return data.
|
|
||||||
Returns: {"status": "success"}
|
|
||||||
"""
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
def success_with_data(data):
|
|
||||||
"""
|
|
||||||
Response when endpoint returns data.
|
|
||||||
Returns: {"status": "success", "data": data}
|
|
||||||
"""
|
|
||||||
return {"status": "success", "data": data}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
from .views import (
|
|
||||||
ConfigView,
|
|
||||||
EnvironmentView,
|
|
||||||
ResetView,
|
|
||||||
StartView,
|
|
||||||
StateView,
|
|
||||||
StopView,
|
|
||||||
)
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("config/", ConfigView.as_view(), name="plant-simulator-config"),
|
|
||||||
path("state/", StateView.as_view(), name="plant-simulator-state"),
|
|
||||||
path("start/", StartView.as_view(), name="plant-simulator-start"),
|
|
||||||
path("stop/", StopView.as_view(), name="plant-simulator-stop"),
|
|
||||||
path("reset/", ResetView.as_view(), name="plant-simulator-reset"),
|
|
||||||
path("environment/", EnvironmentView.as_view(), name="plant-simulator-environment"),
|
|
||||||
]
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
Plant Simulator API views.
|
|
||||||
No database. All responses are static mock data.
|
|
||||||
Response format: {"status": "success"} or {"status": "success", "data": <payload>}. HTTP 200 only.
|
|
||||||
No processing, validation, or use of input parameters in responses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rest_framework import serializers, status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
|
|
||||||
from config.swagger import status_response
|
|
||||||
from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA
|
|
||||||
from .serializers import success_response, success_with_data
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigView(APIView):
|
|
||||||
"""
|
|
||||||
GET endpoint for simulator configuration (ورود).
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Returns only sliders (min, max, step, unit, label, default_value, icon).
|
|
||||||
Used when loading/entering the simulator page.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
None. Query parameters, if sent, are not read or used.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
- data: object with key "sliders" (array of slider configs).
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())},
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
return Response(success_with_data(CONFIG_SLIDERS_ONLY), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class StateView(APIView):
|
|
||||||
"""
|
|
||||||
GET endpoint for plant state, progress, and chart history.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Returns static plant state (height, leaves_count, branches_count,
|
|
||||||
fruits_count, yield, yield_rate, tick, is_healthy, can_continue),
|
|
||||||
progress (growth_progress, light_status, water_status, yield_progress,
|
|
||||||
yield_current, yield_rate_current), and chart (labels, height_history,
|
|
||||||
leaf_history, yield_history, yield_rate_history). Used during or after
|
|
||||||
simulation for UI and chart.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
None. Query parameters, if sent, are not read or used.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
- data: object with keys "plant", "progress", "chart" per
|
|
||||||
PLANT_SIMULATOR_API.md §3.
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
responses={200: status_response("PlantSimulatorStateResponse", data=serializers.JSONField())},
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
return Response(success_with_data(STATE_RESPONSE_DATA), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class StartView(APIView):
|
|
||||||
"""
|
|
||||||
POST endpoint to start simulation.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Returns constants, chart config, plant state, progress, and chart
|
|
||||||
history. Body may contain environment and growth_speed; not read or used.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- body (optional): JSON. May contain "environment" and "growth_speed".
|
|
||||||
Location: body. Not read or validated; not used in response.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
- data: object with "constants", "chart", "plant", "progress", "chart_history".
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
request=OpenApiTypes.OBJECT,
|
|
||||||
responses={200: status_response("PlantSimulatorStartResponse", data=serializers.JSONField())},
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
return Response(success_with_data(START_RESPONSE_DATA), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class StopView(APIView):
|
|
||||||
"""
|
|
||||||
POST endpoint to stop simulation.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Accepts stop request. Returns success only. No processing performed.
|
|
||||||
Body may be empty or contain session_id; not read or used.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- body (optional): JSON or empty. Location: body. Not read or used.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
No "data" field.
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
request=OpenApiTypes.OBJECT,
|
|
||||||
responses={200: status_response("PlantSimulatorStopResponse")},
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
return Response(success_response(), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class ResetView(APIView):
|
|
||||||
"""
|
|
||||||
POST endpoint to reset simulation.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Accepts reset request. Returns success only. No processing performed.
|
|
||||||
Body may be empty or contain session_id; not read or used.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- body (optional): JSON or empty. Location: body. Not read or used.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
No "data" field.
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
request=OpenApiTypes.OBJECT,
|
|
||||||
responses={200: status_response("PlantSimulatorResetResponse")},
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
return Response(success_response(), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentView(APIView):
|
|
||||||
"""
|
|
||||||
PATCH endpoint to update environment (slider values).
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Accepts environment update. Returns success only. No processing
|
|
||||||
performed. Body may contain environment and growth_speed; not
|
|
||||||
read or used in the response.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- body (optional): JSON. May contain "environment" (object)
|
|
||||||
and "growth_speed" (number). Location: body. Not read or used.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
No "data" field.
|
|
||||||
|
|
||||||
No processing or validation is performed on inputs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Plant Simulator"],
|
|
||||||
request=OpenApiTypes.OBJECT,
|
|
||||||
responses={200: status_response("PlantSimulatorEnvironmentResponse")},
|
|
||||||
)
|
|
||||||
def patch(self, request):
|
|
||||||
return Response(success_response(), status=status.HTTP_200_OK)
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1Config(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "sensor_7_in_1"
|
||||||
|
verbose_name = "Sensor 7 in 1"
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
AVG_SOIL_MOISTURE = {
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "سنسور 7 در 1 خاک",
|
||||||
|
"stats": "45%",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "متوسط",
|
||||||
|
"chipColor": "warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_VALUES_LIST = {
|
||||||
|
"sensor": {
|
||||||
|
"name": "سنسور 7 در 1 خاک",
|
||||||
|
"physicalDeviceUuid": None,
|
||||||
|
"sensorCatalogCode": "sensor-7-in-1",
|
||||||
|
"updatedAt": None,
|
||||||
|
},
|
||||||
|
"sensors": [
|
||||||
|
{
|
||||||
|
"id": "soil_moisture",
|
||||||
|
"title": "45%",
|
||||||
|
"subtitle": "رطوبت خاک",
|
||||||
|
"trendNumber": 1.5,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_temperature",
|
||||||
|
"title": "22.5°C",
|
||||||
|
"subtitle": "دمای خاک",
|
||||||
|
"trendNumber": 0.8,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "°C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_ph",
|
||||||
|
"title": "6.8",
|
||||||
|
"subtitle": "pH خاک",
|
||||||
|
"trendNumber": 0.1,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "pH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "electrical_conductivity",
|
||||||
|
"title": "1.2 dS/m",
|
||||||
|
"subtitle": "هدایت الکتریکی",
|
||||||
|
"trendNumber": -0.1,
|
||||||
|
"trend": "negative",
|
||||||
|
"unit": "dS/m",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nitrogen",
|
||||||
|
"title": "30 mg/kg",
|
||||||
|
"subtitle": "نیتروژن",
|
||||||
|
"trendNumber": 2.0,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phosphorus",
|
||||||
|
"title": "15 mg/kg",
|
||||||
|
"subtitle": "فسفر",
|
||||||
|
"trendNumber": 1.0,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "potassium",
|
||||||
|
"title": "20 mg/kg",
|
||||||
|
"subtitle": "پتاسیم",
|
||||||
|
"trendNumber": -1.0,
|
||||||
|
"trend": "negative",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_RADAR_CHART = {
|
||||||
|
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
|
||||||
|
"series": [
|
||||||
|
{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]},
|
||||||
|
{"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_COMPARISON_CHART = {
|
||||||
|
"currentValue": 45,
|
||||||
|
"vsLastWeek": "+4.7%",
|
||||||
|
"vsLastWeekValue": 4.7,
|
||||||
|
"categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||||
|
"series": [
|
||||||
|
{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]},
|
||||||
|
{"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ANOMALY_DETECTION_CARD = {
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": "هدایت الکتریکی",
|
||||||
|
"value": "1.2 dS/m",
|
||||||
|
"expected": "0.8-1.1 dS/m",
|
||||||
|
"deviation": "+0.1 dS/m",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SOIL_MOISTURE_HEATMAP = {
|
||||||
|
"zones": ["سنسور 7 در 1 خاک"],
|
||||||
|
"hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "سنسور 7 در 1 خاک",
|
||||||
|
"data": [
|
||||||
|
{"x": "08:00", "y": 42},
|
||||||
|
{"x": "10:00", "y": 44},
|
||||||
|
{"x": "12:00", "y": 45},
|
||||||
|
{"x": "14:00", "y": 47},
|
||||||
|
{"x": "16:00", "y": 46},
|
||||||
|
{"x": "18:00", "y": 45},
|
||||||
|
{"x": "20:00", "y": 45},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
This app is service-based and does not define local database models.
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from soil.serializers import (
|
||||||
|
SoilAnomalyDetectionSerializer,
|
||||||
|
SoilComparisonChartSerializer,
|
||||||
|
SoilKpiSerializer,
|
||||||
|
SoilMoistureHeatmapSerializer,
|
||||||
|
SoilRadarChartSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1MetaSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
physicalDeviceUuid = serializers.CharField(required=False, allow_null=True)
|
||||||
|
sensorCatalogCode = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
updatedAt = serializers.CharField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1ValueSerializer(serializers.Serializer):
|
||||||
|
id = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
subtitle = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
trendNumber = serializers.FloatField(required=False)
|
||||||
|
trend = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
unit = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1ValuesListSerializer(serializers.Serializer):
|
||||||
|
sensor = Sensor7In1MetaSerializer(required=False)
|
||||||
|
sensors = Sensor7In1ValueSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1SummarySerializer(serializers.Serializer):
|
||||||
|
sensor = Sensor7In1MetaSerializer(required=False)
|
||||||
|
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
|
||||||
|
avgSoilMoisture = SoilKpiSerializer(required=False)
|
||||||
|
sensorRadarChart = SoilRadarChartSerializer(required=False)
|
||||||
|
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
||||||
|
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
||||||
|
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
||||||
|
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm
|
||||||
|
|
||||||
|
from .mock_data import (
|
||||||
|
ANOMALY_DETECTION_CARD,
|
||||||
|
AVG_SOIL_MOISTURE,
|
||||||
|
SENSOR_COMPARISON_CHART,
|
||||||
|
SENSOR_RADAR_CHART,
|
||||||
|
SENSOR_VALUES_LIST,
|
||||||
|
SOIL_MOISTURE_HEATMAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_FIELDS = [
|
||||||
|
{
|
||||||
|
"id": "soil_moisture",
|
||||||
|
"label": "رطوبت خاک",
|
||||||
|
"unit": "%",
|
||||||
|
"payload_keys": ("soil_moisture", "soilMoisture", "moisture"),
|
||||||
|
"ideal_min": 45.0,
|
||||||
|
"ideal_max": 65.0,
|
||||||
|
"radar_label": "رطوبت",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_temperature",
|
||||||
|
"label": "دمای خاک",
|
||||||
|
"unit": "°C",
|
||||||
|
"payload_keys": ("soil_temperature", "soilTemperature", "temperature"),
|
||||||
|
"ideal_min": 18.0,
|
||||||
|
"ideal_max": 28.0,
|
||||||
|
"radar_label": "دما",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soil_ph",
|
||||||
|
"label": "pH خاک",
|
||||||
|
"unit": "pH",
|
||||||
|
"payload_keys": ("soil_ph", "soilPh", "ph"),
|
||||||
|
"ideal_min": 6.0,
|
||||||
|
"ideal_max": 7.5,
|
||||||
|
"radar_label": "pH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "electrical_conductivity",
|
||||||
|
"label": "هدایت الکتریکی",
|
||||||
|
"unit": "dS/m",
|
||||||
|
"payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"),
|
||||||
|
"ideal_min": 0.8,
|
||||||
|
"ideal_max": 1.8,
|
||||||
|
"radar_label": "EC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nitrogen",
|
||||||
|
"label": "نیتروژن",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
"payload_keys": ("nitrogen", "n"),
|
||||||
|
"ideal_min": 20.0,
|
||||||
|
"ideal_max": 40.0,
|
||||||
|
"radar_label": "نیتروژن",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phosphorus",
|
||||||
|
"label": "فسفر",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
"payload_keys": ("phosphorus", "p"),
|
||||||
|
"ideal_min": 10.0,
|
||||||
|
"ideal_max": 25.0,
|
||||||
|
"radar_label": "فسفر",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "potassium",
|
||||||
|
"label": "پتاسیم",
|
||||||
|
"unit": "mg/kg",
|
||||||
|
"payload_keys": ("potassium", "k"),
|
||||||
|
"ideal_min": 15.0,
|
||||||
|
"ideal_max": 35.0,
|
||||||
|
"radar_label": "پتاسیم",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
MIN_REQUIRED_SENSOR_FIELDS = 4
|
||||||
|
MAX_HISTORY_ITEMS = 20
|
||||||
|
MAX_CHART_POINTS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value):
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_payload(payload):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if isinstance(payload.get("payload"), dict):
|
||||||
|
payload = payload["payload"]
|
||||||
|
|
||||||
|
if isinstance(payload.get("data"), dict):
|
||||||
|
nested = payload["data"]
|
||||||
|
if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS):
|
||||||
|
payload = nested
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_readings(payload):
|
||||||
|
payload = _extract_payload(payload)
|
||||||
|
readings = {}
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
for key in field["payload_keys"]:
|
||||||
|
value = _to_float(payload.get(key))
|
||||||
|
if value is not None:
|
||||||
|
readings[field["id"]] = value
|
||||||
|
break
|
||||||
|
return readings
|
||||||
|
|
||||||
|
|
||||||
|
def _format_number(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if float(value).is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:.1f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value, unit):
|
||||||
|
number = _format_number(value)
|
||||||
|
if not number:
|
||||||
|
return number
|
||||||
|
if unit in {"", "pH"}:
|
||||||
|
return number
|
||||||
|
if unit in {"%", "°C"}:
|
||||||
|
return f"{number}{unit}"
|
||||||
|
return f"{number} {unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_range(field):
|
||||||
|
lower = _format_number(field["ideal_min"])
|
||||||
|
upper = _format_number(field["ideal_max"])
|
||||||
|
unit = field["unit"]
|
||||||
|
if unit in {"", "pH"}:
|
||||||
|
return f"{lower}-{upper}"
|
||||||
|
return f"{lower}-{upper} {unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sensor_context(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate_log = None
|
||||||
|
candidate_readings = {}
|
||||||
|
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||||
|
readings = _extract_readings(log.payload)
|
||||||
|
if len(readings) >= MIN_REQUIRED_SENSOR_FIELDS:
|
||||||
|
candidate_log = log
|
||||||
|
candidate_readings = readings
|
||||||
|
break
|
||||||
|
|
||||||
|
if candidate_log is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for log in logs_queryset.filter(physical_device_uuid=candidate_log.physical_device_uuid)[:MAX_HISTORY_ITEMS]:
|
||||||
|
readings = _extract_readings(log.payload)
|
||||||
|
if readings:
|
||||||
|
history.append((log, readings))
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
history = [(candidate_log, candidate_readings)]
|
||||||
|
|
||||||
|
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[candidate_log])
|
||||||
|
farm_sensor = farm_sensor_map.get(
|
||||||
|
(candidate_log.farm_uuid, candidate_log.sensor_catalog_uuid, candidate_log.physical_device_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"farm_sensor": farm_sensor,
|
||||||
|
"latest_log": history[0][0],
|
||||||
|
"latest_readings": history[0][1],
|
||||||
|
"previous_readings": history[1][1] if len(history) > 1 else {},
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sensor_meta(context, fallback_sensor):
|
||||||
|
sensor = deepcopy(fallback_sensor)
|
||||||
|
if not context:
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
farm_sensor = context.get("farm_sensor")
|
||||||
|
latest_log = context["latest_log"]
|
||||||
|
sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid)
|
||||||
|
sensor["updatedAt"] = latest_log.created_at.isoformat()
|
||||||
|
|
||||||
|
if farm_sensor is not None:
|
||||||
|
sensor["name"] = farm_sensor.name or sensor["name"]
|
||||||
|
if farm_sensor.sensor_catalog is not None:
|
||||||
|
sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code
|
||||||
|
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_status_chip(value):
|
||||||
|
if value is None:
|
||||||
|
return ("نامشخص", "secondary", "secondary")
|
||||||
|
if value >= 60:
|
||||||
|
return ("بهینه", "success", "primary")
|
||||||
|
if value >= 45:
|
||||||
|
return ("متوسط", "warning", "warning")
|
||||||
|
return ("کم", "error", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_VALUES_LIST)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
data["sensor"] = _build_sensor_meta(context, data["sensor"])
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
latest_readings = context["latest_readings"]
|
||||||
|
previous_readings = context["previous_readings"]
|
||||||
|
sensors = []
|
||||||
|
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = latest_readings.get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
previous = previous_readings.get(field["id"])
|
||||||
|
change = 0.0 if previous is None else round(value - previous, 2)
|
||||||
|
sensors.append(
|
||||||
|
{
|
||||||
|
"id": field["id"],
|
||||||
|
"title": _format_value(value, field["unit"]),
|
||||||
|
"subtitle": field["label"],
|
||||||
|
"trendNumber": abs(change),
|
||||||
|
"trend": "positive" if change >= 0 else "negative",
|
||||||
|
"unit": field["unit"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if sensors:
|
||||||
|
data["sensors"] = sensors
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
|
||||||
|
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
moisture = context["latest_readings"].get("soil_moisture")
|
||||||
|
if moisture is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
|
||||||
|
data["stats"] = _format_value(moisture, "%")
|
||||||
|
data["chipText"] = chip_text
|
||||||
|
data["chipColor"] = chip_color
|
||||||
|
data["avatarColor"] = avatar_color
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _score_field(value, field):
|
||||||
|
min_value = field["ideal_min"]
|
||||||
|
max_value = field["ideal_max"]
|
||||||
|
midpoint = (min_value + max_value) / 2
|
||||||
|
half_span = max((max_value - min_value) / 2, 0.1)
|
||||||
|
distance = abs(value - midpoint)
|
||||||
|
|
||||||
|
if min_value <= value <= max_value:
|
||||||
|
return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1)
|
||||||
|
|
||||||
|
overflow = max(0.0, distance - half_span)
|
||||||
|
return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_RADAR_CHART)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
latest_readings = context["latest_readings"]
|
||||||
|
scores = []
|
||||||
|
labels = []
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = latest_readings.get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
labels.append(field["radar_label"])
|
||||||
|
scores.append(_score_field(value, field))
|
||||||
|
|
||||||
|
if labels:
|
||||||
|
data["labels"] = labels
|
||||||
|
data["series"] = [
|
||||||
|
{"name": "اکنون", "data": scores},
|
||||||
|
{"name": "هدف", "data": [100.0] * len(labels)},
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_COMPARISON_CHART)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||||
|
moisture_points = [
|
||||||
|
(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture"))
|
||||||
|
for log, readings in history
|
||||||
|
if readings.get("soil_moisture") is not None
|
||||||
|
]
|
||||||
|
if not moisture_points:
|
||||||
|
return data
|
||||||
|
|
||||||
|
categories = [item[0] for item in moisture_points]
|
||||||
|
values = [round(item[1], 2) for item in moisture_points]
|
||||||
|
current_value = values[-1]
|
||||||
|
baseline_value = values[0] if len(values) > 1 else 55.0
|
||||||
|
percent_change = 0.0
|
||||||
|
if baseline_value:
|
||||||
|
percent_change = ((current_value - baseline_value) / baseline_value) * 100
|
||||||
|
|
||||||
|
data["currentValue"] = round(current_value, 2)
|
||||||
|
data["vsLastWeekValue"] = round(percent_change, 2)
|
||||||
|
data["vsLastWeek"] = f"{percent_change:+.1f}%"
|
||||||
|
data["categories"] = categories
|
||||||
|
data["series"] = [
|
||||||
|
{"name": "رطوبت خاک", "data": values},
|
||||||
|
{"name": "بازه هدف", "data": [55.0] * len(values)},
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _build_anomaly_item(field, value):
|
||||||
|
lower = field["ideal_min"]
|
||||||
|
upper = field["ideal_max"]
|
||||||
|
if lower <= value <= upper:
|
||||||
|
return None
|
||||||
|
|
||||||
|
deviation = value - upper if value > upper else value - lower
|
||||||
|
severity = "warning"
|
||||||
|
span = max(upper - lower, 0.1)
|
||||||
|
if abs(deviation) >= span * 0.5:
|
||||||
|
severity = "error"
|
||||||
|
|
||||||
|
sign = "+" if deviation > 0 else ""
|
||||||
|
return {
|
||||||
|
"sensor": field["label"],
|
||||||
|
"value": _format_value(value, field["unit"]),
|
||||||
|
"expected": _format_range(field),
|
||||||
|
"deviation": f"{sign}{_format_value(deviation, field['unit'])}",
|
||||||
|
"severity": severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
|
||||||
|
data = deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
anomalies = []
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = context["latest_readings"].get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
anomaly = _build_anomaly_item(field, value)
|
||||||
|
if anomaly is not None:
|
||||||
|
anomalies.append(anomaly)
|
||||||
|
|
||||||
|
if anomalies:
|
||||||
|
data["anomalies"] = anomalies
|
||||||
|
else:
|
||||||
|
data["anomalies"] = [
|
||||||
|
{
|
||||||
|
"sensor": "سنسور 7 در 1 خاک",
|
||||||
|
"value": "نرمال",
|
||||||
|
"expected": "تمام شاخصها در بازه مجاز هستند",
|
||||||
|
"deviation": "0",
|
||||||
|
"severity": "success",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
|
||||||
|
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||||
|
chart_points = [
|
||||||
|
{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)}
|
||||||
|
for log, readings in history
|
||||||
|
if readings.get("soil_moisture") is not None
|
||||||
|
]
|
||||||
|
if not chart_points:
|
||||||
|
return data
|
||||||
|
|
||||||
|
sensor_name = data["zones"][0]
|
||||||
|
farm_sensor = context.get("farm_sensor")
|
||||||
|
if farm_sensor is not None and farm_sensor.name:
|
||||||
|
sensor_name = farm_sensor.name
|
||||||
|
|
||||||
|
data["zones"] = [sensor_name]
|
||||||
|
data["hours"] = [point["x"] for point in chart_points]
|
||||||
|
data["series"] = [{"name": sensor_name, "data": chart_points}]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_summary_data(farm=None):
|
||||||
|
context = _get_sensor_context(farm)
|
||||||
|
values_list = get_sensor_7_in_1_values_list_data(farm, context=context)
|
||||||
|
return {
|
||||||
|
"sensor": values_list["sensor"],
|
||||||
|
"sensorValuesList": values_list,
|
||||||
|
"avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context),
|
||||||
|
"sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context),
|
||||||
|
"sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context),
|
||||||
|
"anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context),
|
||||||
|
"soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context),
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
|
from farm_hub.models import FarmHub, FarmSensor, FarmType
|
||||||
|
from sensor_catalog.models import SensorCatalog
|
||||||
|
from sensor_external_api.models import SensorExternalRequestLog
|
||||||
|
|
||||||
|
from dashboard.services import get_farm_dashboard_cards
|
||||||
|
|
||||||
|
from .services import get_sensor_7_in_1_summary_data
|
||||||
|
from .views import Sensor7In1SummaryView
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1BaseTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="sensor-7-in-1-user",
|
||||||
|
password="secret123",
|
||||||
|
email="sensor7@example.com",
|
||||||
|
phone_number="09120000017",
|
||||||
|
)
|
||||||
|
self.farm_type = FarmType.objects.create(name="مزرعه سنسور 7 در 1")
|
||||||
|
self.farm = FarmHub.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
farm_type=self.farm_type,
|
||||||
|
name="Farm Sensor 7 in 1",
|
||||||
|
farm_uuid="11111111-1111-1111-1111-111111111111",
|
||||||
|
)
|
||||||
|
self.sensor_catalog = SensorCatalog.objects.create(
|
||||||
|
code="sensor-7-in-1",
|
||||||
|
name="7 in 1 Soil Sensor",
|
||||||
|
returned_data_fields=[
|
||||||
|
"soil_moisture",
|
||||||
|
"soil_temperature",
|
||||||
|
"soil_ph",
|
||||||
|
"electrical_conductivity",
|
||||||
|
"nitrogen",
|
||||||
|
"phosphorus",
|
||||||
|
"potassium",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.sensor = FarmSensor.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
sensor_catalog=self.sensor_catalog,
|
||||||
|
physical_device_uuid="33333333-3333-3333-3333-333333333333",
|
||||||
|
name="Soil Sensor 7-in-1",
|
||||||
|
sensor_type="soil_7_in_1",
|
||||||
|
)
|
||||||
|
SensorExternalRequestLog.objects.create(
|
||||||
|
farm_uuid=self.farm.farm_uuid,
|
||||||
|
sensor_catalog_uuid=self.sensor_catalog.uuid,
|
||||||
|
physical_device_uuid=self.sensor.physical_device_uuid,
|
||||||
|
payload={
|
||||||
|
"soil_moisture": 41.0,
|
||||||
|
"soil_temperature": 21.0,
|
||||||
|
"soil_ph": 6.5,
|
||||||
|
"electrical_conductivity": 1.0,
|
||||||
|
"nitrogen": 28.0,
|
||||||
|
"phosphorus": 14.0,
|
||||||
|
"potassium": 19.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.latest_log = SensorExternalRequestLog.objects.create(
|
||||||
|
farm_uuid=self.farm.farm_uuid,
|
||||||
|
sensor_catalog_uuid=self.sensor_catalog.uuid,
|
||||||
|
physical_device_uuid=self.sensor.physical_device_uuid,
|
||||||
|
payload={
|
||||||
|
"soil_moisture": 48.5,
|
||||||
|
"soil_temperature": 23.2,
|
||||||
|
"soil_ph": 6.8,
|
||||||
|
"electrical_conductivity": 1.4,
|
||||||
|
"nitrogen": 31.0,
|
||||||
|
"phosphorus": 16.0,
|
||||||
|
"potassium": 24.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
|
||||||
|
def test_summary_returns_latest_specific_sensor_data(self):
|
||||||
|
data = get_sensor_7_in_1_summary_data(self.farm)
|
||||||
|
|
||||||
|
self.assertEqual(data["sensor"]["name"], "Soil Sensor 7-in-1")
|
||||||
|
self.assertEqual(data["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
|
||||||
|
self.assertEqual(data["sensorValuesList"]["sensors"][0]["id"], "soil_moisture")
|
||||||
|
self.assertEqual(data["avgSoilMoisture"]["stats"], "48.5%")
|
||||||
|
self.assertEqual(data["sensorComparisonChart"]["currentValue"], 48.5)
|
||||||
|
self.assertEqual(data["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
|
||||||
|
|
||||||
|
def test_dashboard_cards_use_sensor_service_outputs(self):
|
||||||
|
cards = get_farm_dashboard_cards(self.farm)
|
||||||
|
|
||||||
|
self.assertEqual(cards["sensorValuesList"]["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
|
||||||
|
self.assertEqual(cards["sensorValuesList"]["sensors"][0]["title"], "48.5%")
|
||||||
|
self.assertEqual(cards["sensorRadarChart"]["series"][0]["name"], "اکنون")
|
||||||
|
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
|
||||||
|
self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%")
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
|
||||||
|
def test_summary_view_returns_sensor_cards(self):
|
||||||
|
request = self.factory.get(f"/api/sensor-7-in-1/summary/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = Sensor7In1SummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["code"], 200)
|
||||||
|
self.assertEqual(response.data["data"]["sensor"]["sensorCatalogCode"], "sensor-7-in-1")
|
||||||
|
|
||||||
|
def test_summary_view_requires_farm_uuid(self):
|
||||||
|
request = self.factory.get("/api/sensor-7-in-1/summary/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = Sensor7In1SummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import Sensor7In1SummaryView
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
|
||||||
|
from config.swagger import code_response
|
||||||
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
|
from .serializers import Sensor7In1SummarySerializer
|
||||||
|
from .services import get_sensor_7_in_1_summary_data
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1SummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
required_feature_code = "sensor-7-in-1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_farm(request):
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if not farm_uuid:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||||
|
try:
|
||||||
|
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||||
|
except FarmHub.DoesNotExist as exc:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Sensor 7 in 1"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=True,
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SoilConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "soil"
|
||||||
|
verbose_name = "Soil"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
AVG_SOIL_MOISTURE = {
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "کل مزرعه",
|
||||||
|
"stats": "65%",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-plant-2",
|
||||||
|
"chipText": "بهینه",
|
||||||
|
"chipColor": "success",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_RADAR_CHART = {
|
||||||
|
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
|
||||||
|
"series": [
|
||||||
|
{"name": "امروز", "data": [75, 65, 80, 70, 85, 60]},
|
||||||
|
{"name": "ایده آل", "data": [80, 70, 75, 75, 90, 50]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_COMPARISON_CHART = {
|
||||||
|
"currentValue": 48,
|
||||||
|
"vsLastWeek": "+5%",
|
||||||
|
"vsLastWeekValue": 5,
|
||||||
|
"categories": ["دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه", "شنبه", "یکشنبه"],
|
||||||
|
"series": [
|
||||||
|
{"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]},
|
||||||
|
{"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ANOMALY_DETECTION_CARD = {
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": "رطوبت خاک زون 3",
|
||||||
|
"value": "38%",
|
||||||
|
"expected": "45-65%",
|
||||||
|
"deviation": "-12%",
|
||||||
|
"severity": "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sensor": "pH بخش 2",
|
||||||
|
"value": "5.2",
|
||||||
|
"expected": "6.0-7.0",
|
||||||
|
"deviation": "-0.8",
|
||||||
|
"severity": "error",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SOIL_MOISTURE_HEATMAP = {
|
||||||
|
"zones": ["زون 1", "زون 2", "زون 3", "زون 4", "زون 5", "زون 6", "زون 7"],
|
||||||
|
"hours": ["6 ص", "8 ص", "10 ص", "12 ظ", "14 ع", "16 ع", "18 ع"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "زون 1",
|
||||||
|
"data": [
|
||||||
|
{"x": "6 ص", "y": 52},
|
||||||
|
{"x": "8 ص", "y": 48},
|
||||||
|
{"x": "10 ص", "y": 55},
|
||||||
|
{"x": "12 ظ", "y": 60},
|
||||||
|
{"x": "14 ع", "y": 58},
|
||||||
|
{"x": "16 ع", "y": 54},
|
||||||
|
{"x": "18 ع", "y": 50},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "زون 2",
|
||||||
|
"data": [
|
||||||
|
{"x": "6 ص", "y": 45},
|
||||||
|
{"x": "8 ص", "y": 42},
|
||||||
|
{"x": "10 ص", "y": 48},
|
||||||
|
{"x": "12 ظ", "y": 52},
|
||||||
|
{"x": "14 ع", "y": 50},
|
||||||
|
{"x": "16 ع", "y": 47},
|
||||||
|
{"x": "18 ع", "y": 44},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from django.db import models
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class SoilKpiSerializer(serializers.Serializer):
|
||||||
|
id = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
subtitle = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
stats = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarIcon = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipText = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilRadarSeriesSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
data = serializers.ListField(child=serializers.FloatField(), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilRadarChartSerializer(serializers.Serializer):
|
||||||
|
labels = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
series = SoilRadarSeriesSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilComparisonChartSerializer(serializers.Serializer):
|
||||||
|
currentValue = serializers.FloatField(required=False)
|
||||||
|
vsLastWeek = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
vsLastWeekValue = serializers.FloatField(required=False)
|
||||||
|
categories = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
series = SoilRadarSeriesSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilAnomalyItemSerializer(serializers.Serializer):
|
||||||
|
sensor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
value = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
expected = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
deviation = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
severity = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilAnomalyDetectionSerializer(serializers.Serializer):
|
||||||
|
anomalies = SoilAnomalyItemSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilHeatmapPointSerializer(serializers.Serializer):
|
||||||
|
x = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
y = serializers.FloatField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilHeatmapSeriesSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
data = SoilHeatmapPointSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilMoistureHeatmapSerializer(serializers.Serializer):
|
||||||
|
zones = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
hours = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
series = SoilHeatmapSeriesSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilSummarySerializer(serializers.Serializer):
|
||||||
|
avgSoilMoisture = SoilKpiSerializer(required=False)
|
||||||
|
sensorRadarChart = SoilRadarChartSerializer(required=False)
|
||||||
|
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
||||||
|
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
||||||
|
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from farm_alerts.models import AnomalyDetection
|
||||||
|
|
||||||
|
from .mock_data import (
|
||||||
|
ANOMALY_DETECTION_CARD,
|
||||||
|
AVG_SOIL_MOISTURE,
|
||||||
|
SENSOR_COMPARISON_CHART,
|
||||||
|
SENSOR_RADAR_CHART,
|
||||||
|
SOIL_MOISTURE_HEATMAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_avg_soil_moisture_data(farm=None):
|
||||||
|
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||||
|
heatmap = get_soil_moisture_heatmap_data(farm)
|
||||||
|
values = [
|
||||||
|
point.get("y")
|
||||||
|
for series in heatmap.get("series", [])
|
||||||
|
for point in series.get("data", [])
|
||||||
|
if point.get("y") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return data
|
||||||
|
|
||||||
|
average = round(sum(values) / len(values))
|
||||||
|
data["stats"] = f"{average}%"
|
||||||
|
if average >= 60:
|
||||||
|
data["chipText"] = "بهینه"
|
||||||
|
data["chipColor"] = "success"
|
||||||
|
elif average >= 45:
|
||||||
|
data["chipText"] = "متوسط"
|
||||||
|
data["chipColor"] = "warning"
|
||||||
|
else:
|
||||||
|
data["chipText"] = "کم"
|
||||||
|
data["chipColor"] = "error"
|
||||||
|
data["avatarColor"] = "warning"
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_radar_chart_data(farm=None):
|
||||||
|
return deepcopy(SENSOR_RADAR_CHART)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_comparison_chart_data(farm=None):
|
||||||
|
return deepcopy(SENSOR_COMPARISON_CHART)
|
||||||
|
|
||||||
|
|
||||||
|
def get_anomaly_detection_card_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
|
||||||
|
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
|
||||||
|
if not anomalies:
|
||||||
|
return deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": anomaly.sensor,
|
||||||
|
"value": anomaly.value,
|
||||||
|
"expected": anomaly.expected,
|
||||||
|
"deviation": anomaly.deviation,
|
||||||
|
"severity": anomaly.severity,
|
||||||
|
}
|
||||||
|
for anomaly in anomalies
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_soil_moisture_heatmap_data(farm=None):
|
||||||
|
return deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||||
|
|
||||||
|
|
||||||
|
def get_soil_summary_data(farm=None):
|
||||||
|
return {
|
||||||
|
"avgSoilMoisture": get_avg_soil_moisture_data(farm),
|
||||||
|
"sensorRadarChart": get_sensor_radar_chart_data(farm),
|
||||||
|
"sensorComparisonChart": get_sensor_comparison_chart_data(farm),
|
||||||
|
"anomalyDetectionCard": get_anomaly_detection_card_data(farm),
|
||||||
|
"soilMoistureHeatmap": get_soil_moisture_heatmap_data(farm),
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import (
|
||||||
|
AvgSoilMoistureView,
|
||||||
|
SensorComparisonChartView,
|
||||||
|
SensorRadarChartView,
|
||||||
|
SoilAnomalyDetectionView,
|
||||||
|
SoilMoistureHeatmapView,
|
||||||
|
SoilSummaryView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"),
|
||||||
|
path("sensor-radar-chart/", SensorRadarChartView.as_view(), name="soil-sensor-radar-chart"),
|
||||||
|
path("sensor-comparison-chart/", SensorComparisonChartView.as_view(), name="soil-sensor-comparison-chart"),
|
||||||
|
path("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"),
|
||||||
|
path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"),
|
||||||
|
path("summary/", SoilSummaryView.as_view(), name="soil-summary"),
|
||||||
|
]
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
|
||||||
|
from config.swagger import status_response
|
||||||
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
|
from .serializers import (
|
||||||
|
SoilAnomalyDetectionSerializer,
|
||||||
|
SoilComparisonChartSerializer,
|
||||||
|
SoilKpiSerializer,
|
||||||
|
SoilMoistureHeatmapSerializer,
|
||||||
|
SoilRadarChartSerializer,
|
||||||
|
SoilSummarySerializer,
|
||||||
|
)
|
||||||
|
from .services import (
|
||||||
|
get_anomaly_detection_card_data,
|
||||||
|
get_avg_soil_moisture_data,
|
||||||
|
get_sensor_comparison_chart_data,
|
||||||
|
get_sensor_radar_chart_data,
|
||||||
|
get_soil_moisture_heatmap_data,
|
||||||
|
get_soil_summary_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_farm_from_request(request):
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if not farm_uuid:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AvgSoilMoistureView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm for average soil moisture.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_avg_soil_moisture_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorRadarChartView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
],
|
||||||
|
responses={200: status_response("SensorRadarChartResponse", data=SoilRadarChartSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_sensor_radar_chart_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
],
|
||||||
|
responses={200: status_response("SensorComparisonChartResponse", data=SoilComparisonChartSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_sensor_comparison_chart_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilAnomalyDetectionView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
],
|
||||||
|
responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_anomaly_detection_card_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilMoistureHeatmapView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
],
|
||||||
|
responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_soil_moisture_heatmap_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoilSummaryView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Soil"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
],
|
||||||
|
responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_soil_summary_data(_get_farm_from_request(request))},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class WaterConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "water"
|
||||||
|
label = "weather_forecast"
|
||||||
|
verbose_name = "water"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Static mock data for WATER API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FARM_WEATHER_CARD = {
|
||||||
|
"condition": "صاف",
|
||||||
|
"temperature": 24,
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": 45,
|
||||||
|
"windSpeed": 12,
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {
|
||||||
|
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
|
||||||
|
"series": [[18, 22, 26, 28, 25, 20, 18]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
WATER_NEED_PREDICTION = {
|
||||||
|
"totalNext7Days": 3290,
|
||||||
|
"unit": "m3",
|
||||||
|
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
|
||||||
|
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
WATER_STRESS_INDEX = {
|
||||||
|
"id": "water_stress_index",
|
||||||
|
"title": "شاخص تنش آبی",
|
||||||
|
"subtitle": "فعلی",
|
||||||
|
"stats": "12%",
|
||||||
|
"avatarColor": "info",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "پایین",
|
||||||
|
"chipColor": "success",
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherChartDataSerializer(serializers.Serializer):
|
||||||
|
labels = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
series = serializers.ListField(
|
||||||
|
child=serializers.ListField(child=serializers.FloatField()),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FarmWeatherCardSerializer(serializers.Serializer):
|
||||||
|
condition = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
temperature = serializers.FloatField(required=False)
|
||||||
|
unit = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
humidity = serializers.IntegerField(required=False)
|
||||||
|
windSpeed = serializers.FloatField(required=False)
|
||||||
|
windUnit = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chartData = WeatherChartDataSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterNeedSeriesSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
data = serializers.ListField(child=serializers.FloatField(), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterNeedPredictionSerializer(serializers.Serializer):
|
||||||
|
totalNext7Days = serializers.FloatField(required=False)
|
||||||
|
unit = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
categories = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
series = WaterNeedSeriesSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterStressIndexSerializer(serializers.Serializer):
|
||||||
|
id = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
subtitle = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
stats = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
avatarIcon = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipText = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
chipColor = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterSummarySerializer(serializers.Serializer):
|
||||||
|
farmWeatherCard = FarmWeatherCardSerializer(required=False)
|
||||||
|
waterNeedPrediction = WaterNeedPredictionSerializer(required=False)
|
||||||
|
waterStressIndex = WaterStressIndexSerializer(required=False)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from irrigation_recommendation.models import IrrigationRecommendationRequest
|
||||||
|
|
||||||
|
from .mock_data import FARM_WEATHER_CARD, WATER_NEED_PREDICTION, WATER_STRESS_INDEX
|
||||||
|
from .models import WeatherForecastLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_weather_card_data(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return deepcopy(FARM_WEATHER_CARD)
|
||||||
|
|
||||||
|
log = WeatherForecastLog.objects.filter(farm=farm).first()
|
||||||
|
if log is None:
|
||||||
|
return deepcopy(FARM_WEATHER_CARD)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"condition": log.condition or FARM_WEATHER_CARD["condition"],
|
||||||
|
"temperature": log.temperature if log.temperature is not None else FARM_WEATHER_CARD["temperature"],
|
||||||
|
"unit": log.unit or FARM_WEATHER_CARD["unit"],
|
||||||
|
"humidity": log.humidity if log.humidity is not None else FARM_WEATHER_CARD["humidity"],
|
||||||
|
"windSpeed": log.wind_speed if log.wind_speed is not None else FARM_WEATHER_CARD["windSpeed"],
|
||||||
|
"windUnit": log.wind_unit or FARM_WEATHER_CARD["windUnit"],
|
||||||
|
"chartData": deepcopy(log.chart_data or FARM_WEATHER_CARD["chartData"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_irrigation_result(response_payload):
|
||||||
|
if not isinstance(response_payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = response_payload.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
result = response_payload.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_irrigation_result(farm):
|
||||||
|
if farm is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
for request in IrrigationRecommendationRequest.objects.filter(farm=farm):
|
||||||
|
result = _extract_irrigation_result(request.response_payload)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_need_prediction_data(farm=None):
|
||||||
|
default_data = deepcopy(WATER_NEED_PREDICTION)
|
||||||
|
result = _get_latest_irrigation_result(farm)
|
||||||
|
water_balance = result.get("water_balance", {})
|
||||||
|
daily = water_balance.get("daily", [])
|
||||||
|
|
||||||
|
if not daily:
|
||||||
|
return default_data
|
||||||
|
|
||||||
|
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
|
||||||
|
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalNext7Days": round(sum(series_data), 2),
|
||||||
|
"unit": "mm",
|
||||||
|
"categories": categories,
|
||||||
|
"series": [{"name": "نیاز آبی", "data": series_data}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_stress_index_data(farm=None):
|
||||||
|
data = deepcopy(WATER_STRESS_INDEX)
|
||||||
|
result = _get_latest_irrigation_result(farm)
|
||||||
|
moisture_level = (result.get("plan") or {}).get("moistureLevel")
|
||||||
|
|
||||||
|
if moisture_level is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
stress_value = max(0, round(80 - float(moisture_level)))
|
||||||
|
if stress_value <= 15:
|
||||||
|
data["chipText"] = "پایین"
|
||||||
|
data["chipColor"] = "success"
|
||||||
|
data["avatarColor"] = "info"
|
||||||
|
elif stress_value <= 30:
|
||||||
|
data["chipText"] = "متوسط"
|
||||||
|
data["chipColor"] = "warning"
|
||||||
|
data["avatarColor"] = "warning"
|
||||||
|
else:
|
||||||
|
data["chipText"] = "بالا"
|
||||||
|
data["chipColor"] = "error"
|
||||||
|
data["avatarColor"] = "error"
|
||||||
|
|
||||||
|
data["stats"] = f"{stress_value}%"
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_summary_data(farm=None):
|
||||||
|
return {
|
||||||
|
"farmWeatherCard": get_farm_weather_card_data(farm),
|
||||||
|
"waterNeedPrediction": get_water_need_prediction_data(farm),
|
||||||
|
"waterStressIndex": get_water_stress_index_data(farm),
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import FarmWeatherCardView, WaterNeedPredictionView, WaterStressIndexView, WaterSummaryView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("card/", FarmWeatherCardView.as_view(), name="water-card"),
|
||||||
|
path("need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"),
|
||||||
|
path("stress-index/", WaterStressIndexView.as_view(), name="water-stress-index"),
|
||||||
|
path("summary/", WaterSummaryView.as_view(), name="water-summary"),
|
||||||
|
]
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
WATER API views.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
|
||||||
|
from config.swagger import status_response
|
||||||
|
from external_api_adapter import request as external_api_request
|
||||||
|
from farm_hub.models import FarmHub
|
||||||
|
from .models import WeatherForecastLog
|
||||||
|
from .serializers import FarmWeatherCardSerializer, WaterNeedPredictionSerializer, WaterStressIndexSerializer, WaterSummarySerializer
|
||||||
|
from .services import get_water_need_prediction_data, get_water_stress_index_data, get_water_summary_data
|
||||||
|
|
||||||
|
|
||||||
|
class FarmWeatherCardView(APIView):
|
||||||
|
"""
|
||||||
|
GET endpoint for the farm weather card dashboard data.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Returns current weather conditions and an intraday temperature chart
|
||||||
|
for a given farm. Data is fetched from the AI external adapter.
|
||||||
|
If farm_uuid is provided and the farm exists, the result is persisted
|
||||||
|
in WeatherForecastLog for historical reference.
|
||||||
|
|
||||||
|
Input parameters:
|
||||||
|
- farm_uuid (query, optional): UUID of the farm.
|
||||||
|
|
||||||
|
Response structure:
|
||||||
|
- status: string, always "success".
|
||||||
|
- data: object matching the farmWeatherCard shape — condition,
|
||||||
|
temperature, unit, humidity, windSpeed, windUnit, chartData.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["WATER"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm to fetch weather data for.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111"),
|
||||||
|
],
|
||||||
|
responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
|
||||||
|
|
||||||
|
adapter_response = external_api_request(
|
||||||
|
"ai",
|
||||||
|
"/weather-forecast/card",
|
||||||
|
method="GET",
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||||
|
card_data = response_data.get("result", response_data.get("data", response_data))
|
||||||
|
|
||||||
|
self._persist_log(farm_uuid, card_data)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": card_data},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _persist_log(farm_uuid, card_data):
|
||||||
|
farm = None
|
||||||
|
if farm_uuid:
|
||||||
|
try:
|
||||||
|
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
WeatherForecastLog.objects.create(
|
||||||
|
farm=farm,
|
||||||
|
condition=card_data.get("condition", ""),
|
||||||
|
temperature=card_data.get("temperature"),
|
||||||
|
unit=card_data.get("unit", "°C"),
|
||||||
|
humidity=card_data.get("humidity"),
|
||||||
|
wind_speed=card_data.get("windSpeed"),
|
||||||
|
wind_unit=card_data.get("windUnit", "km/h"),
|
||||||
|
chart_data=card_data.get("chartData", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterNeedPredictionView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["WATER"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm to fetch water need prediction for.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
farm = None
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if farm_uuid:
|
||||||
|
try:
|
||||||
|
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
|
farm = None
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_water_need_prediction_data(farm)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterStressIndexView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["WATER"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm to fetch water stress index for.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
farm = None
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if farm_uuid:
|
||||||
|
try:
|
||||||
|
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
|
farm = None
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_water_stress_index_data(farm)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterSummaryView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["WATER"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="UUID of the farm to fetch water summary for.",
|
||||||
|
default="11111111-1111-1111-1111-111111111111",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
farm = None
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if farm_uuid:
|
||||||
|
try:
|
||||||
|
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
|
farm = None
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": get_water_summary_data(farm)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class WeatherForecastConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "weather_forecast"
|
|
||||||
verbose_name = "Weather Forecast"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""
|
|
||||||
Static mock data for Weather Forecast API.
|
|
||||||
Mirrors the farmWeatherCard dashboard card shape.
|
|
||||||
"""
|
|
||||||
|
|
||||||
FARM_WEATHER_CARD = {
|
|
||||||
"condition": "صاف",
|
|
||||||
"temperature": 24,
|
|
||||||
"unit": "°C",
|
|
||||||
"humidity": 45,
|
|
||||||
"windSpeed": 12,
|
|
||||||
"windUnit": "km/h",
|
|
||||||
"chartData": {
|
|
||||||
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
|
|
||||||
"series": [[18, 22, 26, 28, 25, 20, 18]],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class WeatherChartDataSerializer(serializers.Serializer):
|
|
||||||
labels = serializers.ListField(child=serializers.CharField(), required=False)
|
|
||||||
series = serializers.ListField(
|
|
||||||
child=serializers.ListField(child=serializers.FloatField()),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FarmWeatherCardSerializer(serializers.Serializer):
|
|
||||||
condition = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
temperature = serializers.FloatField(required=False)
|
|
||||||
unit = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
humidity = serializers.IntegerField(required=False)
|
|
||||||
windSpeed = serializers.FloatField(required=False)
|
|
||||||
windUnit = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
chartData = WeatherChartDataSerializer(required=False)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
from .views import FarmWeatherCardView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("card/", FarmWeatherCardView.as_view(), name="weather-forecast-card"),
|
|
||||||
]
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
Weather Forecast API views.
|
|
||||||
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
|
|
||||||
Fetches weather card data from the AI external adapter and persists a log entry
|
|
||||||
if a valid farm_uuid is provided.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rest_framework import serializers, status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|
||||||
|
|
||||||
from config.swagger import status_response
|
|
||||||
from external_api_adapter import request as external_api_request
|
|
||||||
from farm_hub.models import FarmHub
|
|
||||||
from .models import WeatherForecastLog
|
|
||||||
from .serializers import FarmWeatherCardSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class FarmWeatherCardView(APIView):
|
|
||||||
"""
|
|
||||||
GET endpoint for the farm weather card dashboard data.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Returns current weather conditions and an intraday temperature chart
|
|
||||||
for a given farm. Data is fetched from the AI external adapter.
|
|
||||||
If farm_uuid is provided and the farm exists, the result is persisted
|
|
||||||
in WeatherForecastLog for historical reference.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- farm_uuid (query, optional): UUID of the farm.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
- data: object matching the farmWeatherCard shape — condition,
|
|
||||||
temperature, unit, humidity, windSpeed, windUnit, chartData.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Weather Forecast"],
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(
|
|
||||||
name="farm_uuid",
|
|
||||||
type=OpenApiTypes.UUID,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=False,
|
|
||||||
description="UUID of the farm to fetch weather data for.",
|
|
||||||
default="11111111-1111-1111-1111-111111111111"),
|
|
||||||
],
|
|
||||||
responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())},
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
farm_uuid = request.query_params.get("farm_uuid")
|
|
||||||
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
|
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
|
||||||
"ai",
|
|
||||||
"/weather-forecast/card",
|
|
||||||
method="GET",
|
|
||||||
query=query,
|
|
||||||
)
|
|
||||||
|
|
||||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
|
||||||
card_data = response_data.get("result", response_data.get("data", response_data))
|
|
||||||
|
|
||||||
self._persist_log(farm_uuid, card_data)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{"status": "success", "data": card_data},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _persist_log(farm_uuid, card_data):
|
|
||||||
farm = None
|
|
||||||
if farm_uuid:
|
|
||||||
try:
|
|
||||||
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
|
||||||
except (FarmHub.DoesNotExist, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
WeatherForecastLog.objects.create(
|
|
||||||
farm=farm,
|
|
||||||
condition=card_data.get("condition", ""),
|
|
||||||
temperature=card_data.get("temperature"),
|
|
||||||
unit=card_data.get("unit", "°C"),
|
|
||||||
humidity=card_data.get("humidity"),
|
|
||||||
wind_speed=card_data.get("windSpeed"),
|
|
||||||
wind_unit=card_data.get("windUnit", "km/h"),
|
|
||||||
chart_data=card_data.get("chartData", {}),
|
|
||||||
)
|
|
||||||
@@ -3,6 +3,164 @@ Static mock data for Yield & Harvest Prediction API.
|
|||||||
Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes.
|
Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CONFIG_SLIDERS_ONLY = {
|
||||||
|
"sliders": [
|
||||||
|
{
|
||||||
|
"key": "light",
|
||||||
|
"label": "نور",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 5,
|
||||||
|
"unit_type": "percent",
|
||||||
|
"default_value": 75,
|
||||||
|
"icon": "☀️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "water",
|
||||||
|
"label": "آب",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 5,
|
||||||
|
"unit_type": "percent",
|
||||||
|
"default_value": 65,
|
||||||
|
"icon": "💧",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "soil_ph",
|
||||||
|
"label": "pH خاک",
|
||||||
|
"min": 4,
|
||||||
|
"max": 9,
|
||||||
|
"step": 0.5,
|
||||||
|
"unit_type": "number",
|
||||||
|
"unit": "",
|
||||||
|
"default_value": 6.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "growth_speed",
|
||||||
|
"label": "سرعت رشد",
|
||||||
|
"min": 0.5,
|
||||||
|
"max": 5,
|
||||||
|
"step": 0.5,
|
||||||
|
"unit_type": "number",
|
||||||
|
"unit": "x",
|
||||||
|
"default_value": 1.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSTANTS = {
|
||||||
|
"max_height": 280,
|
||||||
|
"max_leaves": 14,
|
||||||
|
"max_branches": 6,
|
||||||
|
"max_yield": 500,
|
||||||
|
"yield_unit": "g",
|
||||||
|
"yield_rate_unit": "g/s",
|
||||||
|
"height_unit": "px",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHART_CONFIG = {
|
||||||
|
"title": "پیشرفت رشد",
|
||||||
|
"x_axis_label": "زمان (ثانیه)",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"key": "height",
|
||||||
|
"label": "ارتفاع (px)",
|
||||||
|
"y_axis_id": "yHeight",
|
||||||
|
"min": 0,
|
||||||
|
"max": 280,
|
||||||
|
"unit": "px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "leaves",
|
||||||
|
"label": "تعداد برگ",
|
||||||
|
"y_axis_id": "yLeaf",
|
||||||
|
"min": 0,
|
||||||
|
"max": 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "yield",
|
||||||
|
"label": "محصول (g)",
|
||||||
|
"y_axis_id": "yYield",
|
||||||
|
"min": 0,
|
||||||
|
"max": 500,
|
||||||
|
"unit": "g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "yield_rate",
|
||||||
|
"label": "نرخ محصول (g/s)",
|
||||||
|
"y_axis_id": "yYieldRate",
|
||||||
|
"min": 0,
|
||||||
|
"unit": "g/s",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_labels = [f"{i * 0.2:.1f}s" for i in range(51)]
|
||||||
|
_height = [round(142 * (i / 50) ** 0.9) for i in range(51)]
|
||||||
|
_leaf = [min(5, int(i / 10)) for i in range(51)]
|
||||||
|
_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)]
|
||||||
|
_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)]
|
||||||
|
|
||||||
|
START_RESPONSE_DATA = {
|
||||||
|
"constants": CONSTANTS,
|
||||||
|
"chart": CHART_CONFIG,
|
||||||
|
"plant": {
|
||||||
|
"height": 142,
|
||||||
|
"leaves_count": 5,
|
||||||
|
"branches_count": 2,
|
||||||
|
"fruits_count": 0,
|
||||||
|
"yield": 12.4,
|
||||||
|
"yield_rate": 0.087,
|
||||||
|
"tick": 520,
|
||||||
|
"is_healthy": True,
|
||||||
|
"can_continue": True,
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"growth_progress": 50,
|
||||||
|
"light_status": 75,
|
||||||
|
"water_status": 65,
|
||||||
|
"yield_progress": 2.5,
|
||||||
|
"yield_current": 12.4,
|
||||||
|
"yield_rate_current": 0.087,
|
||||||
|
},
|
||||||
|
"chart_history": {
|
||||||
|
"labels": _labels,
|
||||||
|
"height_history": _height,
|
||||||
|
"leaf_history": _leaf,
|
||||||
|
"yield_history": _yield,
|
||||||
|
"yield_rate_history": _yield_rate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
STATE_RESPONSE_DATA = {
|
||||||
|
"plant": {
|
||||||
|
"height": 142,
|
||||||
|
"leaves_count": 5,
|
||||||
|
"branches_count": 2,
|
||||||
|
"fruits_count": 0,
|
||||||
|
"yield": 12.4,
|
||||||
|
"yield_rate": 0.087,
|
||||||
|
"tick": 520,
|
||||||
|
"is_healthy": True,
|
||||||
|
"can_continue": True,
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"growth_progress": 50,
|
||||||
|
"light_status": 75,
|
||||||
|
"water_status": 65,
|
||||||
|
"yield_progress": 2.5,
|
||||||
|
"yield_current": 12.4,
|
||||||
|
"yield_rate_current": 0.087,
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"labels": _labels,
|
||||||
|
"height_history": _height,
|
||||||
|
"leaf_history": _leaf,
|
||||||
|
"yield_history": _yield,
|
||||||
|
"yield_rate_history": _yield_rate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
YIELD_PREDICTION_CARD = {
|
YIELD_PREDICTION_CARD = {
|
||||||
"id": "yield_prediction",
|
"id": "yield_prediction",
|
||||||
"title": "پیشبینی عملکرد",
|
"title": "پیشبینی عملکرد",
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .mock_data import CONFIG_SLIDERS_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
START_ENVIRONMENT_KEYS = [
|
||||||
|
item["key"]
|
||||||
|
for item in CONFIG_SLIDERS_ONLY["sliders"]
|
||||||
|
if item["key"] != "growth_speed"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_from_sliders():
|
||||||
|
return {
|
||||||
|
item["key"]: item["default_value"]
|
||||||
|
for item in CONFIG_SLIDERS_ONLY["sliders"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
START_REQUEST_EXAMPLE = {
|
||||||
|
"environment": {
|
||||||
|
key: value for key, value in _defaults_from_sliders().items() if key != "growth_speed"
|
||||||
|
},
|
||||||
|
"growth_speed": _defaults_from_sliders().get("growth_speed", 1.5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
START_REQUEST_EXAMPLE_STATIC = {
|
||||||
|
"environment": {
|
||||||
|
"light": 75,
|
||||||
|
"water": 65,
|
||||||
|
"soil_ph": 6.5,
|
||||||
|
},
|
||||||
|
"growth_speed": 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def success_response():
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
def success_with_data(data):
|
||||||
|
return {"status": "success", "data": data}
|
||||||
|
|
||||||
|
|
||||||
class YieldPredictionCardSerializer(serializers.Serializer):
|
class YieldPredictionCardSerializer(serializers.Serializer):
|
||||||
id = serializers.CharField(required=False, allow_blank=True)
|
id = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .mock_data import HARVEST_PREDICTION_CARD, YIELD_PREDICTION_CARD, YIELD_PREDICTION_CHART
|
||||||
|
from .models import YieldHarvestPredictionLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_yield_harvest_summary_data(farm=None):
|
||||||
|
data = {
|
||||||
|
"yield_prediction_card": deepcopy(YIELD_PREDICTION_CARD),
|
||||||
|
"yield_prediction_chart": deepcopy(YIELD_PREDICTION_CHART),
|
||||||
|
"harvest_prediction_card": deepcopy(HARVEST_PREDICTION_CARD),
|
||||||
|
}
|
||||||
|
|
||||||
|
if farm is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
log = YieldHarvestPredictionLog.objects.filter(farm=farm).first()
|
||||||
|
if log is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if log.yield_stats:
|
||||||
|
data["yield_prediction_card"]["stats"] = log.yield_stats
|
||||||
|
if log.yield_chip_text:
|
||||||
|
data["yield_prediction_card"]["chipText"] = log.yield_chip_text
|
||||||
|
if log.chart_data:
|
||||||
|
data["yield_prediction_chart"] = deepcopy(log.chart_data)
|
||||||
|
|
||||||
|
if log.harvest_date:
|
||||||
|
data["harvest_prediction_card"]["date"] = log.harvest_date.isoformat()
|
||||||
|
if log.days_until_harvest is not None:
|
||||||
|
data["harvest_prediction_card"]["daysUntil"] = log.days_until_harvest
|
||||||
|
if log.optimal_window_start:
|
||||||
|
data["harvest_prediction_card"]["optimalWindowStart"] = log.optimal_window_start.isoformat()
|
||||||
|
if log.optimal_window_end:
|
||||||
|
data["harvest_prediction_card"]["optimalWindowEnd"] = log.optimal_window_end.isoformat()
|
||||||
|
|
||||||
|
return data
|
||||||
+25
-1
@@ -1,6 +1,30 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import YieldHarvestSummaryView
|
from .views import (
|
||||||
|
ConfigView,
|
||||||
|
EnvironmentView,
|
||||||
|
ResetView,
|
||||||
|
StartView,
|
||||||
|
StateView,
|
||||||
|
StopView,
|
||||||
|
YieldHarvestSummaryView,
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigView.__module__ = "plant_simulator.views"
|
||||||
|
EnvironmentView.__module__ = "plant_simulator.views"
|
||||||
|
ResetView.__module__ = "plant_simulator.views"
|
||||||
|
StartView.__module__ = "plant_simulator.views"
|
||||||
|
StateView.__module__ = "plant_simulator.views"
|
||||||
|
StopView.__module__ = "plant_simulator.views"
|
||||||
|
|
||||||
|
plant_simulator_urlpatterns = [
|
||||||
|
path("config/", ConfigView.as_view(), name="plant-simulator-config"),
|
||||||
|
path("state/", StateView.as_view(), name="plant-simulator-state"),
|
||||||
|
path("start/", StartView.as_view(), name="plant-simulator-start"),
|
||||||
|
path("stop/", StopView.as_view(), name="plant-simulator-stop"),
|
||||||
|
path("reset/", ResetView.as_view(), name="plant-simulator-reset"),
|
||||||
|
path("environment/", EnvironmentView.as_view(), name="plant-simulator-environment"),
|
||||||
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
|
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
|
||||||
|
|||||||
+62
-7
@@ -1,12 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Yield & Harvest Prediction API views.
|
Yield & Harvest Prediction and Plant Simulator API views.
|
||||||
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
|
|
||||||
Fetches all three prediction payloads (yield card, yield chart, harvest card)
|
|
||||||
from the AI external adapter in a single call and persists a log entry
|
|
||||||
if a valid farm_uuid is provided.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import serializers, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@@ -15,8 +11,67 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|||||||
from config.swagger import status_response
|
from config.swagger import status_response
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA
|
||||||
from .models import YieldHarvestPredictionLog
|
from .models import YieldHarvestPredictionLog
|
||||||
from .serializers import YieldHarvestSummarySerializer
|
from .serializers import YieldHarvestSummarySerializer, success_response, success_with_data
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(success_with_data(CONFIG_SLIDERS_ONLY), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class StateView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
responses={200: status_response("PlantSimulatorStateResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
return Response(success_with_data(STATE_RESPONSE_DATA), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class StartView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
request=OpenApiTypes.OBJECT,
|
||||||
|
responses={200: status_response("PlantSimulatorStartResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(success_with_data(START_RESPONSE_DATA), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class StopView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
request=OpenApiTypes.OBJECT,
|
||||||
|
responses={200: status_response("PlantSimulatorStopResponse")},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(success_response(), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
request=OpenApiTypes.OBJECT,
|
||||||
|
responses={200: status_response("PlantSimulatorResetResponse")},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(success_response(), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Plant Simulator"],
|
||||||
|
request=OpenApiTypes.OBJECT,
|
||||||
|
responses={200: status_response("PlantSimulatorEnvironmentResponse")},
|
||||||
|
)
|
||||||
|
def patch(self, request):
|
||||||
|
return Response(success_response(), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class YieldHarvestSummaryView(APIView):
|
class YieldHarvestSummaryView(APIView):
|
||||||
|
|||||||
Reference in New Issue
Block a user