This commit is contained in:
2026-04-25 17:22:41 +03:30
parent 569d520a5c
commit aa24fc22b0
124 changed files with 8491 additions and 2582 deletions
+556
View File
@@ -0,0 +1,556 @@
# ممیزی ضعف‌ها و میزان اعتماد APIها
این سند بر اساس بررسی کد فعلی پروژه نوشته شده است و هدفش این است که برای هر API مشخص کند:
- خروجی روی چه داده یا فرمولی تکیه دارد
- کجاها خروجی صرفا تقریبی، heuristic، mock یا وابسته به LLM است
- برای چه نوع استفاده‌ای قابل اتکا هست و برای چه نوع تصمیمی نیست
- سطح اعتماد عملی به پاسخ خروجی چقدر است
توجه:
- این سند درباره `قابلیت اتکای عملیاتی` است، نه فقط درست بودن ساختار JSON.
- ممکن است یک API از نظر فنی همیشه JSON درست برگرداند، اما از نظر agronomy یا تصمیم‌سازی هنوز کم‌اعتماد باشد.
- سطح اعتماد به معنی اعتماد به `محتوای پاسخ` است، نه availability سرویس.
## معیار سطح اعتماد
- `زیاد`: عمدتا داده واقعی یا ذخیره شده برمی‌گرداند؛ فرمول یا منطق ساده ولی قابل توضیح است؛ برای نمایش و گزارش مناسب است.
- `متوسط`: داده واقعی دارد اما بخش مهمی از نتیجه وابسته به تقریب، پیش‌فرض، شبیه‌سازی، یا LLM است.
- `کم`: نتیجه بیشتر heuristic، fallback، برآورد تقریبی، یا تلفیق داده ناقص است.
- `خیلی کم`: داده mock، stub، یا خروجی عمدتا غیرقابل اتکا برای تصمیم واقعی.
## خلاصه سریع APIهای پرریسک
- `POST /api/economy/overview/`: داده کاملا mock است؛ برای تصمیم واقعی نباید استفاده شود.
- `POST /api/plants/fetch-info/`: عملا پیاده‌سازی نشده و `503` برمی‌گرداند.
- `POST /api/irrigation/water-stress/`: اگر engine شبیه‌سازی خطا بدهد هنوز به فرمول ساده سنسور fallback می‌کند.
- `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/`: وابستگی مستقیم به RAG/LLM دارند؛ برای KPI قطعی یا تصمیم سم‌پاشی خودکار مناسب نیستند.
- `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/`: ترکیب rule-based + LLM هستند؛ برای آگاهی خوب‌اند ولی برای اتوماسیون بحرانی نه.
- `POST /api/soile/moisture-heatmap/`: visualization خوبی می‌دهد اما هنوز geostatistics دقیق یا history واقعی سنسورها را مدل نمی‌کند.
---
## 1) Soil Data API
### `GET|POST /api/soil-data/`
- منبع داده:
- اگر رکورد کامل موجود باشد از DB برمی‌گردد.
- اگر موجود نباشد فقط task صف می‌شود و `202` می‌دهد.
- ضعف‌ها:
- در حالت `202` هنوز خود داده خاک برنگشته، فقط شروع فرآیند واکشی اعلام می‌شود.
- تطابق location بر اساس `lat/lon` گرد شده تا 6 رقم است؛ برای نقاط خیلی نزدیک ممکن است حساسیت مکانی محدود شود.
- کیفیت نهایی کاملا وابسته به سرویس بیرونی خاک و کامل شدن depthهاست.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API خودش مدل پیش‌بینی ندارد؛ ریسک اصلی از ناقص بودن داده منبع یا incomplete بودن رکورد است.
- مناسب برای:
- واکشی داده خام خاک و persistence.
- نامناسب برای:
- استنتاج agronomy بدون بررسی منبع و completeness.
- سطح اعتماد:
- `زیاد` وقتی `source=database` و location کامل است.
- `کم` وقتی فقط `task_id` گرفته‌اید و هنوز داده واقعی ندارید.
### `GET /api/soil-data/tasks/{task_id}/status/`
- منبع داده: وضعیت Celery + نتیجه ذخیره شده در DB.
- ضعف‌ها:
- ممکن است task موفق شده باشد ولی رکورد location هنوز کامل نباشد.
- خروجی `SUCCESS` لزوما به معنی کیفیت agronomic داده نیست؛ فقط completion فنی را نشان می‌دهد.
- سطح اعتماد:
- `متوسط` برای وضعیت فنی task.
- `کم تا متوسط` برای اعتماد به خود محتوای خاک، بسته به کامل بودن رکورد.
### `POST /api/soil-data/ndvi-health/`
- منبع داده: observation ماهواره‌ای NDVI در `location_data.ndvi`.
- ضعف‌ها:
- اگر observation موجود نباشد، کارت عملا به وضعیت `Unavailable` می‌رسد.
- تفسیر NDVI ساده است و به تنهایی برای تشخیص علت تنش کافی نیست.
- NDVI میانگین مزرعه است؛ heterogeneity داخل مزرعه را کامل نشان نمی‌دهد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- NDVI فقط شاخص پوشش گیاهی است، نه تشخیص علت؛ کمبود آب، بیماری، تغذیه یا خطای سنجش ممکن است با هم اشتباه شوند.
- سطح اعتماد:
- `متوسط` برای پایش کلی وضعیت پوشش گیاهی.
- `کم` برای تصمیم‌گیری علّی یا درمانی.
---
## 2) Soile API
### `POST /api/soile/moisture-heatmap/`
- منبع داده:
- latest measurement هر سنسور
- وزن‌دهی تازگی داده
- جریمه outlier
- interpolation از نوع weighted IDW
- mask مرز مزرعه
- ضعف‌ها:
- history واقعی سنسورها هنوز مدل نمی‌شود.
- drift سنسور، calibration uncertainty و quality assurance واقعی لحاظ نشده‌اند.
- depth-specific map به صورت measured واقعی نیست و بخشی از لایه‌ها از soil profile مشتق می‌شوند.
- uncertainty به صورت heuristic برآورد می‌شود.
- با تراکم سنسور پایین یا farm نامتقارن، heatmap بیشتر visualization است تا اندازه‌گیری دقیق.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مدل interpolation علمی پیشرفته مثل kriging یا مدل uncertainty مکانی ندارد.
- خروجی برای zoning دقیق agronomy کافی نیست.
- سطح اعتماد:
- `متوسط` برای visualization و درک trend کلی.
- `کم` برای تصمیم آبیاری دقیق نقطه‌ای یا نسخه agronomy.
### `POST /api/soile/health-summary/`
- منبع داده: داده سنسور + خلاصه‌ساز سلامت خاک.
- ضعف‌ها:
- خلاصه سلامت ذاتا compression از چند متریک است و همه context مزرعه را در بر نمی‌گیرد.
- اگر سنسورهای مزرعه کم‌تعداد یا داده‌هایشان قدیمی باشد، summary گمراه‌کننده می‌شود.
- سطح اعتماد:
- `متوسط` برای dashboard summary.
- `کم تا متوسط` برای تصمیم اصلاح خاک بدون نمونه‌برداری میدانی.
### `POST /api/soile/anomaly-detection/`
- منبع داده: anomaly آماری + تفسیر RAG.
- ضعف‌ها:
- anomaly detection بر اساس context موجود است و به history و baseline کامل چندفصلی متکی نیست.
- تفسیر نهایی توسط RAG تولید می‌شود و deterministic نیست.
- اگر anomaly آماری واقعی نباشد، LLM ممکن است فقط آن را با متن تخصصی بسط دهد.
- سطح اعتماد:
- `متوسط` برای پیدا کردن موارد مشکوک.
- `کم` برای diagnosis نهایی بدون تایید کارشناس.
---
## 3) Weather API
### `POST /api/weather/farm-card/`
- منبع داده: forecastهای ذخیره‌شده در DB از سرویس هواشناسی خارجی.
- ضعف‌ها:
- docstring سرویس هنوز misleading است، ولی کد واقعا `requests.get` می‌زند.
- اگر forecast تازه نباشد، کارت weather کهنه می‌شود.
- کارت فقط summary ساده از forecast است و uncertainty پیش‌بینی هوا را منتقل نمی‌کند.
- اگر forecast موجود نباشد، خروجی عملا صفر/نامشخص می‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مشکل اصلی فرمول نیست؛ dependency به forecast خارجی و freshness داده است.
- سطح اعتماد:
- `متوسط تا زیاد` وقتی forecast تازه و کامل موجود باشد.
- `کم` وقتی داده weather ناقص یا قدیمی باشد.
### `POST /api/weather/water-need-prediction/`
- منبع داده:
- محاسبات ET/crop profile از `irrigation.evapotranspiration`
- forecast هفت‌روزه
- تفسیر تکمیلی از RAG
- ضعف‌ها:
- دقت خروجی به کیفیت crop profile، growth stage و راندمان روش آبیاری وابسته است.
- horizon فقط کوتاه‌مدت است.
- insight متنی توسط LLM تولید می‌شود و fallback هم دارد.
- اگر forecast ضعیف باشد، کل محاسبه نیاز آبی ضعیف می‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای برنامه‌ریزی کوتاه‌مدت خوب است، اما irrigation scheduling دقیق در مزرعه واقعی هنوز به سنسور و بازخورد عملی نیاز دارد.
- سطح اعتماد:
- `متوسط` برای برنامه‌ریزی 3 تا 7 روزه.
- `کم تا متوسط` برای تصمیم اجرایی بدون کنترل میدانی.
---
## 4) Economy API
### `POST /api/economy/overview/`
- منبع داده: `mock` صریح در سرویس.
- ضعف‌ها:
- کل خروجی ساختگی است.
- اعداد هزینه آب، صرفه‌جویی، درآمد و هزینه کود از مزرعه واقعی استخراج نمی‌شوند.
- trend chart نیز mock است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API فعلا برای عملیات واقعی قابل اتکا نیست.
- سطح اعتماد:
- `خیلی کم`
---
## 5) Plant API
### `GET|POST /api/plants/` و `GET|PUT|PATCH|DELETE /api/plants/{id}/`
- منبع داده: رکوردهای DB.
- ضعف‌ها:
- اعتماد به محتوای agronomic رکوردها بستگی به کسی دارد که داده را وارد کرده است.
- validation دامنه‌ای یا علمی عمیق روی محتوا دیده نمی‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مشکل اصلی از جنس data governance است، نه الگوریتم.
- سطح اعتماد:
- `زیاد` برای این‌که رکوردی که ذخیره شده همان برگردد.
- `متوسط` برای اعتماد علمی به محتوای متنی رکوردها.
### `POST /api/plants/fetch-info/`
- منبع داده: قرار بوده API خارجی باشد، ولی فعلا `None` برمی‌گرداند.
- ضعف‌ها:
- عملا پیاده‌سازی نشده است.
- در حالت فعلی `503` می‌دهد.
- سطح اعتماد:
- `خیلی کم`
---
## 6) Farm Data API
### `POST /api/farm-data/`
- منبع داده:
- boundary مزرعه
- sensor payload
- واکشی خودکار location و weather
- نقاط قوت فعلی:
- مرکز مزرعه با centroid هندسی polygon محاسبه می‌شود، نه average ساده.
- merge سنسورها flat و overwrite خاموش ندارد؛ payload هر sensor جدا می‌ماند.
- ضعف‌ها:
- centroid هندسی هنوز برای همه use-caseها بهترین نقطه نمونه‌برداری نیست؛ مخصوصا در مزرعه‌های خیلی کشیده یا چندبخشی.
- health داده به شدت به درست بودن boundary ورودی وابسته است.
- location و weather فقط برای یک نقطه مرکزی resolve می‌شوند؛ variability در سطح مزرعه از بین می‌رود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API بیشتر data-ingestion است؛ ضعف اصلی spatial representativeness است.
- سطح اعتماد:
- `زیاد` برای ذخیره‌سازی و linkage داده.
- `متوسط` برای این فرض که یک center point نماینده کل مزرعه است.
### `GET /api/farm-data/{farm_uuid}/detail/`
- منبع داده:
- sensor payload
- soil depth data
- weather
- plants و irrigation method
- resolved_metrics با aggregation deterministic
- نقاط قوت فعلی:
- conflict چند سنسور silent overwrite نمی‌شود.
- source هر metric در `metric_sources` مشخص می‌شود.
- ضعف‌ها:
- aggregation متریک‌های عددی در تعارض، به average ختم می‌شود؛ این روش همیشه از نظر agronomy بهترین strategy نیست.
- sensor locality و timestamp تفکیک‌شده در resolved_metrics نهایی از بین می‌رود.
- برای farm چندناحیه‌ای، یک resolved metric ممکن است variance مهم را پنهان کند.
- سطح اعتماد:
- `متوسط تا زیاد` برای unified context.
- `متوسط` برای تصمیم نقطه‌ای داخل مزرعه.
### `POST /api/farm-data/parameters/`
- منبع داده: DB و log تغییرات.
- ضعف‌ها:
- افزودن پارامتر جدید به معنی معتبر بودن agronomic آن نیست.
- کیفیت metadata وابسته به داده ورودی کاربر است.
- سطح اعتماد:
- `زیاد` برای ثبت و نگهداری metadata.
- `متوسط` برای معنی علمی metadata.
---
## 7) Irrigation API
### `GET|POST /api/irrigation/` و `GET /api/irrigation/{id}/`
- منبع داده: رکوردهای روش آبیاری در DB.
- ضعف‌ها:
- درصد راندمان و توضیحات روش آبیاری اگر دستی وارد شده باشند، لزوما calibrated برای مزرعه خاص نیستند.
- سطح اعتماد:
- `زیاد` برای بازگرداندن داده ذخیره‌شده.
- `متوسط` برای کاربرد agronomic عمومی.
### `POST /api/irrigation/recommend/`
- منبع داده:
- sensor/farm context
- plant data
- irrigation method
- محاسبات FAO-56 و optimizer
- متن نهایی از RAG
- نقاط قوت فعلی:
- پاسخ نهایی provenance دارد و مشخص می‌کند کدام فیلد از LLM و کدام از fallback آمده است.
- ضعف‌ها:
- merge نهایی هنوز deterministic است و برای پایداری UI fallback را نگه می‌دارد.
- اگر crop profile، weather یا irrigation method درست نباشد، recommendation قابل اتکا نیست.
- optimizer و LLM هر دو وابسته به کیفیت ورودی هستند.
- خروجی ممکن است از نظر ظاهر کامل باشد، حتی وقتی بخشی از آن fallback است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای تصمیم نهایی آبیاری در مزرعه، باید provenance و fallback بودن بخش‌ها دیده شود.
- سطح اعتماد:
- `متوسط` وقتی داده مزرعه کامل باشد و fallback محدود باشد.
- `کم تا متوسط` وقتی بخش‌های زیادی fallback شده باشند.
### `POST /api/irrigation/water-stress/`
- منبع داده:
- ابتدا سرویس crop simulation
- در صورت خطا fallback به فرمول ساده `clamp(round(35 - (soil_moisture / 2)), 0, 100)`
- ضعف‌ها:
- اگر engine اصلی خطا بدهد هنوز به فرمول ساده سنسوری fallback می‌شود.
- fallback فقط از `soil_moisture` استفاده می‌کند و ET0، بارش، root depth، نوع گیاه، مرحله رشد و روند زمانی را نادیده می‌گیرد.
- بنابراین ظاهر endpoint ممکن است simulation-based باشد اما در failure عملا heuristic برگرداند.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- هر خروجی‌ای که `sourceMetric.engine = sensor_fallback` داشته باشد برای KPI دقیق تنش آبی قابل اتکا نیست.
- سطح اعتماد:
- `متوسط` اگر واقعا از simulation engine آمده باشد.
- `کم` اگر fallback سنسوری فعال شده باشد.
---
## 8) Fertilization API
### `POST /api/fertilization/recommend/`
- منبع داده:
- sensor/farm context
- plant data
- optimizer
- RAG
- نقاط قوت فعلی:
- مانند irrigation، provenance بخش‌ها اضافه شده و تشخیص fallback بهتر شده است.
- ضعف‌ها:
- merge نهایی هنوز deterministic است و ساختار UI را حتی در خروجی ضعیف حفظ می‌کند.
- وضعیت واقعی عناصر غذایی مزرعه به sample quality و coverage محدود است.
- مدل crop-specific کامل و region-specific صریح دیده نمی‌شود.
- پاسخ متنی نهایی LLM-based است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای نسخه کود نهایی بدون آزمون خاک و تایید agronomist کافی نیست.
- سطح اعتماد:
- `متوسط` برای پیشنهاد اولیه.
- `کم تا متوسط` برای prescription نهایی.
---
## 9) Pest & Disease API
### `POST /api/pest-disease/detect/`
- منبع داده:
- تصویر ارسالی
- context مزرعه
- knowledge base
- LLM vision / RAG
- ضعف‌ها:
- دقت شدیدا به کیفیت، زاویه، نور و فاصله تصویر وابسته است.
- خروجی deterministic نیست.
- confidence هم از خود مدل می‌آید و لزوما calibrated نیست.
- ممکن است علائم تغذیه‌ای، abiotic stress و بیماری با هم اشتباه شوند.
- سطح اعتماد:
- `کم تا متوسط` برای triage اولیه.
- `کم` برای تصمیم سم‌پاشی یا تشخیص قطعی.
### `POST /api/pest-disease/risk/`
- منبع داده:
- context مزرعه
- weather
- knowledge base
- RAG
- ضعف‌ها:
- هرچند intent آن RAG-based است، در لایه سرویس هنوز fallback heuristic نیز وجود دارد.
- risk forecast region-specific و crop-specific calibrated model ندارد.
- خروجی نهایی به retrieval quality و model behavior وابسته است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این endpoint باید فعلا به عنوان expert-assist دیده شود، نه predictive model قطعی.
- سطح اعتماد:
- `متوسط` برای آگاهی از ریسک کلی.
- `کم` برای عملیات خودکار یا قطعی.
### `POST /api/pest-disease/risk-summary/`
- منبع داده: خلاصه سبک‌تر از همان سرویس RAG.
- نقاط قوت فعلی:
- دیگر صرفا heuristic ثابت قبلی نیست و از RAG تغذیه می‌شود.
- ضعف‌ها:
- چون summary است، جزئیات uncertainty کمتر دیده می‌شود.
- اگر RAG fallback یا retrieval ضعیف داشته باشد، summary هم ضعیف می‌شود.
- سطح اعتماد:
- `متوسط رو به کم`
---
## 10) Crop Simulation API
### `POST /api/crop-simulation/growth/`
- منبع داده:
- weather
- soil
- crop parameters
- agromanagement
- PCSE engine
- ضعف‌ها:
- اگر پارامترهای واقعی مزرعه ناقص باشند، سیستم از defaultهای زیادی استفاده می‌کند.
- بخشی از پارامترها مثل `ELEV`, `IRRAD`, `RDMSOL`, `WAV`, `NAVAILI` پیش‌فرض‌محور هستند.
- کیفیت شبیه‌سازی به کیفیت profile گیاه و agromanagement وابسته است.
- نکته مهم:
- در failure فعلی، خطا propagate می‌شود و دیگر fallback projection در مسیر اصلی برگردانده نمی‌شود؛ این از نظر شفافیت خوب است.
- سطح اعتماد:
- `متوسط` وقتی ورودی‌ها خوب باشند و PCSE درست اجرا شود.
- `کم تا متوسط` وقتی بخش زیادی از ورودی‌ها default باشند.
### `GET /api/crop-simulation/growth/{task_id}/status/`
- منبع داده: status تسک + نتیجه شبیه‌سازی.
- ضعف‌ها:
- وضعیت `SUCCESS` به معنی خوب بودن ورودی‌های مدل نیست.
- page‌بندی timeline فقط presentation است و تضمین کیفیت علمی مدل نمی‌دهد.
- سطح اعتماد:
- `زیاد` برای وضعیت فنی task.
- `متوسط` برای محتوای simulation output.
### `POST /api/crop-simulation/current-farm-chart/`
- منبع داده: simulation engine + context فعلی مزرعه.
- ضعف‌ها:
- chart از شبیه‌سازی می‌آید، نه مشاهده واقعی برگ/بیوماس/weight.
- `leaf_count_estimate` و برخی شاخص‌ها derived هستند، نه measured.
- اگر weather forecast یا plant profile ضعیف باشد، chart گمراه‌کننده می‌شود.
- سطح اعتماد:
- `متوسط` برای KPI بصری و trend.
- `کم تا متوسط` برای تصمیم agronomy دقیق.
### `POST /api/crop-simulation/harvest-prediction/`
- منبع داده:
- growth simulation
- GDD profile
- extrapolation تا رسیدگی
- ضعف‌ها:
- اگر maturity داخل بازه forecast رخ ندهد، تاریخ برداشت با extrapolation از میانگین رشد برآورد می‌شود.
- بنابراین بخش‌هایی از تاریخ برداشت پیش‌بینی‌شده ممکن است فراتر از خروجی واقعی engine باشند.
- وابستگی زیاد به `required_gdd_for_maturity` و current profile گیاه دارد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای برنامه‌ریزی برداشت به عنوان estimate مناسب است، نه تعهد تقویمی قطعی.
- سطح اعتماد:
- `متوسط`
### `POST /api/crop-simulation/yield-prediction/`
- منبع داده: خروجی شبیه‌سازی رشد و تبدیل آن به yield KPI.
- ضعف‌ها:
- عملکرد نهایی به کیفیت شبیه‌سازی upstream وابسته است.
- اگر داده soil fertility، weather یا management واقعی نباشند، yield estimate فقط تقریبی است.
- حمایت از uncertainty interval یا confidence band دیده نمی‌شود.
- سطح اعتماد:
- `متوسط رو به کم`
---
## 11) Farm Alerts API
### `POST /api/farm-alerts/tracker/`
- منبع داده:
- rule-based tracker
- thresholdهای hard-coded
- خلاصه و notification با RAG/LLM
- ضعف‌ها:
- alert tracker پایه، heuristic و threshold-driven است.
- آستانه‌های moisture، pH، EC، fungal risk و ... hard-coded هستند.
- region-specific و crop-specific calibration محدود است.
- اگر LLM JSON بد برگرداند، fallback rule-based جای آن را می‌گیرد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای هشدارآگاهی مناسب است، نه برای auto-actuation.
- سطح اعتماد:
- `متوسط` برای شناسایی موارد مشکوک.
- `کم تا متوسط` برای اقدام خودکار.
### `POST /api/farm-alerts/timeline/`
- منبع داده: همان tracker + LLM timeline.
- ضعف‌ها:
- timeline بیشتر narration است تا سنجش مستقل.
- اگر مبنای alertها heuristic باشد، timeline فقط همان heuristic را بازبسته‌بندی می‌کند.
- سطح اعتماد:
- `کم تا متوسط`
---
## 12) RAG API
### `POST /api/rag/chat/`
- منبع داده:
- farm context
- history
- images احتمالی
- retrieval از knowledge base
- LLM
- ضعف‌ها:
- پاسخ stream می‌شود و structure قراردادی سختی ندارد.
- hallucination، retrieval miss، و dependence به prompt/tone وجود دارد.
- برای سوال‌های policy/diagnosis/operation ممکن است confident but wrong باشد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این endpoint باید دستیار تحلیلی در نظر گرفته شود، نه source of truth.
- سطح اعتماد:
- `کم تا متوسط`
### `POST /api/rag/recommend/irrigation/`
- ماهیت:
- عملا همان recommendation RAG-based آبیاری را مستقیما expose می‌کند.
- ضعف‌ها:
- همان ضعف‌های `POST /api/irrigation/recommend/` را دارد.
- ظاهر کامل پاسخ می‌تواند fallback بودن بخش‌هایی از محتوا را پنهان کند، هرچند provenance اضافه شده است.
- سطح اعتماد:
- `متوسط` با ورودی خوب
- `کم تا متوسط` با fallback زیاد
### `POST /api/rag/recommend/fertilization/`
- ماهیت:
- مستقیم‌ترین مسیر recommendation کودهی مبتنی بر RAG است.
- ضعف‌ها:
- همان ضعف‌های `POST /api/fertilization/recommend/` را دارد.
- سطح اعتماد:
- `متوسط رو به کم`
---
## 13) جمع‌بندی نهایی بر اساس کاربرد
### APIهای مناسب برای استفاده عملی‌تر
- `POST /api/farm-data/`
- `GET /api/farm-data/{farm_uuid}/detail/`
- `GET|POST /api/soil-data/`
- `POST /api/weather/farm-card/`
این‌ها بیشتر data-centric هستند و اگر داده منبع خوب باشد، قابل اتکاترند.
### APIهای مناسب برای dashboard و insight، نه تصمیم قطعی
- `POST /api/soile/moisture-heatmap/`
- `POST /api/soile/health-summary/`
- `POST /api/weather/water-need-prediction/`
- `POST /api/crop-simulation/current-farm-chart/`
- `POST /api/crop-simulation/harvest-prediction/`
- `POST /api/crop-simulation/yield-prediction/`
- `POST /api/farm-alerts/tracker/`
- `POST /api/farm-alerts/timeline/`
### APIهای کم‌اعتماد یا نیازمند بازطراحی
- `POST /api/economy/overview/`
- `POST /api/plants/fetch-info/`
- `POST /api/irrigation/water-stress/` در حالت fallback
- `POST /api/pest-disease/detect/` برای diagnosis قطعی
- `POST /api/pest-disease/risk/`
- `POST /api/pest-disease/risk-summary/`
- `POST /api/rag/chat/` برای پاسخ‌های حساس و اجرایی
---
## 14) پیشنهاد اولویت اصلاح
1. حذف fallback ساده از `water-stress` یا حداقل expose کردن صریح نوع engine در top-level response
2. جایگزینی mock در `economy` با data pipeline واقعی
3. پیاده‌سازی واقعی `plants/fetch-info`
4. افزودن uncertainty و freshness صریح به heatmap و water-need outputs
5. crop-specific و region-specific کردن alert/risk thresholds
6. اضافه کردن confidence واقعی و source provenance استاندارد به تمام endpointهای RAG-based
+357
View File
@@ -0,0 +1,357 @@
# گزارش کامل اپ‌ها، URLها و بررسی ضعف‌های پیاده‌سازی
## خلاصه اجرایی
این مخزن یک پروژه Django با چند اپ API مستقل است. از نظر مسیرها، ساختار کلی مناسب است، اما چند نقطه مهم در پیاده‌سازی دیده می‌شود:
- بعضی endpointها مستقیما `mock` یا `stub` هستند و خروجی واقعی تولید نمی‌کنند.
- بعضی endpointها در صورت خطای LLM یا نبود سرویس بیرونی، خروجی fallback می‌دهند که واقعی است ولی کاملا مدل‌محور/تقریبی است.
- چند ضعف ساختاری وجود دارد که می‌تواند باعث خروجی ناقص، 405 ناخواسته، یا داده ظاهرا واقعی اما غیرقابل اتکا شود.
- بخش Weather و Plant هنوز به سرویس بیرونی واقعی کامل وصل نشده‌اند.
- در چند بخش، محاسبات ساده‌سازی شده‌اند و برای KPI عملیاتی یا تصمیم‌گیری دقیق کشاورزی کافی نیستند.
---
## URLهای اصلی پروژه
### URLهای عمومی
| Method | URL | توضیح |
|---|---|---|
| GET | `/admin/` | پنل ادمین Django |
| GET | `/api/schema/` | OpenAPI schema |
| GET | `/api/docs/` | Swagger UI |
| GET | `/api/redoc/` | ReDoc |
### App: RAG
Base: `/api/rag/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/rag/chat/` | چت RAG به صورت stream |
| POST | `/api/rag/recommend/irrigation/` | توصیه آبیاری |
| POST | `/api/rag/recommend/fertilization/` | توصیه کودهی |
### App: Farm Alerts
Base: `/api/farm-alerts/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/farm-alerts/tracker/` | تحلیل tracker هشدارها |
| POST | `/api/farm-alerts/timeline/` | ساخت timeline هشدارها |
### App: Location Data / Soil Data
Base: `/api/soil-data/`
| Method | URL | توضیح |
|---|---|---|
| GET | `/api/soil-data/` | واکشی داده خاک با `lat` و `lon` |
| POST | `/api/soil-data/` | واکشی داده خاک با body |
| GET | `/api/soil-data/tasks/<task_id>/status/` | وضعیت تسک خاک |
| POST | `/api/soil-data/ndvi-health/` | کارت NDVI مزرعه |
### App: Soile
Base: `/api/soile/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/soile/anomaly-detection/` | تحلیل ناهنجاری خاک |
| POST | `/api/soile/health-summary/` | خلاصه سلامت خاک |
| POST | `/api/soile/moisture-heatmap/` | heatmap رطوبت خاک |
### App: Farm Data
Base: `/api/farm-data/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/farm-data/` | ایجاد/آپدیت داده مزرعه |
| GET | `/api/farm-data/<farm_uuid>/detail/` | جزئیات تجمیعی مزرعه |
| POST | `/api/farm-data/parameters/` | ایجاد/ویرایش پارامتر سنسور |
### App: Weather
Base: `/api/weather/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/weather/farm-card/` | کارت وضعیت آب‌وهوا |
| POST | `/api/weather/water-need-prediction/` | پیش‌بینی نیاز آبی 7 روز آینده |
### App: Economy
Base: `/api/economy/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/economy/overview/` | نمای اقتصادی مزرعه |
### App: Plant
Base: `/api/plants/`
| Method | URL | توضیح |
|---|---|---|
| GET | `/api/plants/` | لیست گیاهان |
| POST | `/api/plants/` | ایجاد گیاه |
| GET | `/api/plants/<pk>/` | جزئیات گیاه |
| PUT | `/api/plants/<pk>/` | ویرایش کامل گیاه |
| PATCH | `/api/plants/<pk>/` | ویرایش جزئی گیاه |
| DELETE | `/api/plants/<pk>/` | حذف گیاه |
| POST | `/api/plants/fetch-info/` | دریافت اطلاعات گیاه از API بیرونی |
### App: Pest & Disease
Base: `/api/pest-disease/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/pest-disease/detect/` | تشخیص آفت/بیماری از تصویر |
| POST | `/api/pest-disease/risk/` | پیش‌بینی ریسک آفات و بیماری |
| POST | `/api/pest-disease/risk-summary/` | خلاصه KPI ریسک آفات و بیماری |
### App: Irrigation
Base: `/api/irrigation/`
| Method | URL | توضیح |
|---|---|---|
| GET | `/api/irrigation/` | لیست روش‌های آبیاری |
| POST | `/api/irrigation/` | ایجاد روش آبیاری |
| GET | `/api/irrigation/<pk>/` | جزئیات روش آبیاری |
| POST | `/api/irrigation/recommend/` | توصیه آبیاری |
| POST | `/api/irrigation/water-stress/` | شاخص تنش آبی |
> نکته: در کد متدهای `PUT/PATCH/DELETE` برای روش آبیاری نوشته شده‌اند، اما به کلاس اشتباه وصل شده‌اند و عملا روی route جزئیات اعمال نمی‌شوند.
### App: Fertilization
Base: `/api/fertilization/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/fertilization/recommend/` | توصیه کودهی |
### App: Crop Simulation
Base: `/api/crop-simulation/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/crop-simulation/current-farm-chart/` | نمودار شبیه‌سازی وضعیت فعلی مزرعه |
| POST | `/api/crop-simulation/harvest-prediction/` | پیش‌بینی برداشت |
| POST | `/api/crop-simulation/yield-prediction/` | پیش‌بینی عملکرد |
| POST | `/api/crop-simulation/growth/` | شروع شبیه‌سازی رشد |
| GET | `/api/crop-simulation/growth/<task_id>/status/` | وضعیت شبیه‌سازی رشد |
---
## خروجی‌های Mock / Stub / Sample قطعی
### 1) Economy کاملا mock است
- سرویس `EconomicOverviewService` مستقیم `source: "mock"` برمی‌گرداند و همه داده‌ها ثابت هستند: `economy/services.py:7`
- خود description ویو هم صراحتا می‌گوید فعلا mock است: `economy/views.py:31`
- نتیجه: endpoint `POST /api/economy/overview/` فعلا برای استفاده واقعی قابل اتکا نیست.
### 2) Plant Fetch Info هنوز پیاده‌سازی نشده
- تابع اتصال بیرونی عملا `None` برمی‌گرداند: `plant/services.py:10`
- endpoint هم در این حالت `503` می‌دهد: `plant/views.py:292`
- نتیجه: `POST /api/plants/fetch-info/` فعلا هیچ داده واقعی از سرویس خارجی نمی‌گیرد.
### 3) Weather داده نمونه seeded دارد
- migration داده آب‌وهوای نمونه 7 روزه برای همه `SoilLocation`ها می‌سازد: `weather/migrations/0003_seed_weather_forecasts.py:1`
- نتیجه: در محیطی که migration اجرا شده باشد، بخشی از خروجی‌های Weather ممکن است از sample data بیایند نه API واقعی.
---
## خروجی‌هایی که mock مستقیم نیستند ولی fallback / تقریبی می‌دهند
### 1) Weather API از نظر مستندات و رفتار واقعی کد ناهماهنگ است
- داک‌استرینگ هنوز نوشته `TODO: پیاده‌سازی اتصال واقعی به API`: `weather/services.py:23`
- اما خود تابع در عمل `requests.get(...)` می‌زند و `response.json()` برمی‌گرداند: `weather/services.py:67`
- مسیر `no_data` در کد وجود دارد، ولی با پیاده‌سازی فعلی بیشتر یک branch دفاعی/قدیمی است تا رفتار اصلی: `weather/services.py:149`
- در `farm_data` اگر نتیجه weather برابر `no_data` باشد، خطا محسوب نمی‌شود و فرایند ادامه پیدا می‌کند: `farm_data/services.py:143`
- نتیجه: طراحی فعلی هنوز اجازه می‌دهد مزرعه بدون weather قابل اتکا ثبت/آپدیت شود، و این ابهام با وجود داده‌های seed شده شدیدتر می‌شود.
### 2) NDVI در نبود تنظیمات ماهواره‌ای خروجی unavailable می‌دهد
- اگر `SATELLITE_NDVI_ENDPOINT` و `SATELLITE_NDVI_API_KEY` تنظیم نشده باشد، client عملا داده نمی‌آورد: `location_data/remote_sensing.py:77`
- در این حالت کارت NDVI با `vegetation_health_class = "Unavailable"` و پیام نبود داده ماهواره‌ای برمی‌گردد: `location_data/ndvi.py:33`
- نتیجه: `POST /api/soil-data/ndvi-health/` ممکن است پاسخ موفق بدهد ولی داده واقعی NDVI نداشته باشد.
### 3) Farm Alerts در خطای LLM fallback می‌سازد
- اگر LLM خطا بدهد، خروجی خالی برمی‌گردد: `farm_alerts/services.py:353`
- سپس tracker و timeline از fallback داخلی ساخته می‌شوند: `farm_alerts/services.py:376`, `farm_alerts/services.py:413`
- نتیجه: خروجی این endpointها همیشه ممکن است LLM-native نباشد و از هشدارهای ساختاریافته داخلی ساخته شده باشد.
### 4) Soil Anomaly در خطای LLM fallback تحلیلی می‌دهد
- در exception خروجی fallback بازگردانده می‌شود: `rag/services/soil_anomaly.py:181`
- حتی اگر JSON مدل نامعتبر باشد، fallback جایگزین می‌شود: `rag/services/soil_anomaly.py:192`
- نتیجه: `POST /api/soile/anomaly-detection/` ممکن است تحلیل واقعی مدل زبانی نباشد.
### 5) Pest & Disease detect/risk در خطای LLM fallback دارند
- تشخیص تصویر در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:321`
- ریسک آفات/بیماری هم در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:388`
- نتیجه: پاسخ ممکن است ساختاری و معتبر باشد، اما برآمده از rule/fallback باشد نه inference کامل مدل.
### 6) Water Need Prediction insight در failure fallback می‌دهد
- در خطای LLM fallback summary برمی‌گردد: `rag/services/water_need_prediction.py:165`
- نتیجه: لایه insight توضیحی همیشه تضمین نمی‌کند که از مدل آمده باشد.
### 7) توصیه‌های آبیاری و کودهی merge با fallback می‌شوند
- پاسخ آبیاری با fallback merge می‌شود: `rag/services/irrigation.py:147`
- پاسخ کودهی هم با fallback merge می‌شود: `rag/services/fertilization.py:130`
- نتیجه: حتی وقتی LLM جواب می‌دهد، بخش‌هایی از خروجی ممکن است از template/fallback آمده باشد.
### 8) Crop Simulation در failure از projection fallback استفاده می‌کند
- اگر engine اصلی خطا بدهد، `_run_projection_engine` استفاده می‌شود: `crop_simulation/growth_simulation.py:404`
- نتیجه: بعضی نتایج crop simulation ممکن است تقریبی باشند نه خروجی engine اصلی.
---
## ضعف‌های مهم پیاده‌سازی
### 1) باگ واضح در route جزئیات Irrigation
- route جزئیات به `IrrigationMethodDetailView` وصل است: `irrigation/urls.py:12`
- اما متدهای `put/patch/delete` داخل `WaterStressView` تعریف شده‌اند، نه داخل `IrrigationMethodDetailView`: `irrigation/views.py:231`, `irrigation/views.py:287`, `irrigation/views.py:326`, `irrigation/views.py:360`
- علاوه بر این، `WaterStressView` اصلا `_get_method` ندارد و این کد از نظر ساختاری اشتباه است.
- اثر عملی: `PUT/PATCH/DELETE /api/irrigation/<pk>/` به احتمال زیاد `405 Method Not Allowed` می‌دهند و CRUD کامل عملا شکسته است.
### 2) محاسبه تنش آبی بیش از حد ساده‌سازی شده
- فرمول فقط از `soil_moisture` استفاده می‌کند: `irrigation/indicators.py:8`
- فرمول هم یک clamp ساده است: `clamp(round(35 - (soil_moisture / 2)), 0, 100)`: `irrigation/indicators.py:16`
- عوامل مهمی مثل ET0، نوع گیاه، مرحله رشد، ظرفیت مزرعه، بافت خاک، بارش، عمق ریشه و روند زمانی لحاظ نشده‌اند.
- اثر عملی: `POST /api/irrigation/water-stress/` برای KPI واقعی یا تصمیم آبیاری دقیق کافی نیست.
### 3) مرکز مزرعه با average ساده محاسبه می‌شود، نه centroid هندسی دقیق
- مرکز boundary با میانگین نقاط محاسبه می‌شود: `farm_data/services.py:100`
- برای polygonهای نامتقارن یا concave، این روش می‌تواند مرکز واقعی زمین را اشتباه بدهد.
- اثر عملی: داده خاک و هوا ممکن است برای نقطه‌ای غیرواقعی از مزرعه واکشی شوند.
### 4) ادغام داده چند سنسور باعث overwrite خاموش می‌شود
- در `farm_data`, متریک‌های همه sensorها flat می‌شوند و کلیدهای تکراری روی هم overwrite می‌شوند: `farm_data/services.py:155`
- هیچ تفکیک زمانی/مکانی/اولویت‌بندی بین سنسورها وجود ندارد.
- اثر عملی: در مزرعه چند سنسوره، `resolved_metrics` ممکن است فقط آخرین سنسور iterate شده را منعکس کند.
### 5) Weather card و Weather need کاملا وابسته به داده forecast موجود هستند
- اگر forecast نباشد، card خروجی صفر/نامشخص می‌دهد: `weather/farm_weather.py:42`
- build payload پیش‌بینی نیاز آبی هم در نبود forecast خروجی صفر می‌دهد: `weather/water_need_prediction.py:19`
- اثر عملی: endpoint ممکن است 200 بدهد اما محتوای عملیاتی نداشته باشد.
### 6) NDVI بدون boundary واقعی از bbox پیش‌فرض استفاده می‌کند
- اگر boundary وجود نداشته باشد، bbox کوچک پیش‌فرض تولید می‌شود: `location_data/remote_sensing.py:57`
- اثر عملی: NDVI ممکن است برای محدوده تقریبی اطراف center محاسبه شود، نه مرز واقعی مزرعه.
### 7) Heatmap رطوبت خاک مدل مکانی ساده دارد
- فقط latest measurement هر sensor استفاده می‌شود: `soile/services.py:22`, `soile/services.py:32`
- درون‌یابی از نوع IDW ساده است: `soile/services.py:46`
- history واقعی، drift سنسور، عدم قطعیت، zoning مزرعه یا depth-specific map در آن لحاظ نشده‌اند.
- اثر عملی: heatmap برای visualization خوب است ولی برای تصمیم agronomy دقیق کافی نیست.
### 8) Pest/Disease Risk Summary یک heuristic ساده است
- امتیاز بیماری و آفت از ترکیب چند فرمول ثابت ساخته می‌شود: `pest_disease/services.py:39`
- وزن‌ها hard-coded هستند و مدل crop-specific یا region-specific ندارند.
- اثر عملی: `POST /api/pest-disease/risk-summary/` بیشتر KPI تقریبی است تا مدل پیش‌بینی دقیق.
### 9) توصیه‌های RAG در لایه نهایی deterministic merge می‌شوند
- برای irrigation/fertilization، fallback همیشه ساختار نهایی را پر می‌کند: `rag/services/irrigation.py:153`, `rag/services/fertilization.py:135`
- اثر عملی: خروجی از نظر UI پایدار است، اما تشخیص اینکه کدام بخش واقعا از LLM آمده سخت می‌شود.
### 10) Crop Simulation ممکن است از engine اصلی به projection تقریبی سقوط کند
- fallback projection در خطا فعال می‌شود: `crop_simulation/growth_simulation.py:404`
- اگر consumer فقط status 200 ببیند و `simulation_warning` را ignore کند، ممکن است خروجی تقریبی را واقعی فرض کند.
---
## وضعیت هر اپ از نظر اتکا به داده واقعی
| App | وضعیت کلی | توضیح |
|---|---|---|
| `economy` | ضعیف | کاملا mock |
| `plant` | متوسط رو به ضعیف | CRUD واقعی است، ولی `fetch-info` پیاده‌سازی نشده |
| `weather` | متوسط | ساختار خوب است، ولی API بیرونی/seed sample هنوز ریسک دارد |
| `farm_data` | متوسط | هسته aggregation خوب است، ولی center/merge چند سنسور ساده‌سازی شده |
| `location_data` | متوسط | SoilGrids واقعی است، NDVI وابسته به تنظیمات بیرونی است |
| `soile` | متوسط | داده واقعی دارد، اما مدل تحلیلی و interpolation ساده است |
| `pest_disease` | متوسط | fallback زیاد و بخش risk-summary heuristic است |
| `farm_alerts` | متوسط | خروجی قابل استفاده است، ولی در failure به fallback داخلی می‌رود |
| `irrigation` | متوسط رو به ضعیف | recommendation خوب، اما water-stress ساده و CRUD detail معیوب |
| `fertilization` | متوسط | recommendation موجود است ولی heavily fallback-assisted |
| `rag` | متوسط | کارکرد خوب، اما بخشی از خروجی‌ها merge/fallback هستند |
| `crop_simulation` | متوسط | ساختار خوب، ولی fallback projection باید شفاف‌تر expose شود |
---
## endpointهایی که فعلا نباید به عنوان «داده قطعی واقعی» در نظر گرفته شوند
1. `POST /api/economy/overview/`
2. `POST /api/plants/fetch-info/`
3. `POST /api/weather/farm-card/` در محیطی که forecast نمونه/seed شده باشد
4. `POST /api/weather/water-need-prediction/` وقتی forecast واقعی موجود نیست
5. `POST /api/soil-data/ndvi-health/` وقتی satellite config ست نشده
6. `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/` در failureهای LLM
7. `POST /api/soile/anomaly-detection/` در failureهای LLM
8. `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/` در fallback mode
9. `POST /api/irrigation/water-stress/` برای تصمیم agronomy دقیق
10. endpointهای crop simulation وقتی `simulation_warning` نشان‌دهنده fallback engine باشد
---
## اولویت اصلاح پیشنهادی
### اولویت خیلی بالا
1. اصلاح باگ `PUT/PATCH/DELETE` در `irrigation/views.py`
2. حذف/mock-flag شفاف برای `economy`
3. تکمیل واقعی `plant/services.py`
4. شفاف‌سازی منبع forecast در weather (real / seeded / unavailable)
### اولویت بالا
1. جلوگیری از پذیرش silent `no_data` برای weather در بعضی flowهای حساس
2. اصلاح aggregation چند سنسور در `farm_data`
3. استفاده از centroid واقعی polygon به‌جای average ساده نقاط
4. اضافه‌کردن source flag به خروجی‌های fallback در RAG/Farm Alerts/Pest Disease/Soil Anomaly
### اولویت متوسط
1. بهبود مدل water stress
2. بهبود IDW/heatmap و استفاده از time-series
3. بهبود risk-summary آفت/بیماری با crop profile و weather window دقیق‌تر
4. اضافه‌کردن flag صریح برای crop-simulation fallback
---
## فایل‌های کلیدی که این گزارش بر اساس آن‌ها ساخته شده
- `config/urls.py`
- `rag/urls.py`
- `farm_alerts/urls.py`
- `location_data/urls.py`
- `soile/urls.py`
- `farm_data/urls.py`
- `weather/urls.py`
- `economy/urls.py`
- `plant/urls.py`
- `pest_disease/urls.py`
- `irrigation/urls.py`
- `fertilization/urls.py`
- `crop_simulation/urls.py`
- `economy/services.py:7`
- `plant/services.py:10`
- `weather/services.py:19`
- `weather/migrations/0003_seed_weather_forecasts.py:1`
- `farm_data/services.py:123`
- `location_data/remote_sensing.py:75`
- `location_data/ndvi.py:21`
- `soile/services.py:98`
- `pest_disease/services.py:25`
- `irrigation/views.py:196`
- `irrigation/indicators.py:8`
- `rag/services/soil_anomaly.py:137`
- `rag/services/pest_disease.py:350`
- `rag/services/water_need_prediction.py:129`
- `rag/services/irrigation.py:147`
- `rag/services/fertilization.py:130`
- `crop_simulation/growth_simulation.py:404`
+688
View File
@@ -0,0 +1,688 @@
# راهنمای کامل PCSE در این پروژه
این سند توضیح می‌دهد `PCSE` در این پروژه دقیقا چه نقشی دارد، چگونه داده‌ها را مصرف می‌کند، خروجی آن چگونه ساخته می‌شود، و این خروجی چه اثری روی توصیه‌های آبیاری و کودهی دارد. تمرکز اصلی این راهنما روی اتصال بین این فایل‌ها است:
- `crop_simulation/services.py`
- `crop_simulation/recommendation_optimizer.py`
- `crop_simulation/apps.py`
- `irrigation/apps.py`
- `fertilization/apps.py`
- `rag/services/irrigation.py`
- `rag/services/fertilization.py`
---
## 1) PCSE چیست؟
`PCSE` مخفف `Python Crop Simulation Environment` است. این کتابخانه یک چارچوب شبیه‌سازی زراعی است که می‌تواند با مدل‌هایی مثل `WOFOST` رشد گیاه، توسعه فنولوژیک، تولید زیست‌توده، عملکرد، و پاسخ به آب و نیتروژن را شبیه‌سازی کند.
در این پروژه، PCSE نقش این‌ها را بر عهده دارد:
- تبدیل داده‌های مزرعه، هوا، خاک و برنامه مدیریت به یک اجرای شبیه‌سازی
- برآورد خروجی‌های کلیدی مثل:
- `yield_estimate`
- `biomass`
- `max_lai`
- مقایسه سناریوهای مختلف آبیاری و کودهی
- تولید یک مبنای عددی برای recommendation engine
به زبان ساده:
- `RAG` متن توصیه را خوش‌بیان و کاربرپسند می‌کند
- `PCSE` منطق عددی و شبیه‌سازی سناریویی را تامین می‌کند
---
## 2) معماری کلی PCSE در این پروژه
جریان اصلی به این صورت است:
1. داده مزرعه از دیتابیس خوانده می‌شود
2. داده هوا از forecastها به فرمت قابل فهم برای PCSE تبدیل می‌شود
3. داده خاک و وضعیت سایت ساخته می‌شود
4. پروفایل گیاه و `agromanagement` آماده می‌شود
5. اگر recommendation آبیاری یا کودهی وجود داشته باشد، به `TimedEvents` تزریق می‌شود
6. مدل PCSE اجرا می‌شود
7. خروجی‌های روزانه، خلاصه و نهایی جمع می‌شوند
8. از روی آن‌ها شاخص عملکرد و recommendation سناریویی ساخته می‌شود
9. RAG از این خروجی به‌عنوان `context_text` استفاده می‌کند تا متن نهایی توصیه را بسازد
---
## 3) نقطه ورود اصلی PCSE
### `crop_simulation/apps.py`
در `crop_simulation/apps.py` یک optimizer سراسری lazy-loaded تعریف شده:
- `recommendation_optimizer`
- `get_recommendation_optimizer()`
این optimizer از کلاس `SimulationRecommendationOptimizer` در `crop_simulation/recommendation_optimizer.py` ساخته می‌شود.
بنابراین:
- `rag/services/irrigation.py` از `apps.get_app_config("crop_simulation").get_recommendation_optimizer()` استفاده می‌کند
- `rag/services/fertilization.py` هم همین کار را انجام می‌دهد
یعنی هر دو سرویس recommendation در نهایت به موتور شبیه‌سازی crop_simulation وصل هستند.
---
## 4) نقش `irrigation/apps.py`
فایل `irrigation/apps.py` فقط یک AppConfig ساده نیست؛ در عمل تنظیمات پایه optimizer آبیاری را نگه می‌دارد.
### پارامترهای مهم
#### `simulation_model`
مدل پیش‌فرض:
```python
"Wofost81_NWLP_CWB_CNB"
```
این یعنی recommendationهای آبیاری قرار است روی مدلی ساخته شوند که هم water-limited و هم nutrient-aware است.
#### `validity_days`
```python
3
```
توصیه آبیاری کوتاه‌مدت است و بیشتر به forecastهای نزدیک متکی است.
#### `minimum_event_mm`
```python
4.0
```
هر نوبت آبیاری نباید از این کمتر شود، چون در عمل آبیاری‌های خیلی کوچک یا بی‌اثرند یا اجرای میدانی ضعیفی دارند.
#### `significant_rain_threshold_mm`
```python
4.0
```
اگر بارش موثر به این آستانه برسد، recommendation می‌تواند محافظه‌کارتر شود یا پنجره اعتبار کوتاه‌تر شود.
#### `stage_targets`
برای هر مرحله رشد یک هدف رطوبت خاک تعریف شده:
- `initial`: 65%
- `vegetative`: 70%
- `flowering`: 75%
- `fruiting`: 68%
این‌ها مستقیما بر `moisture_target_percent` در توصیه نهایی اثر می‌گذارند.
#### `strategy_profiles`
سه استراتژی اصلی برای آبیاری تعریف شده:
- `conservative`
- `balanced`
- `protective`
هر استراتژی این مولفه‌ها را دارد:
- `multiplier`
- `frequency_factor`
- `event_count`
این‌ها تعیین می‌کنند:
- جمع کل آب بیشتر یا کمتر شود
- تعداد نوبت‌ها بیشتر یا کمتر شود
- توزیع آب در طول بازه forecast چگونه باشد
---
## 5) نقش `fertilization/apps.py`
این فایل هم مثل نسخه آبیاری، تنظیمات پایه recommendation optimizer کودهی را نگه می‌دارد.
### پارامترهای مهم
#### `simulation_model`
باز هم مدل:
```python
"Wofost81_NWLP_CWB_CNB"
```
یعنی recommendation کودهی بر مبنای مدلی ساخته می‌شود که نیتروژن را در سطح شبیه‌سازی لحاظ می‌کند.
#### `validity_days`
```python
7
```
پنجره اعتبار کودهی بلندتر از آبیاری است، چون تصمیم تغذیه‌ای معمولاً کندتر و با اثر تجمعی‌تر است.
#### `rain_delay_threshold_mm`
```python
3.0
```
اگر بارش موثر نزدیک باشد، برخی روش‌های مصرف یا زمان مصرف می‌توانند نامناسب شوند.
#### `stage_targets`
برای هر مرحله رشد، هدف تغذیه‌ای جداگانه تعریف شده است:
- `n`
- `p`
- `k`
- `formula`
- `application_method`
- `timing`
مثلا در مرحله `flowering`:
- نیاز پتاس بالاتر می‌شود
- فرمول `15-10-30` پیشنهاد می‌شود
- روش مصرف و timing هم متناسب با حساسیت گیاه تعیین می‌شود
#### `strategy_profiles`
سه استراتژی اصلی:
- `maintenance`
- `balanced`
- `corrective`
هر کدام ضریب مصرف و تمرکز متفاوت دارند. این استراتژی‌ها پایه سناریوسازی برای شبیه‌سازی یا heuristic هستند.
---
## 6) PCSE در `crop_simulation/services.py` چگونه کار می‌کند؟
### 6.1 نرمال‌سازی ورودی‌ها
قبل از اجرای مدل، ورودی‌ها یکنواخت می‌شوند:
- `_normalize_weather_records()`
- `_normalize_agromanagement()`
- `_normalize_site_parameters_for_model()`
### 6.2 ساخت payload مزرعه
تابع `build_simulation_payload_from_farm()` اطلاعات زیر را می‌سازد:
- weather
- soil
- site_parameters
- crop_parameters
- agromanagement
منابع آن:
- `SensorData`
- `WeatherForecast`
- پروفایل گیاه
- داده لایه خاک
### 6.3 ساخت داده خاک و سایت
در این مرحله مقادیر مهمی مثل این‌ها ساخته می‌شوند:
- `SMFCF`
- `SMW`
- `RDMSOL`
- `WAV`
- `NAVAILI`
- `P_STATUS`
- `K_STATUS`
- `SOIL_PH`
- `EC`
تعبیر عملی این مقادیر:
- `WAV`: آب در دسترس اولیه
- `NAVAILI`: نیتروژن اولیه در دسترس
- `P_STATUS` و `K_STATUS`: شاخص‌های وضعیت فسفر و پتاسیم
- `SOIL_PH` و `EC`: شرایط شیمیایی که روی کارایی تغذیه و رشد اثر دارند
### 6.4 لود کردن bindingهای PCSE
تابع `_load_pcse_bindings()` این اجزا را از package `pcse` می‌گیرد:
- `ParameterProvider`
- `WeatherDataProvider`
- `WeatherDataContainer`
- `pcse.models`
اگر package نصب نباشد، اجرای سناریوی واقعی PCSE ممکن نیست.
### 6.5 اجرای مدل
کلاس `PcseSimulationManager` قلب اجرای شبیه‌سازی است.
متد `run_simulation()` این کارها را انجام می‌دهد:
1. ساخت `PreparedSimulationInput`
2. normalize کردن weather / soil / crop / site / agromanagement
3. اجرای `_run_with_pcse()`
4. در مدل‌های `Wofost81_NWLP` اعمال adjustment برای `P` و `K`
### 6.6 خروجی‌های اصلی مدل
بعد از اجرا، این سه نوع خروجی جمع می‌شوند:
- `daily_output`
- `summary_output`
- `terminal_output`
و در نهایت metrics اصلی ساخته می‌شود:
- `yield_estimate`
- `biomass`
- `max_lai`
---
## 7) eventهای recommendation چگونه وارد شبیه‌سازی می‌شوند؟
این مهم‌ترین بخش اتصال PCSE به recommendationها است.
### `_parse_recommendation_events()`
این تابع recommendation خام را به event قابل الحاق تبدیل می‌کند.
برای آبیاری:
- `event_signal = "irrigate"`
- کلید مقدار می‌تواند `amount` یا `irrigation_amount` باشد
برای کودهی:
- `event_signal = "apply_n"`
- کلید مقدار می‌تواند `N_amount` یا `amount` باشد
### `_merge_management_recommendations()`
این تابع recommendationها را داخل `TimedEvents` همان campaign اصلی قرار می‌دهد.
پس وقتی شما یک recommendation جدید می‌دهید، در عمل:
- یک برنامه مدیریت جدید ساخته نمی‌شود
- همان `agromanagement` پایه مزرعه گرفته می‌شود
- eventهای جدید به آن تزریق می‌شوند
این طراحی مهم است چون:
- تقویم اصلی کشت حفظ می‌شود
- فقط تصمیم‌های مدیریتی جدید روی سناریو سوار می‌شوند
---
## 8) recommendation optimizer دقیقا چه می‌کند؟
فایل `crop_simulation/recommendation_optimizer.py` لایه تصمیم‌گیری سناریویی است.
کلاس اصلی:
- `SimulationRecommendationOptimizer`
این کلاس دو مسیر دارد:
- مسیر مبتنی بر PCSE
- مسیر heuristic fallback
اگر داده کافی و پروفایل simulation گیاه موجود باشد، اول تلاش می‌کند از PCSE استفاده کند. اگر نشد، به heuristic برمی‌گردد.
---
## 9) تاثیر PCSE روی توصیه آبیاری
### 9.1 ورودی‌های optimizer آبیاری
متد:
- `optimize_irrigation()`
ورودی‌ها:
- `sensor`
- `plant`
- `forecasts`
- `daily_water_needs`
- `growth_stage`
- `irrigation_method`
### 9.2 وقتی مسیر PCSE فعال می‌شود
اگر برای گیاه `simulation profile` معتبر وجود داشته باشد، متد `_optimize_irrigation_with_pcse()` اجرا می‌شود.
در این مسیر:
1. تنظیمات از `irrigation/apps.py` خوانده می‌شود
2. soil/site از سنسور و عمق خاک ساخته می‌شود
3. forecastها به weather record تبدیل می‌شوند
4. از روی `strategy_profiles` چند سناریوی آبیاری ساخته می‌شود
5. برای هر سناریو، eventهای `irrigate` به `agromanagement` تزریق می‌شود
6. هر سناریو با `run_single_simulation()` اجرا می‌شود
7. از روی `yield_estimate` هر سناریو، `score` ساخته می‌شود
8. بهترین سناریو انتخاب می‌شود
### 9.3 PCSE دقیقا چه چیزی را تغییر می‌دهد؟
PCSE باعث می‌شود recommendation آبیاری فقط بر پایه ET یا بارش نباشد. بلکه تاثیر برنامه آبیاری روی شاخص عملکرد گیاه هم دیده شود.
یعنی سیستم فقط نمی‌گوید:
- امروز 8 میلی‌متر آب بده
بلکه عملاً سناریوها را مقایسه می‌کند:
- اگر آب کمتر بدهیم، عملکرد چقدر افت می‌کند؟
- اگر آب را در نوبت‌های بیشتری پخش کنیم، نتیجه بهتر می‌شود؟
- اگر آبیاری حمایتی بدهیم، نسبت آب به عملکرد چطور تغییر می‌کند؟
### 9.4 خروجی نهایی آبیاری
خروجی recommendation آبیاری شامل این فیلدهاست:
- `total_irrigation_mm`
- `amount_per_event_mm`
- `events`
- `event_dates`
- `timing`
- `moisture_target_percent`
- `validity_period`
- `reasoning`
### 9.5 اثر مستقیم `irrigation/apps.py`
مقادیر این فایل مستقیم روی recommendation اثر دارند:
- `stage_targets` هدف رطوبت خاک را تعیین می‌کند
- `strategy_profiles` candidate scenarioها را تعریف می‌کند
- `validity_days` متن و پنجره اعتبار را تعیین می‌کند
- `minimum_event_mm` جلوی recommendationهای غیرعملی را می‌گیرد
- `significant_rain_threshold_mm` روی logic بارش موثر اثر دارد
### 9.6 اگر PCSE در دسترس نباشد
مسیر `_optimize_irrigation_with_heuristic()` استفاده می‌شود.
در این مسیر امتیازدهی بر اساس این‌هاست:
- نیاز آبی forecast
- بارش موثر
- رطوبت فعلی خاک
- دمای بالا
- باد
- بازده روش آبیاری
اما در این حالت شبیه‌سازی واقعی عملکرد انجام نمی‌شود. پس recommendation سبک‌تر و تخمینی‌تر است.
---
## 10) تاثیر PCSE روی توصیه کودهی
### 10.1 ورودی‌های optimizer کودهی
متد:
- `optimize_fertilization()`
ورودی‌ها:
- `sensor`
- `plant`
- `forecasts`
- `growth_stage`
### 10.2 مسیر PCSE برای کودهی
اگر simulation profile و forecast موجود باشد، `_optimize_fertilization_with_pcse()` اجرا می‌شود.
در این مسیر:
1. تنظیمات از `fertilization/apps.py` خوانده می‌شود
2. stage target مرحله رشد تعیین می‌شود
3. برای هر strategy profile یک دوز نیتروژن متفاوت ساخته می‌شود
4. event `apply_n` روی `TimedEvents` قرار می‌گیرد
5. هر سناریوی کودهی با PCSE اجرا می‌شود
6. `yield_estimate` سناریوها مقایسه می‌شود
7. بهترین استراتژی انتخاب می‌شود
### 10.3 PCSE دقیقا چه کمکی می‌کند؟
PCSE باعث می‌شود recommendation کودهی فقط بر اساس کمبود لحظه‌ای عناصر نباشد، بلکه اثر احتمالی سناریوی مصرف روی خروجی گیاه هم دیده شود.
یعنی سیستم فقط نمی‌گوید:
- چون نیتروژن پایین است، فلان مقدار کود بده
بلکه مقایسه می‌کند:
- اگر سناریوی نگهدارنده اجرا شود، عملکرد چقدر می‌شود؟
- اگر سناریوی اصلاحی اجرا شود، gain عملکردی چقدر است؟
- آیا افزایش دوز واقعاً ارزش دارد یا خیر؟
### 10.4 اثر `fertilization/apps.py`
مقادیر این فایل مستقیما بر recommendation اثر دارند:
- `stage_targets` دوز هدف N/P/K را تعیین می‌کند
- `formula` نوع کود پیشنهادی را تعیین می‌کند
- `application_method` روش مصرف را تعیین می‌کند
- `timing` زمان مناسب مصرف را تعیین می‌کند
- `strategy_profiles` سناریوهای رقابتی را می‌سازد
- `rain_delay_threshold_mm` روی ریسک زمان‌بندی مصرف اثر دارد
- `validity_days` پنجره اعتبار را تعیین می‌کند
### 10.5 heuristic fallback در کودهی
اگر PCSE اجرا نشود، `_optimize_fertilization_with_heuristic()` استفاده می‌شود.
این مسیر بر این مبنا تصمیم می‌گیرد:
- نیتروژن فعلی
- فسفر فعلی
- پتاسیم فعلی
- pH خاک
- مرحله رشد
- بارش پیش‌رو
خروجی آن هنوز ساختاریافته و مفید است، اما مثل مسیر PCSE مقایسه عملکرد شبیه‌سازی‌شده ندارد.
---
## 11) نقش P و K در حالی که event کودهی فقط `apply_n` است
در نسخه فعلی شبیه‌سازی، event مستقیم کودهی که وارد `TimedEvents` می‌شود بیشتر روی `N_amount` تمرکز دارد.
اما پروژه برای `P` و `K` هم یک adjustment تکمیلی دارد:
- `_estimate_pk_stress_factor()`
- `_apply_pk_adjustment()`
این بخش بعد از اجرای PCSE روی خروجی اعمال می‌شود.
منطق آن:
- اگر فسفر پایین باشد، `p_factor` کاهش می‌یابد
- اگر پتاسیم پایین باشد، `k_factor` کاهش می‌یابد
- اگر `pH` یا `EC` نامناسب باشد، penalty اعمال می‌شود
- سپس این factor روی:
- `yield_estimate`
- `biomass`
- `max_lai`
اعمال می‌شود
پس حتی اگر event سناریویی مستقیم بیشتر نیتروژنی باشد، وضعیت `P`, `K`, `pH`, `EC` باز هم روی recommendation نهایی اثر می‌گذارد.
---
## 12) RAG چطور از خروجی PCSE استفاده می‌کند؟
### در `rag/services/irrigation.py`
این سرویس:
1. forecastها و داده مزرعه را می‌گیرد
2. نیاز آبی روزانه را از FAO-56 محاسبه می‌کند
3. optimizer شبیه‌سازی را صدا می‌زند
4. اگر `optimized_result` موجود باشد، `context_text` آن را به prompt اضافه می‌کند
5. LLM پاسخ را تولید می‌کند
6. پاسخ با fallback ساختاریافته merge می‌شود
نکته مهم:
- LLM مرجع عددی اصلی نیست
- `optimized_result` مرجع اصلی اعداد است
این موضوع حتی در prompt پیش‌فرض هم صریح آمده است.
### در `rag/services/fertilization.py`
منطق مشابه است:
1. sensor و forecast خوانده می‌شود
2. optimizer کودهی اجرا می‌شود
3. `context_text` به system prompt اضافه می‌شود
4. LLM متن recommendation را می‌سازد
5. خروجی با fallback عددی merge می‌شود
در نتیجه:
- PCSE و optimizer عددها را می‌سازند
- RAG متن را کاربرپسند و اجرایی می‌کند
---
## 13) تفاوت بین simulation engine و recommendation layer
### لایه simulation
در `crop_simulation/services.py`:
- سناریو اجرا می‌شود
- eventها merge می‌شوند
- خروجی‌های عملکردی تولید می‌شوند
### لایه recommendation
در `crop_simulation/recommendation_optimizer.py`:
- چند سناریو candidate ساخته می‌شود
- همه با simulation یا heuristic ارزیابی می‌شوند
- بهترین گزینه انتخاب می‌شود
- `context_text` برای RAG تولید می‌شود
### لایه presentation
در `rag/services/irrigation.py` و `rag/services/fertilization.py`:
- متن نهایی
- هشدارها
- list itemها
- توضیح توسعه‌پذیر
ساخته می‌شود.
---
## 14) سناریوی واقعی آبیاری در این پروژه
یک نمونه ساده:
1. forecast هفت روز آینده دریافت می‌شود
2. نیاز آبی روزانه محاسبه می‌شود
3. optimizer سه سناریوی آبیاری می‌سازد:
- محافظه‌کارانه
- متعادل
- حمایتی
4. هر سناریو به `TimedEvents` تزریق می‌شود
5. PCSE برای هر سناریو اجرا می‌شود
6. عملکرد نسبی هر سناریو اندازه‌گیری می‌شود
7. بهترین سناریو انتخاب می‌شود
8. RAG همان سناریو را به زبان قابل فهم برای کاربر توضیح می‌دهد
---
## 15) سناریوی واقعی کودهی در این پروژه
یک نمونه ساده:
1. مرحله رشد تشخیص داده می‌شود
2. target غذایی همان مرحله از `fertilization/apps.py` خوانده می‌شود
3. چند سناریوی دوز و شدت مصرف ساخته می‌شود
4. برای هر سناریو event `apply_n` ساخته می‌شود
5. PCSE سناریوها را اجرا می‌کند
6. خروجی عملکرد مقایسه می‌شود
7. بهترین برنامه انتخاب می‌شود
8. RAG آن را به صورت JSON ساختاریافته به کاربر برمی‌گرداند
---
## 16) مزیت‌های استفاده از PCSE در توصیه آبیاری و کودهی
- recommendationها فقط rule-based نیستند
- تصمیم‌ها بر پایه مقایسه سناریو هستند
- امکان اتصال داده واقعی مزرعه به مدل رشد وجود دارد
- مرحله رشد، هوا، خاک و مدیریت همزمان دیده می‌شوند
- recommendation خروجی قابل توضیح‌تری برای LLM تولید می‌کند
---
## 17) محدودیت‌های فعلی پیاده‌سازی
این پروژه عملی و مفید است، اما چند محدودیت مهم دارد:
- کیفیت recommendation وابسته به کیفیت `simulation profile` گیاه است
- اگر پروفایل simulation وجود نداشته باشد، سیستم به heuristic fallback می‌رود
- event کودهی در شبیه‌سازی فعلی بیشتر نیتروژن‌محور است
- `P` و `K` به شکل adjustment پس از اجرا اعمال می‌شوند، نه لزوما event-driven کامل
- forecastهای هوا کوتاه‌مدت‌اند؛ پس recommendationها مخصوص تصمیم‌گیری عملیاتی نزدیک هستند
- score برخی سناریوها از `yield_estimate / 100` ساخته می‌شود و هنوز می‌تواند با calibration دقیق‌تر بهبود یابد
---
## 18) جمع‌بندی عملی
اگر بخواهیم نقش PCSE را در یک جمله خلاصه کنیم:
> PCSE در این پروژه موتور سنجش اثر تصمیم‌های آبیاری و کودهی روی عملکرد احتمالی گیاه است.
و اگر بخواهیم نقش `irrigation/apps.py` و `fertilization/apps.py` را هم در یک جمله بگوییم:
> این دو فایل policy و defaultهای تصمیم‌گیری را تعریف می‌کنند، و optimizer با استفاده از همان policyها سناریو می‌سازد و با PCSE ارزیابی می‌کند.
بنابراین خروجی نهایی recommendation حاصل ترکیب سه لایه است:
1. داده واقعی مزرعه و forecast
2. شبیه‌سازی سناریویی با PCSE
3. تولید پاسخ ساختاریافته و قابل فهم با RAG
---
## 19) فایل‌هایی که اگر بخواهید این سیستم را توسعه دهید باید اول ببینید
- `crop_simulation/services.py`
- `crop_simulation/recommendation_optimizer.py`
- `crop_simulation/apps.py`
- `irrigation/apps.py`
- `fertilization/apps.py`
- `rag/services/irrigation.py`
- `rag/services/fertilization.py`
اگر بخواهید behavior سیستم را تغییر دهید:
- برای تغییر policy آبیاری: `irrigation/apps.py`
- برای تغییر policy کودهی: `fertilization/apps.py`
- برای تغییر منطق ارزیابی سناریو: `crop_simulation/recommendation_optimizer.py`
- برای تغییر اجرای واقعی مدل: `crop_simulation/services.py`
- برای تغییر متن و ساختار پاسخ: `rag/services/irrigation.py` و `rag/services/fertilization.py`
@@ -0,0 +1,14 @@
در این پایگاه دانش، هشدارهای مزرعه باید به سه سطح استاندارد تقسیم شوند:
- danger: خطر فوری که به اقدام سریع نیاز دارد.
- warning: هشدار مهم که باید در کوتاه مدت پیگیری شود.
- info: اطلاع رسانی برای پایش، ثبت، یا اقدام کم ریسک.
قاعده های کلی:
1. اگر تنش می تواند باعث آسیب سریع به گیاه، ریشه، یا عملکرد شود، سطح danger مناسب است.
2. اگر تنش هنوز بحرانی نیست ولی روند آن نگران کننده است، سطح warning مناسب است.
3. اگر فقط برای پایش یا آگاهی اپراتور مفید است، سطح info مناسب است.
4. پیام ها باید کوتاه، اجرایی، و بدون اغراق باشند.
5. اگر داده کافی نیست، باید عدم قطعیت به صراحت بیان شود.
6. در متن نهایی فقط از داده های ساختاریافته مزرعه و هشدارهای محاسبه شده استفاده شود.
7. زمان، شدت، و اقدام پیشنهادی باید با وضعیت واقعی مزرعه همخوان باشد.
8. برای timeline باید ترتیب زمانی رویدادها حفظ شود و هر رویداد توضیح دهد چرا برای مزرعه مهم است.
@@ -0,0 +1,13 @@
این پایگاه دانش برای تحلیل آفات و بیماری های گیاهی استفاده می شود.
قواعد اصلی:
1. فقط بر اساس شواهد تصویری، داده های مزرعه، و اطلاعات بازیابی شده نتیجه گیری کن.
2. اگر تصویر برای تشخیص قطعی کافی نیست، عدم قطعیت را شفاف بگو.
3. تشخیص باید بین این حالت ها تفکیک کند: no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown.
4. در تحلیل تصویری، نشانه های قابل مشاهده مثل لکه، پوسیدگی، پیچیدگی برگ، سوراخ شدگی، تغییر رنگ، کپک، یا آفت قابل مشاهده ذکر شود.
5. در پیش بینی ریسک، شرایط دما، رطوبت، بارش، رطوبت خاک، pH، EC، و مرحله رشد لحاظ شوند.
6. سطح ریسک فقط یکی از low, medium, high باشد.
7. اقدام های پیشنهادی باید کوتاه، عملیاتی، و محافظه کارانه باشند و از ارائه نسخه درمان قطعی بدون داده کافی خودداری شود.
8. اگر آلودگی قارچی محتمل است، به رطوبت بالا و ماندگاری رطوبت اشاره کن.
9. اگر فشار آفت محتمل است، به گرما، خشکی، ضعف گیاه، و الگوی خسارت برگ اشاره کن.
10. همیشه خلاصه ای از دلیل نتیجه گیری ارائه بده.
@@ -0,0 +1,23 @@
تحليل ناهنجاري خاک و سنسور
هدف اين دانشنامه کمک به تفسير ناهنجاري هاي آماري در داده هاي خاک و سنسور مزرعه است.
اصول کلي:
- ناهنجاري آماري به معناي مشکل قطعي مزرعه نيست؛ اول بايد پايداري رخداد، شدت انحراف، و سازگاري آن با ساير شاخص ها بررسي شود.
- وقتي رطوبت خاک و دماي خاک همزمان ناهنجار مي شوند، احتمال تنش ريشه، آبياري نامناسب، يا موج گرما بيشتر است.
- وقتي EC و رطوبت خاک با هم ناهنجار شوند، فشار شوري، تجمع نمک، کيفيت نامناسب آب يا برنامه کوددهي نامتوازن بايد بررسي شود.
- اگر pH از محدوده معمول مزرعه فاصله بگيرد، دسترسي عناصر غذايي و کارايي جذب ريشه مي تواند تحت تاثير قرار بگيرد.
- ناهنجاري رطوبت هوا در کنار دما و رطوبت خاک مي تواند نشانه شرايط مساعد براي بيماري يا افزايش تبخير-تعرق باشد.
راهنماي تفسير شدت:
- low: انحراف خفيف يا کوتاه مدت؛ معمولا نياز به پايش دارد.
- medium: انحراف قابل توجه؛ بايد با شرايط مزرعه و آبياري تطبيق داده شود.
- high: انحراف مهم؛ بازبيني سريع سنسور و عمليات مزرعه لازم است.
- critical: رخداد شديد يا پرتکرار؛ نياز به اقدام فوري و بررسي ميداني دارد.
اقدامات پيشنهادي عمومي:
- وضعيت آخرين آبياري، زمان بندي و يکنواختي توزيع آب بررسي شود.
- کاليبراسيون سنسور و سلامت سخت افزاري آن در رخدادهاي ناگهاني کنترل شود.
- تغييرات اخير در کوددهي، شوري آب، بارش موثر و دماي محيط در تحليل لحاظ شود.
- اگر ناهنجاري در چند شاخص همزمان ديده شد، اولويت پايش و مداخله بالاتر در نظر گرفته شود.
- اگر ناهنجاري در داده هاي محدود يا ناقص ديده شد، قبل از توصيه قطعي کمبود داده صريح گفته شود.
@@ -0,0 +1,17 @@
تحليل نياز آبي کوتاه مدت مزرعه
اين دانشنامه براي تفسير خروجي محاسبات نياز آبي روزهاي آينده استفاده مي شود.
اصول کلي:
- `et0` تبخير-تعرق مرجع است و نشان مي دهد شرايط اقليمي هر روز چه ميزان تقاضاي تبخير-تعرق ايجاد مي کند.
- `etc` از ضرب `et0` در ضريب گياهي `kc` به دست مي آيد و تخمين مناسب تري از نياز آبي محصول مي دهد.
- `effective_rainfall` بخشي از بارش است که واقعا در تامين نياز آبي گياه موثر واقع مي شود.
- `net_irrigation_mm` نياز آبي خالص پس از کسر بارش موثر است.
- `gross_irrigation_mm` نياز آبي واقعي اجرايي با درنظر گرفتن راندمان سامانه آبياري است.
راهنماي تفسير:
- اگر `gross_irrigation_mm` در چند روز پياپي بالا باشد، برنامه آبياري بايد فشرده تر و منظم تر تنظيم شود.
- اگر راندمان آبياري پايين باشد، اختلاف بين نياز خالص و ناخالص بيشتر مي شود و اتلاف آب بالاتر است.
- در روزهاي گرم، پر باد يا کم بارش، بهتر است اجراي آبياري به صبح زود يا نزديک غروب منتقل شود.
- اگر بارش موثر پيش بيني شده باشد، بخشي از نياز آبي مي تواند بدون آبياري اضافي تامين شود.
- توصيه ها بايد عملياتي، کوتاه مدت، و همسو با forecast فعلي باشند و در صورت عدم قطعيت، آن را صريح بيان کنند.
+72
View File
@@ -51,6 +51,26 @@ knowledge_bases:
tone_file: "config/tones/fertilization_tone.txt" tone_file: "config/tones/fertilization_tone.txt"
description: "پایگاه دانش توصیه کودهی" description: "پایگاه دانش توصیه کودهی"
farm_alerts:
path: "config/knowledge_base/farm_alerts"
tone_file: "config/tones/farm_alerts_tone.txt"
description: "پایگاه دانش تحلیل هشدار و اعلان مزرعه"
pest_disease:
path: "config/knowledge_base/pest_disease"
tone_file: "config/tones/pest_disease_tone.txt"
description: "پایگاه دانش تشخیص و پیش بینی آفات و بیماری گیاهی"
soil_anomaly:
path: "config/knowledge_base/soil_anomaly"
tone_file: "config/tones/soil_anomaly_tone.txt"
description: "پایگاه دانش تحلیل ناهنجاری آماری داده های خاک و سنسور"
water_need_prediction:
path: "config/knowledge_base/water_need_prediction"
tone_file: "config/tones/water_need_prediction_tone.txt"
description: "پایگاه دانش تفسير نياز آبي کوتاه مدت و برنامه ريزي آبياري"
services: services:
support_bot: support_bot:
knowledge_base: "chat" knowledge_base: "chat"
@@ -104,3 +124,55 @@ services:
api_key_env: "GAPGPT_API_KEY" api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1" avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY" avalai_api_key_env: "AVALAI_API_KEY"
farm_alerts:
knowledge_base: "farm_alerts"
tone_file: "config/tones/farm_alerts_tone.txt"
use_user_embeddings: true
description: "سرویس تحلیل tracker و timeline هشدارهای مزرعه"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
pest_disease:
knowledge_base: "pest_disease"
tone_file: "config/tones/pest_disease_tone.txt"
use_user_embeddings: true
description: "سرویس تشخیص و پیش بینی آفات و بیماری"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
soil_anomaly:
knowledge_base: "soil_anomaly"
tone_file: "config/tones/soil_anomaly_tone.txt"
use_user_embeddings: true
description: "سرویس تفسير ناهنجاري هاي آماري خاک و سنسور"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
water_need_prediction:
knowledge_base: "water_need_prediction"
tone_file: "config/tones/water_need_prediction_tone.txt"
use_user_embeddings: true
description: "سرویس تفسير نياز آبي کوتاه مدت مزرعه"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
+8 -1
View File
@@ -25,12 +25,15 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"dashboard_data", "farm_alerts.apps.FarmAlertsConfig",
"rag", "rag",
"location_data", "location_data",
"soile.apps.SoileConfig",
"farm_data.apps.FarmDataConfig", "farm_data.apps.FarmDataConfig",
"weather", "weather",
"economy.apps.EconomyConfig",
"plant", "plant",
"pest_disease.apps.PestDiseaseConfig",
"irrigation", "irrigation",
"fertilization", "fertilization",
"crop_simulation.apps.CropSimulationConfig", "crop_simulation.apps.CropSimulationConfig",
@@ -133,12 +136,16 @@ SPECTACULAR_SETTINGS = {
"COMPONENT_SPLIT_REQUEST": True, "COMPONENT_SPLIT_REQUEST": True,
"TAGS": [ "TAGS": [
{"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"}, {"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"},
{"name": "Farm Alerts", "description": "tracker و timeline مستقل هشدارهای مزرعه"},
{"name": "RAG Chat", "description": "چت هوشمند RAG"}, {"name": "RAG Chat", "description": "چت هوشمند RAG"},
{"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"}, {"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"},
{"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"},
{"name": "Soile", "description": "heatmap مستقل رطوبت خاک و داده های مزرعه"},
{"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"}, {"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"},
{"name": "Economy", "description": "نمای اقتصادی مستقل مزرعه"},
{"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"}, {"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"},
{"name": "Plant", "description": "مدیریت گیاهان و دریافت اطلاعات گیاه"}, {"name": "Plant", "description": "مدیریت گیاهان و دریافت اطلاعات گیاه"},
{"name": "Pest & Disease", "description": "تشخیص تصویری و پیش بینی ریسک آفات و بیماری"},
{"name": "Crop Simulation", "description": "شبیه سازی رشد و مقایسه سناریوهای گیاه"}, {"name": "Crop Simulation", "description": "شبیه سازی رشد و مقایسه سناریوهای گیاه"},
{"name": "Irrigation", "description": "مدیریت روش‌های آبیاری"}, {"name": "Irrigation", "description": "مدیریت روش‌های آبیاری"},
{"name": "Irrigation Recommendation", "description": "درخواست و پیگیری توصیه آبیاری"}, {"name": "Irrigation Recommendation", "description": "درخواست و پیگیری توصیه آبیاری"},
+5
View File
@@ -8,3 +8,8 @@ LOGGING = { # noqa: F405
"handlers": {"console": {"class": "logging.StreamHandler"}}, "handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "WARNING"}, "root": {"handlers": ["console"], "level": "WARNING"},
} }
REST_FRAMEWORK = { # noqa: F405
**REST_FRAMEWORK, # noqa: F405
"DEFAULT_AUTHENTICATION_CLASSES": [],
}
+3
View File
@@ -9,4 +9,7 @@ def test_view(_request):
urlpatterns = [ urlpatterns = [
path("__test__/", test_view), path("__test__/", test_view),
path("api/rag/", include("rag.urls")), path("api/rag/", include("rag.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/pest-disease/", include("pest_disease.urls")),
path("api/farm-data/", include("farm_data.urls")),
] ]
+12 -37
View File
@@ -1,39 +1,14 @@
**قالب خروجی (Output Format):** شما دستيار عمومي CropLogic براي چت با کاربر هستيد.
شما موظف هستید پاسخ خود را **فقط و فقط** در قالب یک شیء JSON معتبر برگردانید. هیچ متن اضافی قبل یا بعد از JSON نباید وجود داشته باشد. ساختار JSON باید به شکل زیر باشد:
{ قواعد مهم:
"content": "متن کلی و دوستانه پاسخ به کشاورز", - اين سرويس خروجی را به صورت متن استريمي `text/plain` برمي گرداند، نه JSON.
"sections": [ - بنابراين فقط متن ساده و خوانا توليد کن و هرگز JSON، markdown fence يا ساختار کدي برنگردان.
// نکته بسیار مهم: این آرایه می‌تواند شامل یک، دو یا هر سه نوع بخش زیر باشد. هر بخش را **فقط و فقط در صورتی** اضافه کن که برای پاسخ به سوال کشاورز ضروری و مرتبط باشد: - پاسخ را به فارسي، دوستانه، شفاف و کاربردي بنويس.
- اگر لازم بود، پاسخ را در 2 تا 4 پاراگراف کوتاه يا چند خط فهرست گونه اما بدون JSON ارائه کن.
- اگر داده کافي نيست، همان را صريح بگو و از حدس زدن پرهيز کن.
// ۱. فقط در صورت نیاز به ارائه توصیه یا برنامه اجرایی: شکل خروجي مورد انتظار:
{ - يک پاسخ متني يکپارچه
"type": "recommendation", - بدون کليد JSON
"title": "عنوان توصیه یا برنامه (مثلاً برنامه آبیاری یا یک توصیه کلی)", - بدون `sections`
"icon": "نام آیکون مناسب مثل droplet یا sprout", - بدون کاراکترهاي ابتدايي/انتهايي اضافه
"content": "در صورتی که توصیه فقط یک متن ساده است، آن را اینجا بنویسید (اختیاری)",
"frequency": "میزان تکرار (اختیاری - فقط اگر برنامه دقیق است)",
"amount": "مقدار مصرف (اختیاری - فقط اگر برنامه دقیق است)",
"timing": "زمان‌بندی مناسب (اختیاری - فقط اگر برنامه دقیق است)",
"expandableExplanation": "توضیح علمی و ساده برای این توصیه (اختیاری)"
},
// ۲. فقط در صورت وجود نکات مهم که باید لیست شوند:
{
"type": "list",
"title": "عنوان لیست",
"icon": "نام آیکون مناسب",
"items": ["نکته اول", "نکته دوم"]
},
// ۳. فقط در صورت وجود خطر برای گیاه/خاک و نیاز به هشدار:
{
"type": "warning",
"title": "عنوان هشدار",
"icon": "نام آیکون مثل alert-triangle",
"content": "متن صریح و هشداردهنده در مورد خطر موجود"
}
]
}
**قانون مهم:** در بخش `recommendation`، اگر توصیه شما صرفاً یک متن ساده است، فقط از فیلدهای `title`، `icon` و `content` استفاده کنید. اما اگر یک برنامه دقیق است، می‌توانید از فیلدهای `frequency`، `amount` و `timing` استفاده کنید. فیلدهای خالی یا نامرتبط را از JSON حذف نکنید، بلکه مقدار آن‌ها را null قرار دهید یا به کلی از شیء حذف کنید (حذف کردن فیلدهای غیرضروری ترجیح داده می‌شود).
+59
View File
@@ -0,0 +1,59 @@
شما دستيار تخصصي تحليل هشدارهاي مزرعه براي CropLogic هستيد.
قواعد عمومي:
- فقط و فقط JSON معتبر برگردان. هيچ متن اضافه، توضيح، markdown يا code fence توليد نکن.
- لحن حرفه اي، دقيق، کوتاه و اجرايي باشد.
- از اغراق، ترساندن بي دليل و توصيه مبهم خودداري کن.
- اگر داده ناکافي است، اين محدوديت را داخل همان JSON و با متن شفاف بيان کن.
- سطح ها فقط از مقادير مجاز استفاده شوند.
قرارداد خروجي:
1) اگر مسئله مربوط به tracker هشدارها بود، خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"headline": "جمع بندي کوتاه وضعيت هشدارها",
"overview": "توضيح کوتاه و اجرايي از مهم ترين وضعيت مزرعه",
"status_level": "danger | warning | info",
"notifications": [
{
"level": "danger | warning | info",
"title": "عنوان هشدار",
"message": "شرح کوتاه و روشن هشدار",
"suggested_action": "اقدام پيشنهادي مشخص",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
]
}
2) اگر مسئله مربوط به timeline هشدارها بود، خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"headline": "عنوان کوتاه timeline",
"overview": "شرح کوتاه روند هشدارها",
"timeline": [
{
"timestamp": "ISO timestamp يا null",
"level": "danger | warning | info",
"title": "عنوان رخداد",
"description": "توضيح رخداد و اثر آن",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
],
"notifications": [
{
"level": "danger | warning | info",
"title": "عنوان هشدار",
"message": "شرح کوتاه و روشن هشدار",
"suggested_action": "اقدام پيشنهادي مشخص",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
]
}
قواعد تکميلي:
- اگر هشدار مهمي وجود ندارد، آرايه هاي `notifications` يا `timeline` را خالي برگردان.
- `headline` و `overview` هميشه الزامي هستند.
- عنوان ها کوتاه و عملياتي باشند.
- `suggested_action` بايد يک اقدام مشخص مزرعه اي باشد، نه توصيه کلي.
+49
View File
@@ -0,0 +1,49 @@
شما دستيار تخصصي آفات و بيماري گياهي براي CropLogic هستيد.
قواعد عمومي:
- فقط و فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- لحن تخصصي، واضح و محتاط باشد.
- از قطعيت کاذب در تشخيص تصويري خودداري کن.
- اگر داده يا شواهد کافي نيست، اين عدم قطعيت را داخل JSON شفاف بيان کن.
- همه متن ها به فارسي و مناسب کاربر مزرعه باشند.
دو نوع خروجي مجاز وجود دارد:
1) اگر مسئله «تشخيص تصويري» بود، فقط اين ساختار JSON را برگردان:
{
"has_issue": true,
"category": "no_issue | pest | disease | nutrient_stress | abiotic_stress | unknown",
"confidence": 0.0,
"severity": "low | medium | high",
"summary": "جمع بندي کوتاه تشخيص",
"detected_signs": ["نشانه 1", "نشانه 2"],
"possible_causes": ["علت 1", "علت 2"],
"immediate_actions": ["اقدام 1", "اقدام 2"],
"reasoning": ["دليل 1", "دليل 2"]
}
2) اگر مسئله «پيش بيني ريسک» بود، فقط اين ساختار JSON را برگردان:
{
"summary": "جمع بندي کوتاه ريسک",
"forecast_window": "بازه زماني",
"overall_risk": "low | medium | high",
"disease_risk": {
"score": 0.0,
"level": "low | medium | high",
"likely_conditions": ["وضعيت 1"],
"reasoning": ["دليل 1", "دليل 2"]
},
"pest_risk": {
"score": 0.0,
"level": "low | medium | high",
"likely_conditions": ["وضعيت 1"],
"reasoning": ["دليل 1", "دليل 2"]
},
"key_drivers": ["عامل 1", "عامل 2"],
"recommended_actions": ["اقدام 1", "اقدام 2"]
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- اگر `category` برابر `unknown` يا `no_issue` بود، از توصيه هاي فوري و قطعي پرهيز کن.
- `recommended_actions` و `immediate_actions` بايد عملي، کوتاه و قابل اجرا باشند.
+22
View File
@@ -0,0 +1,22 @@
شما دستيار تخصصي تحليل ناهنجاري داده هاي خاک و سنسور براي CropLogic هستيد.
قواعد عمومي:
- فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- لحن تخصصي، شفاف و محتاط باشد.
- بين «نشانه آماري» و «تشخيص قطعي ميداني» تفاوت بگذار.
- اگر داده کافي نيست، اين محدوديت را داخل JSON صريح بگو.
خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"summary": "جمع بندي کوتاه ناهنجاري",
"explanation": "توضيح کوتاه از اينکه چه چيزي غيرعادي است",
"likely_cause": "محتمل ترين علت يا علت هاي اصلي",
"recommended_action": "اقدام عملي بعدي",
"monitoring_priority": "low | medium | high | urgent",
"confidence": 0.0
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- `recommended_action` بايد عملياتي و کوتاه باشد.
- اگر ناهنجاري معنادار نيست، `summary` و `explanation` بايد اين موضوع را واضح بگويند.
@@ -0,0 +1,21 @@
شما دستيار تخصصي تفسير نياز آبي کوتاه مدت مزرعه براي CropLogic هستيد.
قواعد عمومي:
- فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- عملياتي، دقيق و کوتاه باش.
- اعداد اصلي را فقط از داده ورودي بردار و عدد متناقض جديد نساز.
- اگر forecast يا راندمان آبياري باعث عدم قطعيت مي شود، آن را داخل JSON روشن بگو.
خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"summary": "جمع بندي نياز آبي بازه کوتاه مدت",
"irrigation_outlook": "برداشت عملياتي از روند آبياري روزهاي آينده",
"recommended_action": "اقدام عملي پيشنهادي براي آبياري",
"risk_note": "ريسک يا عدم قطعيت مهم",
"confidence": 0.0
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- `recommended_action` بايد مشخص و قابل اجرا باشد.
- اگر نياز آبي ناچيز است، اين موضوع را مستقيم در `summary` و `irrigation_outlook` بگو.
+5 -1
View File
@@ -14,10 +14,14 @@ urlpatterns = [
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
# --- App APIs --- # --- App APIs ---
path("api/rag/", include("rag.urls")), path("api/rag/", include("rag.urls")),
path("api/dashboard-data/", include("dashboard_data.urls")), path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/soil-data/", include("location_data.urls")), path("api/soil-data/", include("location_data.urls")),
path("api/soile/", include("soile.urls")),
path("api/farm-data/", include("farm_data.urls")), path("api/farm-data/", include("farm_data.urls")),
path("api/weather/", include("weather.urls")),
path("api/economy/", include("economy.urls")),
path("api/plants/", include("plant.urls")), path("api/plants/", include("plant.urls")),
path("api/pest-disease/", include("pest_disease.urls")),
path("api/irrigation/", include("irrigation.urls")), path("api/irrigation/", include("irrigation.urls")),
path("api/fertilization/", include("fertilization.urls")), path("api/fertilization/", include("fertilization.urls")),
path("api/crop-simulation/", include("crop_simulation.urls")), path("api/crop-simulation/", include("crop_simulation.urls")),
+36
View File
@@ -14,5 +14,41 @@ class CropSimulationConfig(AppConfig):
return SimulationRecommendationOptimizer() return SimulationRecommendationOptimizer()
@cached_property
def current_farm_chart_simulator(self):
from .growth_simulation import CurrentFarmChartSimulator
return CurrentFarmChartSimulator()
@cached_property
def harvest_prediction_service(self):
from .harvest_prediction import HarvestPredictionService
return HarvestPredictionService()
@cached_property
def yield_prediction_service(self):
from .yield_prediction import YieldPredictionService
return YieldPredictionService()
@cached_property
def water_stress_service(self):
from .water_stress import WaterStressSimulationService
return WaterStressSimulationService()
def get_recommendation_optimizer(self): def get_recommendation_optimizer(self):
return self.recommendation_optimizer return self.recommendation_optimizer
def get_current_farm_chart_simulator(self):
return self.current_farm_chart_simulator
def get_harvest_prediction_service(self):
return self.harvest_prediction_service
def get_yield_prediction_service(self):
return self.yield_prediction_service
def get_water_stress_service(self):
return self.water_stress_service
+130 -2
View File
@@ -402,8 +402,7 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i
) )
return response["result"], response.get("scenario_id"), None return response["result"], response.get("scenario_id"), None
except Exception as exc: except Exception as exc:
fallback = _run_projection_engine(context) raise GrowthSimulationError(f"Simulation engine failed: {exc}") from exc
return fallback, None, str(exc)
def summarize_growth_stages( def summarize_growth_stages(
@@ -566,3 +565,132 @@ def run_growth_simulation(payload: dict[str, Any], progress_callback=None) -> di
"daily_records_count": len(simulation_result.get("daily_output", [])), "daily_records_count": len(simulation_result.get("daily_output", [])),
"default_page_size": context.page_size, "default_page_size": context.page_size,
} }
def _estimate_leaf_count(lai: float) -> float:
return max(lai, 0.0) * 12000.0
def _build_current_farm_chart_payload(
context: GrowthSimulationContext,
simulation_result: dict[str, Any],
scenario_id: int | None,
simulation_warning: str | None,
) -> dict[str, Any]:
daily_output = simulation_result.get("daily_output") or []
categories = [str(item.get("DAY")) for item in daily_output]
leaf_count_series = [round(_estimate_leaf_count(_safe_float(item.get("LAI"))), 2) for item in daily_output]
biomass_series = [round(_safe_float(item.get("TAGP")), 2) for item in daily_output]
storage_weight_series = [round(_safe_float(item.get("TWSO")), 2) for item in daily_output]
lai_series = [round(_safe_float(item.get("LAI")), 4) for item in daily_output]
moisture_series = [round(_safe_float(item.get("SM")) * 100.0, 2) for item in daily_output]
latest = daily_output[-1] if daily_output else {}
latest_lai = _safe_float(latest.get("LAI"), 0.0)
latest_biomass = _safe_float(latest.get("TAGP"), 0.0)
latest_storage = _safe_float(latest.get("TWSO"), 0.0)
latest_moisture = _safe_float(latest.get("SM"), 0.0) * 100.0
summary = [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": round(_estimate_leaf_count(latest_lai), 2),
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
},
{
"title": "وزن بیوماس",
"subtitle": "برآورد فعلی",
"amount": round(latest_biomass, 2),
"unit": "kg/ha",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "وزن محصول",
"subtitle": "برآورد فعلی",
"amount": round(latest_storage, 2),
"unit": "kg/ha",
"avatarColor": "warning",
"avatarIcon": "tabler-scale",
},
{
"title": "رطوبت خاک",
"subtitle": "آخرین روز",
"amount": round(latest_moisture, 2),
"unit": "%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
},
]
return {
"farm_uuid": context.farm_uuid,
"plant_name": context.plant_name,
"engine": simulation_result.get("engine"),
"model_name": simulation_result.get("model_name"),
"scenario_id": scenario_id,
"simulation_warning": simulation_warning,
"categories": categories,
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": leaf_count_series},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": biomass_series},
{"name": "وزن محصول", "key": "storage_organ_weight", "data": storage_weight_series},
{"name": "شاخص سطح برگ", "key": "lai", "data": lai_series},
{"name": "رطوبت خاک", "key": "soil_moisture_percent", "data": moisture_series},
],
"summary": summary,
"current_state": {
"date": latest.get("DAY"),
"leaf_count_estimate": round(_estimate_leaf_count(latest_lai), 2),
"leaf_area_index": round(latest_lai, 4),
"biomass_weight": round(latest_biomass, 2),
"storage_organ_weight": round(latest_storage, 2),
"soil_moisture_percent": round(latest_moisture, 2),
"development_stage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"gdd": round(_safe_float(latest.get("GDD"), 0.0), 2),
},
"metrics": simulation_result.get("metrics") or {},
"daily_output": daily_output,
}
class CurrentFarmChartSimulator:
"""سازنده chart وضعیت فعلی مزرعه برای خروجی مستقل از dashboard."""
def simulate(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
if not farm_uuid:
raise GrowthSimulationError("farm_uuid is required.")
resolved_plant_name = plant_name
if not resolved_plant_name:
sensor = (
SensorData.objects.prefetch_related("plants")
.filter(farm_uuid=farm_uuid)
.first()
)
if sensor is None:
raise GrowthSimulationError("Farm not found.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
simulation_result, scenario_id, simulation_warning = _run_simulation(context)
return _build_current_farm_chart_payload(
context,
simulation_result,
scenario_id,
simulation_warning,
)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from farm_data.models import SensorData
from plant.gdd import resolve_growth_profile
from .growth_simulation import (
DEFAULT_DYNAMIC_PARAMETERS,
DEFAULT_PAGE_SIZE,
GrowthSimulationError,
_run_simulation,
build_growth_context,
)
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _harvest_description(
*,
plant_name: str,
current_gdd: float,
required_gdd: float,
remaining_gdd: float,
estimated_days: int,
maturity_reached_in_simulation: bool,
) -> str:
if maturity_reached_in_simulation:
return (
f"شبيه ساز رشد نشان مي دهد {plant_name} با روند فعلي در حدود {estimated_days} روز ديگر "
f"به بازه برداشت مي رسد. تا امروز {round(current_gdd, 1)} واحد-روز رشد ثبت شده و "
f"نياز باقيمانده تا بلوغ حدود {round(remaining_gdd, 1)} واحد-روز است."
)
return (
f"شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي "
f"براورد مي شود {plant_name} حدود {estimated_days} روز ديگر به برداشت برسد. "
f"تا امروز {round(current_gdd, 1)} از {round(required_gdd, 1)} واحد-روز مورد نياز طي شده است."
)
def build_harvest_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
resolved_plant_name = plant_name
if not resolved_plant_name:
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
if sensor is None:
raise GrowthSimulationError("Farm not found.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
simulation_result, scenario_id, simulation_warning = _run_simulation(context)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("No simulation output available.")
profile = resolve_growth_profile(context.plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
current_gdd = _safe_float(profile.get("current_cumulative_gdd"), 0.0)
cumulative_gdd = current_gdd
maturity_date = None
daily_gdd_forecast = []
for item in daily_output:
day_gdd = _safe_float(item.get("GDD"), 0.0)
cumulative_gdd += day_gdd
day_value = item.get("DAY")
iso_day = day_value.isoformat() if isinstance(day_value, date) else str(day_value)
daily_gdd_forecast.append(
{
"date": iso_day,
"gdd": round(day_gdd, 3),
"cumulative_gdd": round(cumulative_gdd, 3),
"development_stage": round(_safe_float(item.get("DVS"), 0.0), 4),
}
)
if _safe_float(item.get("DVS"), 0.0) >= 2.0 or cumulative_gdd >= required_gdd:
maturity_date = date.fromisoformat(iso_day)
break
maturity_reached_in_simulation = maturity_date is not None
if maturity_date is None:
last_day = date.fromisoformat(str(daily_output[-1].get("DAY")))
simulated_days = max(len(daily_output), 1)
avg_daily_gdd = max((cumulative_gdd - current_gdd) / simulated_days, 0.0)
remaining_after_simulation = max(required_gdd - cumulative_gdd, 0.0)
extra_days = 0
if avg_daily_gdd > 0 and remaining_after_simulation > 0:
extra_days = int(remaining_after_simulation / avg_daily_gdd)
if remaining_after_simulation % avg_daily_gdd:
extra_days += 1
maturity_date = last_day + timedelta(days=max(extra_days, 0))
remaining_gdd = max(required_gdd - current_gdd, 0.0)
days_until = max((maturity_date - date.today()).days, 0)
window_start = maturity_date - timedelta(days=3)
window_end = maturity_date + timedelta(days=3)
return {
"date": maturity_date.isoformat(),
"dateFormatted": f"{maturity_date.day} {maturity_date.strftime('%B')} {maturity_date.year}",
"daysUntil": days_until,
"description": _harvest_description(
plant_name=context.plant_name,
current_gdd=current_gdd,
required_gdd=required_gdd,
remaining_gdd=remaining_gdd,
estimated_days=days_until,
maturity_reached_in_simulation=maturity_reached_in_simulation,
),
"optimalWindowStart": window_start.isoformat(),
"optimalWindowEnd": window_end.isoformat(),
"gddDetails": {
"current_cumulative_gdd": round(current_gdd, 3),
"required_gdd_for_maturity": round(required_gdd, 3),
"remaining_gdd": round(remaining_gdd, 3),
"estimated_days_to_harvest": days_until,
"predicted_harvest_date": maturity_date.isoformat(),
"predicted_harvest_window": {
"start": window_start.isoformat(),
"end": window_end.isoformat(),
},
"daily_gdd_forecast": daily_gdd_forecast,
"simulation_engine": simulation_result.get("engine"),
"simulation_model_name": simulation_result.get("model_name"),
"simulation_warning": simulation_warning,
"scenario_id": scenario_id,
},
}
class HarvestPredictionService:
def get_harvest_prediction(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
return build_harvest_prediction_payload(farm_uuid=farm_uuid, plant_name=plant_name)
+55
View File
@@ -72,3 +72,58 @@ class GrowthSimulationResultSerializer(serializers.Serializer):
pagination = GrowthPaginationSerializer() pagination = GrowthPaginationSerializer()
daily_records_count = serializers.IntegerField() daily_records_count = serializers.IntegerField()
default_page_size = serializers.IntegerField() default_page_size = serializers.IntegerField()
class CurrentFarmChartRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class CurrentFarmChartResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(allow_null=True)
plant_name = serializers.CharField()
engine = serializers.CharField(allow_null=True)
model_name = serializers.CharField(allow_null=True)
scenario_id = serializers.IntegerField(allow_null=True)
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
categories = serializers.ListField(child=serializers.CharField())
series = serializers.JSONField()
summary = serializers.JSONField()
current_state = serializers.JSONField()
metrics = serializers.JSONField()
daily_output = serializers.JSONField()
class HarvestPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class HarvestPredictionResponseSerializer(serializers.Serializer):
date = serializers.CharField()
dateFormatted = serializers.CharField()
daysUntil = serializers.IntegerField()
description = serializers.CharField()
optimalWindowStart = serializers.CharField()
optimalWindowEnd = serializers.CharField()
gddDetails = serializers.JSONField()
class YieldPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class YieldPredictionResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
plant_name = serializers.CharField(allow_null=True)
predictedYieldTons = serializers.FloatField()
predictedYieldRaw = serializers.FloatField()
unit = serializers.CharField()
sourceUnit = serializers.CharField()
simulationEngine = serializers.CharField(allow_null=True)
simulationModel = serializers.CharField(allow_null=True)
scenarioId = serializers.IntegerField(allow_null=True)
simulationWarning = serializers.CharField(allow_null=True, allow_blank=True)
supportingMetrics = serializers.JSONField()
+155 -10
View File
@@ -54,16 +54,31 @@ class PlantGrowthSimulationApiTests(TestCase):
] ]
def test_run_growth_simulation_returns_stage_timeline(self): def test_run_growth_simulation_returns_stage_timeline(self):
result = run_growth_simulation( with patch("crop_simulation.growth_simulation._run_simulation") as mock_run_simulation:
{ mock_run_simulation.return_value = (
"plant_name": self.plant.name, {
"dynamic_parameters": ["DVS", "LAI", "TAGP"], "engine": "pcse",
"weather": self.weather, "model_name": "wofost",
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0}, "metrics": {"yield_estimate": 10.0},
"site_parameters": {"WAV": 40.0}, "daily_output": [
"page_size": 2, {"DAY": "2026-04-01", "DVS": 0.1, "LAI": 0.2, "TAGP": 10.0},
} {"DAY": "2026-04-02", "DVS": 0.3, "LAI": 0.4, "TAGP": 20.0},
) {"DAY": "2026-04-03", "DVS": 1.1, "LAI": 0.6, "TAGP": 30.0},
],
},
12,
None,
)
result = run_growth_simulation(
{
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"weather": self.weather,
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"page_size": 2,
}
)
self.assertEqual(result["plant_name"], self.plant.name) self.assertEqual(result["plant_name"], self.plant.name)
self.assertGreaterEqual(result["daily_records_count"], 3) self.assertGreaterEqual(result["daily_records_count"], 3)
@@ -143,3 +158,133 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(payload["pagination"]["page"], 2) self.assertEqual(payload["pagination"]["page"], 2)
self.assertEqual(len(payload["stages_page"]), 1) self.assertEqual(len(payload["stages_page"]), 1)
self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative") self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative")
@patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_simulation_payload(self, mock_get_app_config):
mock_simulator = SimpleNamespace(
simulate=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": 12,
"simulation_warning": None,
"categories": ["2026-04-01", "2026-04-02"],
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": [120.0, 140.0]},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": [35.0, 45.0]},
],
"summary": [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": 140.0,
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
}
],
"current_state": {
"date": "2026-04-02",
"leaf_count_estimate": 140.0,
"leaf_area_index": 0.0117,
"biomass_weight": 45.0,
"storage_organ_weight": 10.0,
"soil_moisture_percent": 41.2,
"development_stage": 0.35,
"gdd": 9.0,
},
"metrics": {"yield_estimate": 10.0},
"daily_output": [],
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_current_farm_chart_simulator=lambda: mock_simulator
)
response = self.client.post(
"/current-farm-chart/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["plant_name"], self.plant.name)
self.assertEqual(payload["scenario_id"], 12)
self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0)
self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate")
@patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_harvest_prediction=lambda **_kwargs: {
"date": "2026-05-14",
"dateFormatted": "14 May 2026",
"daysUntil": 43,
"description": "شبيه ساز نشان مي دهد حدود 43 روز ديگر تا برداشت باقي مانده است.",
"optimalWindowStart": "2026-05-11",
"optimalWindowEnd": "2026-05-17",
"gddDetails": {
"current_cumulative_gdd": 50.0,
"required_gdd_for_maturity": 1200.0,
"remaining_gdd": 1150.0,
"simulation_engine": "growth_projection",
},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_harvest_prediction_service=lambda: mock_service
)
response = self.client.post(
"/harvest-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["daysUntil"], 43)
self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection")
@patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_yield_prediction=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"predictedYieldTons": 5.4,
"predictedYieldRaw": 5400.0,
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": "growth_projection",
"simulationModel": "growth_projection_v1",
"scenarioId": 12,
"simulationWarning": None,
"supportingMetrics": {"yield_estimate": 5400.0},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_yield_prediction_service=lambda: mock_service
)
response = self.client.post(
"/yield-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["predictedYieldTons"], 5.4)
self.assertEqual(payload["sourceUnit"], "kg/ha")
+10 -1
View File
@@ -1,9 +1,18 @@
from django.urls import path from django.urls import path
from .views import PlantGrowthSimulationStatusView, PlantGrowthSimulationView from .views import (
CurrentFarmSimulationChartView,
HarvestPredictionView,
PlantGrowthSimulationStatusView,
PlantGrowthSimulationView,
YieldPredictionView,
)
urlpatterns = [ urlpatterns = [
path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"),
path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"), path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"),
path( path(
"growth/<str:task_id>/status/", "growth/<str:task_id>/status/",
+177 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from django.apps import apps
from drf_spectacular.utils import OpenApiExample, extend_schema from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
@@ -13,9 +15,15 @@ from config.openapi import (
from .growth_simulation import MAX_PAGE_SIZE, paginate_growth_stages from .growth_simulation import MAX_PAGE_SIZE, paginate_growth_stages
from .serializers import ( from .serializers import (
CurrentFarmChartRequestSerializer,
CurrentFarmChartResponseSerializer,
GrowthSimulationQueuedSerializer, GrowthSimulationQueuedSerializer,
GrowthSimulationRequestSerializer, GrowthSimulationRequestSerializer,
GrowthSimulationResultSerializer, GrowthSimulationResultSerializer,
HarvestPredictionRequestSerializer,
HarvestPredictionResponseSerializer,
YieldPredictionRequestSerializer,
YieldPredictionResponseSerializer,
) )
from .tasks import run_growth_simulation_task from .tasks import run_growth_simulation_task
@@ -99,7 +107,7 @@ class PlantGrowthSimulationView(APIView):
value={ value={
"plant_name": "گوجه‌فرنگی", "plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI", "TAGP"], "dynamic_parameters": ["DVS", "LAI", "TAGP"],
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "11111111-1111-1111-1111-111111111111",
}, },
request_only=True, request_only=True,
), ),
@@ -173,3 +181,171 @@ class PlantGrowthSimulationStatusView(APIView):
{"code": 200, "msg": "success", "data": payload}, {"code": 200, "msg": "success", "data": payload},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
CurrentFarmChartEnvelopeSerializer = build_envelope_serializer(
"CurrentFarmChartEnvelopeSerializer",
CurrentFarmChartResponseSerializer,
)
HarvestPredictionEnvelopeSerializer = build_envelope_serializer(
"HarvestPredictionEnvelopeSerializer",
HarvestPredictionResponseSerializer,
)
YieldPredictionEnvelopeSerializer = build_envelope_serializer(
"YieldPredictionEnvelopeSerializer",
YieldPredictionResponseSerializer,
)
class CurrentFarmSimulationChartView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="chart شبیه سازی وضعیت فعلی مزرعه",
description=(
"با دریافت farm_uuid، یک شبیه سازی از وضعیت فعلی مزرعه اجرا می کند و داده chart شامل برگ، وزن، بیوماس، رطوبت و خروجی روزانه را برمی گرداند."
),
request=CurrentFarmChartRequestSerializer,
responses={
200: build_response(
CurrentFarmChartEnvelopeSerializer,
"خروجی chart شبیه سازی وضعیت فعلی مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در اجرای chart شبیه سازی مزرعه.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست chart",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = CurrentFarmChartRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator()
try:
result = simulator.simulate(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در اجرای chart شبیه سازی مزرعه: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{"code": 200, "msg": "success", "data": result},
status=status.HTTP_200_OK,
)
class HarvestPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی زمان تقریبی برداشت",
description=(
"با دریافت farm_uuid، از شبیه ساز رشد برای برآورد زمان باقی مانده تا برداشت استفاده می کند "
"و تاریخ تقریبی برداشت را برمی گرداند."
),
request=HarvestPredictionRequestSerializer,
responses={
200: build_response(
HarvestPredictionEnvelopeSerializer,
"خروجی پیش بینی زمان برداشت مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در پیش بینی زمان برداشت.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست harvest prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = HarvestPredictionRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("crop_simulation").get_harvest_prediction_service()
try:
result = service.get_harvest_prediction(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در پیش بینی زمان برداشت: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{"code": 200, "msg": "success", "data": result},
status=status.HTTP_200_OK,
)
class YieldPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی عملکرد مزرعه",
description="با دریافت farm_uuid، خروجی شبیه ساز رشد را به برآورد عملکرد قابل استفاده در KPI تبدیل می کند.",
request=YieldPredictionRequestSerializer,
responses={
200: build_response(YieldPredictionEnvelopeSerializer, "خروجی پیش بینی عملکرد مزرعه."),
400: build_response(GrowthSimulationErrorSerializer, "داده ورودی نامعتبر است."),
500: build_response(GrowthSimulationErrorSerializer, "خطا در پیش بینی عملکرد."),
},
examples=[
OpenApiExample(
"نمونه درخواست yield prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = YieldPredictionRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("crop_simulation").get_yield_prediction_service()
try:
result = service.get_yield_prediction(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from statistics import mean
from typing import Any
from farm_data.models import SensorData
from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context
def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(upper, value))
def _level_for_index(water_stress: int) -> str:
if water_stress <= 20:
return "پایین"
if water_stress <= 45:
return "متوسط"
return "بالا"
def _stage_sensitivity(dvs: float) -> tuple[str, float]:
if dvs < 0.2:
return "establishment", 0.9
if dvs < 1.0:
return "vegetative", 1.0
if dvs < 1.3:
return "flowering", 1.2
if dvs < 2.0:
return "reproductive", 1.1
return "maturity", 0.85
def _compute_water_stress_index(
*,
daily_output: list[dict[str, Any]],
soil_parameters: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
latest = daily_output[-1] if daily_output else {}
recent_window = daily_output[-3:] if daily_output else []
smfcf = _safe_float(soil_parameters.get("SMFCF"), 0.34)
smw = _safe_float(soil_parameters.get("SMW"), 0.14)
rdmsol = max(_safe_float(soil_parameters.get("RDMSOL"), 120.0), 1.0)
latest_sm = _safe_float(latest.get("SM"), 0.0)
available_water_ratio = _clamp((latest_sm - smw) / max(smfcf - smw, 0.01), 0.0, 1.0)
moisture_deficit = (1.0 - available_water_ratio) * 65.0
recent_et0 = mean(_safe_float(item.get("ET0"), 0.0) for item in recent_window) if recent_window else 0.0
et0_pressure = _clamp((recent_et0 / 0.45) * 18.0, 0.0, 18.0)
recent_rain = sum(_safe_float(item.get("RAIN"), 0.0) for item in recent_window)
rainfall_relief = _clamp(recent_rain * 2.5, 0.0, 15.0)
moisture_trend = 0.0
if len(recent_window) >= 2:
moisture_trend = max(
(_safe_float(recent_window[0].get("SM"), latest_sm) - latest_sm) * 100.0,
0.0,
)
trend_pressure = _clamp(moisture_trend * 1.6, 0.0, 12.0)
stage_code, stage_multiplier = _stage_sensitivity(_safe_float(latest.get("DVS"), 0.0))
root_depth_relief = _clamp(((rdmsol - 60.0) / 60.0) * 6.0, 0.0, 6.0)
raw_score = ((moisture_deficit + et0_pressure + trend_pressure - rainfall_relief - root_depth_relief) *
stage_multiplier)
water_stress = int(round(_clamp(raw_score, 0.0, 100.0)))
return water_stress, {
"soilMoisturePercent": round(latest_sm * 100.0, 2),
"availableWaterRatio": round(available_water_ratio, 4),
"fieldCapacity": round(smfcf, 4),
"wiltingPoint": round(smw, 4),
"rootDepthCm": round(rdmsol, 2),
"recentEt0": round(recent_et0, 4),
"recentRain": round(recent_rain, 2),
"soilMoistureDrop": round(moisture_trend, 2),
"developmentStage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"stageCode": stage_code,
"stageSensitivity": round(stage_multiplier, 2),
"engine": "crop_simulation",
"formula": (
"stress = clamp(((moisture_deficit + et0_pressure + trend_pressure - "
"rainfall_relief - root_depth_relief) * stage_sensitivity), 0, 100)"
),
}
class WaterStressSimulationService:
def _resolve_plant_name(self, *, farm_uuid: str, plant_name: str | None) -> str:
if plant_name:
return plant_name
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
if sensor is None:
raise GrowthSimulationError("Farm not found.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
return plant.name
def get_water_stress(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
resolved_plant_name = self._resolve_plant_name(farm_uuid=farm_uuid, plant_name=plant_name)
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
}
)
simulation_result, _scenario_id, simulation_warning = _run_simulation(context)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("Water stress simulation produced no daily output.")
water_stress, source_metric = _compute_water_stress_index(
daily_output=daily_output,
soil_parameters=context.soil_parameters,
)
if simulation_warning:
source_metric["simulationWarning"] = simulation_warning
return {
"farm_uuid": str(farm_uuid),
"plant_name": context.plant_name,
"waterStressIndex": water_stress,
"level": _level_for_index(water_stress),
"sourceMetric": source_metric,
}
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
from .growth_simulation import CurrentFarmChartSimulator, GrowthSimulationError
def build_yield_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
simulator = CurrentFarmChartSimulator()
result = simulator.simulate(farm_uuid=farm_uuid, plant_name=plant_name)
yield_estimate = float((result.get("metrics") or {}).get("yield_estimate") or 0.0)
predicted_yield_tons = round(max(yield_estimate / 1000.0, 0.0), 2)
return {
"farm_uuid": farm_uuid,
"plant_name": result.get("plant_name"),
"predictedYieldTons": predicted_yield_tons,
"predictedYieldRaw": round(yield_estimate, 2),
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": result.get("engine"),
"simulationModel": result.get("model_name"),
"scenarioId": result.get("scenario_id"),
"simulationWarning": result.get("simulation_warning"),
"supportingMetrics": result.get("metrics") or {},
}
class YieldPredictionService:
def get_yield_prediction(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
try:
return build_yield_prediction_payload(farm_uuid=farm_uuid, plant_name=plant_name)
except GrowthSimulationError:
raise
-19
View File
@@ -1,19 +0,0 @@
from .models import DashboardAiRequestLog
def request_dashboard_ai_bundle(sensor_id: str, payload: dict) -> dict:
log = DashboardAiRequestLog.objects.create(
sensor_id=sensor_id,
request_payload=payload,
response_payload={},
status="pending",
)
response_payload = log.response_payload or {}
return {
"log_id": log.id,
"timeline": response_payload.get("timeline", []),
"recommendations": response_payload.get("recommendations", []),
"alerts": response_payload.get("alerts", []),
"structured_context": payload.get("structured_context", {}),
"system_prompt": payload.get("system_prompt", ""),
}
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class DashboardDataConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard_data"
verbose_name = "Dashboard Data"
-81
View File
@@ -1,81 +0,0 @@
from datetime import timedelta
from django.utils import timezone
CARD_TTLS = {
"farmOverviewKpis": timedelta(days=3),
"farmWeatherCard": timedelta(days=1),
"farmAlertsTracker": timedelta(days=1),
"sensorValuesList": timedelta(days=1),
"sensorRadarChart": timedelta(days=1),
"sensorComparisonChart": timedelta(days=1),
"anomalyDetectionCard": timedelta(days=1),
"farmAlertsTimeline": timedelta(days=1),
"waterNeedPrediction": timedelta(days=1),
"harvestPredictionCard": timedelta(days=1),
"yieldPredictionChart": timedelta(days=1),
"soilMoistureHeatmap": timedelta(days=1),
"ndviHealthCard": timedelta(days=1),
"recommendationsList": timedelta(days=1),
"economicOverview": timedelta(days=1),
}
WMO_CONDITIONS = {
0: "صاف",
1: "عمدتاً صاف",
2: "نیمه‌ابری",
3: "ابری",
45: "مه",
48: "مه یخ‌زده",
51: "نم‌نم باران",
61: "بارش خفیف",
63: "بارش متوسط",
65: "بارش شدید",
71: "برف خفیف",
80: "رگبار",
95: "رعد و برق",
}
PERSIAN_WEEKDAYS = ["دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه", "یکشنبه"]
def ttl_for_card(card_name: str):
return CARD_TTLS[card_name]
def is_fresh(snapshot) -> bool:
return snapshot and snapshot.expires_at > timezone.now()
def safe_number(value, default=0):
return default if value is None else value
def average(values, default=0):
clean_values = [value for value in values if value is not None]
if not clean_values:
return default
return sum(clean_values) / len(clean_values)
def latest_history_value(history, field_name, default=None):
if not history:
return default
return getattr(history[0], field_name, default)
def compute_trend(current, previous):
current_value = safe_number(current, 0)
previous_value = safe_number(previous, current_value)
diff = round(current_value - previous_value, 1)
return {
"trendNumber": diff,
"trend": "positive" if diff >= 0 else "negative",
}
def weather_condition(weather_code):
return WMO_CONDITIONS.get(weather_code, "نامشخص")
-872
View File
@@ -1,872 +0,0 @@
# مستند فرمول‌های `dashboard_data/cards`
این فایل توضیح می‌دهد هر کارت در `dashboard_data/cards` چطور داده‌های خروجی خود را محاسبه می‌کند، از چه فیلدهایی استفاده می‌کند، و چه fallbackهایی دارد.
## منبع داده‌های مشترک
کانتکست بیشتر کارت‌ها از `dashboard_data/context.py` می‌آید:
- `sensor`: رکورد اصلی سنسور
- `location`: لوکیشن سنسور
- `depths`: داده‌های عمق خاک از `SoilDepthData`
- `forecasts`: حداکثر ۷ پیش‌بینی آب‌وهوا از امروز به بعد
- `history`: حداکثر ۳۰ رکورد تاریخچه سنسور، مرتب‌شده از جدید به قدیم
- `plants`: گیاه‌های متصل به سنسور
- `irrigation_methods`: حداکثر ۵ روش آبیاری
## توابع کمکی مشترک
این توابع در `dashboard_data/card_utils.py` استفاده می‌شوند:
- `safe_number(value, default=0)`: اگر مقدار `None` باشد، `default` برمی‌گرداند.
- `average(values, default=0)`: میانگین مقادیر غیر `None` را می‌دهد؛ اگر هیچ مقداری نبود `default` برمی‌گرداند.
- `latest_history_value(history, field_name, default=None)`: مقدار `field_name` را از جدیدترین رکورد history می‌گیرد.
- `compute_trend(current, previous)`:
- `diff = round(current_value - previous_value, 1)`
- `trend = "positive"` اگر `diff >= 0`، وگرنه `"negative"`
---
## 1) `farm_overview_kpis.py`
تابع سازنده: `build_farm_overview_kpis`
### ورودی‌های اصلی
- `sensor.soil_moisture``moisture`
- `sensor.soil_ph``ph`
- `sensor.electrical_conductivity``ec`
- `sensor.soil_temperature`
- میانگین `forecast.humidity_mean` برای ۳ پیش‌بینی اول → `humidity`
### فرمول‌ها
#### 1. امتیاز سلامت مزرعه (`farm_health_score`)
```text
health_score = clamp(
round(
100
- abs(65 - moisture)
- (abs(6.8 - ph) * 10)
- (ec * 5)
),
0,
100
)
```
توضیح:
- رطوبت ایده‌آل ۶۵٪ فرض شده.
- pH ایده‌آل ۶.۸ فرض شده.
- EC بالاتر، امتیاز را کم می‌کند.
آستانه‌های نمایش:
- اگر `health_score >= 70` → وضعیت `خوب` و رنگ `success`
- در غیر این صورت → `متوسط` و رنگ `warning`
#### 2. شاخص تنش آبی (`water_stress_index`)
```text
water_stress = clamp(round(35 - (moisture / 2)), 0, 100)
```
توضیح:
- هرچه رطوبت خاک بیشتر شود، تنش آبی کمتر می‌شود.
آستانه نمایش:
- اگر `water_stress <= 20``پایین`
- در غیر این صورت → `متوسط`
#### 3. ریسک بیماری (`disease_risk`)
```text
disease_risk = clamp(
round((humidity * 0.4) + (soil_temperature * 0.6) - 20),
0,
100
)
```
توضیح:
- دمای خاک وزن ۶۰٪ دارد.
- رطوبت هوا وزن ۴۰٪ دارد.
آستانه نمایش:
- اگر `disease_risk < 30``پایین`
- در غیر این صورت → `متوسط`
#### 4. میانگین رطوبت خاک (`avg_soil_moisture`)
```text
avg_soil_moisture = round(moisture)
```
نکته:
- اینجا عملاً فقط از `sensor.soil_moisture` فعلی استفاده می‌شود و واقعاً میانگین چند سنسور یا چند ناحیه محاسبه نمی‌شود.
آستانه نمایش:
- اگر `45 <= moisture <= 75``بهینه`
- در غیر این صورت → `نیازمند بررسی`
#### 5. پیش‌بینی عملکرد (`yield_prediction`)
```text
yield_prediction = round(max(5, health_score / 2.1), 1)
```
فرمول متن chip:
```text
yield_chip = "+" + str(max(0, health_score - 50)) + "%"
```
#### 6. ریسک آفات (`pest_risk`)
```text
pest_risk = max(5, round(disease_risk * 0.7))
```
نکته:
- ریسک آفات به‌صورت مستقیم از ۷۰٪ ریسک بیماری ساخته شده.
---
## 2) `farm_weather_card.py`
تابع سازنده: `build_farm_weather_card`
### منطق کلی
اگر `forecasts` خالی باشد:
- `condition = "نامشخص"`
- `temperature = 0`
- `humidity = 0`
- `windSpeed = 0`
- `chartData.labels = []`
- `chartData.series = [[]]`
در غیر این صورت:
### فرمول‌ها
#### 1. وضعیت آب‌وهوا
```text
condition = weather_condition(current_forecast.weather_code)
```
که `weather_code` با جدول `WMO_CONDITIONS` به متن فارسی تبدیل می‌شود.
#### 2. دما
```text
temperature = round(
safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)
)
```
یعنی:
- اول `temperature_mean`
- اگر `None` بود، `temperature_max`
#### 3. رطوبت
```text
humidity = round(average([current_forecast.humidity_mean], default=0))
```
نکته:
- چون فقط یک مقدار داخل `average` قرار می‌گیرد، عملاً همان `humidity_mean` فعلی است.
#### 4. سرعت باد
```text
windSpeed = round(safe_number(current_forecast.wind_speed_max, 0))
```
#### 5. نمودار دما
برای ۷ روز اول:
```text
labels = [str(forecast.forecast_date) for forecast in forecasts[:7]]
series = [[round(safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]]
```
---
## 3) `farm_alerts_tracker.py`
تابع سازنده: `build_farm_alerts_tracker`
### ورودی‌ها
- `sensor.soil_moisture``moisture`
- میانگین `humidity_mean` برای ۳ forecast اول → `humidity`
- `temperature_min` برای ۳ forecast اول
### فرمول‌ها
#### 1. هشدار کمبود آب
```text
low_water_count = 2 if moisture < 45 else 0
```
#### 2. هشدار ریسک قارچی
```text
fungal_count = 1 if (humidity > 70 and moisture > 60) else 0
```
#### 3. هشدار یخبندان
```text
frost_count = count(
forecast for first 3 forecasts
if temperature_min <= 0
)
```
در کد:
```text
frost_count = sum(
1 for forecast in forecasts[:3]
if safe_number(forecast.temperature_min, 10) <= 0
)
```
#### 4. مجموع هشدارها
```text
totalAlerts = low_water_count + fungal_count + frost_count
```
#### 5. مقدار radial bar
```text
radialBarValue = min(100, totalAlerts * 10)
```
---
## 4) `sensor_values_list.py`
تابع سازنده: `build_sensor_values_list`
این کارت برای هر آیتم، مقدار فعلی و trend را می‌سازد.
### فرمول trend
برای هر سنسور که از `compute_trend` استفاده می‌کند:
```text
trendNumber = round(current - previous, 1)
trend = "positive" if trendNumber >= 0 else "negative"
```
### آیتم‌ها
#### 1. دمای هوا
```text
title = round(current_weather.temperature_mean or 0) + "°C"
previous = latest_history_value(history, "soil_temperature", 0)
```
نکته مهم:
- trend دمای هوا با `soil_temperature` از history مقایسه می‌شود، نه با history دمای هوا.
#### 2. دمای خاک
```text
current = sensor.soil_temperature
previous = latest_history_value(history, "soil_temperature", 0)
```
#### 3. رطوبت هوا
```text
current = current_weather.humidity_mean or 0
previous = 0
```
نکته:
- همیشه نسبت به صفر trend می‌گیرد، نه history.
#### 4. رطوبت خاک
```text
current = sensor.soil_moisture
previous = latest_history_value(history, "soil_moisture", 0)
```
#### 5. pH خاک
```text
current = sensor.soil_ph
previous = latest_history_value(history, "soil_ph", 0)
```
#### 6. هدایت الکتریکی
```text
current = sensor.electrical_conductivity
previous = latest_history_value(history, "electrical_conductivity", 0)
```
#### 7. شدت نور
```text
title = "850"
trendNumber = 0
trend = "positive"
```
نکته:
- این مقدار کاملاً ثابت (hard-coded) است و فرمولی ندارد.
#### 8. سرعت باد
```text
current = current_weather.wind_speed_max or 0
previous = 0
```
نکته:
- trend سرعت باد هم نسبت به صفر محاسبه می‌شود.
---
## 5) `sensor_radar_chart.py`
تابع سازنده: `build_sensor_radar_chart`
### تابع نرمال‌سازی
```text
to_score(value, lower, upper):
if value is None -> 0
if value <= lower -> 0
if value >= upper -> 100
else -> round(((value - lower) / (upper - lower)) * 100)
```
### سری «امروز»
به‌ترتیب:
```text
soil_temperature_score = to_score(sensor.soil_temperature, 0, 40)
soil_moisture_score = to_score(sensor.soil_moisture, 0, 100)
soil_ph_score = to_score(sensor.soil_ph, 0, 14)
ec_score = to_score(sensor.electrical_conductivity, 0, 5)
light_score = 85
wind_score = to_score(current_weather.wind_speed_max if current_weather else 0, 0, 30)
```
خروجی:
```text
series[0].data = [
soil_temperature_score,
soil_moisture_score,
soil_ph_score,
ec_score,
85,
wind_score
]
```
### سری «ایده‌آل»
```text
[80, 70, 75, 75, 90, 50]
```
نکته:
- این مقادیر ثابت هستند و از دیتابیس محاسبه نمی‌شوند.
---
## 6) `sensor_comparison_chart.py`
تابع سازنده: `build_sensor_comparison_chart`
### ورودی‌ها
- `current_sensor.soil_moisture``current_value`
- `history[:7]` → داده‌های هفته جاری
- `history[7:14]` → داده‌های هفته قبل
### فرمول‌ها
#### 1. مقدار فعلی
```text
currentValue = round(sensor.soil_moisture)
```
#### 2. سری هفته جاری
```text
recent = reversed(history[:7])
this_week = [round(item.soil_moisture or current_value) for item in recent]
```
اگر کمتر از ۷ مقدار باشد:
```text
while len(this_week) < 7:
this_week.append(current_value)
```
#### 3. سری هفته قبل
```text
previous = reversed(history[7:14])
last_week = [round(item.soil_moisture or (current_value - 5)) for item in previous]
```
اگر کمتر از ۷ مقدار باشد:
```text
while len(last_week) < 7:
last_week.append(max(0, current_value - 5))
```
#### 4. درصد تغییر نسبت به هفته قبل
```text
avg_this = sum(this_week) / len(this_week)
avg_last = sum(last_week) / len(last_week)
delta = round(((avg_this - avg_last) / avg_last) * 100) if avg_last else 0
```
نمایش متن:
```text
vsLastWeek = f"{'+' if delta >= 0 else ''}{delta}%"
vsLastWeekValue = delta
```
#### 5. دسته‌بندی روزها
روزهای ۷ روز اخیر با نام فارسی weekday ساخته می‌شوند:
```text
categories = [
PERSIAN_WEEKDAYS[(today - offset_days).weekday()]
for offset_days in range(6, -1, -1)
]
```
---
## 7) `anomaly_detection_card.py`
تابع سازنده: `build_anomaly_detection_card`
این کارت anomalyها را فقط برای دو شاخص تولید می‌کند: رطوبت خاک و pH خاک.
### 1. anomaly رطوبت خاک
فقط وقتی ساخته می‌شود که:
```text
moisture < 45
```
ساختار خروجی:
```text
value = round(moisture) + "%"
expected = "45-65%"
deviation = round(moisture - 55) + "%"
severity = "warning"
```
نکته:
- deviation نسبت به نقطه مرجع ۵۵٪ محاسبه می‌شود، نه مرز ۴۵٪.
### 2. anomaly pH خاک
فقط وقتی ساخته می‌شود که:
```text
soil_ph < 6 or soil_ph > 7
```
ساختار خروجی:
```text
value = format(soil_ph, ".1f")
expected = "6.0-7.0"
deviation = round(soil_ph - 6.5, 1)
severity = "error" if (soil_ph < 5.5 or soil_ph > 7.5) else "warning"
```
---
## 8) `farm_alerts_timeline.py`
تابع سازنده: `build_farm_alerts_timeline`
### منطق
این کارت هیچ فرمول داخلی ندارد و داده را مستقیماً از `ai_bundle` برمی‌دارد:
```text
alerts = ai_bundle.get("timeline", [])
```
---
## 9) `water_need_prediction.py`
تابع سازنده: `build_water_need_prediction`
برای ۷ forecast اول:
### فرمول نیاز آبی روزانه
```text
et0 = safe_number(forecast.et0, 4)
rain = safe_number(forecast.precipitation, 0)
need = max(0, round((et0 * 100) - (rain * 20)))
```
توضیح:
- `ET0` در ۱۰۰ ضرب می‌شود.
- بارش در ۲۰ ضرب و از آن کم می‌شود.
- مقدار منفی به صفر clamp می‌شود.
### خروجی نهایی
```text
daily_needs = [need for first 7 forecasts]
totalNext7Days = sum(daily_needs)
categories = ["روز 1", "روز 2", ...]
series = [{"name": "نیاز آبی", "data": daily_needs}]
```
واحد خروجی:
```text
unit = "m³"
```
---
## 10) `harvest_prediction_card.py`
تابع سازنده: `build_harvest_prediction_card`
### ورودی‌ها
- میانگین `temperature_mean` تمام forecastها → `avg_temp`
- `sensor.soil_moisture``moisture_factor`
- نام اولین گیاه → `plant_name`
### فرمول‌ها
#### 1. میانگین دما
```text
avg_temp = average([forecast.temperature_mean for forecast in forecasts], default=24)
```
#### 2. فاکتور رطوبت
```text
moisture_factor = sensor.soil_moisture if available else 50
```
#### 3. روز باقی‌مانده تا برداشت
```text
days_until = max(10, int(90 - avg_temp - (moisture_factor / 5)))
```
توضیح:
- هرچه دمای متوسط بیشتر باشد، `days_until` کمتر می‌شود.
- هرچه رطوبت خاک بیشتر باشد، `days_until` کمتر می‌شود.
- حداقل ۱۰ روز است.
#### 4. تاریخ برداشت و بازه بهینه
```text
target_date = today + days_until
optimalWindowStart = target_date - 3 days
optimalWindowEnd = target_date + 3 days
```
#### 5. توضیح متنی
اگر گیاه وجود داشته باشد:
```text
description = "بر اساس دمای فعلی، رطوبت خاک و اطلاعات <plant_name>. بازه بهینه برداشت محاسبه شده است."
```
اگر گیاهی وجود نداشته باشد:
```text
plant_name = "محصول"
```
---
## 11) `yield_prediction_chart.py`
تابع سازنده: `build_yield_prediction_chart`
### فرمول‌ها
#### 1. مقدار پایه
```text
base = max(10, round(sensor.soil_moisture * 0.6))
```
#### 2. سری سال جاری
```text
current_year = [
base + 0,
base + 2,
base + 4,
base + 6,
base + 8,
base + 10,
base + 12,
base + 11,
base + 9,
base + 7,
base + 5,
base + 4
]
```
#### 3. سری سال گذشته
```text
last_year = [value - 3 for value in current_year]
```
#### 4. خلاصه کارت
عملکرد پیش‌بینی‌شده:
```text
summary[0].amount = current_year[9] + " تن"
```
یعنی مقدار ماه دهم لیست (اندیس ۹).
تاریخ برداشت:
```text
harvest_month = "حدود " + str(today.month)
summary[1].amount = "+8%"
```
نکته:
- `+8%` مقدار ثابت است و از فرمول نیامده.
---
## 12) `soil_moisture_heatmap.py`
تابع سازنده: `build_soil_moisture_heatmap`
### ورودی‌ها
- `sensor.soil_moisture``base_moisture`
- `depth.wv0033` برای هر لایه عمق خاک
### منطق اولیه
```text
hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"]
```
اگر `depths` خالی باشد:
```text
depths = [None, None]
```
### فرمول zone offset
برای هر depth:
```text
depth_offset = 0 if depth is None else round(depth.wv0033 / 10)
```
### فرمول هر خانه heatmap
برای هر zone و هر ساعت:
```text
value = clamp(
round(base_moisture + depth_offset - abs(3 - hour_index) * 2),
0,
100
)
```
توضیح:
- `hour_index = 3` مرکز نمودار است و بیشترین مقدار را می‌دهد.
- هرچه از مرکز دورتر شویم، به ازای هر پله ۲ واحد کم می‌شود.
ساختار خروجی هر نقطه:
```text
{"x": hour, "y": value}
```
---
## 13) `ndvi_health_card.py`
تابع سازنده: `build_ndvi_health_card`
### ورودی‌ها
- `sensor.nitrogen``nitrogen`
- `sensor.soil_moisture``moisture`
### فرمول NDVI
```text
ndvi = round(
clamp(((nitrogen / 100) * 0.4) + ((moisture / 100) * 0.6), 0.1, 0.95),
2
)
```
توضیح:
- نیتروژن ۴۰٪ وزن دارد.
- رطوبت خاک ۶۰٪ وزن دارد.
- خروجی بین `0.1` و `0.95` محدود می‌شود.
### وضعیت‌های متنی
#### 1. تنش نیتروژن
```text
"پایین" if nitrogen >= 30 else "بالا"
```
#### 2. سلامت محصول
```text
"خوب" if ndvi >= 0.65 else "متوسط"
```
---
## 14) `recommendations_list.py`
تابع سازنده: `build_recommendations_list`
### منطق
این کارت فرمول داخلی ندارد:
```text
recommendations = ai_bundle.get("recommendations", [])
```
---
## 15) `economic_overview.py`
تابع سازنده: `build_economic_overview`
### ورودی‌ها
- `forecast.et0` برای ۶ forecast اول
- `sensor.nitrogen`
- `sensor.phosphorus`
- `sensor.potassium`
### فرمول‌ها
#### 1. هزینه آب
```text
water_cost = round(sum(max(0, forecast.et0 * 20) for first 6 forecasts))
```
در کد با fallback:
```text
water_cost = round(
sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6])
)
```
#### 2. نیاز کودی
```text
fertilizer_need = round((nitrogen + phosphorus + potassium) / 3)
```
با fallback صفر برای هر کدام.
#### 3. پیش‌بینی درآمد
```text
revenue = round(max(1000, water_cost * 4.5))
```
#### 4. صرفه‌جویی آب هوشمند
```text
smart_saving = round(water_cost * 0.18)
```
### chartSeries
#### سری هزینه آب
```text
water_series = [max(1, round(water_cost / 6)) for _ in range(6)]
```
#### سری کود
```text
fertilizer_series = [max(1, round(fertilizer_need / 6)) for _ in range(6)]
```
نکته:
- هر دو سری، ۶ مقدار تکراری یکسان تولید می‌کنند و روند ماهانه واقعی ندارند.
---
## کارت‌های بدون فرمول محاسباتی
این کارت‌ها فقط داده را از `ai_bundle` می‌خوانند:
- `farm_alerts_timeline.py`
- `recommendations_list.py`
---
## نکات مهم برای تیم
- چند اسم کارت با واقعیت محاسبه‌شان دقیقاً منطبق نیست؛ مثلاً `avg_soil_moisture` واقعاً average چند منبع نیست.
- بعضی trendها نسبت به history درستِ همان شاخص محاسبه نمی‌شوند؛ مخصوصاً در `sensor_values_list.py`.
- چند مقدار hard-coded هستند، مثل:
- `light_score = 85`
- `sensor_values_list` برای نور = `850`
- `yield_prediction_chart` برای برداشت = `+8%`
- سری ایده‌آل در `sensor_radar_chart`
- چند کارت به‌جای مدل تحلیلی واقعی، از فرمول‌های heuristic ساده استفاده می‌کنند.
-50
View File
@@ -1,50 +0,0 @@
from dashboard_data.card_utils import safe_number
def build_economic_overview(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
sensor = (context or {}).get("sensor")
forecasts = (context or {}).get("forecasts", [])
if sensor is None:
return {"economicData": [], "chartSeries": [], "chartCategories": []}
water_cost = round(sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6]))
fertilizer_need = round((safe_number(sensor.nitrogen, 0) + safe_number(sensor.phosphorus, 0) + safe_number(sensor.potassium, 0)) / 3)
revenue = round(max(1000, water_cost * 4.5))
return {
"economicData": [
{
"title": "هزینه آب",
"value": f"{water_cost}",
"subtitle": "این ماه",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "صرفه‌جویی آب هوشمند",
"value": f"{round(water_cost * 0.18)}",
"subtitle": "۱۸٪ صرفه‌جویی شده",
"avatarIcon": "tabler-bulb",
"avatarColor": "success",
},
{
"title": "بازده سرمایه پلتفرم",
"value": "127%",
"subtitle": "نسبت به سال گذشته",
"avatarIcon": "tabler-chart-line",
"avatarColor": "info",
},
{
"title": "پیش‌بینی درآمد",
"value": f"{round(revenue / 1000)}k",
"subtitle": "این فصل",
"avatarIcon": "tabler-currency-euro",
"avatarColor": "success",
},
],
"chartSeries": [
{"name": "هزینه آب", "data": [max(1, round(water_cost / 6)) for _ in range(6)]},
{"name": "کود", "data": [max(1, round(fertilizer_need / 6)) for _ in range(6)]},
],
"chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
}
@@ -1,6 +0,0 @@
def build_farm_alerts_timeline(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
ai_bundle = ai_bundle or {}
return {
"alerts": ai_bundle.get("timeline", []),
"structuredContext": ai_bundle.get("structured_context", {}),
}
-249
View File
@@ -1,249 +0,0 @@
from __future__ import annotations
from typing import Any
from dashboard_data.card_utils import average, safe_number
DEFAULT_HEALTH_PROFILE = {
"moisture": {"ideal_value": 65.0, "min_range": 45.0, "max_range": 75.0, "weight": 0.45},
"ph": {"ideal_value": 6.6, "min_range": 6.0, "max_range": 7.5, "weight": 0.30},
"ec": {"ideal_value": 1.2, "min_range": 0.2, "max_range": 3.0, "weight": 0.25},
}
METRIC_SPECS = {
"moisture": {
"sensor_field": "soil_moisture",
"label": "رطوبت خاک",
"unit": "%",
},
"ph": {
"sensor_field": "soil_ph",
"label": "pH خاک",
"unit": "pH",
},
"ec": {
"sensor_field": "electrical_conductivity",
"label": "هدایت الکتریکی",
"unit": "dS/m",
},
}
def _normalize_metric(value: float, ideal_value: float, min_range: float, max_range: float) -> float:
if max_range <= min_range:
return 0.0
if value <= min_range or value >= max_range:
return 0.0
if value == ideal_value:
return 1.0
if value < ideal_value:
span = ideal_value - min_range
if span <= 0:
return 0.0
return max(0.0, min(1.0, (value - min_range) / span))
span = max_range - ideal_value
if span <= 0:
return 0.0
return max(0.0, min(1.0, (max_range - value) / span))
def _resolve_plant_profile(context: dict[str, Any]) -> tuple[dict[str, dict[str, float]], str]:
plants = context.get("plants", [])
for plant in plants:
profile = getattr(plant, "health_profile", None) or {}
if profile:
merged = {
metric: {
**DEFAULT_HEALTH_PROFILE.get(metric, {}),
**profile.get(metric, {}),
}
for metric in set(DEFAULT_HEALTH_PROFILE) | set(profile)
}
return merged, getattr(plant, "name", "گیاه")
return DEFAULT_HEALTH_PROFILE, (plants[0].name if plants else "پروفایل پیش‌فرض")
def _compute_health_score(sensor: Any, profile: dict[str, dict[str, float]]) -> tuple[int, list[dict[str, Any]]]:
weighted_sum = 0.0
total_weight = 0.0
components: list[dict[str, Any]] = []
for metric_type, config in profile.items():
spec = METRIC_SPECS.get(metric_type)
if spec is None:
continue
sensor_value = getattr(sensor, spec["sensor_field"], None)
if sensor_value is None:
continue
current_value = float(safe_number(sensor_value, 0))
ideal_value = float(config.get("ideal_value", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("ideal_value", 0)))
min_range = float(config.get("min_range", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("min_range", 0)))
max_range = float(config.get("max_range", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("max_range", 0)))
weight = float(config.get("weight", DEFAULT_HEALTH_PROFILE.get(metric_type, {}).get("weight", 0)))
if weight <= 0:
continue
normalized_value = _normalize_metric(
value=current_value,
ideal_value=ideal_value,
min_range=min_range,
max_range=max_range,
)
weighted_sum += weight * normalized_value
total_weight += weight
components.append(
{
"metricType": metric_type,
"label": spec["label"],
"unit": spec["unit"],
"currentValue": round(current_value, 2),
"idealValue": round(ideal_value, 2),
"minRange": round(min_range, 2),
"maxRange": round(max_range, 2),
"weight": round(weight, 3),
"normalizedValue": round(normalized_value, 4),
"weightedContribution": round(weight * normalized_value, 4),
}
)
if total_weight <= 0:
return 0, components
score = round((weighted_sum / total_weight) * 100)
return max(0, min(100, score)), components
def _health_language(health_score: int, ai_bundle: dict | None = None) -> dict[str, str]:
ai_bundle = ai_bundle or {}
ai_health = ai_bundle.get("farmOverviewKpis", {}) if isinstance(ai_bundle, dict) else {}
short_chip_text = ai_health.get("short_chip_text")
action_hint = ai_health.get("action_hint")
explanation = ai_health.get("explanation")
if isinstance(short_chip_text, str) and short_chip_text.strip() and isinstance(action_hint, str) and action_hint.strip() and isinstance(explanation, str) and explanation.strip():
return {
"short_chip_text": short_chip_text.strip(),
"action_hint": action_hint.strip(),
"explanation": explanation.strip(),
}
if health_score >= 85:
return {
"short_chip_text": "بسیار خوب",
"action_hint": "برنامه فعلی پایش و نگهداری حفظ شود.",
"explanation": "شاخص سلامت مزرعه به محدوده بسیار خوب رسیده و بیشتر پارامترهای کلیدی نزدیک به پروفایل ایده‌آل گیاه هستند.",
}
if health_score >= 70:
return {
"short_chip_text": "پایدار",
"action_hint": "تنظیمات فعلی حفظ و فقط شاخص‌های مرزی پایش شوند.",
"explanation": "سلامت مزرعه در محدوده قابل قبول است، اما برخی پارامترها هنوز با مقدار ایده‌آل فاصله دارند.",
}
if health_score >= 50:
return {
"short_chip_text": "نیازمند تنظیم",
"action_hint": "پارامترهای دور از محدوده ایده‌آل در اولویت اصلاح قرار گیرند.",
"explanation": "امتیاز سلامت نشان می‌دهد بخشی از شرایط محیطی از پروفایل مطلوب گیاه فاصله گرفته و باید تنظیم شود.",
}
return {
"short_chip_text": "تنش بالا",
"action_hint": "اصلاح فوری رطوبت، تغذیه یا شوری بر اساس اجزای امتیاز انجام شود.",
"explanation": "سلامت مزرعه در محدوده ضعیف قرار دارد و چند شاخص اصلی خارج از بازه قابل قبول گیاه هستند.",
}
def build_farm_overview_kpis(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
sensor = context.get("sensor")
forecasts = context.get("forecasts", [])
if sensor is None:
return {"kpis": []}
profile, profile_source = _resolve_plant_profile(context)
health_score, health_components = _compute_health_score(sensor, profile)
health_language = _health_language(health_score, ai_bundle=ai_bundle)
moisture = safe_number(sensor.soil_moisture, 0)
ph = safe_number(sensor.soil_ph, 7)
humidity = average([forecast.humidity_mean for forecast in forecasts[:3]], default=45)
water_stress = max(0, min(100, round(35 - (moisture / 2))))
disease_risk = max(0, min(100, round((humidity * 0.4) + (safe_number(sensor.soil_temperature, 0) * 0.6) - 20)))
yield_prediction = round(max(5, (health_score / 2.1)), 1)
primary_gap = min(health_components, key=lambda item: item["normalizedValue"], default=None)
return {
"kpis": [
{
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": f"پروفایل {profile_source}",
"stats": f"{health_score}%",
"avatarColor": "success" if health_score >= 70 else "warning" if health_score >= 50 else "error",
"avatarIcon": "tabler-heartbeat",
"chipText": health_language["short_chip_text"],
"chipColor": "success" if health_score >= 70 else "warning" if health_score >= 50 else "error",
"actionHint": health_language["action_hint"],
"explanation": health_language["explanation"],
"healthScoreDetails": {
"method": "normalized_weighted_average",
"profileSource": profile_source,
"components": health_components,
},
},
{
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": f"{water_stress}%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
"chipText": "پایین" if water_stress <= 20 else "متوسط",
"chipColor": "success" if water_stress <= 20 else "warning",
},
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "۷ روز اخیر",
"stats": "پایین" if disease_risk < 30 else "متوسط",
"avatarColor": "success" if disease_risk < 30 else "warning",
"avatarIcon": "tabler-bug",
"chipText": f"{disease_risk}%",
"chipColor": "success" if disease_risk < 30 else "warning",
},
{
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": f"{round(moisture)}%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه" if 45 <= moisture <= 75 else "نیازمند بررسی",
"chipColor": "success" if 45 <= moisture <= 75 else "warning",
},
{
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": f"{yield_prediction} تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": (
primary_gap["label"] if primary_gap else "پایدار"
),
"chipColor": "warning" if primary_gap and primary_gap["normalizedValue"] < 0.6 else "success",
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش‌بینی هوشمند",
"stats": f"{max(5, round(disease_risk * 0.7))}%",
"avatarColor": "warning",
"avatarIcon": "tabler-bug-off",
"chipText": "تحت نظر",
"chipColor": "warning",
},
]
}
-32
View File
@@ -1,32 +0,0 @@
from dashboard_data.card_utils import average, safe_number, weather_condition
def build_farm_weather_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
forecasts = (context or {}).get("forecasts", [])
if not forecasts:
return {
"condition": "نامشخص",
"temperature": 0,
"unit": "°C",
"humidity": 0,
"windSpeed": 0,
"windUnit": "km/h",
"chartData": {"labels": [], "series": [[]]},
}
current_forecast = forecasts[0]
labels = [str(forecast.forecast_date) for forecast in forecasts[:7]]
series = [[round(safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]]
return {
"condition": weather_condition(current_forecast.weather_code),
"temperature": round(safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)),
"unit": "°C",
"humidity": round(average([current_forecast.humidity_mean], default=0)),
"windSpeed": round(safe_number(current_forecast.wind_speed_max, 0)),
"windUnit": "km/h",
"chartData": {
"labels": labels,
"series": series,
},
}
@@ -1,42 +0,0 @@
from __future__ import annotations
from datetime import date
from plant.gdd import predict_harvest_from_forecasts
def _harvest_language(prediction: dict, plant_name: str, ai_bundle: dict | None = None) -> str:
ai_bundle = ai_bundle or {}
ai_payload = ai_bundle.get("harvestPredictionCard", {}) if isinstance(ai_bundle, dict) else {}
description = ai_payload.get("description")
if isinstance(description, str) and description.strip():
return description.strip()
return (
f"برای {plant_name}، رشد تجمعی بر اساس مدل GDD محاسبه شده است. "
f"تا امروز {prediction['current_cumulative_gdd']} واحد-روز رشد ثبت شده و "
f"برای رسیدن به بلوغ حدود {prediction['remaining_gdd']} واحد-روز دیگر نیاز است."
)
def build_harvest_prediction_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
forecasts = context.get("forecasts", [])
plants = context.get("plants", [])
plant = plants[0] if plants else None
plant_name = plant.name if plant else "محصول"
prediction = predict_harvest_from_forecasts(forecasts=forecasts, plant=plant).__dict__
target_date = date.fromisoformat(prediction["predicted_harvest_date"])
window_start = date.fromisoformat(prediction["predicted_harvest_window"]["start"])
window_end = date.fromisoformat(prediction["predicted_harvest_window"]["end"])
return {
"date": prediction["predicted_harvest_date"],
"dateFormatted": f"{target_date.day} {target_date.strftime('%B')} {target_date.year}",
"daysUntil": prediction["estimated_days_to_harvest"],
"description": _harvest_language(prediction, plant_name=plant_name, ai_bundle=ai_bundle),
"optimalWindowStart": window_start.isoformat(),
"optimalWindowEnd": window_end.isoformat(),
"gddDetails": prediction,
}
@@ -1,6 +0,0 @@
def build_recommendations_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
ai_bundle = ai_bundle or {}
return {
"recommendations": ai_bundle.get("recommendations", []),
"structuredContext": ai_bundle.get("structured_context", {}),
}
@@ -1,125 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from dashboard_data.card_utils import PERSIAN_WEEKDAYS
INTERPOLATION_LIMIT = 3
QUALITY_REAL = "REAL"
QUALITY_INTERPOLATED = "INTERPOLATED"
QUALITY_MISSING = "MISSING"
def _day_categories() -> list[date]:
return [date.today() - timedelta(days=offset) for offset in range(6, -1, -1)]
def _day_label(day: date) -> str:
return PERSIAN_WEEKDAYS[day.weekday()]
def _history_value_map(history: list[Any], field_name: str) -> dict[date, float | None]:
value_map: dict[date, float | None] = {}
for item in history:
timestamp = getattr(item, "recorded_at", None)
if timestamp is None:
continue
day = timestamp.date()
if day in value_map:
continue
value = getattr(item, field_name, None)
value_map[day] = float(value) if value is not None else None
return value_map
def _apply_linear_interpolation(points: list[dict[str, Any]], limit: int = INTERPOLATION_LIMIT) -> list[dict[str, Any]]:
output = [dict(point) for point in points]
known_indexes = [index for index, point in enumerate(output) if point["value"] is not None]
for start_index, end_index in zip(known_indexes, known_indexes[1:]):
gap = end_index - start_index - 1
if gap <= 0 or gap > limit:
continue
start_value = output[start_index]["value"]
end_value = output[end_index]["value"]
if start_value is None or end_value is None:
continue
step = (end_value - start_value) / (gap + 1)
for offset in range(1, gap + 1):
target_index = start_index + offset
output[target_index]["value"] = round(start_value + (step * offset), 2)
output[target_index]["quality_flag"] = QUALITY_INTERPOLATED
return output
def _build_week_points(
history: list[Any],
field_name: str,
day_offset_start: int,
) -> list[dict[str, Any]]:
days = [date.today() - timedelta(days=offset) for offset in range(day_offset_start + 6, day_offset_start - 1, -1)]
value_map = _history_value_map(history, field_name)
raw_points = [
{
"timestamp": day.isoformat(),
"value": round(value_map[day], 2) if day in value_map and value_map[day] is not None else None,
"quality_flag": QUALITY_REAL if day in value_map and value_map[day] is not None else QUALITY_MISSING,
}
for day in days
]
return _apply_linear_interpolation(raw_points)
def _average_known(points: list[dict[str, Any]]) -> float | None:
values = [point["value"] for point in points if point["value"] is not None]
if not values:
return None
return sum(values) / len(values)
def build_sensor_comparison_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
history = (context or {}).get("history", [])
current_sensor = (context or {}).get("sensor")
current_value = getattr(current_sensor, "soil_moisture", None) if current_sensor is not None else None
current_value = round(float(current_value), 2) if current_value is not None else None
this_week_points = _build_week_points(history, "soil_moisture", day_offset_start=0)
last_week_points = _build_week_points(history, "soil_moisture", day_offset_start=7)
this_week_avg = _average_known(this_week_points)
last_week_avg = _average_known(last_week_points)
delta = 0
if this_week_avg is not None and last_week_avg not in (None, 0):
delta = round(((this_week_avg - last_week_avg) / last_week_avg) * 100)
categories = [_day_label(day) for day in _day_categories()]
return {
"currentValue": current_value,
"currentValueQuality": QUALITY_REAL if current_value is not None else QUALITY_MISSING,
"vsLastWeek": f"{'+' if delta >= 0 else ''}{delta}%",
"vsLastWeekValue": delta,
"categories": categories,
"series": [
{
"name": "هفته جاری",
"data": [point["value"] for point in this_week_points],
"points": this_week_points,
},
{
"name": "هفته قبل",
"data": [point["value"] for point in last_week_points],
"points": last_week_points,
},
],
"qualityLegend": {
QUALITY_REAL: "اندازه‌گیری واقعی سنسور",
QUALITY_INTERPOLATED: "برآورد خطی برای شکاف کوتاه داده",
QUALITY_MISSING: "داده معتبر در دسترس نیست",
},
}
-130
View File
@@ -1,130 +0,0 @@
from __future__ import annotations
from typing import Any
from dashboard_data.card_utils import average, safe_number
DEFAULT_IDEAL_SENSOR_PROFILE = {
"temperature": {"ideal": 24.0, "min": 18.0, "max": 30.0},
"moisture": {"ideal": 65.0, "min": 45.0, "max": 80.0},
"ph": {"ideal": 6.5, "min": 6.0, "max": 7.2},
"ec": {"ideal": 1.4, "min": 1.0, "max": 2.0},
"humidity": {"ideal": 60.0, "min": 45.0, "max": 75.0},
}
METRIC_ORDER = [
("temperature", "دما"),
("moisture", "رطوبت"),
("ph", "pH"),
("ec", "هدایت الکتریکی"),
("humidity", "رطوبت هوا"),
]
def _normalize_to_ideal_score(value: float | None, minimum: float, ideal: float, maximum: float) -> int:
if value is None or maximum <= minimum:
return 0
if value <= minimum or value >= maximum:
return 0
if value == ideal:
return 100
if value < ideal:
span = ideal - minimum
if span <= 0:
return 0
return round(max(0.0, min(1.0, (value - minimum) / span)) * 100)
span = maximum - ideal
if span <= 0:
return 0
return round(max(0.0, min(1.0, (maximum - value) / span)) * 100)
def _resolve_profile(context: dict[str, Any]) -> tuple[dict[str, dict[str, float]], str]:
plants = context.get("plants", [])
for plant in plants:
plant_profile = getattr(plant, "health_profile", None) or {}
if plant_profile:
translated: dict[str, dict[str, float]] = {}
for metric in DEFAULT_IDEAL_SENSOR_PROFILE:
metric_data = plant_profile.get(metric)
if not metric_data:
continue
translated[metric] = {
"ideal": float(metric_data.get("ideal_value", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["ideal"])),
"min": float(metric_data.get("min_range", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["min"])),
"max": float(metric_data.get("max_range", DEFAULT_IDEAL_SENSOR_PROFILE[metric]["max"])),
}
merged = {
metric: {**DEFAULT_IDEAL_SENSOR_PROFILE.get(metric, {}), **translated.get(metric, {})}
for metric in DEFAULT_IDEAL_SENSOR_PROFILE
}
return merged, f"plant:{getattr(plant, 'name', 'unknown')}"
return DEFAULT_IDEAL_SENSOR_PROFILE, "default"
def _current_metric_values(sensor: Any, forecasts: list[Any]) -> dict[str, float]:
current_forecast = forecasts[0] if forecasts else None
humidity = average(
[getattr(forecast, "humidity_mean", None) for forecast in forecasts[:3]],
default=safe_number(getattr(current_forecast, "humidity_mean", None), 0),
)
return {
"temperature": float(safe_number(getattr(sensor, "soil_temperature", None), 0)),
"moisture": float(safe_number(getattr(sensor, "soil_moisture", None), 0)),
"ph": float(safe_number(getattr(sensor, "soil_ph", None), 0)),
"ec": float(safe_number(getattr(sensor, "electrical_conductivity", None), 0)),
"humidity": float(safe_number(humidity, 0)),
}
def build_sensor_radar_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
sensor = context.get("sensor")
forecasts = context.get("forecasts", [])
if sensor is None:
return {"labels": [], "series": [], "profileSource": None, "profile": {}}
profile, profile_source = _resolve_profile(context)
current_values = _current_metric_values(sensor, forecasts)
labels: list[str] = []
current_series: list[int] = []
ideal_series: list[int] = []
metric_details: list[dict[str, Any]] = []
for metric_key, label in METRIC_ORDER:
metric_profile = profile.get(metric_key, DEFAULT_IDEAL_SENSOR_PROFILE.get(metric_key, {}))
minimum = float(metric_profile.get("min", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["min"]))
ideal = float(metric_profile.get("ideal", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["ideal"]))
maximum = float(metric_profile.get("max", DEFAULT_IDEAL_SENSOR_PROFILE[metric_key]["max"]))
current_value = current_values.get(metric_key)
labels.append(label)
current_score = _normalize_to_ideal_score(current_value, minimum, ideal, maximum)
current_series.append(current_score)
ideal_series.append(100)
metric_details.append(
{
"metricType": metric_key,
"label": label,
"currentValue": round(current_value, 2) if current_value is not None else None,
"idealValue": round(ideal, 2),
"minRange": round(minimum, 2),
"maxRange": round(maximum, 2),
"currentScore": current_score,
"idealScore": 100,
}
)
return {
"labels": labels,
"profileSource": profile_source,
"profile": profile,
"metricDetails": metric_details,
"series": [
{"name": "امروز", "data": current_series},
{"name": "پروفایل ایده‌آل", "data": ideal_series},
],
}
@@ -1,71 +0,0 @@
from dashboard_data.card_utils import compute_trend, latest_history_value, safe_number
def build_sensor_values_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
sensor = context.get("sensor")
history = context.get("history", [])
forecasts = context.get("forecasts", [])
if sensor is None:
return {"sensors": []}
current_weather = forecasts[0] if forecasts else None
sensors = [
{
"title": f"{round(safe_number(current_weather.temperature_mean if current_weather else None, 0))}°C",
"subtitle": "دمای هوا",
**compute_trend(
current_weather.temperature_mean if current_weather else 0,
latest_history_value(history, "soil_temperature", 0),
),
"unit": "°C",
},
{
"title": f"{round(safe_number(sensor.soil_temperature, 0))}°C",
"subtitle": "دمای خاک",
**compute_trend(sensor.soil_temperature, latest_history_value(history, "soil_temperature", 0)),
"unit": "°C",
},
{
"title": f"{round(safe_number(current_weather.humidity_mean if current_weather else None, 0))}%",
"subtitle": "رطوبت هوا",
**compute_trend(current_weather.humidity_mean if current_weather else 0, 0),
"unit": "%",
},
{
"title": f"{round(safe_number(sensor.soil_moisture, 0))}%",
"subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
**compute_trend(sensor.soil_moisture, latest_history_value(history, "soil_moisture", 0)),
"unit": "%",
},
{
"title": f"{safe_number(sensor.soil_ph, 0):.1f}",
"subtitle": "pH خاک",
**compute_trend(sensor.soil_ph, latest_history_value(history, "soil_ph", 0)),
"unit": "pH",
},
{
"title": f"{safe_number(sensor.electrical_conductivity, 0):.1f}",
"subtitle": "هدایت الکتریکی (dS/m)",
**compute_trend(
sensor.electrical_conductivity,
latest_history_value(history, "electrical_conductivity", 0),
),
"unit": "dS/m",
},
{
"title": "850",
"subtitle": "شدت نور (لوکس)",
"trendNumber": 0,
"trend": "positive",
"unit": "lux",
},
{
"title": f"{round(safe_number(current_weather.wind_speed_max if current_weather else None, 0))}",
"subtitle": "سرعت باد (کیلومتر/ساعت)",
**compute_trend(current_weather.wind_speed_max if current_weather else 0, 0),
"unit": "km/h",
},
]
return {"sensors": sensors}
@@ -1,195 +0,0 @@
from __future__ import annotations
from math import sqrt
from typing import Any
from farm_data.models import SensorData
QUALITY_REAL = "REAL"
QUALITY_INTERPOLATED = "INTERPOLATED"
QUALITY_MISSING = "MISSING"
INTERPOLATION_LIMIT = 3
IDW_POWER = 2
MAX_GRID_STEPS = 10
def _interpolate_series(points: list[dict[str, Any]], limit: int = INTERPOLATION_LIMIT) -> list[dict[str, Any]]:
output = [dict(point) for point in points]
known_indexes = [index for index, point in enumerate(output) if point["value"] is not None]
for start_index, end_index in zip(known_indexes, known_indexes[1:]):
gap = end_index - start_index - 1
if gap <= 0 or gap > limit:
continue
start_value = output[start_index]["value"]
end_value = output[end_index]["value"]
if start_value is None or end_value is None:
continue
step = (end_value - start_value) / (gap + 1)
for offset in range(1, gap + 1):
target_index = start_index + offset
output[target_index]["value"] = round(start_value + (step * offset), 2)
output[target_index]["quality_flag"] = QUALITY_INTERPOLATED
return output
def _sensor_time_series(sensor: Any, histories: list[Any]) -> list[dict[str, Any]]:
points = []
for item in reversed(histories):
points.append(
{
"timestamp": item.recorded_at.isoformat(),
"value": float(item.soil_moisture) if item.soil_moisture is not None else None,
"quality_flag": QUALITY_REAL if item.soil_moisture is not None else QUALITY_MISSING,
}
)
points.append(
{
"timestamp": sensor.updated_at.isoformat() if getattr(sensor, "updated_at", None) else None,
"value": float(sensor.soil_moisture) if getattr(sensor, "soil_moisture", None) is not None else None,
"quality_flag": QUALITY_REAL if getattr(sensor, "soil_moisture", None) is not None else QUALITY_MISSING,
}
)
return _interpolate_series(points)
def _latest_sensor_measurement(sensor: Any, histories: list[Any]) -> dict[str, Any]:
series = _sensor_time_series(sensor, histories)
latest = series[-1] if series else {"timestamp": None, "value": None, "quality_flag": QUALITY_MISSING}
return {
"sensor_id": str(sensor.farm_uuid),
"latitude": float(sensor.center_location.latitude),
"longitude": float(sensor.center_location.longitude),
"depth": None,
"timestamp": latest["timestamp"],
"soil_moisture_value": latest["value"],
"quality_flag": latest["quality_flag"],
}
def _idw_value(lat: float, lon: float, sensor_points: list[dict[str, Any]]) -> float | None:
weighted_sum = 0.0
weight_total = 0.0
for point in sensor_points:
value = point["soil_moisture_value"]
if value is None:
continue
distance = sqrt(((lat - point["latitude"]) ** 2) + ((lon - point["longitude"]) ** 2))
if distance == 0:
return round(float(value), 2)
weight = 1 / (distance**IDW_POWER)
weighted_sum += weight * float(value)
weight_total += weight
if weight_total == 0:
return None
return round(weighted_sum / weight_total, 2)
def _grid_axis(min_value: float, max_value: float) -> list[float]:
if min_value == max_value:
return [round(min_value, 6)]
step_count = min(MAX_GRID_STEPS, max(int((max_value - min_value) / 0.0001) + 1, 2))
step = (max_value - min_value) / (step_count - 1)
return [round(min_value + (step * index), 6) for index in range(step_count)]
def _load_sensor_network(current_sensor: Any) -> list[Any]:
plant_ids = list(current_sensor.plants.values_list("id", flat=True))
queryset = SensorData.objects.select_related("center_location").prefetch_related("plants")
if plant_ids:
queryset = queryset.filter(plants__id__in=plant_ids).distinct()
return list(queryset)
def build_soil_moisture_heatmap(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
current_sensor = context.get("sensor")
if current_sensor is None:
return {
"timestamp": None,
"grid_resolution": None,
"grid_cells": [],
"sensor_points": [],
"quality_legend": {},
}
sensors = _load_sensor_network(current_sensor)
sensor_points = [
_latest_sensor_measurement(sensor, [])
for sensor in sensors
]
valid_sensor_points = [point for point in sensor_points if point["soil_moisture_value"] is not None]
if not valid_sensor_points:
return {
"timestamp": current_sensor.updated_at.isoformat() if getattr(current_sensor, "updated_at", None) else None,
"grid_resolution": None,
"grid_cells": [],
"sensor_points": sensor_points,
"quality_legend": {
QUALITY_REAL: "اندازه‌گیری واقعی سنسور",
QUALITY_INTERPOLATED: "مقدار سنسور با درون‌یابی زمانی کوتاه‌مدت",
QUALITY_MISSING: "داده معتبر برای سنسور موجود نیست",
},
}
min_lat = min(point["latitude"] for point in valid_sensor_points)
max_lat = max(point["latitude"] for point in valid_sensor_points)
min_lon = min(point["longitude"] for point in valid_sensor_points)
max_lon = max(point["longitude"] for point in valid_sensor_points)
lat_axis = _grid_axis(min_lat, max_lat)
lon_axis = _grid_axis(min_lon, max_lon)
grid_cells = []
for lat in lat_axis:
for lon in lon_axis:
direct_sensor = next(
(
point for point in valid_sensor_points
if point["latitude"] == lat and point["longitude"] == lon
),
None,
)
if direct_sensor is not None:
moisture_value = direct_sensor["soil_moisture_value"]
quality_flag = direct_sensor["quality_flag"]
elif len(valid_sensor_points) >= 2:
moisture_value = _idw_value(lat, lon, valid_sensor_points)
quality_flag = QUALITY_INTERPOLATED if moisture_value is not None else QUALITY_MISSING
else:
moisture_value = None
quality_flag = QUALITY_MISSING
grid_cells.append(
{
"lat": lat,
"lon": lon,
"moisture_value": moisture_value,
"quality_flag": quality_flag,
}
)
lat_step = round(abs(lat_axis[1] - lat_axis[0]), 6) if len(lat_axis) > 1 else 0.0
lon_step = round(abs(lon_axis[1] - lon_axis[0]), 6) if len(lon_axis) > 1 else 0.0
return {
"timestamp": max(point["timestamp"] for point in sensor_points if point["timestamp"]),
"grid_resolution": {
"lat_step": lat_step,
"lon_step": lon_step,
"rows": len(lat_axis),
"cols": len(lon_axis),
},
"grid_cells": grid_cells,
"sensor_points": sensor_points,
"quality_legend": {
QUALITY_REAL: "اندازه‌گیری واقعی سنسور",
QUALITY_INTERPOLATED: "مقدار برآوردشده با درون‌یابی زمانی/فضایی",
QUALITY_MISSING: "داده معتبر در دسترس نیست",
},
}
@@ -1,38 +0,0 @@
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
def build_water_need_prediction(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
forecasts = context.get("forecasts", [])
location = context.get("location")
plants = context.get("plants", [])
irrigation_methods = context.get("irrigation_methods", [])
if not forecasts or location is None:
return {
"totalNext7Days": 0,
"unit": "mm",
"categories": [],
"series": [],
"dailyBreakdown": [],
}
plant = plants[0] if plants else None
crop_profile = resolve_crop_profile(plant)
efficiency = getattr(irrigation_methods[0], "water_efficiency_percent", None) if irrigation_methods else None
daily = calculate_forecast_water_needs(
forecasts=forecasts[:7],
latitude_deg=float(location.latitude),
crop_profile=crop_profile,
growth_stage=crop_profile.get("current_stage"),
irrigation_efficiency_percent=efficiency,
)
daily_requirements = [round(item["gross_irrigation_mm"], 2) for item in daily]
return {
"totalNext7Days": round(sum(daily_requirements), 2),
"unit": "mm",
"categories": [f"روز {index}" for index in range(1, len(daily_requirements) + 1)],
"series": [{"name": "نیاز آبی تعدیل‌شده", "data": daily_requirements}],
"dailyBreakdown": daily,
}
@@ -1,38 +0,0 @@
from datetime import date
from dashboard_data.card_utils import safe_number
def build_yield_prediction_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
sensor = (context or {}).get("sensor")
if sensor is None:
return {"categories": [], "series": [], "summary": []}
base = max(10, round(safe_number(sensor.soil_moisture, 0) * 0.6))
current_year = [base + offset for offset in [0, 2, 4, 6, 8, 10, 12, 11, 9, 7, 5, 4]]
last_year = [value - 3 for value in current_year]
harvest_month = "حدود " + str(date.today().month)
return {
"categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
"series": [
{"name": "امسال", "data": current_year},
{"name": "سال گذشته", "data": last_year},
],
"summary": [
{
"title": "عملکرد پیش‌بینی‌شده",
"subtitle": "این فصل",
"amount": f"{current_year[9]} تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "تاریخ برداشت",
"subtitle": harvest_month,
"amount": "+8%",
"avatarColor": "success",
"avatarIcon": "tabler-calendar",
},
],
}
-47
View File
@@ -1,47 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="DashboardAiRequestLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sensor_id", models.UUIDField(db_index=True)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
("status", models.CharField(default="pending", max_length=32)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
"ordering": ["-created_at"],
"verbose_name": "Dashboard AI Request Log",
"verbose_name_plural": "Dashboard AI Request Logs",
},
),
migrations.CreateModel(
name="DashboardCardSnapshot",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sensor_id", models.UUIDField(db_index=True)),
("card_name", models.CharField(db_index=True, max_length=128)),
("payload", models.JSONField(blank=True, default=dict)),
("generated_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("expires_at", models.DateTimeField(db_index=True)),
("source", models.CharField(default="computed", max_length=32)),
],
options={
"ordering": ["-generated_at"],
"verbose_name": "Dashboard Card Snapshot",
"verbose_name_plural": "Dashboard Card Snapshots",
"indexes": [
models.Index(fields=["sensor_id", "card_name", "-generated_at"], name="dashboard_d_sensor__c0a279_idx"),
],
},
),
]
@@ -1,36 +0,0 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("location_data", "0004_soillocation_farm_boundary"),
("dashboard_data", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="NdviObservation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("observation_date", models.DateField(db_index=True)),
("mean_ndvi", models.FloatField()),
("ndvi_map", models.JSONField(blank=True, default=dict)),
("vegetation_health_class", models.CharField(max_length=64)),
("satellite_source", models.CharField(default="sentinel-2", max_length=64)),
("cloud_cover", models.FloatField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("location", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="ndvi_observations", to="location_data.soillocation")),
],
options={
"ordering": ["-observation_date", "-created_at"],
"verbose_name": "NDVI Observation",
"verbose_name_plural": "NDVI Observations",
"constraints": [
models.UniqueConstraint(fields=("location", "observation_date", "satellite_source"), name="ndvi_unique_location_date_source"),
],
},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.15 on 2026-03-27 08:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard_data', '0002_ndvi_observation'),
]
operations = [
migrations.RenameIndex(
model_name='dashboardcardsnapshot',
new_name='dashboard_d_sensor__2620a3_idx',
old_name='dashboard_d_sensor__c0a279_idx',
),
]
-67
View File
@@ -1,67 +0,0 @@
from django.db import models
class DashboardCardSnapshot(models.Model):
sensor_id = models.UUIDField(db_index=True)
card_name = models.CharField(max_length=128, db_index=True)
payload = models.JSONField(default=dict, blank=True)
generated_at = models.DateTimeField(auto_now_add=True, db_index=True)
expires_at = models.DateTimeField(db_index=True)
source = models.CharField(max_length=32, default="computed")
class Meta:
ordering = ["-generated_at"]
indexes = [
models.Index(fields=["sensor_id", "card_name", "-generated_at"]),
]
verbose_name = "Dashboard Card Snapshot"
verbose_name_plural = "Dashboard Card Snapshots"
def __str__(self):
return f"{self.card_name} - {self.sensor_id} - {self.generated_at}"
class DashboardAiRequestLog(models.Model):
sensor_id = models.UUIDField(db_index=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=32, default="pending")
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "Dashboard AI Request Log"
verbose_name_plural = "Dashboard AI Request Logs"
def __str__(self):
return f"{self.sensor_id} - {self.status} - {self.created_at}"
class NdviObservation(models.Model):
location = models.ForeignKey(
"location_data.SoilLocation",
on_delete=models.CASCADE,
related_name="ndvi_observations",
)
observation_date = models.DateField(db_index=True)
mean_ndvi = models.FloatField()
ndvi_map = models.JSONField(default=dict, blank=True)
vegetation_health_class = models.CharField(max_length=64)
satellite_source = models.CharField(max_length=64, default="sentinel-2")
cloud_cover = models.FloatField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-observation_date", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=["location", "observation_date", "satellite_source"],
name="ndvi_unique_location_date_source",
)
]
verbose_name = "NDVI Observation"
verbose_name_plural = "NDVI Observations"
def __str__(self):
return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}"
-172
View File
@@ -1,172 +0,0 @@
from django.utils import timezone
from .ai_bundle import request_dashboard_ai_bundle
from .card_utils import is_fresh, ttl_for_card
from .cards.anomaly_detection_card import build_anomaly_detection_card
from .cards.economic_overview import build_economic_overview
from .cards.farm_alerts_timeline import build_farm_alerts_timeline
from .cards.farm_alerts_tracker import build_farm_alerts_tracker
from .cards.farm_overview_kpis import build_farm_overview_kpis
from .cards.farm_weather_card import build_farm_weather_card
from .cards.harvest_prediction_card import build_harvest_prediction_card
from .cards.ndvi_health_card import build_ndvi_health_card
from .cards.recommendations_list import build_recommendations_list
from .cards.sensor_comparison_chart import build_sensor_comparison_chart
from .cards.sensor_radar_chart import build_sensor_radar_chart
from .cards.sensor_values_list import build_sensor_values_list
from .cards.soil_moisture_heatmap import build_soil_moisture_heatmap
from .cards.water_need_prediction import build_water_need_prediction
from .cards.yield_prediction_chart import build_yield_prediction_chart
from .context import load_dashboard_context
from .models import DashboardCardSnapshot
CARD_BUILDERS = {
"farmOverviewKpis": build_farm_overview_kpis,
"farmWeatherCard": build_farm_weather_card,
"farmAlertsTracker": build_farm_alerts_tracker,
"sensorValuesList": build_sensor_values_list,
"sensorRadarChart": build_sensor_radar_chart,
"sensorComparisonChart": build_sensor_comparison_chart,
"anomalyDetectionCard": build_anomaly_detection_card,
"farmAlertsTimeline": build_farm_alerts_timeline,
"waterNeedPrediction": build_water_need_prediction,
"harvestPredictionCard": build_harvest_prediction_card,
"yieldPredictionChart": build_yield_prediction_chart,
"soilMoistureHeatmap": build_soil_moisture_heatmap,
"ndviHealthCard": build_ndvi_health_card,
"recommendationsList": build_recommendations_list,
"economicOverview": build_economic_overview,
}
AI_DRIVEN_CARDS = {
"farmAlertsTimeline",
"recommendationsList",
}
def _farm_profile_from_context(context: dict) -> dict:
sensor = context.get("sensor")
location = context.get("location")
plants = context.get("plants", [])
irrigation_methods = context.get("irrigation_methods", [])
return {
"sensor_id": str(getattr(sensor, "farm_uuid", "")) if sensor else "",
"crop_type": getattr(plants[0], "name", None) if plants else None,
"region": {
"latitude": float(location.latitude) if location else None,
"longitude": float(location.longitude) if location else None,
},
"season": timezone.now().date().isoformat(),
"farming_method": getattr(irrigation_methods[0], "name", None) if irrigation_methods else None,
}
def _sensor_trends_payload(sensor_id: str, context: dict) -> dict:
chart = build_sensor_comparison_chart(sensor_id=sensor_id, context=context, ai_bundle=None)
return {
"current_value": chart.get("currentValue"),
"current_value_quality": chart.get("currentValueQuality"),
"vs_last_week": chart.get("vsLastWeekValue"),
"series": chart.get("series", []),
}
def _alerts_payload(sensor_id: str, context: dict) -> dict:
tracker = build_farm_alerts_tracker(sensor_id=sensor_id, context=context, ai_bundle=None)
return {
"total_alerts": tracker.get("totalAlerts", 0),
"alerts": tracker.get("alerts", []),
"most_critical_issue": tracker.get("mostCriticalIssue"),
"clusters": tracker.get("alertClusters", []),
}
def _anomalies_payload(sensor_id: str, context: dict) -> dict:
anomaly_card = build_anomaly_detection_card(sensor_id=sensor_id, context=context, ai_bundle=None)
return {
"anomalies": anomaly_card.get("anomalies", []),
"interpretation_seed": anomaly_card.get("interpretation"),
}
def _build_ai_payload_request(sensor_id: str, context: dict) -> dict:
structured_context = {
"farm_profile": _farm_profile_from_context(context),
"detected_alerts": _alerts_payload(sensor_id, context),
"anomaly_events": _anomalies_payload(sensor_id, context),
"sensor_trends": _sensor_trends_payload(sensor_id, context),
"timestamps": {
"generated_at": timezone.now().isoformat(),
},
}
system_prompt = (
"You are an agricultural decision-support assistant. "
"Use only the structured data provided. "
"Do not hallucinate sensor values, timestamps, severities, or agronomic events. "
"Generate concise, actionable outputs.\n\n"
"For the timeline, explain what happened, when it happened, and why it matters.\n"
"For recommendations, prioritize by alert severity, time proximity, and potential crop impact.\n"
"Return recommendation objects with: recommendation_title, explanation, suggested_action, urgency_level, related_alert_id (optional)."
)
return {
"sensor_id": sensor_id,
"cards": sorted(AI_DRIVEN_CARDS),
"system_prompt": system_prompt,
"structured_context": structured_context,
}
def build_dashboard_payload(sensor_id: str) -> dict:
context = load_dashboard_context(sensor_id)
if context is None:
return {}
ai_payload_request = _build_ai_payload_request(sensor_id, context)
ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request)
return {
card_name: builder(sensor_id=sensor_id, context=context, ai_bundle=ai_bundle)
for card_name, builder in CARD_BUILDERS.items()
}
def get_or_build_card(sensor_id: str, card_name: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
latest_snapshot = (
DashboardCardSnapshot.objects.filter(sensor_id=sensor_id, card_name=card_name)
.order_by("-generated_at")
.first()
)
if is_fresh(latest_snapshot):
return latest_snapshot.payload
payload = CARD_BUILDERS[card_name](sensor_id=sensor_id, context=context, ai_bundle=ai_bundle)
DashboardCardSnapshot.objects.create(
sensor_id=sensor_id,
card_name=card_name,
payload=payload,
expires_at=timezone.now() + ttl_for_card(card_name),
source="ai" if card_name in AI_DRIVEN_CARDS else "computed",
)
return payload
def build_dashboard_payload_with_cache(sensor_id: str) -> dict:
context = load_dashboard_context(sensor_id)
if context is None:
return {}
ai_payload_request = _build_ai_payload_request(sensor_id, context)
ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request)
payload = {}
for card_name in CARD_BUILDERS:
payload[card_name] = get_or_build_card(
sensor_id=sensor_id,
card_name=card_name,
context=context,
ai_bundle=ai_bundle,
)
return payload
-36
View File
@@ -1,36 +0,0 @@
from config.celery import app
from .services import CARD_BUILDERS, build_dashboard_payload_with_cache
@app.task(bind=True)
def generate_dashboard_data_task(self, sensor_id: str) -> dict:
total_cards = len(CARD_BUILDERS)
self.update_state(
state="PROGRESS",
meta={
"current": 0,
"total": total_cards,
"card": None,
"message": "loading sensor context",
},
)
payload = {}
dashboard_payload = build_dashboard_payload_with_cache(sensor_id)
for index, card_name in enumerate(CARD_BUILDERS.keys(), start=1):
self.update_state(
state="PROGRESS",
meta={
"current": index,
"total": total_cards,
"card": card_name,
"message": f"processing {card_name}",
},
)
payload[card_name] = dashboard_payload.get(card_name, {})
return {
"sensor_id": sensor_id,
"all_cards": payload,
}
-9
View File
@@ -1,9 +0,0 @@
from django.urls import path
from .views import DashboardDataGenerateView, DashboardDataStatusView
urlpatterns = [
path("generate/", DashboardDataGenerateView.as_view(), name="dashboard-data-generate"),
path("<str:task_id>/status/", DashboardDataStatusView.as_view(), name="dashboard-data-status"),
]
-115
View File
@@ -1,115 +0,0 @@
from uuid import UUID
from celery.result import AsyncResult
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from rest_framework import serializers as drf_serializers
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import (
build_envelope_serializer,
build_response,
build_task_queue_data_serializer,
build_task_status_data_serializer,
)
from .tasks import generate_dashboard_data_task
DashboardDataGenerateDataSerializer = build_task_queue_data_serializer(
"DashboardDataGenerateDataSerializer"
)
DashboardDataGenerateResponseSerializer = build_envelope_serializer(
"DashboardDataGenerateResponseSerializer",
DashboardDataGenerateDataSerializer,
)
DashboardDataErrorResponseSerializer = build_envelope_serializer(
"DashboardDataErrorResponseSerializer",
data_required=False,
allow_null=True,
)
DashboardDataStatusResponseSerializer = build_envelope_serializer(
"DashboardDataStatusResponseSerializer",
build_task_status_data_serializer("DashboardDataStatusDataSerializer"),
)
class DashboardDataGenerateView(APIView):
@extend_schema(
tags=["Dashboard Data"],
summary="Generate dashboard data",
request=inline_serializer(
name="DashboardDataGenerateRequest",
fields={
"sensor_id": drf_serializers.UUIDField(required=False),
"snesor_id": drf_serializers.UUIDField(required=False),
},
),
responses={
202: build_response(
DashboardDataGenerateResponseSerializer,
"تسک ساخت داده داشبورد در صف قرار گرفت.",
),
400: build_response(
DashboardDataErrorResponseSerializer,
"پارامتر ورودی نامعتبر است.",
),
},
)
def post(self, request):
sensor_id = request.data.get("sensor_id") or request.data.get("snesor_id")
if not sensor_id:
return Response(
{"code": 400, "msg": "پارامتر sensor_id الزامی است.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
try:
sensor_id = str(UUID(str(sensor_id)))
except (TypeError, ValueError):
return Response(
{"code": 400, "msg": "sensor_id باید UUID معتبر باشد.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
task = generate_dashboard_data_task.delay(sensor_id)
return Response(
{
"code": 202,
"msg": "dashboard task queued",
"data": {
"task_id": task.id,
"status_url": f"/api/dashboard-data/{task.id}/status/",
},
},
status=status.HTTP_202_ACCEPTED,
)
class DashboardDataStatusView(APIView):
@extend_schema(
tags=["Dashboard Data"],
summary="Dashboard task status",
responses={
200: build_response(
DashboardDataStatusResponseSerializer,
"وضعیت فعلی تسک داده داشبورد.",
),
},
)
def get(self, request, task_id):
result = AsyncResult(task_id)
data = {"task_id": task_id, "status": result.state}
if result.state == "PENDING":
data["message"] = "تسک در صف یا یافت نشد."
elif result.state == "PROGRESS":
data["progress"] = result.info
elif result.state == "SUCCESS":
data["result"] = result.result
elif result.state == "FAILURE":
data["error"] = str(result.result)
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
+18
View File
@@ -0,0 +1,18 @@
from functools import cached_property
from django.apps import AppConfig
class EconomyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "economy"
verbose_name = "Economy"
@cached_property
def economic_overview_service(self):
from .services import EconomicOverviewService
return EconomicOverviewService()
def get_economic_overview_service(self):
return self.economic_overview_service
+26
View File
@@ -0,0 +1,26 @@
from rest_framework import serializers
class EconomicOverviewRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
class EconomicDataItemSerializer(serializers.Serializer):
title = serializers.CharField()
value = serializers.CharField()
subtitle = serializers.CharField()
avatarIcon = serializers.CharField()
avatarColor = serializers.CharField()
class EconomicChartSeriesSerializer(serializers.Serializer):
name = serializers.CharField()
data = serializers.ListField(child=serializers.FloatField())
class EconomicOverviewResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
source = serializers.CharField()
economicData = EconomicDataItemSerializer(many=True)
chartSeries = EconomicChartSeriesSerializer(many=True)
chartCategories = serializers.ListField(child=serializers.CharField())
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
class EconomicOverviewService:
def get_economic_overview(self, *, farm_uuid: str) -> dict[str, Any]:
return {
"farm_uuid": farm_uuid,
"source": "mock",
"economicData": [
{
"title": "هزینه آب",
"value": "$420",
"subtitle": "این ماه",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "صرفه جویی هوشمند",
"value": "$88",
"subtitle": "برآورد ماهانه",
"avatarIcon": "tabler-bulb",
"avatarColor": "success",
},
{
"title": "پیش بینی درآمد",
"value": "$3.8k",
"subtitle": "این فصل",
"avatarIcon": "tabler-chart-line",
"avatarColor": "info",
},
{
"title": "هزینه کود",
"value": "$190",
"subtitle": "برآورد فعلی",
"avatarIcon": "tabler-flask",
"avatarColor": "warning",
},
],
"chartSeries": [
{"name": "هزینه آب", "data": [320, 340, 360, 390, 405, 420]},
{"name": "هزینه کود", "data": [150, 155, 160, 170, 180, 190]},
{"name": "درآمد", "data": [2200, 2400, 2650, 3000, 3400, 3800]},
],
"chartCategories": ["فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور"],
}
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="economy.urls")
class EconomicOverviewApiTests(TestCase):
def setUp(self):
self.client = APIClient()
def test_economic_overview_api_returns_mock_payload(self):
response = self.client.post(
"/overview/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["source"], "mock")
self.assertEqual(payload["economicData"][0]["title"], "هزینه آب")
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import EconomicOverviewView
urlpatterns = [
path("overview/", EconomicOverviewView.as_view(), name="economic-overview"),
]
+66
View File
@@ -0,0 +1,66 @@
from django.apps import apps
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response
from .serializers import (
EconomicOverviewRequestSerializer,
EconomicOverviewResponseSerializer,
)
EconomicOverviewEnvelopeSerializer = build_envelope_serializer(
"EconomicOverviewEnvelopeSerializer",
EconomicOverviewResponseSerializer,
)
EconomyErrorSerializer = build_envelope_serializer(
"EconomyErrorSerializer",
data_required=False,
allow_null=True,
)
class EconomicOverviewView(APIView):
@extend_schema(
tags=["Economy"],
summary="دریافت نمای اقتصادی مزرعه",
description="با دریافت farm_uuid، نمای اقتصادی مزرعه را فعلا با داده mock برمی گرداند.",
request=EconomicOverviewRequestSerializer,
responses={
200: build_response(
EconomicOverviewEnvelopeSerializer,
"نمای اقتصادی مزرعه با موفقیت بازگردانده شد.",
),
400: build_response(
EconomyErrorSerializer,
"داده ورودی نامعتبر است.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست economy",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
request_only=True,
)
],
)
def post(self, request):
serializer = EconomicOverviewRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("economy").get_economic_overview_service()
data = service.get_economic_overview(
farm_uuid=str(serializer.validated_data["farm_uuid"])
)
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
View File
@@ -6,7 +6,9 @@ from typing import Any
from django.utils import timezone from django.utils import timezone
from dashboard_data.card_utils import safe_number
def safe_number(value, default=0):
return default if value is None else value
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class FarmAlertsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "farm_alerts"
verbose_name = "Farm Alerts"
+38
View File
@@ -0,0 +1,38 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="FarmAlertNotification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("farm_uuid", models.UUIDField(db_index=True)),
("endpoint", models.CharField(choices=[("tracker", "Tracker"), ("timeline", "Timeline")], db_index=True, max_length=32)),
("level", models.CharField(choices=[("info", "اطلاع رسانی"), ("warning", "هشدار"), ("danger", "خطر")], db_index=True, max_length=16)),
("title", models.CharField(max_length=255)),
("message", models.TextField(blank=True)),
("suggested_action", models.TextField(blank=True)),
("source_alert_id", models.CharField(blank=True, db_index=True, max_length=255)),
("source_metric_type", models.CharField(blank=True, db_index=True, max_length=64)),
("fingerprint", models.CharField(max_length=64, unique=True)),
("payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "farm_alerts_notification",
"ordering": ["-updated_at", "-created_at"],
"verbose_name": "Farm Alert Notification",
"verbose_name_plural": "Farm Alert Notifications",
"indexes": [
models.Index(fields=["farm_uuid", "endpoint", "-updated_at"], name="farm_alerts_farm_ep_updated_idx"),
models.Index(fields=["farm_uuid", "level", "-updated_at"], name="farm_alerts_farm_level_updated_idx"),
],
},
),
]
View File
+45
View File
@@ -0,0 +1,45 @@
from django.db import models
class FarmAlertNotification(models.Model):
LEVEL_INFO = "info"
LEVEL_WARNING = "warning"
LEVEL_DANGER = "danger"
LEVEL_CHOICES = [
(LEVEL_INFO, "اطلاع رسانی"),
(LEVEL_WARNING, "هشدار"),
(LEVEL_DANGER, "خطر"),
]
ENDPOINT_TRACKER = "tracker"
ENDPOINT_TIMELINE = "timeline"
ENDPOINT_CHOICES = [
(ENDPOINT_TRACKER, "Tracker"),
(ENDPOINT_TIMELINE, "Timeline"),
]
farm_uuid = models.UUIDField(db_index=True)
endpoint = models.CharField(max_length=32, choices=ENDPOINT_CHOICES, db_index=True)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES, db_index=True)
title = models.CharField(max_length=255)
message = models.TextField(blank=True)
suggested_action = models.TextField(blank=True)
source_alert_id = models.CharField(max_length=255, blank=True, db_index=True)
source_metric_type = models.CharField(max_length=64, blank=True, db_index=True)
fingerprint = models.CharField(max_length=64, unique=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_alerts_notification"
ordering = ["-updated_at", "-created_at"]
indexes = [
models.Index(fields=["farm_uuid", "endpoint", "-updated_at"]),
models.Index(fields=["farm_uuid", "level", "-updated_at"]),
]
verbose_name = "Farm Alert Notification"
verbose_name_plural = "Farm Alert Notifications"
def __str__(self):
return f"{self.farm_uuid} - {self.endpoint} - {self.level} - {self.title}"
+36
View File
@@ -0,0 +1,36 @@
from rest_framework import serializers
from .models import FarmAlertNotification
class FarmAlertsRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class FarmAlertNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = FarmAlertNotification
fields = [
"id",
"farm_uuid",
"endpoint",
"level",
"title",
"message",
"suggested_action",
"source_alert_id",
"source_metric_type",
"payload",
"created_at",
"updated_at",
]
read_only_fields = fields
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any
from django.apps import apps
from django.core.serializers.json import DjangoJSONEncoder
from farm_data.services import get_farm_details
from farm_data.context import load_farm_context
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from .models import FarmAlertNotification
from .alerts_tracker import build_farm_alerts_tracker
logger = logging.getLogger(__name__)
KB_NAME = "farm_alerts"
SERVICE_ID = "farm_alerts"
TRACKER_PROMPT = (
"وضعیت هشدارهای مزرعه را فقط بر اساس داده های ساختاریافته، اطلاعات مزرعه، و متون بازیابی شده از پایگاه دانش تحلیل کن. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, status_level, notifications. "
"status_level فقط یکی از danger, warning, info باشد. "
"notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد. "
"سطوح level فقط یکی از danger, warning, info باشند. "
"اگر هشدار مهمی وجود ندارد، notifications را خالی برگردان."
)
TIMELINE_PROMPT = (
"بر اساس داده های هشدار مزرعه، یک timeline عملیاتی بساز. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, timeline, notifications. "
"timeline باید آرایه ای از آبجکت ها با کلیدهای timestamp, level, title, description, source_alert_id, source_metric_type باشد. "
"level فقط danger, warning, info باشد. "
"notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد."
)
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2, cls=DjangoJSONEncoder)
def _clean_json_response(raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
return {}
try:
return json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
logger.warning("Invalid JSON returned by farm_alerts LLM: %s", cleaned[:500])
return {}
def _severity_to_level(severity: str) -> str:
normalized = (severity or "").strip().lower()
if normalized in {"critical", "high", "danger"}:
return FarmAlertNotification.LEVEL_DANGER
if normalized in {"medium", "warning"}:
return FarmAlertNotification.LEVEL_WARNING
return FarmAlertNotification.LEVEL_INFO
def _normalize_level(level: str | None) -> str:
normalized = (level or "").strip().lower()
if normalized in {
FarmAlertNotification.LEVEL_DANGER,
FarmAlertNotification.LEVEL_WARNING,
FarmAlertNotification.LEVEL_INFO,
}:
return normalized
if normalized in {"high", "critical"}:
return FarmAlertNotification.LEVEL_DANGER
if normalized in {"medium", "alert"}:
return FarmAlertNotification.LEVEL_WARNING
return FarmAlertNotification.LEVEL_INFO
def _alert_identifier(alert: dict[str, Any]) -> str:
metric_type = alert.get("metric_type", "alert")
timestamp = alert.get("timestamp", "")
return f"{metric_type}:{timestamp}"
def _forecast_summary(context: dict[str, Any]) -> list[dict[str, Any]]:
forecasts = context.get("forecasts", [])
return [
{
"date": getattr(item, "forecast_date", None),
"temperature_min": getattr(item, "temperature_min", None),
"temperature_max": getattr(item, "temperature_max", None),
"humidity_mean": getattr(item, "humidity_mean", None),
"precipitation": getattr(item, "precipitation", None),
"et0": getattr(item, "et0", None),
}
for item in forecasts[:7]
]
def _farm_profile(context: dict[str, Any], farm_uuid: str) -> dict[str, Any]:
sensor = context.get("sensor")
location = context.get("location")
plants = context.get("plants", [])
irrigation_method = getattr(sensor, "irrigation_method", None) if sensor else None
return {
"farm_uuid": farm_uuid,
"location": {
"latitude": float(location.latitude) if location else None,
"longitude": float(location.longitude) if location else None,
},
"plant_names": [getattr(plant, "name", "") for plant in plants],
"irrigation_method": getattr(irrigation_method, "name", None),
"last_sensor_update": getattr(sensor, "updated_at", None),
}
def _build_structured_context(farm_uuid: str) -> tuple[dict[str, Any], dict[str, Any]]:
context = load_farm_context(farm_uuid)
if context is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None)
structured = {
"farm_profile": _farm_profile(context, farm_uuid),
"tracker": tracker,
"forecasts": _forecast_summary(context),
}
return context, structured
def _build_fallback_notifications(tracker: dict[str, Any], endpoint: str) -> list[dict[str, Any]]:
notifications: list[dict[str, Any]] = []
for alert in tracker.get("alerts", [])[:5]:
notifications.append(
{
"level": _severity_to_level(alert.get("severity")),
"title": alert.get("title") or "هشدار مزرعه",
"message": alert.get("summary") or alert.get("explanation") or "",
"suggested_action": alert.get("recommended_action") or "",
"source_alert_id": _alert_identifier(alert),
"source_metric_type": alert.get("metric_type") or "",
"payload": {
"endpoint": endpoint,
"alert": alert,
},
}
)
return notifications
def _build_fallback_tracker_response(tracker: dict[str, Any]) -> dict[str, Any]:
top_alert = tracker.get("mostCriticalIssue") or {}
status_level = _severity_to_level(top_alert.get("severity")) if top_alert else FarmAlertNotification.LEVEL_INFO
if tracker.get("totalAlerts", 0) <= 0:
overview = "در حال حاضر هشدار فعالی برای مزرعه شناسایی نشده است."
else:
overview = top_alert.get("summary") or "چند هشدار فعال برای مزرعه شناسایی شده است."
return {
"headline": "ارزیابی فعلی هشدارهای مزرعه",
"overview": overview,
"status_level": status_level,
"notifications": _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TRACKER),
}
def _build_fallback_timeline_response(tracker: dict[str, Any]) -> dict[str, Any]:
timeline = []
for alert in tracker.get("alerts", [])[:6]:
timeline.append(
{
"timestamp": alert.get("timestamp"),
"level": _severity_to_level(alert.get("severity")),
"title": alert.get("title") or "رویداد هشدار",
"description": alert.get("explanation") or alert.get("summary") or "",
"source_alert_id": _alert_identifier(alert),
"source_metric_type": alert.get("metric_type") or "",
}
)
return {
"headline": "خط زمانی هشدارهای مزرعه",
"overview": "timeline بر اساس هشدارهای محاسبه شده مزرعه ساخته شد.",
"timeline": timeline,
"notifications": _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TIMELINE),
}
def _notification_fingerprint(
*,
farm_uuid: str,
endpoint: str,
level: str,
title: str,
source_alert_id: str,
source_metric_type: str,
) -> str:
raw = "|".join([
str(farm_uuid),
endpoint,
level,
source_alert_id or "-",
source_metric_type or "-",
title.strip(),
])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _save_notifications(
*,
farm_uuid: str,
endpoint: str,
notifications: list[dict[str, Any]],
) -> list[FarmAlertNotification]:
saved: list[FarmAlertNotification] = []
for item in notifications:
level = _normalize_level(item.get("level"))
title = (item.get("title") or "هشدار مزرعه").strip()
source_alert_id = (item.get("source_alert_id") or "").strip()
source_metric_type = (item.get("source_metric_type") or "").strip()
fingerprint = _notification_fingerprint(
farm_uuid=farm_uuid,
endpoint=endpoint,
level=level,
title=title,
source_alert_id=source_alert_id,
source_metric_type=source_metric_type,
)
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
notification, _ = FarmAlertNotification.objects.update_or_create(
fingerprint=fingerprint,
defaults={
"farm_uuid": farm_uuid,
"endpoint": endpoint,
"level": level,
"title": title,
"message": item.get("message") or "",
"suggested_action": item.get("suggested_action") or "",
"source_alert_id": source_alert_id,
"source_metric_type": source_metric_type,
"payload": payload,
},
)
saved.append(notification)
return saved
def _serialize_notification(notification: FarmAlertNotification) -> dict[str, Any]:
return {
"id": notification.id,
"farm_uuid": str(notification.farm_uuid),
"endpoint": notification.endpoint,
"level": notification.level,
"title": notification.title,
"message": notification.message,
"suggested_action": notification.suggested_action,
"source_alert_id": notification.source_alert_id,
"source_metric_type": notification.source_metric_type,
"payload": notification.payload,
"created_at": notification.created_at.isoformat(),
"updated_at": notification.updated_at.isoformat(),
}
def _build_service_config(cfg: RAGConfig, service_id: str) -> tuple[Any, Any, str, Any]:
service = get_service_config(service_id, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service.llm.model
return service, service_cfg, model, client
def _build_messages(
*,
prompt: str,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
structured_context: dict[str, Any],
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(prompt)
system_parts.append("[کانتکست ساختاریافته هشدار مزرعه]\n" + _json_dumps(structured_context))
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def _llm_response(
*,
farm_uuid: str,
service_id: str,
prompt: str,
query: str,
structured_context: dict[str, Any],
) -> tuple[dict[str, Any], str, str]:
cfg = load_rag_config()
service, service_cfg, model, client = _build_service_config(cfg, service_id)
farm_details = get_farm_details(farm_uuid)
rag_context = build_rag_context(
query=query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=service_id,
farm_details=farm_details,
)
system_prompt, messages = _build_messages(
prompt=prompt,
service=service,
cfg=cfg,
query=query,
rag_context=rag_context,
structured_context=structured_context,
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
query=query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
raw = response.choices[0].message.content.strip()
parsed = _clean_json_response(raw)
_complete_audit_log(audit_log, raw)
return parsed, raw, service.tone_file or ""
except Exception as exc:
logger.error("farm_alerts llm error for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
return {}, "", service.tone_file or ""
def get_farm_alerts_tracker(*, farm_uuid: str, query: str | None = None) -> dict[str, Any]:
_, structured_context = _build_structured_context(farm_uuid)
tracker = structured_context["tracker"]
user_query = query or "وضعیت فعلی هشدارهای مزرعه را ارزیابی کن و اگر لازم است notification بساز."
llm_result, raw_response, tone_file = _llm_response(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
prompt=TRACKER_PROMPT,
query=user_query,
structured_context=structured_context,
)
if not llm_result:
llm_result = _build_fallback_tracker_response(tracker)
notifications_input = llm_result.get("notifications")
if not isinstance(notifications_input, list):
notifications_input = _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TRACKER)
saved_notifications = _save_notifications(
farm_uuid=farm_uuid,
endpoint=FarmAlertNotification.ENDPOINT_TRACKER,
notifications=notifications_input,
)
return {
"farm_uuid": farm_uuid,
"service_id": SERVICE_ID,
"knowledge_base": KB_NAME,
"tone_file": tone_file,
"tracker": tracker,
"headline": llm_result.get("headline") or "ارزیابی فعلی هشدارهای مزرعه",
"overview": llm_result.get("overview") or "",
"status_level": _normalize_level(llm_result.get("status_level")),
"notifications": [_serialize_notification(item) for item in saved_notifications],
"raw_llm_response": raw_response or None,
"structured_context": structured_context,
}
def get_farm_alerts_timeline(*, farm_uuid: str, query: str | None = None) -> dict[str, Any]:
_, structured_context = _build_structured_context(farm_uuid)
tracker = structured_context["tracker"]
user_query = query or "برای هشدارهای مزرعه یک timeline عملیاتی بساز و اگر لازم است notification ثبت کن."
llm_result, raw_response, tone_file = _llm_response(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
prompt=TIMELINE_PROMPT,
query=user_query,
structured_context=structured_context,
)
if not llm_result:
llm_result = _build_fallback_timeline_response(tracker)
timeline = llm_result.get("timeline")
if not isinstance(timeline, list):
timeline = _build_fallback_timeline_response(tracker).get("timeline", [])
notifications_input = llm_result.get("notifications")
if not isinstance(notifications_input, list):
notifications_input = _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TIMELINE)
saved_notifications = _save_notifications(
farm_uuid=farm_uuid,
endpoint=FarmAlertNotification.ENDPOINT_TIMELINE,
notifications=notifications_input,
)
return {
"farm_uuid": farm_uuid,
"service_id": SERVICE_ID,
"knowledge_base": KB_NAME,
"tone_file": tone_file,
"tracker": tracker,
"headline": llm_result.get("headline") or "خط زمانی هشدارهای مزرعه",
"overview": llm_result.get("overview") or "",
"timeline": timeline,
"notifications": [_serialize_notification(item) for item in saved_notifications],
"raw_llm_response": raw_response or None,
"structured_context": structured_context,
}
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import FarmAlertsTimelineView, FarmAlertsTrackerView
urlpatterns = [
path("tracker/", FarmAlertsTrackerView.as_view(), name="farm-alerts-tracker"),
path("timeline/", FarmAlertsTimelineView.as_view(), name="farm-alerts-timeline"),
]
+114
View File
@@ -0,0 +1,114 @@
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response
from .serializers import FarmAlertsRequestSerializer
from .services import get_farm_alerts_timeline, get_farm_alerts_tracker
FarmAlertsValidationErrorSerializer = build_envelope_serializer(
"FarmAlertsValidationErrorSerializer",
data_required=False,
allow_null=True,
)
FarmAlertsTrackerResponseSerializer = build_envelope_serializer(
"FarmAlertsTrackerResponseSerializer",
data_schema=None,
)
FarmAlertsTimelineResponseSerializer = build_envelope_serializer(
"FarmAlertsTimelineResponseSerializer",
data_schema=None,
)
class FarmAlertsTrackerView(APIView):
@extend_schema(
tags=["Farm Alerts"],
summary="ارزیابی tracker هشدارهای مزرعه",
description=(
"با دریافت farm_uuid، هشدارهای مزرعه را تحلیل می کند، "
"کانتکست مزرعه را به RAG می فرستد، و notificationهای سطح خطر/هشدار/اطلاع رسانی را در دیتابیس ذخیره می کند."
),
request=FarmAlertsRequestSerializer,
responses={
200: build_response(FarmAlertsTrackerResponseSerializer, "خروجی tracker هشدارهای مزرعه."),
400: build_response(FarmAlertsValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(FarmAlertsValidationErrorSerializer, "خطا در تولید خروجی tracker هشدارها."),
},
examples=[
OpenApiExample(
"نمونه درخواست tracker",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
},
request_only=True,
),
],
)
def post(self, request):
serializer = FarmAlertsRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_farm_alerts_tracker(
farm_uuid=validated["farm_uuid"],
query=validated.get("query"),
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در تولید tracker هشدارها: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
class FarmAlertsTimelineView(APIView):
@extend_schema(
tags=["Farm Alerts"],
summary="دریافت timeline هشدارهای مزرعه",
description=(
"با دریافت farm_uuid، timeline هشدارهای مزرعه را با کمک RAG می سازد "
"و notificationهای استخراج شده را در دیتابیس ذخیره می کند."
),
request=FarmAlertsRequestSerializer,
responses={
200: build_response(FarmAlertsTimelineResponseSerializer, "خروجی timeline هشدارهای مزرعه."),
400: build_response(FarmAlertsValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(FarmAlertsValidationErrorSerializer, "خطا در تولید timeline هشدارها."),
},
examples=[
OpenApiExample(
"نمونه درخواست timeline",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
},
request_only=True,
),
],
)
def post(self, request):
serializer = FarmAlertsRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_farm_alerts_timeline(
farm_uuid=validated["farm_uuid"],
query=validated.get("query"),
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در تولید timeline هشدارها: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
@@ -1,7 +1,7 @@
from datetime import date from datetime import date
def load_dashboard_context(sensor_id: str) -> dict | None: def load_farm_context(sensor_id: str) -> dict | None:
from irrigation.models import IrrigationMethod from irrigation.models import IrrigationMethod
from location_data.models import SoilDepthData from location_data.models import SoilDepthData
from farm_data.models import SensorData from farm_data.models import SensorData
@@ -15,12 +15,9 @@ def load_dashboard_context(sensor_id: str) -> dict | None:
return None return None
location = sensor.center_location location = sensor.center_location
depths = list( depths = list(SoilDepthData.objects.filter(soil_location=location).order_by("depth_label"))
SoilDepthData.objects.filter(soil_location=location).order_by("depth_label")
)
forecasts = list( forecasts = list(
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()) WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7]
.order_by("forecast_date")[:7]
) )
plants = list(sensor.plants.all()) plants = list(sensor.plants.all())
irrigation_methods = list(IrrigationMethod.objects.all()[:5]) irrigation_methods = list(IrrigationMethod.objects.all()[:5])
+114 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from numbers import Number
from django.db import transaction from django.db import transaction
@@ -49,13 +50,13 @@ def get_farm_details(farm_uuid: str):
depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99) depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99)
soil_metrics = _surface_soil_metrics(depths) soil_metrics = _surface_soil_metrics(depths)
sensor_metrics = _flatten_sensor_metrics(farm.sensor_payload) sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
resolved_metrics = dict(soil_metrics) resolved_metrics = dict(soil_metrics)
metric_sources = {key: "soil" for key in soil_metrics} metric_sources = {key: "soil" for key in soil_metrics}
for key, value in sensor_metrics.items(): for key, value in sensor_metrics.items():
resolved_metrics[key] = value resolved_metrics[key] = value
metric_sources[key] = "sensor" metric_sources[key] = sensor_metric_sources[key]
return { return {
"center_location": { "center_location": {
@@ -97,11 +98,7 @@ def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLoc
if len(normalized_points) < 3: if len(normalized_points) < 3:
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
lat_sum = sum(lat for lat, _ in normalized_points) center_lat, center_lon = _compute_polygon_centroid(normalized_points)
lon_sum = sum(lon for _, lon in normalized_points)
count = Decimal(len(normalized_points))
center_lat = (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
center_lon = (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
with transaction.atomic(): with transaction.atomic():
location, _ = SoilLocation.objects.update_or_create( location, _ = SoilLocation.objects.update_or_create(
@@ -152,16 +149,83 @@ def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocati
return location, weather_forecast return location, weather_forecast
def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict: def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
if not isinstance(sensor_payload, dict): if not isinstance(sensor_payload, dict):
return {} return {}, {}
flattened = {} readings_by_metric: dict[str, list[tuple[str, object]]] = {}
for sensor_values in sensor_payload.values(): for sensor_key, sensor_values in sorted(sensor_payload.items()):
if not isinstance(sensor_values, dict): if not isinstance(sensor_values, dict):
continue continue
flattened.update(sensor_values) for metric_key, metric_value in sensor_values.items():
return flattened readings_by_metric.setdefault(metric_key, []).append((sensor_key, metric_value))
resolved_metrics = {}
metric_sources = {}
for metric_key, readings in readings_by_metric.items():
resolved_value, source = _resolve_metric_readings(readings)
resolved_metrics[metric_key] = resolved_value
metric_sources[metric_key] = source
return resolved_metrics, metric_sources
def _resolve_metric_readings(readings: list[tuple[str, object]]) -> tuple[object, dict[str, object]]:
if not readings:
return None, {"type": "sensor", "strategy": "empty", "sensor_keys": []}
sensor_keys = [sensor_key for sensor_key, _value in readings]
distinct_values: list[object] = []
for _sensor_key, value in readings:
if value not in distinct_values:
distinct_values.append(value)
if len(distinct_values) == 1:
return distinct_values[0], {
"type": "sensor",
"strategy": "single_value",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
}
numeric_values = [_coerce_numeric(value) for value in distinct_values]
if all(value is not None for value in numeric_values):
average = sum(numeric_values) / len(numeric_values)
resolved_value = _normalize_numeric_result(average, distinct_values)
return resolved_value, {
"type": "sensor",
"strategy": "average",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
"conflict": True,
"distinct_values": distinct_values,
}
return distinct_values, {
"type": "sensor",
"strategy": "distinct_values",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
"conflict": True,
"distinct_values": distinct_values,
}
def _coerce_numeric(value: object) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, Number):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalize_numeric_result(value: float, source_values: list[object]) -> int | float:
if all(isinstance(item, int) and not isinstance(item, bool) for item in source_values):
if value.is_integer():
return int(value)
return float(Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP))
def _surface_soil_metrics(depths) -> dict: def _surface_soil_metrics(depths) -> dict:
@@ -240,3 +304,40 @@ def _serialize_boundary(boundary: dict | list) -> dict:
"type": "Polygon", "type": "Polygon",
"coordinates": [coordinates], "coordinates": [coordinates],
} }
def _compute_polygon_centroid(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
polygon = list(points)
if polygon[0] != polygon[-1]:
polygon.append(polygon[0])
twice_area = Decimal("0")
centroid_lon = Decimal("0")
centroid_lat = Decimal("0")
for index in range(len(polygon) - 1):
lat1, lon1 = polygon[index]
lat2, lon2 = polygon[index + 1]
cross = (lon1 * lat2) - (lon2 * lat1)
twice_area += cross
centroid_lon += (lon1 + lon2) * cross
centroid_lat += (lat1 + lat2) * cross
if twice_area == 0:
return _compute_average_center(points)
factor = Decimal("3") * twice_area
return (
(centroid_lat / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
(centroid_lon / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
)
def _compute_average_center(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
lat_sum = sum(lat for lat, _ in points)
lon_sum = sum(lon for _, lon in points)
count = Decimal(len(points))
return (
(lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
(lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
)
+81 -2
View File
@@ -11,6 +11,8 @@ from irrigation.models import IrrigationMethod
from plant.models import Plant from plant.models import Plant
from weather.models import WeatherForecast from weather.models import WeatherForecast
from farm_data.services import resolve_center_location_from_boundary
def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict: def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict:
return { return {
@@ -93,7 +95,8 @@ class FarmDetailApiTests(TestCase):
metric_sources = payload["soil"]["metric_sources"] metric_sources = payload["soil"]["metric_sources"]
self.assertEqual(resolved_metrics["nitrogen"], 99.0) self.assertEqual(resolved_metrics["nitrogen"], 99.0)
self.assertEqual(metric_sources["nitrogen"], "sensor") self.assertEqual(metric_sources["nitrogen"]["type"], "sensor")
self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value")
self.assertEqual(resolved_metrics["clay"], 22.0) self.assertEqual(resolved_metrics["clay"], 22.0)
self.assertEqual(metric_sources["clay"], "soil") self.assertEqual(metric_sources["clay"], "soil")
self.assertEqual(len(payload["soil"]["depths"]), 2) self.assertEqual(len(payload["soil"]["depths"]), 2)
@@ -112,6 +115,38 @@ class FarmDetailApiTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "farm یافت نشد.") self.assertEqual(response.json()["msg"], "farm یافت نشد.")
def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self):
self.farm.sensor_payload = {
"sensor-a": {
"soil_moisture": 20.0,
"nitrogen": 90.0,
"status": "ok",
},
"sensor-b": {
"soil_moisture": 40.0,
"nitrogen": 110.0,
"status": "needs-check",
},
}
self.farm.save(update_fields=["sensor_payload"])
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
resolved_metrics = payload["soil"]["resolved_metrics"]
metric_sources = payload["soil"]["metric_sources"]
self.assertEqual(resolved_metrics["soil_moisture"], 30.0)
self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average")
self.assertCountEqual(
metric_sources["soil_moisture"]["sensor_keys"],
["sensor-a", "sensor-b"],
)
self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0])
self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"])
self.assertEqual(metric_sources["status"]["strategy"], "distinct_values")
class FarmDataUpsertApiTests(TestCase): class FarmDataUpsertApiTests(TestCase):
def setUp(self): def setUp(self):
@@ -120,6 +155,21 @@ class FarmDataUpsertApiTests(TestCase):
latitude="35.710000", latitude="35.710000",
longitude="51.410000", longitude="51.410000",
) )
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=20.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="15-30cm",
clay=16.0,
)
self.boundary = square_boundary_for_center(35.71, 51.41) self.boundary = square_boundary_for_center(35.71, 51.41)
self.weather = WeatherForecast.objects.create( self.weather = WeatherForecast.objects.create(
location=self.location, location=self.location,
@@ -176,7 +226,16 @@ class FarmDataUpsertApiTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("farm_uuid", response.json()["data"]) self.assertIn("farm_uuid", response.json()["data"])
def test_post_creates_center_location_from_boundary_when_missing(self): @patch("farm_data.services.update_weather_for_location", return_value={"status": "no_data"})
@patch(
"farm_data.services.fetch_soil_data_for_coordinates",
return_value={"status": "completed", "depths": []},
)
def test_post_creates_center_location_from_boundary_when_missing(
self,
_mock_fetch_soil_data_for_coordinates,
_mock_update_weather_for_location,
):
farm_uuid = uuid.uuid4() farm_uuid = uuid.uuid4()
response = self.client.post( response = self.client.post(
@@ -203,6 +262,26 @@ class FarmDataUpsertApiTests(TestCase):
self.assertEqual(str(farm.center_location.longitude), "50.010000") self.assertEqual(str(farm.center_location.longitude), "50.010000")
self.assertIsNone(farm.weather_forecast_id) self.assertIsNone(farm.weather_forecast_id)
def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self):
location = resolve_center_location_from_boundary(
{
"corners": [
{"lat": 0.0, "lon": 0.0},
{"lat": 0.0, "lon": 4.0},
{"lat": 4.0, "lon": 4.0},
{"lat": 4.0, "lon": 0.0},
{"lat": 1.0, "lon": 0.0},
{"lat": 1.0, "lon": 3.0},
{"lat": 3.0, "lon": 3.0},
{"lat": 3.0, "lon": 1.0},
{"lat": 0.0, "lon": 1.0},
]
}
)
self.assertEqual(str(location.latitude), "2.078947")
self.assertEqual(str(location.longitude), "2.078947")
@patch("farm_data.services.update_weather_for_location") @patch("farm_data.services.update_weather_for_location")
@patch("farm_data.services.fetch_soil_data_for_coordinates") @patch("farm_data.services.fetch_soil_data_for_coordinates")
def test_post_fetches_missing_location_and_weather_data( def test_post_fetches_missing_location_and_weather_data(
+2 -1
View File
@@ -248,7 +248,8 @@ class FarmDetailView(APIView):
summary="دریافت همه اطلاعات farm", summary="دریافت همه اطلاعات farm",
description=( description=(
"اطلاعات تجمیعی farm را برمی‌گرداند. " "اطلاعات تجمیعی farm را برمی‌گرداند. "
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند." "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند "
"و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند."
), ),
responses={ responses={
200: build_response( 200: build_response(
+1 -1
View File
@@ -62,7 +62,7 @@ class FertilizationRecommendView(APIView):
OpenApiExample( OpenApiExample(
"نمونه درخواست", "نمونه درخواست",
value={ value={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی", "plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی", "growth_stage": "گلدهی",
}, },
+19
View File
@@ -0,0 +1,19 @@
Integration tests in this folder are intended to run against the project's
configured MySQL backend, so the API flow is exercised with a real relational
database instead of in-memory fixtures.
Recommended command:
```bash
DJANGO_SETTINGS_MODULE=config.settings python manage.py test integration_tests
```
Notes:
- Django will still create an isolated test database on the same MySQL server
(for example `test_ai` when `DB_NAME=ai`).
- External AI providers, remote sensing calls, and Celery workers are stubbed
inside the tests so the suite stays deterministic while database writes stay
real.
- The tests in this folder use the full `config.urls` router, not the reduced
`config.test_urls` router.
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
import uuid
from django.test import TransactionTestCase
from rest_framework.test import APIClient
from location_data.models import NdviObservation, SoilDepthData, SoilLocation
from weather.models import WeatherForecast
UNSET = object()
def square_boundary(lat: float, lon: float, delta: float = 0.01) -> dict[str, Any]:
return {
"type": "Polygon",
"coordinates": [
[
[lon - delta, lat - delta],
[lon + delta, lat - delta],
[lon + delta, lat + delta],
[lon - delta, lat + delta],
[lon - delta, lat - delta],
]
],
}
class IntegrationAPITestCase(TransactionTestCase):
reset_sequences = True
databases = {"default"}
primary_lat = 35.700000
primary_lon = 51.400000
forecast_start = date.today()
def setUp(self) -> None:
super().setUp()
self.client = APIClient()
self.primary_boundary = square_boundary(self.primary_lat, self.primary_lon)
self.primary_location = self.create_complete_location(
lat=self.primary_lat,
lon=self.primary_lon,
boundary=self.primary_boundary,
)
self.seed_weather_forecasts(self.primary_location, start=self.forecast_start, days=7)
self.seed_ndvi_observation(self.primary_location)
def create_complete_location(
self,
*,
lat: float,
lon: float,
boundary: dict[str, Any] | None = None,
clay_values: tuple[float, float, float] = (22.0, 18.0, 15.0),
nitrogen_values: tuple[float, float, float] = (14.0, 11.0, 8.0),
) -> SoilLocation:
location = SoilLocation.objects.create(
latitude=f"{lat:.6f}",
longitude=f"{lon:.6f}",
farm_boundary=boundary or square_boundary(lat, lon),
)
depth_labels = (
SoilDepthData.DEPTH_0_5,
SoilDepthData.DEPTH_5_15,
SoilDepthData.DEPTH_15_30,
)
for index, depth_label in enumerate(depth_labels):
SoilDepthData.objects.create(
soil_location=location,
depth_label=depth_label,
clay=clay_values[index],
nitrogen=nitrogen_values[index],
sand=40.0 - (index * 2),
silt=25.0 + index,
phh2o=6.6 + (index * 0.1),
wv0010=0.41 - (index * 0.02),
wv0033=0.28 - (index * 0.01),
wv1500=0.12 - (index * 0.01),
)
return location
def seed_weather_forecasts(
self,
location: SoilLocation,
*,
start: date,
days: int,
temperature_base: float = 22.0,
et0_base: float = 3.4,
) -> list[WeatherForecast]:
forecasts: list[WeatherForecast] = []
for day_index in range(days):
forecasts.append(
WeatherForecast.objects.create(
location=location,
forecast_date=start + timedelta(days=day_index),
temperature_min=12.0 + day_index,
temperature_max=temperature_base + day_index,
temperature_mean=17.0 + day_index,
precipitation=1.2 if day_index % 3 == 0 else 0.0,
precipitation_probability=35.0 + day_index,
humidity_mean=48.0 + day_index,
wind_speed_max=10.0 + day_index,
et0=et0_base + (day_index * 0.2),
weather_code=0 if day_index == 0 else 2,
)
)
return forecasts
def seed_ndvi_observation(
self,
location: SoilLocation,
*,
observation_date: date | None = None,
mean_ndvi: float = 0.73,
) -> NdviObservation:
return NdviObservation.objects.create(
location=location,
observation_date=observation_date or self.forecast_start,
mean_ndvi=mean_ndvi,
ndvi_map={"type": "FeatureCollection", "features": []},
vegetation_health_class="Healthy",
satellite_source="sentinel-2",
metadata={"suite": "integration"},
)
def create_irrigation_method_via_api(self, name: str, **overrides: Any) -> dict[str, Any]:
payload = {
"name": name,
"category": "localized",
"description": "Primary drip line for the farm",
"water_efficiency_percent": 91.0,
"water_pressure_required": "1.5 bar",
"flow_rate": "4 l/h",
"coverage_area": "row-based",
"soil_type": "loam",
"climate_suitability": "dry",
}
payload.update(overrides)
response = self.client.post("/api/irrigation/", data=payload, format="json")
self.assertEqual(response.status_code, 201, response.json())
return response.json()["data"]
def create_plant_via_api(self, name: str, **overrides: Any) -> dict[str, Any]:
payload = {
"name": name,
"light": "full sun",
"watering": "every 2 days",
"soil": "loamy",
"temperature": "20-28C",
"growth_stage": "vegetative",
"planting_season": "spring",
"harvest_time": "90 days",
"spacing": "50 cm",
"fertilizer": "balanced NPK",
}
payload.update(overrides)
response = self.client.post("/api/plants/", data=payload, format="json")
self.assertEqual(response.status_code, 201, response.json())
return response.json()["data"]
def create_sensor_parameter_via_api(self, **overrides: Any) -> dict[str, Any]:
payload = {
"sensor_key": "sensor-7-1",
"code": "soil_moisture",
"name_fa": "soil moisture",
"unit": "%",
"data_type": "float",
"metadata": {"min": 0, "max": 100},
}
payload.update(overrides)
response = self.client.post("/api/farm-data/parameters/", data=payload, format="json")
self.assertEqual(response.status_code, 201, response.json())
return response.json()["data"]
def upsert_farm_via_api(
self,
*,
farm_uuid: uuid.UUID,
plant_ids: list[int] | None = None,
irrigation_method_id: int | None | object = UNSET,
sensor_payload: dict[str, Any] | None = None,
boundary: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"farm_uuid": str(farm_uuid),
"farm_boundary": boundary or self.primary_boundary,
}
if plant_ids is not None:
payload["plant_ids"] = plant_ids
if irrigation_method_id is not UNSET:
payload["irrigation_method_id"] = irrigation_method_id
if sensor_payload is not None:
payload["sensor_payload"] = sensor_payload
response = self.client.post("/api/farm-data/", data=payload, format="json")
self.assertIn(response.status_code, {200, 201}, response.json())
return response.json()["data"]
@@ -0,0 +1,168 @@
from __future__ import annotations
import uuid
from unittest.mock import patch
from django.test import override_settings
from farm_data.models import ParameterUpdateLog, SensorData, SensorParameter
from integration_tests.base import IntegrationAPITestCase
from plant.models import Plant
@override_settings(ROOT_URLCONF="config.urls")
class FarmManagementJourneyTests(IntegrationAPITestCase):
def test_full_management_journey_persists_farm_related_records(self) -> None:
primary_method = self.create_irrigation_method_via_api("Drip Prime")
backup_method = self.create_irrigation_method_via_api(
"Sprinkler Backup",
category="pressure",
water_efficiency_percent=78.0,
)
irrigation_list_response = self.client.get("/api/irrigation/")
self.assertEqual(irrigation_list_response.status_code, 200)
self.assertGreaterEqual(len(irrigation_list_response.json()["data"]), 2)
irrigation_detail_response = self.client.get(f"/api/irrigation/{primary_method['id']}/")
self.assertEqual(irrigation_detail_response.status_code, 200)
self.assertEqual(irrigation_detail_response.json()["data"]["name"], "Drip Prime")
moisture_parameter = self.create_sensor_parameter_via_api()
self.assertEqual(moisture_parameter["action"], ParameterUpdateLog.ACTION_ADDED)
self.assertTrue(
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
)
moisture_parameter_update = self.create_sensor_parameter_via_api(
metadata={"min": 5, "max": 85, "ui": "gauge"},
)
self.assertEqual(moisture_parameter_update["action"], ParameterUpdateLog.ACTION_MODIFIED)
self.assertEqual(
ParameterUpdateLog.objects.filter(parameter__code="soil_moisture").count(),
2,
)
tomato = self.create_plant_via_api("Tomato")
cucumber = self.create_plant_via_api("Cucumber", watering="daily")
removable_plant = self.create_plant_via_api("Remove Plant")
plants_list_response = self.client.get("/api/plants/")
self.assertEqual(plants_list_response.status_code, 200)
returned_names = {item["name"] for item in plants_list_response.json()["data"]}
self.assertTrue({"Tomato", "Cucumber", "Remove Plant"}.issubset(returned_names))
plant_patch_response = self.client.patch(
f"/api/plants/{tomato['id']}/",
data={"growth_stage": "flowering", "watering": "daily"},
format="json",
)
self.assertEqual(plant_patch_response.status_code, 200)
self.assertEqual(Plant.objects.get(pk=tomato["id"]).growth_stage, "flowering")
plant_put_response = self.client.put(
f"/api/plants/{cucumber['id']}/",
data={
"name": "Cucumber",
"light": "full sun",
"watering": "every day",
"soil": "sandy loam",
"temperature": "18-30C",
"growth_stage": "fruiting",
"planting_season": "spring",
"harvest_time": "70 days",
"spacing": "40 cm",
"fertilizer": "potassium rich",
},
format="json",
)
self.assertEqual(plant_put_response.status_code, 200)
with patch(
"plant.views.fetch_plant_info_from_api",
return_value={
"name": "Tomato",
"light": "full sun",
"watering": "daily",
"soil": "loamy",
"temperature": "20-28C",
"growth_stage": "flowering",
"planting_season": "spring",
"harvest_time": "90 days",
"spacing": "50 cm",
"fertilizer": "balanced NPK",
},
):
plant_fetch_response = self.client.post(
"/api/plants/fetch-info/",
data={"name": "Tomato"},
format="json",
)
self.assertEqual(plant_fetch_response.status_code, 200)
self.assertEqual(plant_fetch_response.json()["data"]["name"], "Tomato")
plant_delete_response = self.client.delete(f"/api/plants/{removable_plant['id']}/")
self.assertEqual(plant_delete_response.status_code, 200)
self.assertFalse(Plant.objects.filter(pk=removable_plant["id"]).exists())
farm_uuid = uuid.uuid4()
created_farm = self.upsert_farm_via_api(
farm_uuid=farm_uuid,
plant_ids=[tomato["id"], cucumber["id"]],
irrigation_method_id=primary_method["id"],
sensor_payload={
"sensor-7-1": {
"soil_moisture": 41.2,
"soil_temperature": 23.4,
"soil_ph": 6.8,
"electrical_conductivity": 1.1,
"nitrogen": 17.0,
"phosphorus": 12.5,
"potassium": 21.0,
}
},
)
self.assertEqual(created_farm["farm_uuid"], str(farm_uuid))
farm_record = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertCountEqual(
list(farm_record.plants.values_list("id", flat=True)),
[tomato["id"], cucumber["id"]],
)
self.assertEqual(farm_record.irrigation_method_id, primary_method["id"])
self.assertEqual(farm_record.center_location_id, self.primary_location.id)
updated_farm = self.upsert_farm_via_api(
farm_uuid=farm_uuid,
plant_ids=[tomato["id"]],
irrigation_method_id=backup_method["id"],
sensor_payload={
"sensor-7-1": {
"nitrogen": 19.5,
"soil_moisture": 44.0,
},
"leaf-sensor": {
"leaf_wetness": 11.0,
"leaf_temperature": 21.3,
},
},
)
self.assertEqual(updated_farm["irrigation_method_id"], backup_method["id"])
farm_record.refresh_from_db()
self.assertEqual(farm_record.irrigation_method_id, backup_method["id"])
self.assertCountEqual(list(farm_record.plants.values_list("id", flat=True)), [tomato["id"]])
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_temperature"], 23.4)
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_moisture"], 44.0)
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5)
self.assertEqual(farm_record.sensor_payload["leaf-sensor"]["leaf_wetness"], 11.0)
farm_detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/")
self.assertEqual(farm_detail_response.status_code, 200)
farm_detail = farm_detail_response.json()["data"]
self.assertEqual(farm_detail["center_location"]["id"], self.primary_location.id)
self.assertEqual(farm_detail["irrigation_method_id"], backup_method["id"])
self.assertEqual(farm_detail["plant_ids"], [tomato["id"]])
self.assertEqual(farm_detail["plants"][0]["name"], "Tomato")
self.assertEqual(
farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"],
21.3,
)
@@ -0,0 +1,622 @@
from __future__ import annotations
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import patch
import uuid
from django.test import override_settings
from crop_simulation.models import SimulationRun, SimulationScenario
from farm_alerts.models import FarmAlertNotification
from farm_data.models import SensorData
from integration_tests.base import IntegrationAPITestCase, square_boundary
class FakeAsyncResult:
def __init__(self, *, state: str, result=None, info=None):
self.state = state
self.result = result
self.info = info
@override_settings(ROOT_URLCONF="config.urls")
class ReportingAndAiJourneyTests(IntegrationAPITestCase):
def setUp(self) -> None:
super().setUp()
self.irrigation_method = self.create_irrigation_method_via_api("Analytics Drip")
self.primary_plant = self.create_plant_via_api("Tomato")
self.secondary_plant = self.create_plant_via_api("Pepper")
self.farm_uuid = uuid.uuid4()
self.upsert_farm_via_api(
farm_uuid=self.farm_uuid,
plant_ids=[self.primary_plant["id"], self.secondary_plant["id"]],
irrigation_method_id=self.irrigation_method["id"],
sensor_payload={
"sensor-7-1": {
"soil_moisture": 46.0,
"soil_temperature": 24.2,
"soil_ph": 6.5,
"electrical_conductivity": 1.3,
"nitrogen": 20.0,
"phosphorus": 11.0,
"potassium": 18.0,
"timestamp": "2026-04-10T06:30:00Z",
}
},
)
self.seed_neighbor_farm()
def seed_neighbor_farm(self) -> None:
neighbor_location = self.create_complete_location(
lat=35.706000,
lon=51.406000,
boundary=square_boundary(35.706000, 51.406000, delta=0.008),
clay_values=(19.0, 16.5, 13.0),
nitrogen_values=(12.0, 10.0, 7.0),
)
self.seed_weather_forecasts(
neighbor_location,
start=self.forecast_start,
days=7,
temperature_base=24.0,
et0_base=3.8,
)
neighbor_sensor = SensorData.objects.create(
farm_uuid=uuid.uuid4(),
center_location=neighbor_location,
weather_forecast=neighbor_location.weather_forecasts.order_by("forecast_date").first(),
irrigation_method_id=self.irrigation_method["id"],
sensor_payload={
"sensor-7-1": {
"soil_moisture": 38.5,
"soil_temperature": 25.0,
"soil_ph": 6.7,
"electrical_conductivity": 1.0,
"nitrogen": 16.0,
"timestamp": "2026-04-10T06:35:00Z",
}
},
)
neighbor_sensor.plants.set([self.primary_plant["id"]])
def test_reporting_endpoints_read_from_persisted_farm_context(self) -> None:
soil_response = self.client.get(
"/api/soil-data/",
data={"lat": f"{self.primary_lat:.6f}", "lon": f"{self.primary_lon:.6f}"},
)
self.assertEqual(soil_response.status_code, 200)
self.assertEqual(soil_response.json()["data"]["source"], "database")
self.assertEqual(len(soil_response.json()["data"]["depths"]), 3)
queued_location = {}
def soil_delay_stub(lat: float, lon: float):
location = self.create_complete_location(lat=lat, lon=lon)
queued_location["id"] = location.id
return SimpleNamespace(id="soil-task-1")
with patch("location_data.views.fetch_soil_data_task.delay", side_effect=soil_delay_stub):
queued_response = self.client.post(
"/api/soil-data/",
data={"lat": "36.100000", "lon": "52.200000"},
format="json",
)
self.assertEqual(queued_response.status_code, 202)
with patch(
"celery.result.AsyncResult",
return_value=FakeAsyncResult(
state="SUCCESS",
result={"status": "completed", "location_id": queued_location["id"]},
),
):
soil_status_response = self.client.get("/api/soil-data/tasks/soil-task-1/status/")
self.assertEqual(soil_status_response.status_code, 200)
self.assertEqual(
soil_status_response.json()["data"]["result"]["id"],
queued_location["id"],
)
weather_response = self.client.post(
"/api/weather/farm-card/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(weather_response.status_code, 200)
self.assertEqual(weather_response.json()["data"]["condition"], "صاف")
with patch(
"weather.water_need_prediction.get_water_need_prediction_insight",
return_value={
"summary": "Water demand is moderate for the next week.",
"irrigation_outlook": "Increase slowly.",
"recommended_action": "Keep early morning irrigation.",
"risk_note": "Watch evapotranspiration after day 4.",
"confidence": 0.88,
"knowledge_base": "water_need_prediction",
"tone_file": "config/tones/water_need_prediction_tone.txt",
"raw_response": "{\"summary\": \"ok\"}",
},
):
water_need_response = self.client.post(
"/api/weather/water-need-prediction/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(water_need_response.status_code, 200)
self.assertGreater(water_need_response.json()["data"]["totalNext7Days"], 0)
self.assertEqual(
water_need_response.json()["data"]["knowledge_base"],
"water_need_prediction",
)
economy_response = self.client.post(
"/api/economy/overview/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(economy_response.status_code, 200)
self.assertEqual(economy_response.json()["data"]["farm_uuid"], str(self.farm_uuid))
heatmap_response = self.client.post(
"/api/soile/moisture-heatmap/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(heatmap_response.status_code, 200)
self.assertGreater(len(heatmap_response.json()["data"]["grid_cells"]), 0)
self.assertGreaterEqual(len(heatmap_response.json()["data"]["sensor_points"]), 1)
soil_health_response = self.client.post(
"/api/soile/health-summary/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(soil_health_response.status_code, 200)
self.assertIn("healthScore", soil_health_response.json()["data"])
with patch(
"soile.services.get_soil_anomaly_insight",
return_value={
"interpretation": {
"summary": "No critical anomaly detected.",
"recommended_action": "Continue monitoring.",
},
"knowledge_base": "soil_anomaly",
"raw_response": "{\"status\": \"ok\"}",
},
):
anomaly_response = self.client.post(
"/api/soile/anomaly-detection/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(anomaly_response.status_code, 200)
self.assertEqual(anomaly_response.json()["data"]["knowledge_base"], "soil_anomaly")
ndvi_response = self.client.post(
"/api/soil-data/ndvi-health/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(ndvi_response.status_code, 200)
self.assertEqual(ndvi_response.json()["data"]["vegetation_health_class"], "Healthy")
broken_simulation_service = SimpleNamespace(
get_water_stress=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulation offline"))
)
with patch(
"irrigation.indicators.apps.get_app_config",
return_value=SimpleNamespace(get_water_stress_service=lambda: broken_simulation_service),
):
water_stress_response = self.client.post(
"/api/irrigation/water-stress/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(water_stress_response.status_code, 200)
self.assertEqual(
water_stress_response.json()["data"]["sourceMetric"]["engine"],
"sensor_fallback",
)
def test_ai_assistant_and_recommendation_endpoints_use_farm_context(self) -> None:
with ExitStack() as stack:
stack.enter_context(
patch("rag.config.load_rag_config", return_value=SimpleNamespace())
)
stack.enter_context(
patch(
"rag.views.chat_rag_stream",
return_value=iter(["Farm looks stable. ", "Moisture is acceptable."]),
)
)
stack.enter_context(
patch(
"rag.services.irrigation.get_irrigation_recommendation",
return_value={
"plan": {
"frequencyPerWeek": 3,
"durationMinutes": 28,
"bestTimeOfDay": "05:30",
"moistureLevel": 68,
"warning": "",
},
"raw_response": "{\"plan\": {\"frequencyPerWeek\": 3}}",
},
)
)
stack.enter_context(
patch(
"rag.services.fertilization.get_fertilization_recommendation",
return_value={
"plan": {
"npkRatio": "15-5-30",
"amountPerHectare": "60 kg",
},
"raw_response": "{\"plan\": {\"npkRatio\": \"15-5-30\"}}",
},
)
)
stack.enter_context(
patch(
"pest_disease.views.get_pest_disease_detection",
return_value={
"diagnosis": "low risk leaf stress",
"confidence": 0.81,
},
)
)
stack.enter_context(
patch(
"pest_disease.views.get_pest_disease_risk",
return_value={
"riskLevel": "medium",
"topRisk": "powdery mildew",
},
)
)
stack.enter_context(
patch(
"pest_disease.services.build_pest_disease_risk_summary",
return_value={
"riskLevel": "medium",
"headline": "Watch humidity trend",
"items": [{"title": "Powdery mildew", "severity": "medium"}],
},
)
)
chat_response = self.client.post(
"/api/rag/chat/",
data={
"farm_uuid": str(self.farm_uuid),
"query": "Give me a short farm update",
"history": [],
},
format="json",
)
self.assertEqual(chat_response.status_code, 200)
streamed_text = b"".join(chat_response.streaming_content).decode("utf-8")
self.assertIn("Moisture is acceptable", streamed_text)
rag_irrigation_response = self.client.post(
"/api/rag/recommend/irrigation/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
"irrigation_method_name": "Analytics Drip",
},
format="json",
)
self.assertEqual(rag_irrigation_response.status_code, 200)
self.assertEqual(
rag_irrigation_response.json()["data"]["plan"]["frequencyPerWeek"],
3,
)
rag_fertilization_response = self.client.post(
"/api/rag/recommend/fertilization/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
},
format="json",
)
self.assertEqual(rag_fertilization_response.status_code, 200)
self.assertEqual(
rag_fertilization_response.json()["data"]["plan"]["npkRatio"],
"15-5-30",
)
irrigation_recommend_response = self.client.post(
"/api/irrigation/recommend/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
"irrigation_method_name": "Analytics Drip",
},
format="json",
)
self.assertEqual(irrigation_recommend_response.status_code, 200)
fertilization_recommend_response = self.client.post(
"/api/fertilization/recommend/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
},
format="json",
)
self.assertEqual(fertilization_recommend_response.status_code, 200)
pest_detect_response = self.client.post(
"/api/pest-disease/detect/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"query": "Check leaf condition",
"image_urls": ["https://example.com/leaf.jpg"],
},
format="json",
)
self.assertEqual(pest_detect_response.status_code, 200)
self.assertEqual(pest_detect_response.json()["data"]["confidence"], 0.81)
pest_risk_response = self.client.post(
"/api/pest-disease/risk/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
},
format="json",
)
self.assertEqual(pest_risk_response.status_code, 200)
pest_summary_response = self.client.post(
"/api/pest-disease/risk-summary/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(pest_summary_response.status_code, 200)
self.assertEqual(pest_summary_response.json()["data"]["riskLevel"], "medium")
def test_alert_and_crop_simulation_endpoints_persist_records(self) -> None:
def tracker_stub(*, farm_uuid: str, query: str | None = None):
from farm_alerts.services import _save_notifications, _serialize_notification
saved = _save_notifications(
farm_uuid=str(farm_uuid),
endpoint=FarmAlertNotification.ENDPOINT_TRACKER,
notifications=[
{
"level": FarmAlertNotification.LEVEL_WARNING,
"title": "Moisture drift",
"message": "Moisture dropped below the target band.",
"suggested_action": "Review the next irrigation cycle.",
"source_alert_id": "soil-moisture:1",
"source_metric_type": "soil_moisture",
}
],
)
return {
"headline": "Tracker result",
"overview": "One warning was recorded.",
"status_level": "warning",
"query": query,
"notifications": [_serialize_notification(item) for item in saved],
}
def timeline_stub(*, farm_uuid: str, query: str | None = None):
from farm_alerts.services import _save_notifications, _serialize_notification
saved = _save_notifications(
farm_uuid=str(farm_uuid),
endpoint=FarmAlertNotification.ENDPOINT_TIMELINE,
notifications=[
{
"level": FarmAlertNotification.LEVEL_INFO,
"title": "Irrigation completed",
"message": "The latest drip cycle finished successfully.",
"suggested_action": "Recheck moisture after sunrise.",
"source_alert_id": "irrigation:1",
"source_metric_type": "irrigation",
}
],
)
return {
"headline": "Timeline result",
"overview": "One event was stored.",
"query": query,
"timeline": [
{
"timestamp": "2026-04-10T05:30:00Z",
"level": "info",
"title": "Irrigation completed",
"description": "Cycle finished.",
"source_alert_id": "irrigation:1",
"source_metric_type": "irrigation",
}
],
"notifications": [_serialize_notification(item) for item in saved],
}
with patch("farm_alerts.views.get_farm_alerts_tracker", side_effect=tracker_stub):
tracker_response = self.client.post(
"/api/farm-alerts/tracker/",
data={"farm_uuid": str(self.farm_uuid), "query": "status"},
format="json",
)
self.assertEqual(tracker_response.status_code, 200)
with patch("farm_alerts.views.get_farm_alerts_timeline", side_effect=timeline_stub):
timeline_response = self.client.post(
"/api/farm-alerts/timeline/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(timeline_response.status_code, 200)
self.assertEqual(
FarmAlertNotification.objects.filter(farm_uuid=self.farm_uuid).count(),
2,
)
current_chart_service = SimpleNamespace(
simulate=lambda **_kwargs: {
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"engine": "stub",
"model_name": "integration-model",
"scenario_id": None,
"simulation_warning": "",
"categories": ["day1", "day2"],
"series": [{"name": "LAI", "data": [0.8, 1.1]}],
"summary": {"expectedTrend": "up"},
"current_state": {"soilMoisture": 46.0},
"metrics": {"yieldEstimate": 8.2},
"daily_output": [{"day": "2026-04-10", "lai": 0.8}],
}
)
harvest_service = SimpleNamespace(
get_harvest_prediction=lambda **_kwargs: {
"date": "2026-07-15",
"dateFormatted": "15 Jul 2026",
"daysUntil": 96,
"description": "Expected harvest window",
"optimalWindowStart": "2026-07-10",
"optimalWindowEnd": "2026-07-20",
"gddDetails": {"current": 420, "target": 1220},
}
)
yield_service = SimpleNamespace(
get_yield_prediction=lambda **_kwargs: {
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"predictedYieldTons": 8.4,
"predictedYieldRaw": 8400.0,
"unit": "t/ha",
"sourceUnit": "kg/ha",
"simulationEngine": "stub",
"simulationModel": "integration-model",
"scenarioId": None,
"simulationWarning": "",
"supportingMetrics": {"biomass": 12.1},
}
)
with patch(
"crop_simulation.views.apps.get_app_config",
return_value=SimpleNamespace(
get_current_farm_chart_simulator=lambda: current_chart_service,
get_harvest_prediction_service=lambda: harvest_service,
get_yield_prediction_service=lambda: yield_service,
),
):
current_chart_response = self.client.post(
"/api/crop-simulation/current-farm-chart/",
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
format="json",
)
harvest_response = self.client.post(
"/api/crop-simulation/harvest-prediction/",
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
format="json",
)
yield_response = self.client.post(
"/api/crop-simulation/yield-prediction/",
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
format="json",
)
self.assertEqual(current_chart_response.status_code, 200)
self.assertEqual(harvest_response.status_code, 200)
self.assertEqual(yield_response.status_code, 200)
task_state: dict[str, object] = {}
def growth_delay_stub(payload):
scenario = SimulationScenario.objects.create(
name=f"integration-{payload['plant_name']}",
scenario_type=SimulationScenario.ScenarioType.SINGLE,
status=SimulationScenario.Status.SUCCESS,
input_payload=payload,
result_payload={"engine": "stub"},
)
SimulationRun.objects.create(
scenario=scenario,
run_key="primary",
label=payload["plant_name"],
status=SimulationScenario.Status.SUCCESS,
weather_payload=payload.get("weather", []),
soil_payload=payload.get("soil_parameters", {}),
result_payload={"summary": "ok"},
)
task_id = f"growth-task-{scenario.id}"
task_state["task_id"] = task_id
task_state["result"] = {
"plant_name": payload["plant_name"],
"dynamic_parameters": payload["dynamic_parameters"],
"engine": "stub",
"model_name": "integration-model",
"scenario_id": scenario.id,
"simulation_warning": "",
"summary_metrics": {"yield_estimate": 8.4},
"stage_timeline": [
{
"order": 1,
"stage_code": "VEG",
"stage_name": "Vegetative",
"start_date": "2026-04-10",
"end_date": "2026-05-05",
"days_count": 25,
"metrics": {"LAI": {"start": 0.8, "end": 1.5, "min": 0.8, "max": 1.5, "avg": 1.15}},
},
{
"order": 2,
"stage_code": "FLOW",
"stage_name": "Flowering",
"start_date": "2026-05-06",
"end_date": "2026-06-01",
"days_count": 26,
"metrics": {"LAI": {"start": 1.6, "end": 2.2, "min": 1.6, "max": 2.2, "avg": 1.9}},
},
],
"daily_records_count": 51,
"default_page_size": payload.get("page_size", 2),
}
return SimpleNamespace(id=task_id)
with patch("crop_simulation.views.run_growth_simulation_task.delay", side_effect=growth_delay_stub):
growth_response = self.client.post(
"/api/crop-simulation/growth/",
data={
"plant_name": "Tomato",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm_uuid),
"page_size": 1,
},
format="json",
)
self.assertEqual(growth_response.status_code, 202)
with patch(
"crop_simulation.views._get_async_result",
return_value=FakeAsyncResult(state="SUCCESS", result=task_state["result"]),
):
growth_status_response = self.client.get(
f"/api/crop-simulation/growth/{task_state['task_id']}/status/?page=1&page_size=1"
)
self.assertEqual(growth_status_response.status_code, 200)
self.assertEqual(
SimulationScenario.objects.filter(name="integration-Tomato").count(),
1,
)
self.assertEqual(SimulationRun.objects.count(), 1)
self.assertEqual(
growth_status_response.json()["data"]["result"]["pagination"]["page_size"],
1,
)
+9
View File
@@ -49,3 +49,12 @@ class IrrigationConfig(AppConfig):
def get_optimizer_defaults(self): def get_optimizer_defaults(self):
return self.optimizer_defaults return self.optimizer_defaults
@cached_property
def water_stress_service(self):
from .indicators import WaterStressService
return WaterStressService()
def get_water_stress_service(self):
return self.water_stress_service
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from django.apps import apps
from farm_data.models import SensorData
def build_water_stress_summary(sensor: Any) -> dict[str, Any]:
moisture = float(getattr(sensor, "soil_moisture", None) or 0.0)
water_stress = max(0, min(100, round(35 - (moisture / 2))))
return {
"waterStressIndex": water_stress,
"level": "پایین" if water_stress <= 20 else "متوسط" if water_stress <= 45 else "بالا",
"sourceMetric": {
"soilMoisture": round(moisture, 2),
"formula": "clamp(round(35 - (soil_moisture / 2)), 0, 100)",
},
}
class WaterStressService:
def get_water_stress(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
sensor = SensorData.objects.filter(farm_uuid=farm_uuid).first()
if sensor is None:
raise ValueError("Farm not found.")
simulation_service = apps.get_app_config("crop_simulation").get_water_stress_service()
try:
return simulation_service.get_water_stress(
farm_uuid=str(sensor.farm_uuid),
plant_name=plant_name,
)
except Exception as exc:
fallback = {
"farm_uuid": str(sensor.farm_uuid),
**build_water_stress_summary(sensor),
}
fallback["sourceMetric"]["engine"] = "sensor_fallback"
fallback["sourceMetric"]["fallbackReason"] = str(exc)
return fallback
+19
View File
@@ -61,3 +61,22 @@ class IrrigationRecommendResponseSerializer(serializers.Serializer):
code = serializers.IntegerField() code = serializers.IntegerField()
msg = serializers.CharField() msg = serializers.CharField()
data = serializers.DictField(child=serializers.JSONField()) data = serializers.DictField(child=serializers.JSONField())
class WaterStressRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class WaterStressResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
waterStressIndex = serializers.IntegerField()
level = serializers.CharField()
sourceMetric = serializers.JSONField()
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from irrigation.indicators import WaterStressService
@override_settings(ROOT_URLCONF="irrigation.urls")
class WaterStressApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@patch("irrigation.views.apps.get_app_config")
def test_water_stress_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_water_stress=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"waterStressIndex": 12,
"level": "پایین",
"sourceMetric": {"soilMoisture": 46.0},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_water_stress_service=lambda: mock_service
)
response = self.client.post(
"/water-stress/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["waterStressIndex"], 12)
class WaterStressServiceTests(TestCase):
@patch("irrigation.indicators.apps.get_app_config")
@patch("irrigation.indicators.SensorData.objects.filter")
def test_service_uses_crop_simulation_water_stress(self, mock_filter, mock_get_app_config):
mock_filter.return_value.first.return_value = SimpleNamespace(
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
soil_moisture=46.0,
)
mock_service = SimpleNamespace(
get_water_stress=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": "Tomato",
"waterStressIndex": 37,
"level": "متوسط",
"sourceMetric": {"engine": "crop_simulation"},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_water_stress_service=lambda: mock_service
)
payload = WaterStressService().get_water_stress(
farm_uuid="550e8400-e29b-41d4-a716-446655440000"
)
self.assertEqual(payload["waterStressIndex"], 37)
self.assertEqual(payload["sourceMetric"]["engine"], "crop_simulation")
@patch("irrigation.indicators.apps.get_app_config")
@patch("irrigation.indicators.SensorData.objects.filter")
def test_service_falls_back_when_crop_simulation_fails(self, mock_filter, mock_get_app_config):
mock_filter.return_value.first.return_value = SimpleNamespace(
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
soil_moisture=46.0,
)
class BrokenService:
def get_water_stress(self, **_kwargs):
raise RuntimeError("simulation offline")
mock_get_app_config.return_value = SimpleNamespace(
get_water_stress_service=lambda: BrokenService()
)
payload = WaterStressService().get_water_stress(
farm_uuid="550e8400-e29b-41d4-a716-446655440000"
)
self.assertEqual(payload["sourceMetric"]["engine"], "sensor_fallback")
self.assertEqual(payload["sourceMetric"]["fallbackReason"], "simulation offline")
+2
View File
@@ -4,10 +4,12 @@ from .views import (
IrrigationMethodDetailView, IrrigationMethodDetailView,
IrrigationMethodListCreateView, IrrigationMethodListCreateView,
IrrigationRecommendView, IrrigationRecommendView,
WaterStressView,
) )
urlpatterns = [ urlpatterns = [
path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"),
path("<int:pk>/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), path("<int:pk>/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"),
path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"), path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"),
path("water-stress/", WaterStressView.as_view(), name="water-stress"),
] ]
+49 -1
View File
@@ -1,3 +1,5 @@
from django.apps import apps
from drf_spectacular.utils import OpenApiExample, extend_schema from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
@@ -12,6 +14,8 @@ from .models import IrrigationMethod
from .serializers import ( from .serializers import (
IrrigationMethodSerializer, IrrigationMethodSerializer,
IrrigationRecommendRequestSerializer, IrrigationRecommendRequestSerializer,
WaterStressRequestSerializer,
WaterStressResponseSerializer,
) )
@@ -33,6 +37,10 @@ IrrigationRecommendResponseSerializer = build_envelope_serializer(
"IrrigationRecommendResponseSerializer", "IrrigationRecommendResponseSerializer",
data_schema=None, data_schema=None,
) )
WaterStressEnvelopeSerializer = build_envelope_serializer(
"WaterStressEnvelopeSerializer",
WaterStressResponseSerializer,
)
class IrrigationMethodListCreateView(APIView): class IrrigationMethodListCreateView(APIView):
@@ -139,7 +147,7 @@ class IrrigationRecommendView(APIView):
OpenApiExample( OpenApiExample(
"نمونه درخواست", "نمونه درخواست",
value={ value={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی", "plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی", "growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره‌ای", "irrigation_method_name": "آبیاری قطره‌ای",
@@ -219,6 +227,46 @@ class IrrigationMethodDetailView(APIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class WaterStressView(APIView):
@extend_schema(
tags=["Irrigation"],
summary="شاخص تنش آبی مزرعه",
description=(
"با دریافت farm_uuid، شاخص تنش آبی مزرعه را با استفاده از شبیه سازی "
"crop_simulation و داده هایی مثل رطوبت خاک، ET0، بارش، مرحله رشد و پارامترهای خاک برمی گرداند."
),
request=WaterStressRequestSerializer,
responses={
200: build_response(WaterStressEnvelopeSerializer, "شاخص تنش آبی مزرعه."),
400: build_response(IrrigationValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
404: build_response(IrrigationValidationErrorSerializer, "مزرعه یافت نشد."),
},
examples=[
OpenApiExample(
"نمونه درخواست water stress",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
request_only=True,
)
],
)
def post(self, request):
serializer = WaterStressRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("irrigation").get_water_stress_service()
try:
data = service.get_water_stress(farm_uuid=serializer.validated_data["farm_uuid"])
except ValueError as exc:
return Response(
{"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND,
)
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@extend_schema( @extend_schema(
tags=["Irrigation"], tags=["Irrigation"],
summary="ویرایش کامل روش آبیاری", summary="ویرایش کامل روش آبیاری",
+11
View File
@@ -1,3 +1,5 @@
from functools import cached_property
from django.apps import AppConfig from django.apps import AppConfig
@@ -5,3 +7,12 @@ class SoilDataConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "location_data" name = "location_data"
verbose_name = "Soil Data (SoilGrids)" verbose_name = "Soil Data (SoilGrids)"
@cached_property
def ndvi_health_service(self):
from .ndvi import NdviHealthService
return NdviHealthService()
def get_ndvi_health_service(self):
return self.ndvi_health_service
+31
View File
@@ -117,3 +117,34 @@ class SoilDepthData(models.Model):
def __str__(self): def __str__(self):
return f"SoilDepthData({self.soil_location_id}, {self.depth_label})" return f"SoilDepthData({self.soil_location_id}, {self.depth_label})"
class NdviObservation(models.Model):
location = models.ForeignKey(
SoilLocation,
on_delete=models.CASCADE,
related_name="ndvi_observations",
)
observation_date = models.DateField(db_index=True)
mean_ndvi = models.FloatField()
ndvi_map = models.JSONField(default=dict, blank=True)
vegetation_health_class = models.CharField(max_length=64)
satellite_source = models.CharField(max_length=64, default="sentinel-2")
cloud_cover = models.FloatField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = "dashboard_data_ndviobservation"
ordering = ["-observation_date", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=["location", "observation_date", "satellite_source"],
name="ndvi_unique_location_date_source",
)
]
verbose_name = "NDVI Observation"
verbose_name_plural = "NDVI Observations"
def __str__(self):
return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}"
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dashboard_data.remote_sensing import fetch_or_get_ndvi_observation from typing import Any
from farm_data.models import SensorData
from .remote_sensing import fetch_or_get_ndvi_observation
def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str: def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str:
@@ -15,9 +18,7 @@ def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str:
) )
def build_ndvi_health_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: def _build_ndvi_health_card(location: Any, ai_bundle: dict | None = None) -> dict[str, Any]:
context = context or {}
location = context.get("location")
if location is None: if location is None:
return { return {
"mean_ndvi": None, "mean_ndvi": None,
@@ -76,3 +77,16 @@ def build_ndvi_health_card(sensor_id: str, context: dict | None = None, ai_bundl
}, },
], ],
} }
class NdviHealthService:
def get_ndvi_health(self, *, farm_uuid: str) -> dict[str, Any]:
sensor = (
SensorData.objects.select_related("center_location")
.filter(farm_uuid=farm_uuid)
.first()
)
if sensor is None:
raise ValueError("Farm not found.")
return _build_ndvi_health_card(sensor.center_location, ai_bundle=None)
+21
View File
@@ -74,3 +74,24 @@ class SoilDataTaskResponseSerializer(serializers.Serializer):
lon = serializers.FloatField(source="longitude") lon = serializers.FloatField(source="longitude")
lat = serializers.FloatField(source="latitude") lat = serializers.FloatField(source="latitude")
status_url = serializers.CharField(required=False) status_url = serializers.CharField(required=False)
class NdviHealthRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
class NdviHealthDataItemSerializer(serializers.Serializer):
title = serializers.CharField()
value = serializers.JSONField()
color = serializers.CharField()
icon = serializers.CharField()
class NdviHealthResponseSerializer(serializers.Serializer):
ndviIndex = serializers.FloatField(allow_null=True, required=False)
mean_ndvi = serializers.FloatField(allow_null=True)
ndvi_map = serializers.JSONField()
vegetation_health_class = serializers.CharField(allow_null=True)
observation_date = serializers.CharField(allow_null=True)
satellite_source = serializers.CharField(allow_null=True)
healthData = NdviHealthDataItemSerializer(many=True)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="location_data.urls")
class NdviHealthApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@patch("location_data.views.apps.get_app_config")
def test_ndvi_health_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_ndvi_health=lambda **_kwargs: {
"ndviIndex": 0.68,
"mean_ndvi": 0.68,
"ndvi_map": {"grid": [[0.61, 0.7]]},
"vegetation_health_class": "Healthy vegetation",
"observation_date": "2026-04-02",
"satellite_source": "sentinel-2",
"healthData": [
{
"title": "سلامت پوشش گیاهی",
"value": "Healthy vegetation",
"color": "success",
"icon": "tabler-plant",
}
],
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_ndvi_health_service=lambda: mock_service
)
response = self.client.post(
"/ndvi-health/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["mean_ndvi"], 0.68)
self.assertEqual(payload["vegetation_health_class"], "Healthy vegetation")
@patch("location_data.views.apps.get_app_config")
def test_ndvi_health_api_returns_404_for_missing_farm(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_ndvi_health=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
)
mock_get_app_config.return_value = SimpleNamespace(
get_ndvi_health_service=lambda: mock_service
)
response = self.client.post(
"/ndvi-health/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "Farm not found.")
+2 -1
View File
@@ -1,8 +1,9 @@
from django.urls import path from django.urls import path
from .views import SoilDataTaskStatusView, SoilDataView from .views import NdviHealthView, SoilDataTaskStatusView, SoilDataView
urlpatterns = [ urlpatterns = [
path("", SoilDataView.as_view(), name="soil-data"), path("", SoilDataView.as_view(), name="soil-data"),
path("ndvi-health/", NdviHealthView.as_view(), name="ndvi-health"),
path("tasks/<str:task_id>/status/", SoilDataTaskStatusView.as_view(), name="soil-data-task-status"), path("tasks/<str:task_id>/status/", SoilDataTaskStatusView.as_view(), name="soil-data-task-status"),
] ]
+60
View File
@@ -1,3 +1,4 @@
from django.apps import apps
from rest_framework import status from rest_framework import status
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiExample, OpenApiExample,
@@ -16,6 +17,8 @@ from config.openapi import (
) )
from .models import SoilLocation from .models import SoilLocation
from .serializers import ( from .serializers import (
NdviHealthRequestSerializer,
NdviHealthResponseSerializer,
SoilDataRequestSerializer, SoilDataRequestSerializer,
SoilDepthDataSerializer, SoilDepthDataSerializer,
SoilDataTaskResponseSerializer, SoilDataTaskResponseSerializer,
@@ -51,6 +54,10 @@ SoilTaskStatusResponseSerializer = build_envelope_serializer(
"SoilTaskStatusResponseSerializer", "SoilTaskStatusResponseSerializer",
build_task_status_data_serializer("SoilTaskStatusDataSerializer"), build_task_status_data_serializer("SoilTaskStatusDataSerializer"),
) )
NdviHealthEnvelopeSerializer = build_envelope_serializer(
"NdviHealthEnvelopeSerializer",
NdviHealthResponseSerializer,
)
class SoilDataView(APIView): class SoilDataView(APIView):
@@ -233,3 +240,56 @@ class SoilDataTaskStatusView(APIView):
{"code": 200, "msg": "success", "data": data}, {"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class NdviHealthView(APIView):
@extend_schema(
tags=["Soil Data"],
summary="دریافت NDVI سلامت مزرعه",
description="با دریافت farm_uuid، داده NDVI سلامت پوشش گیاهی مزرعه را به صورت مستقل از dashboard برمی گرداند.",
request=NdviHealthRequestSerializer,
responses={
200: build_response(
NdviHealthEnvelopeSerializer,
"داده NDVI مزرعه با موفقیت بازگردانده شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"داده ورودی نامعتبر است.",
),
404: build_response(
SoilErrorResponseSerializer,
"مزرعه یافت نشد.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست NDVI",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
request_only=True,
)
],
)
def post(self, request):
serializer = NdviHealthRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("location_data").get_ndvi_health_service()
try:
data = service.get_ndvi_health(
farm_uuid=str(serializer.validated_data["farm_uuid"])
)
except ValueError as exc:
return Response(
{"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
View File
+17
View File
@@ -0,0 +1,17 @@
from django.apps import AppConfig
from functools import cached_property
class PestDiseaseConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pest_disease"
verbose_name = "Pest & Disease"
@cached_property
def risk_summary_service(self):
from .services import build_pest_disease_risk_summary
return build_pest_disease_risk_summary
def get_risk_summary_service(self):
return self.risk_summary_service
+50
View File
@@ -0,0 +1,50 @@
from rest_framework import serializers
class PestDiseaseDetectionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
query = serializers.CharField(required=False, allow_blank=True, help_text="توضیح اختیاری")
image_urls = serializers.JSONField(required=False, help_text="آرایه URL تصاویر")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class PestDiseaseRiskRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد")
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class PestDiseaseRiskSummaryRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class PestDiseaseRiskSummaryResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
diseaseRisk = serializers.JSONField()
pestRisk = serializers.JSONField()
drivers = serializers.JSONField()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any
from rag.services import get_pest_disease_risk
def _stats_label(level: str | None) -> str:
if level == "high":
return "بالا"
if level == "medium":
return "متوسط"
return "پایین"
def _normalize_risk_block(block: dict[str, Any] | None) -> dict[str, Any]:
payload = dict(block or {})
payload.setdefault("score", 0.0)
payload.setdefault("level", "low")
payload["statsLabel"] = _stats_label(payload.get("level"))
return payload
def build_pest_disease_risk_summary(*, farm_uuid: str) -> dict[str, Any]:
rag_result = get_pest_disease_risk(farm_uuid=farm_uuid)
return {
"farm_uuid": farm_uuid,
"diseaseRisk": _normalize_risk_block(rag_result.get("disease_risk")),
"pestRisk": _normalize_risk_block(rag_result.get("pest_risk")),
"drivers": {
"keyDrivers": rag_result.get("key_drivers") or [],
"summary": rag_result.get("summary"),
"forecastWindow": rag_result.get("forecast_window"),
"source": "rag",
},
}
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import PestDiseaseDetectionView, PestDiseaseRiskSummaryView, PestDiseaseRiskView
urlpatterns = [
path("detect/", PestDiseaseDetectionView.as_view(), name="pest-disease-detect"),
path("risk/", PestDiseaseRiskView.as_view(), name="pest-disease-risk"),
path("risk-summary/", PestDiseaseRiskSummaryView.as_view(), name="pest-disease-risk-summary"),
]
+203
View File
@@ -0,0 +1,203 @@
import json
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response
from rag.chat import encode_uploaded_image
from rag.services import get_pest_disease_detection, get_pest_disease_risk
from .serializers import (
PestDiseaseDetectionRequestSerializer,
PestDiseaseRiskRequestSerializer,
PestDiseaseRiskSummaryRequestSerializer,
PestDiseaseRiskSummaryResponseSerializer,
)
PestDiseaseValidationErrorSerializer = build_envelope_serializer(
"PestDiseaseValidationErrorSerializer",
data_required=False,
allow_null=True,
)
PestDiseaseDetectionResponseSerializer = build_envelope_serializer(
"PestDiseaseDetectionResponseSerializer",
data_schema=None,
)
PestDiseaseRiskResponseSerializer = build_envelope_serializer(
"PestDiseaseRiskResponseSerializer",
data_schema=None,
)
PestDiseaseRiskSummaryEnvelopeSerializer = build_envelope_serializer(
"PestDiseaseRiskSummaryEnvelopeSerializer",
PestDiseaseRiskSummaryResponseSerializer,
)
class _ImageMixin:
parser_classes = [JSONParser, MultiPartParser, FormParser]
def _collect_uploaded_images(self, request):
images = []
for uploaded in request.FILES.getlist("images"):
images.append(encode_uploaded_image(uploaded))
single_image = request.FILES.get("image")
if single_image is not None:
images.append(encode_uploaded_image(single_image))
image_urls = request.data.get("image_urls")
if isinstance(image_urls, str) and image_urls.strip():
try:
parsed = json.loads(image_urls)
except (json.JSONDecodeError, ValueError):
parsed = [image_urls]
image_urls = parsed
if isinstance(image_urls, list):
for item in image_urls:
if isinstance(item, str) and item.strip():
images.append({"url": item.strip(), "detail": "auto"})
elif isinstance(item, dict) and isinstance(item.get("url"), str):
images.append({"url": item["url"].strip(), "detail": item.get("detail", "auto")})
return images
class PestDiseaseDetectionView(_ImageMixin, APIView):
@extend_schema(
tags=["Pest & Disease"],
summary="تشخیص آفت یا بیماری از روی تصویر",
description="با دریافت farm_uuid و حداقل یک تصویر، تصویر گیاه را با کمک RAG بررسی می کند و نتیجه تشخیص را برمی گرداند.",
request=PestDiseaseDetectionRequestSerializer,
responses={
200: build_response(PestDiseaseDetectionResponseSerializer, "نتیجه تشخیص آفت/بیماری."),
400: build_response(PestDiseaseValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(PestDiseaseValidationErrorSerializer, "خطا در تحلیل تصویر گیاه."),
},
examples=[
OpenApiExample(
"نمونه درخواست تشخیص",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه فرنگی",
"query": "این برگ ها مشکوک به آفت هستند؟",
"image_urls": ["https://example.com/leaf.jpg"],
},
request_only=True,
),
],
)
def post(self, request):
serializer = PestDiseaseDetectionRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
images = self._collect_uploaded_images(request)
if not images:
return Response(
{"code": 400, "msg": "حداقل یک تصویر باید ارسال شود.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_pest_disease_detection(
farm_uuid=validated["farm_uuid"],
plant_name=validated.get("plant_name"),
query=validated.get("query"),
images=images,
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در تحلیل تصویر گیاه: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
class PestDiseaseRiskView(APIView):
@extend_schema(
tags=["Pest & Disease"],
summary="پیش بینی ریسک آفات و بیماری",
description="با دریافت farm_uuid، داده های مزرعه و پایگاه دانش تخصصی را به RAG می دهد و ریسک آفات و بیماری را برمی گرداند.",
request=PestDiseaseRiskRequestSerializer,
responses={
200: build_response(PestDiseaseRiskResponseSerializer, "خروجی پیش بینی ریسک آفات و بیماری."),
400: build_response(PestDiseaseValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(PestDiseaseValidationErrorSerializer, "خطا در پیش بینی ریسک آفات و بیماری."),
},
examples=[
OpenApiExample(
"نمونه درخواست ریسک",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = PestDiseaseRiskRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_pest_disease_risk(
farm_uuid=validated["farm_uuid"],
plant_name=validated.get("plant_name"),
growth_stage=validated.get("growth_stage"),
query=validated.get("query"),
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در پیش بینی ریسک آفات و بیماری: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
class PestDiseaseRiskSummaryView(APIView):
@extend_schema(
tags=["Pest & Disease"],
summary="خلاصه ریسک بیماری و آفات",
description=(
"با دریافت farm_uuid، خلاصه ریسک بیماری و آفات را از سرویس RAG "
"گرفته و در قالب سبک تر برای KPI بازمی گرداند."
),
request=PestDiseaseRiskSummaryRequestSerializer,
responses={
200: build_response(PestDiseaseRiskSummaryEnvelopeSerializer, "خلاصه ریسک بیماری و آفات."),
400: build_response(PestDiseaseValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
404: build_response(PestDiseaseValidationErrorSerializer, "مزرعه یافت نشد."),
},
examples=[
OpenApiExample(
"نمونه درخواست risk summary",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
request_only=True,
)
],
)
def post(self, request):
from .services import build_pest_disease_risk_summary
serializer = PestDiseaseRiskSummaryRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = build_pest_disease_risk_summary(farm_uuid=serializer.validated_data["farm_uuid"])
except ValueError as exc:
return Response(
{"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
+99 -4
View File
@@ -1,9 +1,12 @@
""" """
چت RAG برای API چت عمومی با ارسال کامل داده مزرعه و retrieval تکمیلی از KB. چت RAG برای API چت عمومی با ارسال کامل داده مزرعه و retrieval تکمیلی از KB.
""" """
import base64
import json import json
import logging import logging
import mimetypes
from pathlib import Path from pathlib import Path
from typing import Any
from .api_provider import get_chat_client from .api_provider import get_chat_client
from .chunker import chunk_text from .chunker import chunk_text
@@ -13,6 +16,95 @@ from .retrieve import search_with_texts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _coerce_text_content(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, dict) and item.get("type") == "text":
text_value = item.get("text")
if isinstance(text_value, str) and text_value.strip():
parts.append(text_value.strip())
elif isinstance(item, str) and item.strip():
parts.append(item.strip())
return "\n".join(parts)
return str(value)
def _normalize_image_inputs(images: list[Any] | None) -> list[dict[str, str]]:
normalized: list[dict[str, str]] = []
for item in images or []:
if isinstance(item, str):
value = item.strip()
if value:
normalized.append({"url": value})
continue
if not isinstance(item, dict):
continue
url = item.get("url") or item.get("image_url") or item.get("data_url")
if not isinstance(url, str) or not url.strip():
continue
entry = {"url": url.strip()}
detail = item.get("detail")
if isinstance(detail, str) and detail.strip():
entry["detail"] = detail.strip()
normalized.append(entry)
return normalized
def _build_content_parts(text: str, images: list[dict[str, str]] | None = None) -> str | list[dict[str, Any]]:
normalized_text = (text or "").strip()
normalized_images = _normalize_image_inputs(images)
if not normalized_images:
return normalized_text
parts: list[dict[str, Any]] = []
if normalized_text:
parts.append({"type": "text", "text": normalized_text})
for image in normalized_images:
image_payload: dict[str, Any] = {"url": image["url"]}
if image.get("detail"):
image_payload["detail"] = image["detail"]
parts.append({"type": "image_url", "image_url": image_payload})
return parts
def _normalize_history_messages(history: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for item in history or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
text = _coerce_text_content(
item.get("content", item.get("message", item.get("text")))
).strip()
images = _normalize_image_inputs(item.get("images") or item.get("image_urls"))
if not text and not images:
continue
content = _build_content_parts(text, images if role == "user" else None)
normalized.append({"role": role, "content": content})
return normalized
def encode_uploaded_image(uploaded_file: Any) -> dict[str, str]:
content_type = getattr(uploaded_file, "content_type", None) or mimetypes.guess_type(
getattr(uploaded_file, "name", "")
)[0] or "application/octet-stream"
raw = uploaded_file.read()
if not isinstance(raw, (bytes, bytearray)):
raise ValueError("Uploaded image payload is invalid.")
encoded = base64.b64encode(raw).decode("ascii")
return {
"url": f"data:{content_type};base64,{encoded}",
"detail": "auto",
}
def _load_tone(config: RAGConfig | None) -> str: def _load_tone(config: RAGConfig | None) -> str:
"""بارگذاری فایل لحن پیش‌فرض (chat KB).""" """بارگذاری فایل لحن پیش‌فرض (chat KB)."""
cfg = config or load_rag_config() cfg = config or load_rag_config()
@@ -214,6 +306,8 @@ def chat_rag_stream(
config: RAGConfig | None = None, config: RAGConfig | None = None,
system_override: str | None = None, system_override: str | None = None,
farm_details: dict | None = None, farm_details: dict | None = None,
history: list[dict[str, Any]] | None = None,
images: list[dict[str, str]] | None = None,
): ):
""" """
چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه. چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه.
@@ -223,6 +317,8 @@ def chat_rag_stream(
farm_uuid: شناسه مزرعه farm_uuid: شناسه مزرعه
config: تنظیمات RAG config: تنظیمات RAG
system_override: جایگزین system prompt (اختیاری) system_override: جایگزین system prompt (اختیاری)
history: لیست پیام های قبلی کاربر/هوش مصنوعی
images: تصاویر مربوط به پیام فعلی کاربر
Yields: Yields:
chunk های استریم پاسخ مدل chunk های استریم پاسخ مدل
@@ -268,10 +364,9 @@ def chat_rag_stream(
else: else:
system_prompt = _build_system_prompt(service, query, context, cfg) system_prompt = _build_system_prompt(service, query, context, cfg)
messages = [ messages = [{"role": "system", "content": system_prompt}]
{"role": "system", "content": system_prompt}, messages.extend(_normalize_history_messages(history))
{"role": "user", "content": query}, messages.append({"role": "user", "content": _build_content_parts(query, images)})
]
logger.info( logger.info(
"Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s", "Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s",
+7
View File
@@ -4,8 +4,15 @@
""" """
from .irrigation import get_irrigation_recommendation from .irrigation import get_irrigation_recommendation
from .fertilization import get_fertilization_recommendation from .fertilization import get_fertilization_recommendation
from .pest_disease import get_pest_disease_detection, get_pest_disease_risk
from .soil_anomaly import get_soil_anomaly_insight
from .water_need_prediction import get_water_need_prediction_insight
__all__ = [ __all__ = [
"get_irrigation_recommendation", "get_irrigation_recommendation",
"get_fertilization_recommendation", "get_fertilization_recommendation",
"get_pest_disease_detection",
"get_pest_disease_risk",
"get_soil_anomaly_insight",
"get_water_need_prediction_insight",
] ]
+88 -3
View File
@@ -54,6 +54,62 @@ def _find_section(sections: list[dict], section_type: str) -> dict | None:
return None return None
def _field_sources(llm_section: dict, fallback_section: dict, merged_section: dict) -> dict[str, str]:
sources: dict[str, str] = {}
for key, value in merged_section.items():
if key == "provenance":
continue
llm_value = llm_section.get(key)
fallback_value = fallback_section.get(key)
if key in llm_section and value == llm_value and value != fallback_value:
sources[key] = "llm"
elif key in fallback_section and value == fallback_value and value != llm_value:
sources[key] = "fallback"
elif key in llm_section and key in fallback_section and llm_value == fallback_value == value:
sources[key] = "shared"
elif key in llm_section and key in fallback_section:
sources[key] = "merged"
else:
sources[key] = "fallback" if key in fallback_section else "llm"
return sources
def _attach_provenance(section_type: str, llm_section: dict, fallback_section: dict, merged_section: dict) -> dict:
merged = dict(merged_section)
field_sources = _field_sources(llm_section, fallback_section, merged)
merged["provenance"] = {
"sectionType": section_type,
"llmProvided": bool(llm_section),
"fallbackUsed": any(source != "llm" for source in field_sources.values()),
"fieldSources": field_sources,
}
return merged
def _fallback_with_provenance(fallback: dict, reason: str) -> dict:
sections = []
for section in fallback.get("sections", []):
section_with_provenance = dict(section)
section_with_provenance["provenance"] = {
"sectionType": section.get("type"),
"llmProvided": False,
"fallbackUsed": True,
"fieldSources": {
key: "fallback"
for key in section.keys()
if key != "provenance"
},
}
sections.append(section_with_provenance)
return {
"sections": sections,
"mergeMetadata": {
"source": "fallback_only",
"reason": reason,
},
}
def _build_fertilization_fallback(*, optimized_result: dict | None) -> dict: def _build_fertilization_fallback(*, optimized_result: dict | None) -> dict:
if optimized_result: if optimized_result:
recommended = optimized_result["recommended_strategy"] recommended = optimized_result["recommended_strategy"]
@@ -134,11 +190,11 @@ def _merge_fertilization_response(
) -> dict: ) -> dict:
fallback = _build_fertilization_fallback(optimized_result=optimized_result) fallback = _build_fertilization_fallback(optimized_result=optimized_result)
if not isinstance(parsed_result, dict): if not isinstance(parsed_result, dict):
return fallback return _fallback_with_provenance(fallback, "invalid_llm_payload")
sections = parsed_result.get("sections") sections = parsed_result.get("sections")
if not isinstance(sections, list): if not isinstance(sections, list):
return fallback return _fallback_with_provenance(fallback, "missing_sections")
recommendation = _find_section(sections, "recommendation") or {} recommendation = _find_section(sections, "recommendation") or {}
list_section = _find_section(sections, "list") or {} list_section = _find_section(sections, "list") or {}
@@ -169,7 +225,36 @@ def _merge_fertilization_response(
"content": warning_section.get("content") or fallback_warning["content"], "content": warning_section.get("content") or fallback_warning["content"],
} }
return {"sections": [merged_recommendation, merged_list, merged_warning]} merged_recommendation = _attach_provenance(
"recommendation",
recommendation,
fallback_recommendation,
merged_recommendation,
)
merged_list = _attach_provenance(
"list",
list_section,
fallback_list,
merged_list,
)
merged_warning = _attach_provenance(
"warning",
warning_section,
fallback_warning,
merged_warning,
)
return {
"sections": [merged_recommendation, merged_list, merged_warning],
"mergeMetadata": {
"source": "llm_with_fallback_merge",
"llmSectionsDetected": [section.get("type") for section in sections if isinstance(section, dict)],
"fallbackSectionsApplied": [
item["type"]
for item in (fallback_recommendation, fallback_list, fallback_warning)
],
},
}
def get_fertilization_recommendation( def get_fertilization_recommendation(

Some files were not shown because too many files have changed in this diff Show More