diff --git a/API_RELIABILITY_AUDIT_FA.md b/API_RELIABILITY_AUDIT_FA.md new file mode 100644 index 0000000..3b2e58e --- /dev/null +++ b/API_RELIABILITY_AUDIT_FA.md @@ -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 + diff --git a/APPS_URLS_AUDIT.md b/APPS_URLS_AUDIT.md new file mode 100644 index 0000000..68b836d --- /dev/null +++ b/APPS_URLS_AUDIT.md @@ -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//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//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//` | جزئیات گیاه | +| PUT | `/api/plants//` | ویرایش کامل گیاه | +| PATCH | `/api/plants//` | ویرایش جزئی گیاه | +| DELETE | `/api/plants//` | حذف گیاه | +| 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//` | جزئیات روش آبیاری | +| 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//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//` به احتمال زیاد `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` + diff --git a/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md b/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md new file mode 100644 index 0000000..bc926d2 --- /dev/null +++ b/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md @@ -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` + diff --git a/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt b/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt new file mode 100644 index 0000000..11927c2 --- /dev/null +++ b/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt @@ -0,0 +1,14 @@ +در این پایگاه دانش، هشدارهای مزرعه باید به سه سطح استاندارد تقسیم شوند: +- danger: خطر فوری که به اقدام سریع نیاز دارد. +- warning: هشدار مهم که باید در کوتاه مدت پیگیری شود. +- info: اطلاع رسانی برای پایش، ثبت، یا اقدام کم ریسک. + +قاعده های کلی: +1. اگر تنش می تواند باعث آسیب سریع به گیاه، ریشه، یا عملکرد شود، سطح danger مناسب است. +2. اگر تنش هنوز بحرانی نیست ولی روند آن نگران کننده است، سطح warning مناسب است. +3. اگر فقط برای پایش یا آگاهی اپراتور مفید است، سطح info مناسب است. +4. پیام ها باید کوتاه، اجرایی، و بدون اغراق باشند. +5. اگر داده کافی نیست، باید عدم قطعیت به صراحت بیان شود. +6. در متن نهایی فقط از داده های ساختاریافته مزرعه و هشدارهای محاسبه شده استفاده شود. +7. زمان، شدت، و اقدام پیشنهادی باید با وضعیت واقعی مزرعه همخوان باشد. +8. برای timeline باید ترتیب زمانی رویدادها حفظ شود و هر رویداد توضیح دهد چرا برای مزرعه مهم است. diff --git a/config/knowledge_base/pest_disease/pest_disease_knowledge.txt b/config/knowledge_base/pest_disease/pest_disease_knowledge.txt new file mode 100644 index 0000000..4b36bab --- /dev/null +++ b/config/knowledge_base/pest_disease/pest_disease_knowledge.txt @@ -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. همیشه خلاصه ای از دلیل نتیجه گیری ارائه بده. diff --git a/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt b/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt new file mode 100644 index 0000000..e602ff4 --- /dev/null +++ b/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt @@ -0,0 +1,23 @@ +تحليل ناهنجاري خاک و سنسور + +هدف اين دانشنامه کمک به تفسير ناهنجاري هاي آماري در داده هاي خاک و سنسور مزرعه است. + +اصول کلي: +- ناهنجاري آماري به معناي مشکل قطعي مزرعه نيست؛ اول بايد پايداري رخداد، شدت انحراف، و سازگاري آن با ساير شاخص ها بررسي شود. +- وقتي رطوبت خاک و دماي خاک همزمان ناهنجار مي شوند، احتمال تنش ريشه، آبياري نامناسب، يا موج گرما بيشتر است. +- وقتي EC و رطوبت خاک با هم ناهنجار شوند، فشار شوري، تجمع نمک، کيفيت نامناسب آب يا برنامه کوددهي نامتوازن بايد بررسي شود. +- اگر pH از محدوده معمول مزرعه فاصله بگيرد، دسترسي عناصر غذايي و کارايي جذب ريشه مي تواند تحت تاثير قرار بگيرد. +- ناهنجاري رطوبت هوا در کنار دما و رطوبت خاک مي تواند نشانه شرايط مساعد براي بيماري يا افزايش تبخير-تعرق باشد. + +راهنماي تفسير شدت: +- low: انحراف خفيف يا کوتاه مدت؛ معمولا نياز به پايش دارد. +- medium: انحراف قابل توجه؛ بايد با شرايط مزرعه و آبياري تطبيق داده شود. +- high: انحراف مهم؛ بازبيني سريع سنسور و عمليات مزرعه لازم است. +- critical: رخداد شديد يا پرتکرار؛ نياز به اقدام فوري و بررسي ميداني دارد. + +اقدامات پيشنهادي عمومي: +- وضعيت آخرين آبياري، زمان بندي و يکنواختي توزيع آب بررسي شود. +- کاليبراسيون سنسور و سلامت سخت افزاري آن در رخدادهاي ناگهاني کنترل شود. +- تغييرات اخير در کوددهي، شوري آب، بارش موثر و دماي محيط در تحليل لحاظ شود. +- اگر ناهنجاري در چند شاخص همزمان ديده شد، اولويت پايش و مداخله بالاتر در نظر گرفته شود. +- اگر ناهنجاري در داده هاي محدود يا ناقص ديده شد، قبل از توصيه قطعي کمبود داده صريح گفته شود. diff --git a/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt b/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt new file mode 100644 index 0000000..413be5e --- /dev/null +++ b/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt @@ -0,0 +1,17 @@ +تحليل نياز آبي کوتاه مدت مزرعه + +اين دانشنامه براي تفسير خروجي محاسبات نياز آبي روزهاي آينده استفاده مي شود. + +اصول کلي: +- `et0` تبخير-تعرق مرجع است و نشان مي دهد شرايط اقليمي هر روز چه ميزان تقاضاي تبخير-تعرق ايجاد مي کند. +- `etc` از ضرب `et0` در ضريب گياهي `kc` به دست مي آيد و تخمين مناسب تري از نياز آبي محصول مي دهد. +- `effective_rainfall` بخشي از بارش است که واقعا در تامين نياز آبي گياه موثر واقع مي شود. +- `net_irrigation_mm` نياز آبي خالص پس از کسر بارش موثر است. +- `gross_irrigation_mm` نياز آبي واقعي اجرايي با درنظر گرفتن راندمان سامانه آبياري است. + +راهنماي تفسير: +- اگر `gross_irrigation_mm` در چند روز پياپي بالا باشد، برنامه آبياري بايد فشرده تر و منظم تر تنظيم شود. +- اگر راندمان آبياري پايين باشد، اختلاف بين نياز خالص و ناخالص بيشتر مي شود و اتلاف آب بالاتر است. +- در روزهاي گرم، پر باد يا کم بارش، بهتر است اجراي آبياري به صبح زود يا نزديک غروب منتقل شود. +- اگر بارش موثر پيش بيني شده باشد، بخشي از نياز آبي مي تواند بدون آبياري اضافي تامين شود. +- توصيه ها بايد عملياتي، کوتاه مدت، و همسو با forecast فعلي باشند و در صورت عدم قطعيت، آن را صريح بيان کنند. diff --git a/config/rag_config.yaml b/config/rag_config.yaml index 4e70bf4..4af0257 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -51,6 +51,26 @@ knowledge_bases: tone_file: "config/tones/fertilization_tone.txt" 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: support_bot: knowledge_base: "chat" @@ -104,3 +124,55 @@ services: api_key_env: "GAPGPT_API_KEY" avalai_base_url: "https://api.avalai.ir/v1" 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" diff --git a/config/settings.py b/config/settings.py index 760b51f..100ec2b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -25,12 +25,15 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "dashboard_data", + "farm_alerts.apps.FarmAlertsConfig", "rag", "location_data", + "soile.apps.SoileConfig", "farm_data.apps.FarmDataConfig", "weather", + "economy.apps.EconomyConfig", "plant", + "pest_disease.apps.PestDiseaseConfig", "irrigation", "fertilization", "crop_simulation.apps.CropSimulationConfig", @@ -133,12 +136,16 @@ SPECTACULAR_SETTINGS = { "COMPONENT_SPLIT_REQUEST": True, "TAGS": [ {"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"}, + {"name": "Farm Alerts", "description": "tracker و timeline مستقل هشدارهای مزرعه"}, {"name": "RAG Chat", "description": "چت هوشمند RAG"}, {"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"}, {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, + {"name": "Soile", "description": "heatmap مستقل رطوبت خاک و داده های مزرعه"}, {"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"}, + {"name": "Economy", "description": "نمای اقتصادی مستقل مزرعه"}, {"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"}, {"name": "Plant", "description": "مدیریت گیاهان و دریافت اطلاعات گیاه"}, + {"name": "Pest & Disease", "description": "تشخیص تصویری و پیش بینی ریسک آفات و بیماری"}, {"name": "Crop Simulation", "description": "شبیه سازی رشد و مقایسه سناریوهای گیاه"}, {"name": "Irrigation", "description": "مدیریت روش‌های آبیاری"}, {"name": "Irrigation Recommendation", "description": "درخواست و پیگیری توصیه آبیاری"}, diff --git a/config/settings_test.py b/config/settings_test.py index 18bd6a6..2cfc573 100644 --- a/config/settings_test.py +++ b/config/settings_test.py @@ -8,3 +8,8 @@ LOGGING = { # noqa: F405 "handlers": {"console": {"class": "logging.StreamHandler"}}, "root": {"handlers": ["console"], "level": "WARNING"}, } + +REST_FRAMEWORK = { # noqa: F405 + **REST_FRAMEWORK, # noqa: F405 + "DEFAULT_AUTHENTICATION_CLASSES": [], +} diff --git a/config/test_urls.py b/config/test_urls.py index c884f06..8bdb1b8 100644 --- a/config/test_urls.py +++ b/config/test_urls.py @@ -9,4 +9,7 @@ def test_view(_request): urlpatterns = [ path("__test__/", test_view), 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")), ] diff --git a/config/tones/chat_tone.txt b/config/tones/chat_tone.txt index 6c15d63..f5856cb 100644 --- a/config/tones/chat_tone.txt +++ b/config/tones/chat_tone.txt @@ -1,39 +1,14 @@ -**قالب خروجی (Output Format):** -شما موظف هستید پاسخ خود را **فقط و فقط** در قالب یک شیء JSON معتبر برگردانید. هیچ متن اضافی قبل یا بعد از JSON نباید وجود داشته باشد. ساختار JSON باید به شکل زیر باشد: +شما دستيار عمومي CropLogic براي چت با کاربر هستيد. -{ - "content": "متن کلی و دوستانه پاسخ به کشاورز", - "sections": [ - // نکته بسیار مهم: این آرایه می‌تواند شامل یک، دو یا هر سه نوع بخش زیر باشد. هر بخش را **فقط و فقط در صورتی** اضافه کن که برای پاسخ به سوال کشاورز ضروری و مرتبط باشد: +قواعد مهم: +- اين سرويس خروجی را به صورت متن استريمي `text/plain` برمي گرداند، نه JSON. +- بنابراين فقط متن ساده و خوانا توليد کن و هرگز JSON، markdown fence يا ساختار کدي برنگردان. +- پاسخ را به فارسي، دوستانه، شفاف و کاربردي بنويس. +- اگر لازم بود، پاسخ را در 2 تا 4 پاراگراف کوتاه يا چند خط فهرست گونه اما بدون JSON ارائه کن. +- اگر داده کافي نيست، همان را صريح بگو و از حدس زدن پرهيز کن. - // ۱. فقط در صورت نیاز به ارائه توصیه یا برنامه اجرایی: - { - "type": "recommendation", - "title": "عنوان توصیه یا برنامه (مثلاً برنامه آبیاری یا یک توصیه کلی)", - "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 قرار دهید یا به کلی از شیء حذف کنید (حذف کردن فیلدهای غیرضروری ترجیح داده می‌شود). +شکل خروجي مورد انتظار: +- يک پاسخ متني يکپارچه +- بدون کليد JSON +- بدون `sections` +- بدون کاراکترهاي ابتدايي/انتهايي اضافه diff --git a/config/tones/farm_alerts_tone.txt b/config/tones/farm_alerts_tone.txt new file mode 100644 index 0000000..6f937ae --- /dev/null +++ b/config/tones/farm_alerts_tone.txt @@ -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` بايد يک اقدام مشخص مزرعه اي باشد، نه توصيه کلي. diff --git a/config/tones/pest_disease_tone.txt b/config/tones/pest_disease_tone.txt new file mode 100644 index 0000000..4c524d4 --- /dev/null +++ b/config/tones/pest_disease_tone.txt @@ -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` بايد عملي، کوتاه و قابل اجرا باشند. diff --git a/config/tones/soil_anomaly_tone.txt b/config/tones/soil_anomaly_tone.txt new file mode 100644 index 0000000..dcb45ce --- /dev/null +++ b/config/tones/soil_anomaly_tone.txt @@ -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` بايد اين موضوع را واضح بگويند. diff --git a/config/tones/water_need_prediction_tone.txt b/config/tones/water_need_prediction_tone.txt new file mode 100644 index 0000000..2928044 --- /dev/null +++ b/config/tones/water_need_prediction_tone.txt @@ -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` بگو. diff --git a/config/urls.py b/config/urls.py index f635694..f31ba76 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,10 +14,14 @@ urlpatterns = [ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), # --- App APIs --- 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/soile/", include("soile.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/pest-disease/", include("pest_disease.urls")), path("api/irrigation/", include("irrigation.urls")), path("api/fertilization/", include("fertilization.urls")), path("api/crop-simulation/", include("crop_simulation.urls")), diff --git a/crop_simulation/apps.py b/crop_simulation/apps.py index a936717..b00fa43 100644 --- a/crop_simulation/apps.py +++ b/crop_simulation/apps.py @@ -14,5 +14,41 @@ class CropSimulationConfig(AppConfig): 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): 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 diff --git a/crop_simulation/growth_simulation.py b/crop_simulation/growth_simulation.py index 8fde124..e5c30ff 100644 --- a/crop_simulation/growth_simulation.py +++ b/crop_simulation/growth_simulation.py @@ -402,8 +402,7 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i ) return response["result"], response.get("scenario_id"), None except Exception as exc: - fallback = _run_projection_engine(context) - return fallback, None, str(exc) + raise GrowthSimulationError(f"Simulation engine failed: {exc}") from exc 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", [])), "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, + ) diff --git a/crop_simulation/harvest_prediction.py b/crop_simulation/harvest_prediction.py new file mode 100644 index 0000000..7362abb --- /dev/null +++ b/crop_simulation/harvest_prediction.py @@ -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) diff --git a/crop_simulation/serializers.py b/crop_simulation/serializers.py index acf11ad..9162a94 100644 --- a/crop_simulation/serializers.py +++ b/crop_simulation/serializers.py @@ -72,3 +72,58 @@ class GrowthSimulationResultSerializer(serializers.Serializer): pagination = GrowthPaginationSerializer() daily_records_count = 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() diff --git a/crop_simulation/test_growth_simulation_api.py b/crop_simulation/test_growth_simulation_api.py index 73b95b5..82188f5 100644 --- a/crop_simulation/test_growth_simulation_api.py +++ b/crop_simulation/test_growth_simulation_api.py @@ -54,16 +54,31 @@ class PlantGrowthSimulationApiTests(TestCase): ] def test_run_growth_simulation_returns_stage_timeline(self): - 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, - } - ) + with patch("crop_simulation.growth_simulation._run_simulation") as mock_run_simulation: + mock_run_simulation.return_value = ( + { + "engine": "pcse", + "model_name": "wofost", + "metrics": {"yield_estimate": 10.0}, + "daily_output": [ + {"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.assertGreaterEqual(result["daily_records_count"], 3) @@ -143,3 +158,133 @@ class PlantGrowthSimulationApiTests(TestCase): self.assertEqual(payload["pagination"]["page"], 2) self.assertEqual(len(payload["stages_page"]), 1) 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") diff --git a/crop_simulation/urls.py b/crop_simulation/urls.py index 24ed1a6..21a83b5 100644 --- a/crop_simulation/urls.py +++ b/crop_simulation/urls.py @@ -1,9 +1,18 @@ from django.urls import path -from .views import PlantGrowthSimulationStatusView, PlantGrowthSimulationView +from .views import ( + CurrentFarmSimulationChartView, + HarvestPredictionView, + PlantGrowthSimulationStatusView, + PlantGrowthSimulationView, + YieldPredictionView, +) 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//status/", diff --git a/crop_simulation/views.py b/crop_simulation/views.py index fc0e2a6..9d66cd2 100644 --- a/crop_simulation/views.py +++ b/crop_simulation/views.py @@ -1,5 +1,7 @@ from __future__ import annotations +from django.apps import apps + from drf_spectacular.utils import OpenApiExample, extend_schema from rest_framework import status 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 .serializers import ( + CurrentFarmChartRequestSerializer, + CurrentFarmChartResponseSerializer, GrowthSimulationQueuedSerializer, GrowthSimulationRequestSerializer, GrowthSimulationResultSerializer, + HarvestPredictionRequestSerializer, + HarvestPredictionResponseSerializer, + YieldPredictionRequestSerializer, + YieldPredictionResponseSerializer, ) from .tasks import run_growth_simulation_task @@ -99,7 +107,7 @@ class PlantGrowthSimulationView(APIView): value={ "plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS", "LAI", "TAGP"], - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", }, request_only=True, ), @@ -173,3 +181,171 @@ class PlantGrowthSimulationStatusView(APIView): {"code": 200, "msg": "success", "data": payload}, 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) diff --git a/crop_simulation/water_stress.py b/crop_simulation/water_stress.py new file mode 100644 index 0000000..a5ba06d --- /dev/null +++ b/crop_simulation/water_stress.py @@ -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, + } diff --git a/crop_simulation/yield_prediction.py b/crop_simulation/yield_prediction.py new file mode 100644 index 0000000..cb26b0c --- /dev/null +++ b/crop_simulation/yield_prediction.py @@ -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 diff --git a/dashboard_data/ai_bundle.py b/dashboard_data/ai_bundle.py deleted file mode 100644 index 9ce1f85..0000000 --- a/dashboard_data/ai_bundle.py +++ /dev/null @@ -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", ""), - } diff --git a/dashboard_data/apps.py b/dashboard_data/apps.py deleted file mode 100644 index 146b272..0000000 --- a/dashboard_data/apps.py +++ /dev/null @@ -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" - diff --git a/dashboard_data/card_utils.py b/dashboard_data/card_utils.py deleted file mode 100644 index 5ad107a..0000000 --- a/dashboard_data/card_utils.py +++ /dev/null @@ -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, "نامشخص") - diff --git a/dashboard_data/cards/CARD_FORMULAS.md b/dashboard_data/cards/CARD_FORMULAS.md deleted file mode 100644 index 2205cd5..0000000 --- a/dashboard_data/cards/CARD_FORMULAS.md +++ /dev/null @@ -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 = "بر اساس دمای فعلی، رطوبت خاک و اطلاعات . بازه بهینه برداشت محاسبه شده است." -``` - -اگر گیاهی وجود نداشته باشد: - -```text -plant_name = "محصول" -``` - ---- - -## 11) `yield_prediction_chart.py` - -تابع سازنده: `build_yield_prediction_chart` - -### فرمول‌ها - -#### 1. مقدار پایه - -```text -base = max(10, round(sensor.soil_moisture * 0.6)) -``` - -#### 2. سری سال جاری - -```text -current_year = [ - base + 0, - base + 2, - base + 4, - base + 6, - base + 8, - base + 10, - base + 12, - base + 11, - base + 9, - base + 7, - base + 5, - base + 4 -] -``` - -#### 3. سری سال گذشته - -```text -last_year = [value - 3 for value in current_year] -``` - -#### 4. خلاصه کارت - -عملکرد پیش‌بینی‌شده: - -```text -summary[0].amount = current_year[9] + " تن" -``` - -یعنی مقدار ماه دهم لیست (اندیس ۹). - -تاریخ برداشت: - -```text -harvest_month = "حدود " + str(today.month) -summary[1].amount = "+8%" -``` - -نکته: -- `+8%` مقدار ثابت است و از فرمول نیامده. - ---- - -## 12) `soil_moisture_heatmap.py` - -تابع سازنده: `build_soil_moisture_heatmap` - -### ورودی‌ها - -- `sensor.soil_moisture` → `base_moisture` -- `depth.wv0033` برای هر لایه عمق خاک - -### منطق اولیه - -```text -hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"] -``` - -اگر `depths` خالی باشد: - -```text -depths = [None, None] -``` - -### فرمول zone offset - -برای هر depth: - -```text -depth_offset = 0 if depth is None else round(depth.wv0033 / 10) -``` - -### فرمول هر خانه heatmap - -برای هر zone و هر ساعت: - -```text -value = clamp( - round(base_moisture + depth_offset - abs(3 - hour_index) * 2), - 0, - 100 -) -``` - -توضیح: -- `hour_index = 3` مرکز نمودار است و بیشترین مقدار را می‌دهد. -- هرچه از مرکز دورتر شویم، به ازای هر پله ۲ واحد کم می‌شود. - -ساختار خروجی هر نقطه: - -```text -{"x": hour, "y": value} -``` - ---- - -## 13) `ndvi_health_card.py` - -تابع سازنده: `build_ndvi_health_card` - -### ورودی‌ها - -- `sensor.nitrogen` → `nitrogen` -- `sensor.soil_moisture` → `moisture` - -### فرمول NDVI - -```text -ndvi = round( - clamp(((nitrogen / 100) * 0.4) + ((moisture / 100) * 0.6), 0.1, 0.95), - 2 -) -``` - -توضیح: -- نیتروژن ۴۰٪ وزن دارد. -- رطوبت خاک ۶۰٪ وزن دارد. -- خروجی بین `0.1` و `0.95` محدود می‌شود. - -### وضعیت‌های متنی - -#### 1. تنش نیتروژن - -```text -"پایین" if nitrogen >= 30 else "بالا" -``` - -#### 2. سلامت محصول - -```text -"خوب" if ndvi >= 0.65 else "متوسط" -``` - ---- - -## 14) `recommendations_list.py` - -تابع سازنده: `build_recommendations_list` - -### منطق - -این کارت فرمول داخلی ندارد: - -```text -recommendations = ai_bundle.get("recommendations", []) -``` - ---- - -## 15) `economic_overview.py` - -تابع سازنده: `build_economic_overview` - -### ورودی‌ها - -- `forecast.et0` برای ۶ forecast اول -- `sensor.nitrogen` -- `sensor.phosphorus` -- `sensor.potassium` - -### فرمول‌ها - -#### 1. هزینه آب - -```text -water_cost = round(sum(max(0, forecast.et0 * 20) for first 6 forecasts)) -``` - -در کد با fallback: - -```text -water_cost = round( - sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6]) -) -``` - -#### 2. نیاز کودی - -```text -fertilizer_need = round((nitrogen + phosphorus + potassium) / 3) -``` - -با fallback صفر برای هر کدام. - -#### 3. پیش‌بینی درآمد - -```text -revenue = round(max(1000, water_cost * 4.5)) -``` - -#### 4. صرفه‌جویی آب هوشمند - -```text -smart_saving = round(water_cost * 0.18) -``` - -### chartSeries - -#### سری هزینه آب - -```text -water_series = [max(1, round(water_cost / 6)) for _ in range(6)] -``` - -#### سری کود - -```text -fertilizer_series = [max(1, round(fertilizer_need / 6)) for _ in range(6)] -``` - -نکته: -- هر دو سری، ۶ مقدار تکراری یکسان تولید می‌کنند و روند ماهانه واقعی ندارند. - ---- - -## کارت‌های بدون فرمول محاسباتی - -این کارت‌ها فقط داده را از `ai_bundle` می‌خوانند: - -- `farm_alerts_timeline.py` -- `recommendations_list.py` - ---- - -## نکات مهم برای تیم - -- چند اسم کارت با واقعیت محاسبه‌شان دقیقاً منطبق نیست؛ مثلاً `avg_soil_moisture` واقعاً average چند منبع نیست. -- بعضی trendها نسبت به history درستِ همان شاخص محاسبه نمی‌شوند؛ مخصوصاً در `sensor_values_list.py`. -- چند مقدار hard-coded هستند، مثل: - - `light_score = 85` - - `sensor_values_list` برای نور = `850` - - `yield_prediction_chart` برای برداشت = `+8%` - - سری ایده‌آل در `sensor_radar_chart` -- چند کارت به‌جای مدل تحلیلی واقعی، از فرمول‌های heuristic ساده استفاده می‌کنند. - diff --git a/dashboard_data/cards/economic_overview.py b/dashboard_data/cards/economic_overview.py deleted file mode 100644 index 498c6f0..0000000 --- a/dashboard_data/cards/economic_overview.py +++ /dev/null @@ -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": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], - } diff --git a/dashboard_data/cards/farm_alerts_timeline.py b/dashboard_data/cards/farm_alerts_timeline.py deleted file mode 100644 index d2fd225..0000000 --- a/dashboard_data/cards/farm_alerts_timeline.py +++ /dev/null @@ -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", {}), - } diff --git a/dashboard_data/cards/farm_overview_kpis.py b/dashboard_data/cards/farm_overview_kpis.py deleted file mode 100644 index 13224c8..0000000 --- a/dashboard_data/cards/farm_overview_kpis.py +++ /dev/null @@ -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", - }, - ] - } diff --git a/dashboard_data/cards/farm_weather_card.py b/dashboard_data/cards/farm_weather_card.py deleted file mode 100644 index bcab705..0000000 --- a/dashboard_data/cards/farm_weather_card.py +++ /dev/null @@ -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, - }, - } diff --git a/dashboard_data/cards/harvest_prediction_card.py b/dashboard_data/cards/harvest_prediction_card.py deleted file mode 100644 index 7c06037..0000000 --- a/dashboard_data/cards/harvest_prediction_card.py +++ /dev/null @@ -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, - } diff --git a/dashboard_data/cards/recommendations_list.py b/dashboard_data/cards/recommendations_list.py deleted file mode 100644 index 2cbf2f4..0000000 --- a/dashboard_data/cards/recommendations_list.py +++ /dev/null @@ -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", {}), - } diff --git a/dashboard_data/cards/sensor_comparison_chart.py b/dashboard_data/cards/sensor_comparison_chart.py deleted file mode 100644 index 768b20b..0000000 --- a/dashboard_data/cards/sensor_comparison_chart.py +++ /dev/null @@ -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: "داده معتبر در دسترس نیست", - }, - } diff --git a/dashboard_data/cards/sensor_radar_chart.py b/dashboard_data/cards/sensor_radar_chart.py deleted file mode 100644 index bb655c6..0000000 --- a/dashboard_data/cards/sensor_radar_chart.py +++ /dev/null @@ -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}, - ], - } diff --git a/dashboard_data/cards/sensor_values_list.py b/dashboard_data/cards/sensor_values_list.py deleted file mode 100644 index 6641b74..0000000 --- a/dashboard_data/cards/sensor_values_list.py +++ /dev/null @@ -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} diff --git a/dashboard_data/cards/soil_moisture_heatmap.py b/dashboard_data/cards/soil_moisture_heatmap.py deleted file mode 100644 index e9440e3..0000000 --- a/dashboard_data/cards/soil_moisture_heatmap.py +++ /dev/null @@ -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: "داده معتبر در دسترس نیست", - }, - } diff --git a/dashboard_data/cards/water_need_prediction.py b/dashboard_data/cards/water_need_prediction.py deleted file mode 100644 index 4a83f9c..0000000 --- a/dashboard_data/cards/water_need_prediction.py +++ /dev/null @@ -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, - } diff --git a/dashboard_data/cards/yield_prediction_chart.py b/dashboard_data/cards/yield_prediction_chart.py deleted file mode 100644 index 1c813ea..0000000 --- a/dashboard_data/cards/yield_prediction_chart.py +++ /dev/null @@ -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", - }, - ], - } diff --git a/dashboard_data/migrations/0001_initial.py b/dashboard_data/migrations/0001_initial.py deleted file mode 100644 index 0e05bb0..0000000 --- a/dashboard_data/migrations/0001_initial.py +++ /dev/null @@ -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"), - ], - }, - ), - ] - diff --git a/dashboard_data/migrations/0002_ndvi_observation.py b/dashboard_data/migrations/0002_ndvi_observation.py deleted file mode 100644 index 798d866..0000000 --- a/dashboard_data/migrations/0002_ndvi_observation.py +++ /dev/null @@ -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"), - ], - }, - ), - ] diff --git a/dashboard_data/migrations/0003_rename_dashboard_d_sensor__c0a279_idx_dashboard_d_sensor__2620a3_idx.py b/dashboard_data/migrations/0003_rename_dashboard_d_sensor__c0a279_idx_dashboard_d_sensor__2620a3_idx.py deleted file mode 100644 index 8eb4a2d..0000000 --- a/dashboard_data/migrations/0003_rename_dashboard_d_sensor__c0a279_idx_dashboard_d_sensor__2620a3_idx.py +++ /dev/null @@ -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', - ), - ] diff --git a/dashboard_data/models.py b/dashboard_data/models.py deleted file mode 100644 index 5a34b0c..0000000 --- a/dashboard_data/models.py +++ /dev/null @@ -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}" diff --git a/dashboard_data/services.py b/dashboard_data/services.py deleted file mode 100644 index 710ffec..0000000 --- a/dashboard_data/services.py +++ /dev/null @@ -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 diff --git a/dashboard_data/tasks.py b/dashboard_data/tasks.py deleted file mode 100644 index 37e0cec..0000000 --- a/dashboard_data/tasks.py +++ /dev/null @@ -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, - } diff --git a/dashboard_data/urls.py b/dashboard_data/urls.py deleted file mode 100644 index 0de3205..0000000 --- a/dashboard_data/urls.py +++ /dev/null @@ -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("/status/", DashboardDataStatusView.as_view(), name="dashboard-data-status"), -] - diff --git a/dashboard_data/views.py b/dashboard_data/views.py deleted file mode 100644 index e5503b7..0000000 --- a/dashboard_data/views.py +++ /dev/null @@ -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, - ) diff --git a/dashboard_data/__init__.py b/economy/__init__.py similarity index 100% rename from dashboard_data/__init__.py rename to economy/__init__.py diff --git a/economy/apps.py b/economy/apps.py new file mode 100644 index 0000000..bc317e5 --- /dev/null +++ b/economy/apps.py @@ -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 diff --git a/economy/serializers.py b/economy/serializers.py new file mode 100644 index 0000000..6f85d4f --- /dev/null +++ b/economy/serializers.py @@ -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()) diff --git a/economy/services.py b/economy/services.py new file mode 100644 index 0000000..9ab4001 --- /dev/null +++ b/economy/services.py @@ -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": ["فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور"], + } diff --git a/economy/test_economic_overview_api.py b/economy/test_economic_overview_api.py new file mode 100644 index 0000000..cf8702a --- /dev/null +++ b/economy/test_economic_overview_api.py @@ -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"], "هزینه آب") diff --git a/economy/urls.py b/economy/urls.py new file mode 100644 index 0000000..9d5eb80 --- /dev/null +++ b/economy/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import EconomicOverviewView + + +urlpatterns = [ + path("overview/", EconomicOverviewView.as_view(), name="economic-overview"), +] diff --git a/economy/views.py b/economy/views.py new file mode 100644 index 0000000..50a8683 --- /dev/null +++ b/economy/views.py @@ -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, + ) diff --git a/farm_alerts/__init__.py b/farm_alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard_data/cards/farm_alerts_tracker.py b/farm_alerts/alerts_tracker.py similarity index 99% rename from dashboard_data/cards/farm_alerts_tracker.py rename to farm_alerts/alerts_tracker.py index 8d7e92e..fb971a2 100644 --- a/dashboard_data/cards/farm_alerts_tracker.py +++ b/farm_alerts/alerts_tracker.py @@ -6,7 +6,9 @@ from typing import Any 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} diff --git a/farm_alerts/apps.py b/farm_alerts/apps.py new file mode 100644 index 0000000..4d111cf --- /dev/null +++ b/farm_alerts/apps.py @@ -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" diff --git a/farm_alerts/migrations/0001_initial.py b/farm_alerts/migrations/0001_initial.py new file mode 100644 index 0000000..e4a53ed --- /dev/null +++ b/farm_alerts/migrations/0001_initial.py @@ -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"), + ], + }, + ), + ] diff --git a/farm_alerts/migrations/__init__.py b/farm_alerts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farm_alerts/models.py b/farm_alerts/models.py new file mode 100644 index 0000000..a5a84a2 --- /dev/null +++ b/farm_alerts/models.py @@ -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}" diff --git a/farm_alerts/serializers.py b/farm_alerts/serializers.py new file mode 100644 index 0000000..4f10216 --- /dev/null +++ b/farm_alerts/serializers.py @@ -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 diff --git a/farm_alerts/services.py b/farm_alerts/services.py new file mode 100644 index 0000000..c4d77e9 --- /dev/null +++ b/farm_alerts/services.py @@ -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, + } diff --git a/farm_alerts/urls.py b/farm_alerts/urls.py new file mode 100644 index 0000000..4befa81 --- /dev/null +++ b/farm_alerts/urls.py @@ -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"), +] diff --git a/farm_alerts/views.py b/farm_alerts/views.py new file mode 100644 index 0000000..cc83925 --- /dev/null +++ b/farm_alerts/views.py @@ -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) diff --git a/dashboard_data/context.py b/farm_data/context.py similarity index 79% rename from dashboard_data/context.py rename to farm_data/context.py index a8b8daa..080db18 100644 --- a/dashboard_data/context.py +++ b/farm_data/context.py @@ -1,7 +1,7 @@ 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 location_data.models import SoilDepthData from farm_data.models import SensorData @@ -15,12 +15,9 @@ def load_dashboard_context(sensor_id: str) -> dict | None: return None location = sensor.center_location - depths = list( - SoilDepthData.objects.filter(soil_location=location).order_by("depth_label") - ) + depths = list(SoilDepthData.objects.filter(soil_location=location).order_by("depth_label")) forecasts = list( - WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()) - .order_by("forecast_date")[:7] + WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7] ) plants = list(sensor.plants.all()) irrigation_methods = list(IrrigationMethod.objects.all()[:5]) diff --git a/farm_data/services.py b/farm_data/services.py index b01c06f..75b24e8 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -1,6 +1,7 @@ from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP +from numbers import Number 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) 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) metric_sources = {key: "soil" for key in soil_metrics} for key, value in sensor_metrics.items(): resolved_metrics[key] = value - metric_sources[key] = "sensor" + metric_sources[key] = sensor_metric_sources[key] return { "center_location": { @@ -97,11 +98,7 @@ def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLoc if len(normalized_points) < 3: raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") - lat_sum = sum(lat for lat, _ in 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) + center_lat, center_lon = _compute_polygon_centroid(normalized_points) with transaction.atomic(): location, _ = SoilLocation.objects.update_or_create( @@ -152,16 +149,83 @@ def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocati 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): - return {} + return {}, {} - flattened = {} - for sensor_values in sensor_payload.values(): + readings_by_metric: dict[str, list[tuple[str, object]]] = {} + for sensor_key, sensor_values in sorted(sensor_payload.items()): if not isinstance(sensor_values, dict): continue - flattened.update(sensor_values) - return flattened + for metric_key, metric_value in sensor_values.items(): + 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: @@ -240,3 +304,40 @@ def _serialize_boundary(boundary: dict | list) -> dict: "type": "Polygon", "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), + ) diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index 98c5756..bf83cb6 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -11,6 +11,8 @@ from irrigation.models import IrrigationMethod from plant.models import Plant 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: return { @@ -93,7 +95,8 @@ class FarmDetailApiTests(TestCase): metric_sources = payload["soil"]["metric_sources"] 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(metric_sources["clay"], "soil") self.assertEqual(len(payload["soil"]["depths"]), 2) @@ -112,6 +115,38 @@ class FarmDetailApiTests(TestCase): self.assertEqual(response.status_code, 404) 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): def setUp(self): @@ -120,6 +155,21 @@ class FarmDataUpsertApiTests(TestCase): latitude="35.710000", 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.weather = WeatherForecast.objects.create( location=self.location, @@ -176,7 +226,16 @@ class FarmDataUpsertApiTests(TestCase): self.assertEqual(response.status_code, 400) 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() response = self.client.post( @@ -203,6 +262,26 @@ class FarmDataUpsertApiTests(TestCase): self.assertEqual(str(farm.center_location.longitude), "50.010000") 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.fetch_soil_data_for_coordinates") def test_post_fetches_missing_location_and_weather_data( diff --git a/farm_data/views.py b/farm_data/views.py index 5f6d0c3..d53321d 100644 --- a/farm_data/views.py +++ b/farm_data/views.py @@ -248,7 +248,8 @@ class FarmDetailView(APIView): summary="دریافت همه اطلاعات farm", description=( "اطلاعات تجمیعی farm را برمی‌گرداند. " - "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند." + "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند " + "و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند." ), responses={ 200: build_response( diff --git a/fertilization/views.py b/fertilization/views.py index e8d8673..7c151da 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -62,7 +62,7 @@ class FertilizationRecommendView(APIView): OpenApiExample( "نمونه درخواست", value={ - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", "plant_name": "گوجه‌فرنگی", "growth_stage": "گلدهی", }, diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 0000000..e72cb41 --- /dev/null +++ b/integration_tests/README.md @@ -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. + diff --git a/dashboard_data/cards/__init__.py b/integration_tests/__init__.py similarity index 100% rename from dashboard_data/cards/__init__.py rename to integration_tests/__init__.py diff --git a/integration_tests/base.py b/integration_tests/base.py new file mode 100644 index 0000000..6724f7e --- /dev/null +++ b/integration_tests/base.py @@ -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"] diff --git a/integration_tests/test_management_api_flow.py b/integration_tests/test_management_api_flow.py new file mode 100644 index 0000000..b9fae15 --- /dev/null +++ b/integration_tests/test_management_api_flow.py @@ -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, + ) diff --git a/integration_tests/test_reporting_and_ai_api_flow.py b/integration_tests/test_reporting_and_ai_api_flow.py new file mode 100644 index 0000000..1d534d1 --- /dev/null +++ b/integration_tests/test_reporting_and_ai_api_flow.py @@ -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, + ) diff --git a/irrigation/apps.py b/irrigation/apps.py index 4e6aa74..45e106b 100644 --- a/irrigation/apps.py +++ b/irrigation/apps.py @@ -49,3 +49,12 @@ class IrrigationConfig(AppConfig): def get_optimizer_defaults(self): 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 diff --git a/irrigation/indicators.py b/irrigation/indicators.py new file mode 100644 index 0000000..5172103 --- /dev/null +++ b/irrigation/indicators.py @@ -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 diff --git a/irrigation/serializers.py b/irrigation/serializers.py index ff806d2..38098a6 100644 --- a/irrigation/serializers.py +++ b/irrigation/serializers.py @@ -61,3 +61,22 @@ class IrrigationRecommendResponseSerializer(serializers.Serializer): code = serializers.IntegerField() msg = serializers.CharField() 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() diff --git a/irrigation/test_water_stress_api.py b/irrigation/test_water_stress_api.py new file mode 100644 index 0000000..1ac2985 --- /dev/null +++ b/irrigation/test_water_stress_api.py @@ -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") diff --git a/irrigation/urls.py b/irrigation/urls.py index 0b63a7e..ff4d9e7 100644 --- a/irrigation/urls.py +++ b/irrigation/urls.py @@ -4,10 +4,12 @@ from .views import ( IrrigationMethodDetailView, IrrigationMethodListCreateView, IrrigationRecommendView, + WaterStressView, ) urlpatterns = [ path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), path("/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"), + path("water-stress/", WaterStressView.as_view(), name="water-stress"), ] diff --git a/irrigation/views.py b/irrigation/views.py index 49a757d..8d720ec 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -1,3 +1,5 @@ +from django.apps import apps + from drf_spectacular.utils import OpenApiExample, extend_schema from rest_framework import status from rest_framework.response import Response @@ -12,6 +14,8 @@ from .models import IrrigationMethod from .serializers import ( IrrigationMethodSerializer, IrrigationRecommendRequestSerializer, + WaterStressRequestSerializer, + WaterStressResponseSerializer, ) @@ -33,6 +37,10 @@ IrrigationRecommendResponseSerializer = build_envelope_serializer( "IrrigationRecommendResponseSerializer", data_schema=None, ) +WaterStressEnvelopeSerializer = build_envelope_serializer( + "WaterStressEnvelopeSerializer", + WaterStressResponseSerializer, +) class IrrigationMethodListCreateView(APIView): @@ -139,7 +147,7 @@ class IrrigationRecommendView(APIView): OpenApiExample( "نمونه درخواست", value={ - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", "plant_name": "گوجه‌فرنگی", "growth_stage": "گلدهی", "irrigation_method_name": "آبیاری قطره‌ای", @@ -219,6 +227,46 @@ class IrrigationMethodDetailView(APIView): 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( tags=["Irrigation"], summary="ویرایش کامل روش آبیاری", diff --git a/location_data/apps.py b/location_data/apps.py index 621ebbb..fbc7554 100644 --- a/location_data/apps.py +++ b/location_data/apps.py @@ -1,3 +1,5 @@ +from functools import cached_property + from django.apps import AppConfig @@ -5,3 +7,12 @@ class SoilDataConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "location_data" 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 diff --git a/location_data/models.py b/location_data/models.py index 4c1b1c4..9197b79 100644 --- a/location_data/models.py +++ b/location_data/models.py @@ -117,3 +117,34 @@ class SoilDepthData(models.Model): def __str__(self): 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}" diff --git a/dashboard_data/cards/ndvi_health_card.py b/location_data/ndvi.py similarity index 81% rename from dashboard_data/cards/ndvi_health_card.py rename to location_data/ndvi.py index c3d0f27..6ecbe7d 100644 --- a/dashboard_data/cards/ndvi_health_card.py +++ b/location_data/ndvi.py @@ -1,6 +1,9 @@ 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: @@ -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: - context = context or {} - location = context.get("location") +def _build_ndvi_health_card(location: Any, ai_bundle: dict | None = None) -> dict[str, Any]: if location is None: return { "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) diff --git a/dashboard_data/remote_sensing.py b/location_data/remote_sensing.py similarity index 100% rename from dashboard_data/remote_sensing.py rename to location_data/remote_sensing.py diff --git a/location_data/serializers.py b/location_data/serializers.py index 13a6f9b..1c9cc31 100644 --- a/location_data/serializers.py +++ b/location_data/serializers.py @@ -74,3 +74,24 @@ class SoilDataTaskResponseSerializer(serializers.Serializer): lon = serializers.FloatField(source="longitude") lat = serializers.FloatField(source="latitude") 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) diff --git a/location_data/test_ndvi_health_api.py b/location_data/test_ndvi_health_api.py new file mode 100644 index 0000000..cdd87e0 --- /dev/null +++ b/location_data/test_ndvi_health_api.py @@ -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.") diff --git a/location_data/urls.py b/location_data/urls.py index 35d9c96..68971d7 100644 --- a/location_data/urls.py +++ b/location_data/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import SoilDataTaskStatusView, SoilDataView +from .views import NdviHealthView, SoilDataTaskStatusView, SoilDataView urlpatterns = [ path("", SoilDataView.as_view(), name="soil-data"), + path("ndvi-health/", NdviHealthView.as_view(), name="ndvi-health"), path("tasks//status/", SoilDataTaskStatusView.as_view(), name="soil-data-task-status"), ] diff --git a/location_data/views.py b/location_data/views.py index 50d574d..c0bb901 100644 --- a/location_data/views.py +++ b/location_data/views.py @@ -1,3 +1,4 @@ +from django.apps import apps from rest_framework import status from drf_spectacular.utils import ( OpenApiExample, @@ -16,6 +17,8 @@ from config.openapi import ( ) from .models import SoilLocation from .serializers import ( + NdviHealthRequestSerializer, + NdviHealthResponseSerializer, SoilDataRequestSerializer, SoilDepthDataSerializer, SoilDataTaskResponseSerializer, @@ -51,6 +54,10 @@ SoilTaskStatusResponseSerializer = build_envelope_serializer( "SoilTaskStatusResponseSerializer", build_task_status_data_serializer("SoilTaskStatusDataSerializer"), ) +NdviHealthEnvelopeSerializer = build_envelope_serializer( + "NdviHealthEnvelopeSerializer", + NdviHealthResponseSerializer, +) class SoilDataView(APIView): @@ -233,3 +240,56 @@ class SoilDataTaskStatusView(APIView): {"code": 200, "msg": "success", "data": data}, 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, + ) diff --git a/pest_disease/__init__.py b/pest_disease/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pest_disease/apps.py b/pest_disease/apps.py new file mode 100644 index 0000000..edf0331 --- /dev/null +++ b/pest_disease/apps.py @@ -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 diff --git a/pest_disease/serializers.py b/pest_disease/serializers.py new file mode 100644 index 0000000..8d1e7c2 --- /dev/null +++ b/pest_disease/serializers.py @@ -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() diff --git a/pest_disease/services.py b/pest_disease/services.py new file mode 100644 index 0000000..df0f3a6 --- /dev/null +++ b/pest_disease/services.py @@ -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", + }, + } diff --git a/pest_disease/urls.py b/pest_disease/urls.py new file mode 100644 index 0000000..6a044a1 --- /dev/null +++ b/pest_disease/urls.py @@ -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"), +] diff --git a/pest_disease/views.py b/pest_disease/views.py new file mode 100644 index 0000000..aedcc86 --- /dev/null +++ b/pest_disease/views.py @@ -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) diff --git a/rag/chat.py b/rag/chat.py index 4c8762d..ebc6636 100644 --- a/rag/chat.py +++ b/rag/chat.py @@ -1,9 +1,12 @@ """ چت RAG برای API چت عمومی — با ارسال کامل داده مزرعه و retrieval تکمیلی از KB. """ +import base64 import json import logging +import mimetypes from pathlib import Path +from typing import Any from .api_provider import get_chat_client from .chunker import chunk_text @@ -13,6 +16,95 @@ from .retrieve import search_with_texts 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: """بارگذاری فایل لحن پیش‌فرض (chat KB).""" cfg = config or load_rag_config() @@ -214,6 +306,8 @@ def chat_rag_stream( config: RAGConfig | None = None, system_override: str | None = None, farm_details: dict | None = None, + history: list[dict[str, Any]] | None = None, + images: list[dict[str, str]] | None = None, ): """ چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه. @@ -223,6 +317,8 @@ def chat_rag_stream( farm_uuid: شناسه مزرعه config: تنظیمات RAG system_override: جایگزین system prompt (اختیاری) + history: لیست پیام های قبلی کاربر/هوش مصنوعی + images: تصاویر مربوط به پیام فعلی کاربر Yields: chunk های استریم پاسخ مدل @@ -268,10 +364,9 @@ def chat_rag_stream( else: system_prompt = _build_system_prompt(service, query, context, cfg) - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": query}, - ] + messages = [{"role": "system", "content": system_prompt}] + messages.extend(_normalize_history_messages(history)) + messages.append({"role": "user", "content": _build_content_parts(query, images)}) logger.info( "Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s", diff --git a/rag/services/__init__.py b/rag/services/__init__.py index 1d64c4a..d446bb5 100644 --- a/rag/services/__init__.py +++ b/rag/services/__init__.py @@ -4,8 +4,15 @@ """ from .irrigation import get_irrigation_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__ = [ "get_irrigation_recommendation", "get_fertilization_recommendation", + "get_pest_disease_detection", + "get_pest_disease_risk", + "get_soil_anomaly_insight", + "get_water_need_prediction_insight", ] diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 37dead5..b1b6e0c 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -54,6 +54,62 @@ def _find_section(sections: list[dict], section_type: str) -> dict | 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: if optimized_result: recommended = optimized_result["recommended_strategy"] @@ -134,11 +190,11 @@ def _merge_fertilization_response( ) -> dict: fallback = _build_fertilization_fallback(optimized_result=optimized_result) if not isinstance(parsed_result, dict): - return fallback + return _fallback_with_provenance(fallback, "invalid_llm_payload") sections = parsed_result.get("sections") if not isinstance(sections, list): - return fallback + return _fallback_with_provenance(fallback, "missing_sections") recommendation = _find_section(sections, "recommendation") 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"], } - 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( diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 9069cc1..b405523 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -57,6 +57,62 @@ def _find_section(sections: list[dict], section_type: str) -> dict | 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_irrigation_fallback( *, optimized_result: dict | None, @@ -155,11 +211,11 @@ def _merge_irrigation_response( daily_water_needs=daily_water_needs, ) if not isinstance(parsed_result, dict): - return fallback + return _fallback_with_provenance(fallback, "invalid_llm_payload") sections = parsed_result.get("sections") if not isinstance(sections, list): - return fallback + return _fallback_with_provenance(fallback, "missing_sections") recommendation = _find_section(sections, "recommendation") or {} list_section = _find_section(sections, "list") or {} @@ -190,7 +246,36 @@ def _merge_irrigation_response( "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 _resolve_irrigation_method( diff --git a/rag/services/pest_disease.py b/rag/services/pest_disease.py new file mode 100644 index 0000000..5377a2b --- /dev/null +++ b/rag/services/pest_disease.py @@ -0,0 +1,415 @@ +""" +سرویس RAG برای تشخیص تصویری و پیش بینی ریسک آفات و بیماری گیاه. +""" +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +from rag.api_provider import get_chat_client +from rag.chat import ( + _build_content_parts, + _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 rag.user_data import build_plant_text + +logger = logging.getLogger(__name__) + +KB_NAME = "pest_disease" +SERVICE_ID = "pest_disease" + +DETECTION_PROMPT = ( + "شما یک دستیار تخصصی تشخیص آفات و بیماری گیاهی هستی. " + "با استفاده از تصویر، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش تحلیل کن. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: " + "has_issue, category, confidence, severity, summary, detected_signs, possible_causes, immediate_actions, reasoning. " + "category فقط یکی از no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown باشد. " + "severity فقط یکی از low, medium, high باشد." +) + +RISK_PROMPT = ( + "شما یک دستیار تخصصی پیش بینی ریسک آفات و بیماری گیاهی هستی. " + "با استفاده از داده های مزرعه، آب و هوا، مرحله رشد، و متن های بازیابی شده از پایگاه دانش تحلیل کن. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: " + "summary, forecast_window, overall_risk, disease_risk, pest_risk, key_drivers, recommended_actions. " + "overall_risk فقط یکی از low, medium, high باشد. " + "disease_risk و pest_risk باید آبجکت هایی با کلیدهای score, level, likely_conditions, reasoning باشند و level فقط یکی از low, medium, high باشد." +) + + +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 _normalize_images(images: list[dict[str, str]] | None) -> list[dict[str, str]]: + output: list[dict[str, str]] = [] + for item in images or []: + if not isinstance(item, dict): + continue + url = item.get("url") + if not isinstance(url, str) or not url.strip(): + continue + output.append({"url": url.strip(), "detail": item.get("detail", "auto")}) + return output + + +def _clean_json(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 pest_disease LLM: %s", cleaned[:500]) + return {} + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + return farm_details + + +def _build_service_client(cfg: RAGConfig): + 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) + return service, client, service.llm.model + + +def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]: + weather = farm_details.get("weather") or {} + soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {} + humidity = _safe_float(weather.get("humidity_mean"), 55.0) + temp = _safe_float(weather.get("temperature_mean"), 24.0) + rain = _safe_float(weather.get("precipitation"), 0.0) + moisture = _safe_float(soil.get("soil_moisture"), _safe_float(soil.get("wv0033"), 35.0)) + ec = _safe_float(soil.get("electrical_conductivity"), 0.0) + ph = _safe_float(soil.get("soil_ph") or soil.get("phh2o"), 7.0) + + fungal_score = min(max(round((humidity * 0.45) + (moisture * 0.35) + (rain * 2.5) - 25, 2), 0.0), 100.0) + pest_score = min(max(round((temp * 2.2) + max(0.0, 45.0 - moisture) + (ec * 3.0) - 20, 2), 0.0), 100.0) + abiotic_stress = min(max(round((abs(ph - 6.8) * 18.0) + (ec * 8.0), 2), 0.0), 100.0) + return { + "humidity_mean": humidity, + "temperature_mean": temp, + "precipitation": rain, + "soil_moisture": moisture, + "ec": ec, + "ph": ph, + "fungal_score": fungal_score, + "pest_score": pest_score, + "abiotic_stress_score": abiotic_stress, + } + + +def _risk_level(score: float) -> str: + if score >= 70: + return "high" + if score >= 40: + return "medium" + return "low" + + +def _build_risk_fallback(farm_details: dict[str, Any], plant_name: str | None, growth_stage: str | None) -> dict[str, Any]: + risk = _weather_risk_summary(farm_details) + disease_level = _risk_level(risk["fungal_score"]) + pest_level = _risk_level(risk["pest_score"]) + overall_score = max(risk["fungal_score"], risk["pest_score"], risk["abiotic_stress_score"]) + overall_level = _risk_level(overall_score) + drivers = [] + if risk["humidity_mean"] >= 70: + drivers.append("رطوبت بالا") + if risk["soil_moisture"] >= 60: + drivers.append("رطوبت خاک بالا") + if risk["temperature_mean"] >= 30: + drivers.append("دمای بالا") + if risk["precipitation"] > 2: + drivers.append("بارش موثر") + if risk["ec"] > 2.5: + drivers.append("EC بالا") + if abs(risk["ph"] - 6.8) > 0.8: + drivers.append("خروج pH از محدوده مطلوب") + if not drivers: + drivers.append("شرایط فعلی مزرعه نسبتا پایدار است") + + return { + "summary": "برآورد ریسک آفات و بیماری بر اساس داده های فعلی مزرعه ساخته شد.", + "forecast_window": "24 تا 72 ساعت آینده", + "overall_risk": overall_level, + "disease_risk": { + "score": risk["fungal_score"], + "level": disease_level, + "likely_conditions": [ + "فشار قارچی و بیماری برگی" if disease_level != "low" else "ریسک بیماری فعلا پایین است", + ], + "reasoning": [ + f"رطوبت میانگین حدود {risk['humidity_mean']} درصد است.", + f"رطوبت خاک حدود {risk['soil_moisture']} درصد برآورد شده است.", + ], + }, + "pest_risk": { + "score": risk["pest_score"], + "level": pest_level, + "likely_conditions": [ + "فشار آفات مکنده یا تنش زا" if pest_level != "low" else "ریسک آفت فعلا پایین است", + ], + "reasoning": [ + f"دمای میانگین حدود {risk['temperature_mean']} درجه است.", + f"EC فعلی حدود {risk['ec']} و pH حدود {risk['ph']} است.", + ], + }, + "key_drivers": drivers, + "recommended_actions": [ + "بازدید مزرعه و بررسی برگ ها و پشت برگ انجام شود.", + "در صورت مشاهده علائم مشکوک، نمونه برداری تصویری نزدیک تر انجام شود.", + "رطوبت ماندگار و یکنواختی آبیاری پایش شود.", + ], + "farm_context": { + "plant_name": plant_name, + "growth_stage": growth_stage, + "risk_summary": risk, + }, + } + + +def _build_detection_fallback(images: list[dict[str, str]], plant_name: str | None) -> dict[str, Any]: + return { + "has_issue": False, + "category": "unknown", + "confidence": 0.2, + "severity": "low", + "summary": "تحلیل خودکار تصویر انجام نشد یا برای نتیجه قطعی داده کافی نبود.", + "detected_signs": [], + "possible_causes": ["کیفیت یا زاویه تصویر برای تشخیص کافی نیست"], + "immediate_actions": [ + "یک تصویر نزدیک تر از برگ و ساقه ارسال شود.", + "در صورت مشاهده گسترش علائم، بازدید میدانی انجام شود.", + ], + "reasoning": [ + f"تعداد تصاویر دریافتی: {len(images)}", + f"نام گیاه: {plant_name or 'نامشخص'}", + ], + } + + +def _build_detection_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + plant_text: str, + images: list[dict[str, str]], +) -> tuple[str, list[dict[str, Any]]]: + 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(DETECTION_PROMPT) + if plant_text: + system_parts.append("[اطلاعات گیاه]\n" + plant_text) + 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": _build_content_parts(query, images)}, + ] + return system_prompt, messages + + +def _build_risk_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + structured_context: dict[str, Any], + plant_text: str, +) -> 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(RISK_PROMPT) + if plant_text: + system_parts.append("[اطلاعات گیاه]\n" + plant_text) + system_parts.append("[کانتکست ساختاریافته ریسک]\n" + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)) + 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 get_pest_disease_detection( + *, + farm_uuid: str, + plant_name: str | None = None, + query: str | None = None, + images: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + normalized_images = _normalize_images(images) + if not normalized_images: + raise ValueError("حداقل یک تصویر برای تشخیص لازم است.") + + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name") + user_query = query or "این تصویر را بررسی کن و بگو آیا گیاه دچار آفت یا بیماری شده است یا نه." + plant_text = build_plant_text(resolved_plant_name, "") if resolved_plant_name else "" + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_detection_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + plant_text=plant_text or "", + images=normalized_images, + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_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(raw) + _complete_audit_log(audit_log, raw) + except Exception as exc: + logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) + fallback = _build_detection_fallback(normalized_images, resolved_plant_name) + _fail_audit_log(audit_log, str(exc), json.dumps(fallback, ensure_ascii=False)) + return { + **fallback, + "farm_uuid": farm_uuid, + "knowledge_base": KB_NAME, + "tone_file": service.tone_file, + "raw_response": None, + } + + if not parsed: + parsed = _build_detection_fallback(normalized_images, resolved_plant_name) + parsed.setdefault("has_issue", parsed.get("category") not in {"no_issue", "unknown"}) + parsed.setdefault("category", "unknown") + parsed.setdefault("confidence", 0.4) + parsed.setdefault("severity", "low") + parsed.setdefault("detected_signs", []) + parsed.setdefault("possible_causes", []) + parsed.setdefault("immediate_actions", []) + parsed.setdefault("reasoning", []) + parsed["farm_uuid"] = farm_uuid + parsed["knowledge_base"] = KB_NAME + parsed["tone_file"] = service.tone_file + parsed["raw_response"] = raw + return parsed + + +def get_pest_disease_risk( + *, + farm_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name") + fallback = _build_risk_fallback(farm_details, resolved_plant_name, growth_stage) + user_query = query or "ریسک آفات و بیماری این مزرعه را برای چند روز آینده پیش بینی کن." + plant_text = build_plant_text(resolved_plant_name, growth_stage or "") if resolved_plant_name else "" + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_risk_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + structured_context=fallback, + plant_text=plant_text or "", + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_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(raw) + _complete_audit_log(audit_log, raw) + except Exception as exc: + logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc), json.dumps(fallback, ensure_ascii=False)) + fallback["farm_uuid"] = farm_uuid + fallback["knowledge_base"] = KB_NAME + fallback["tone_file"] = service.tone_file + fallback["raw_response"] = None + return fallback + + if not parsed: + parsed = fallback + parsed.setdefault("summary", fallback["summary"]) + parsed.setdefault("forecast_window", fallback["forecast_window"]) + parsed.setdefault("overall_risk", fallback["overall_risk"]) + parsed.setdefault("disease_risk", fallback["disease_risk"]) + parsed.setdefault("pest_risk", fallback["pest_risk"]) + parsed.setdefault("key_drivers", fallback["key_drivers"]) + parsed.setdefault("recommended_actions", fallback["recommended_actions"]) + parsed["farm_uuid"] = farm_uuid + parsed["knowledge_base"] = KB_NAME + parsed["tone_file"] = service.tone_file + parsed["raw_response"] = raw + return parsed diff --git a/rag/services/soil_anomaly.py b/rag/services/soil_anomaly.py new file mode 100644 index 0000000..cad6d33 --- /dev/null +++ b/rag/services/soil_anomaly.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +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 + +logger = logging.getLogger(__name__) + +KB_NAME = "soil_anomaly" +SERVICE_ID = "soil_anomaly" + +SOIL_ANOMALY_PROMPT = ( + "شما یک دستیار تخصصی تحلیل ناهنجاری داده های خاک و سنسور مزرعه هستی. " + "ورودی شامل داده های ساختاریافته ناهنجاری، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش است. " + "فقط JSON معتبر برگردان و فقط این کلیدها را تولید کن: " + "summary, explanation, likely_cause, recommended_action, monitoring_priority, confidence. " + "monitoring_priority فقط یکی از low, medium, high, urgent باشد. " + "confidence عددی بین 0 و 1 باشد. " + "اگر ناهنجاری معناداری وجود ندارد، این موضوع را شفاف و بدون اغراق بیان کن." +) + + +def _clean_json(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 soil_anomaly LLM: %s", cleaned[:500]) + return {} + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + return farm_details + + +def _build_service_client(cfg: RAGConfig): + 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) + return service, client, service.llm.model + + +def _fallback_from_payload(anomaly_payload: dict[str, Any]) -> dict[str, Any]: + interpretation = anomaly_payload.get("interpretation") or {} + anomalies = anomaly_payload.get("anomalies") or [] + top_anomaly = anomalies[0] if anomalies else None + + if top_anomaly is None: + return { + "summary": "در داده های اخیر ناهنجاری معناداری دیده نشد.", + "explanation": interpretation.get("explanation") + or "داده های فعلی با الگوی معمول مزرعه سازگار هستند و مورد غیرعادی برجسته ای دیده نمی شود.", + "likely_cause": interpretation.get("likely_cause") + or "شرایط فعلی مزرعه پایدار است یا داده کافی برای تشخیص رخداد غیرعادی وجود ندارد.", + "recommended_action": interpretation.get("recommended_action") + or "پایش عادی ادامه یابد و روندها در بازه بعدی دوباره بررسی شوند.", + "monitoring_priority": "low", + "confidence": 0.55, + } + + severity = str(top_anomaly.get("severity") or "medium") + priority_map = { + "low": "medium", + "medium": "high", + "high": "urgent", + "critical": "urgent", + } + return { + "summary": f"ناهنجاري در شاخص {top_anomaly.get('label', 'نامشخص')} شناسايي شد.", + "explanation": interpretation.get("explanation") + or f"مقدار {top_anomaly.get('label', 'اين شاخص')} از الگوي آماري معمول مزرعه فاصله گرفته است.", + "likely_cause": interpretation.get("likely_cause") + or "اين الگو مي تواند ناشي از تغيير شرايط محيطي، آبياري، شوري يا خطاي اندازه گيري سنسور باشد.", + "recommended_action": interpretation.get("recommended_action") + or "روند اين شاخص و شرايط مزرعه در کوتاه مدت بازبيني و در صورت تداوم، اقدام اصلاحي انجام شود.", + "monitoring_priority": priority_map.get(severity, "high"), + "confidence": 0.7 if severity in {"high", "critical"} else 0.6, + } + + +def _build_messages( + *, + 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(SOIL_ANOMALY_PROMPT) + system_parts.append( + "[کانتکست ساختاریافته ناهنجاري خاک]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + 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 get_soil_anomaly_insight( + *, + farm_uuid: str, + anomaly_payload: dict[str, Any], + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + fallback = _fallback_from_payload(anomaly_payload) + user_query = query or "ناهنجاري هاي داده هاي خاک اين مزرعه را تفسير کن و اقدام مناسب پيشنهاد بده." + structured_context = { + "farm_uuid": farm_uuid, + "anomaly_payload": anomaly_payload, + "fallback_interpretation": fallback, + } + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_messages( + service=service, + cfg=cfg, + query=user_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=user_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(raw) + _complete_audit_log(audit_log, raw) + except Exception as exc: + logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc), json.dumps(fallback, ensure_ascii=False)) + return { + **fallback, + "farm_uuid": farm_uuid, + "knowledge_base": KB_NAME, + "tone_file": service.tone_file, + "raw_response": None, + } + + if not parsed: + parsed = fallback + parsed.setdefault("summary", fallback["summary"]) + parsed.setdefault("explanation", fallback["explanation"]) + parsed.setdefault("likely_cause", fallback["likely_cause"]) + parsed.setdefault("recommended_action", fallback["recommended_action"]) + parsed.setdefault("monitoring_priority", fallback["monitoring_priority"]) + parsed.setdefault("confidence", fallback["confidence"]) + parsed["farm_uuid"] = farm_uuid + parsed["knowledge_base"] = KB_NAME + parsed["tone_file"] = service.tone_file + parsed["raw_response"] = raw + return parsed diff --git a/rag/services/water_need_prediction.py b/rag/services/water_need_prediction.py new file mode 100644 index 0000000..efa0475 --- /dev/null +++ b/rag/services/water_need_prediction.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +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 + +logger = logging.getLogger(__name__) + +KB_NAME = "water_need_prediction" +SERVICE_ID = "water_need_prediction" + +WATER_NEED_PROMPT = ( + "شما یک دستیار تخصصی تحليل نياز آبي کوتاه مدت مزرعه هستي. " + "ورودي شامل محاسبات ساختاريافته نياز آبي، اطلاعات مزرعه و متن هاي بازيابي شده از پايگاه دانش است. " + "فقط JSON معتبر با اين کليدها برگردان: " + "summary, irrigation_outlook, recommended_action, risk_note, confidence. " + "confidence عددي بين 0 و 1 باشد. " + "اعداد اصلي را از داده ورودي بگير و عدد متناقض جديد نساز." +) + + +def _clean_json(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 water_need_prediction LLM: %s", cleaned[:500]) + return {} + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + return farm_details + + +def _build_service_client(cfg: RAGConfig): + 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) + return service, client, service.llm.model + + +def _fallback_from_payload(prediction_payload: dict[str, Any]) -> dict[str, Any]: + total = float(prediction_payload.get("totalNext7Days") or 0.0) + daily = prediction_payload.get("dailyBreakdown") or [] + peak_day = max(daily, key=lambda item: float(item.get("gross_irrigation_mm", 0.0) or 0.0), default=None) + if total <= 0: + return { + "summary": "براي چند روز آينده نياز آبي معناداري برآورد نشد.", + "irrigation_outlook": "بارش موثر يا شرايط فعلي باعث شده نياز خالص آبياري پايين باشد.", + "recommended_action": "پايش رطوبت خاک ادامه يابد و قبل از هر آبياري جديد forecast دوباره بررسي شود.", + "risk_note": "اگر forecast تغيير کند يا بارش موثر رخ ندهد، برآورد بايد به روز شود.", + "confidence": 0.58, + } + + peak_text = "" + if peak_day: + peak_text = ( + f" بيشترين فشار آبي در {peak_day.get('forecast_date')} " + f"با حدود {peak_day.get('gross_irrigation_mm')} ميلي متر برآورد شده است." + ) + return { + "summary": f"جمع نياز آبي 7 روز آينده حدود {round(total, 2)} ميلي متر برآورد شده است.", + "irrigation_outlook": "الگوي آبياري بايد در چند روز آينده بر اساس نياز روزانه و بارش موثر تنظيم شود." + peak_text, + "recommended_action": "برنامه آبياري کوتاه مدت بر اساس روزهاي اوج نياز تنظيم و صبح زود يا نزديک غروب اجرا شود.", + "risk_note": "در صورت تغيير دما، باد يا بارش، مقادير gross irrigation ممکن است تغيير کنند.", + "confidence": 0.72, + } + + +def _build_messages( + *, + 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(WATER_NEED_PROMPT) + system_parts.append( + "[کانتکست ساختاريافته نياز آبي]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + 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 get_water_need_prediction_insight( + *, + farm_uuid: str, + prediction_payload: dict[str, Any], + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + fallback = _fallback_from_payload(prediction_payload) + user_query = query or "نياز آبي کوتاه مدت اين مزرعه را تفسير کن و اقدام عملياتي پيشنهاد بده." + structured_context = { + "farm_uuid": farm_uuid, + "prediction_payload": prediction_payload, + "fallback_summary": fallback, + } + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_messages( + service=service, + cfg=cfg, + query=user_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=user_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(raw) + _complete_audit_log(audit_log, raw) + except Exception as exc: + logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc), json.dumps(fallback, ensure_ascii=False)) + return { + **fallback, + "farm_uuid": farm_uuid, + "knowledge_base": KB_NAME, + "tone_file": service.tone_file, + "raw_response": None, + } + + if not parsed: + parsed = fallback + parsed.setdefault("summary", fallback["summary"]) + parsed.setdefault("irrigation_outlook", fallback["irrigation_outlook"]) + parsed.setdefault("recommended_action", fallback["recommended_action"]) + parsed.setdefault("risk_note", fallback["risk_note"]) + parsed.setdefault("confidence", fallback["confidence"]) + parsed["farm_uuid"] = farm_uuid + parsed["knowledge_base"] = KB_NAME + parsed["tone_file"] = service.tone_file + parsed["raw_response"] = raw + return parsed diff --git a/rag/tests/test_chat_context.py b/rag/tests/test_chat_context.py index fd9d14e..30e4b7c 100644 --- a/rag/tests/test_chat_context.py +++ b/rag/tests/test_chat_context.py @@ -2,7 +2,7 @@ from unittest.mock import patch from django.test import SimpleTestCase -from rag.chat import build_rag_context +from rag.chat import _normalize_history_messages, build_rag_context class ChatContextTests(SimpleTestCase): @@ -37,6 +37,22 @@ class ChatContextTests(SimpleTestCase): self.assertIn("farm chunk 1", sent_texts) self.assertIn("farm chunk 2", sent_texts) + def test_normalize_history_messages_supports_user_images(self): + messages = _normalize_history_messages( + [ + {"role": "user", "content": "این تصویر مزرعه است", "image_urls": ["https://example.com/a.jpg"]}, + {"role": "assistant", "content": "تصویر دریافت شد."}, + ] + ) + + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["role"], "user") + self.assertIsInstance(messages[0]["content"], list) + self.assertEqual(messages[0]["content"][0]["type"], "text") + self.assertEqual(messages[0]["content"][1]["type"], "image_url") + self.assertEqual(messages[1]["role"], "assistant") + self.assertEqual(messages[1]["content"], "تصویر دریافت شد.") + @patch("rag.chat.search_with_texts", return_value=[]) @patch("rag.chat.chunk_text", return_value=["farm chunk"]) def test_build_rag_context_returns_full_farm_when_kb_empty( diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index 71e0136..dcc72eb 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -144,6 +144,10 @@ class RecommendationServiceDefaultsTests(TestCase): "آبیاری قطره‌ای", ) self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") + self.assertEqual(result["mergeMetadata"]["source"], "llm_with_fallback_merge") + self.assertEqual(result["sections"][1]["provenance"]["sectionType"], "list") + self.assertEqual(result["sections"][1]["provenance"]["fieldSources"]["title"], "llm") + self.assertEqual(result["sections"][0]["provenance"]["fieldSources"]["amount"], "fallback") @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @@ -185,6 +189,7 @@ class RecommendationServiceDefaultsTests(TestCase): self.assertEqual(self.farm.irrigation_method_id, sprinkler.id) self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id) mock_build_irrigation_method_text.assert_called_once_with("بارانی") + self.assertEqual(result["sections"][0]["provenance"]["fieldSources"]["content"], "llm") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @@ -212,6 +217,8 @@ class RecommendationServiceDefaultsTests(TestCase): self.assertEqual(result["sections"][0]["fertilizerType"], "20-20-20") mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "رویشی") self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") + self.assertEqual(result["sections"][2]["provenance"]["fieldSources"]["content"], "llm") + self.assertEqual(result["sections"][0]["provenance"]["fieldSources"]["fertilizerType"], "fallback") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @@ -238,3 +245,5 @@ class RecommendationServiceDefaultsTests(TestCase): self.assertEqual(result["sections"][0]["applicationMethod"], "کودآبیاری") self.assertEqual(result["sections"][2]["type"], "warning") + self.assertEqual(result["mergeMetadata"]["source"], "fallback_only") + self.assertFalse(result["sections"][0]["provenance"]["llmProvided"]) diff --git a/rag/views.py b/rag/views.py index 6a1137b..8f94dd3 100644 --- a/rag/views.py +++ b/rag/views.py @@ -1,6 +1,7 @@ """ ویوهای RAG — چت با استریم """ +import json import logging from django.http import StreamingHttpResponse @@ -13,6 +14,7 @@ from drf_spectacular.utils import ( ) from rest_framework import status from rest_framework import serializers as drf_serializers +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -22,7 +24,7 @@ from config.openapi import ( build_message_response_serializer, build_response, ) -from .chat import chat_rag_stream +from .chat import chat_rag_stream, encode_uploaded_image logger = logging.getLogger(__name__) @@ -47,11 +49,45 @@ RagFertilizationResponseSerializer = build_envelope_serializer( class ChatView(APIView): - """ - چت RAG با استریم. - POST با {"query": "متن سوال", "farm_uuid": "شناسه مزرعه"}. - همیشه از سرویس ثابت `chat` استفاده می‌کند و اطلاعات مزرعه را مستقیم به مدل می‌فرستد. - """ + parser_classes = [JSONParser, MultiPartParser, FormParser] + + def _parse_history(self, raw_history): + if raw_history in (None, "", []): + return [] + if isinstance(raw_history, list): + return raw_history + if isinstance(raw_history, str): + try: + parsed = json.loads(raw_history) + except (json.JSONDecodeError, ValueError): + raise ValueError("history باید JSON معتبر باشد.") + if not isinstance(parsed, list): + raise ValueError("history باید آرایه باشد.") + return parsed + raise ValueError("history فرمت پشتیبانی شده ندارد.") + + def _collect_uploaded_images(self, request: 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_urls = json.loads(image_urls) + except (json.JSONDecodeError, ValueError): + parsed_urls = [image_urls] + image_urls = parsed_urls + 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): + image_payload = {"url": item["url"].strip(), "detail": item.get("detail", "auto")} + images.append(image_payload) + return images @extend_schema( tags=["RAG Chat"], @@ -63,6 +99,14 @@ class ChatView(APIView): "query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"), "message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"), "farm_uuid": drf_serializers.CharField(help_text="شناسه مزرعه"), + "history": drf_serializers.JSONField(required=False, help_text="آرایه پیام های قبلی با role=user/assistant"), + "image_urls": drf_serializers.JSONField(required=False, help_text="آرایه URL تصاویر برای پیام فعلی"), + "image": drf_serializers.FileField(required=False, help_text="یک تصویر برای پیام فعلی"), + "images": drf_serializers.ListField( + child=drf_serializers.FileField(), + required=False, + help_text="چند تصویر برای پیام فعلی", + ), }, ), responses={ @@ -83,8 +127,13 @@ class ChatView(APIView): OpenApiExample( "نمونه درخواست", value={ - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", "query": "وضعیت مزرعه من چطور است؟", + "history": [ + {"role": "user", "content": "رطوبت خاک من پایین بود؟"}, + {"role": "assistant", "content": "بله، رطوبت خاک کمتر از محدوده مطلوب بود."}, + ], + "image_urls": ["https://example.com/farm-photo.jpg"], }, request_only=True, ), @@ -97,9 +146,19 @@ class ChatView(APIView): data = request.data if request.method == "POST" else request.query_params message = data.get("query", data.get("message")) farm_uuid = data.get("farm_uuid") + raw_history = data.get("history") + try: + images = self._collect_uploaded_images(request) + except ValueError as exc: + return Response( + {"code": 400, "msg": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + if message is None and images: + message = "لطفا تصویر ارسالی را در کنار اطلاعات مزرعه بررسی کن." if not message or not isinstance(message, str): return Response( - {"code": 400, "msg": "پارامتر query الزامی است."}, + {"code": 400, "msg": "پارامتر query الزامی است، مگر اینکه تصویر ارسال شده باشد."}, status=status.HTTP_400_BAD_REQUEST, ) message = str(message).strip() @@ -119,6 +178,13 @@ class ChatView(APIView): {"code": 400, "msg": "farm_uuid نباید خالی باشد."}, status=status.HTTP_400_BAD_REQUEST, ) + try: + history = self._parse_history(raw_history) + except ValueError as exc: + return Response( + {"code": 400, "msg": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) cfg = load_rag_config() farm_details = get_farm_details(farm_uuid) if farm_details is None: @@ -134,6 +200,8 @@ class ChatView(APIView): farm_uuid=farm_uuid, config=cfg, farm_details=farm_details, + history=history, + images=images, ): yield chunk except Exception as e: @@ -188,7 +256,7 @@ class IrrigationRecommendationView(APIView): OpenApiExample( "نمونه درخواست", value={ - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", "plant_name": "گوجه‌فرنگی", "growth_stage": "میوه‌دهی", "irrigation_method_name": "آبیاری قطره‌ای", @@ -270,7 +338,7 @@ class FertilizationRecommendationView(APIView): OpenApiExample( "نمونه درخواست", value={ - "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "11111111-1111-1111-1111-111111111111", "plant_name": "گوجه‌فرنگی", "growth_stage": "رویشی", }, diff --git a/scripts/generate_mock_data.py b/scripts/generate_mock_data.py index cf39830..d868714 100644 --- a/scripts/generate_mock_data.py +++ b/scripts/generate_mock_data.py @@ -257,21 +257,6 @@ DASHBOARD_RESULT = { "activeAlerts": 2, "waterNeedMm": 18.4, }, - "sensorValuesList": { - "items": [ - {"label": "رطوبت خاک", "value": 45.2, "unit": "%"}, - {"label": "دما خاک", "value": 22.5, "unit": "°C"}, - ] - }, - "recommendationsList": { - "items": [ - { - "recommendation_title": "تنظیم نوبت آبیاری", - "suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.", - "urgency_level": "high", - } - ] - }, }, } @@ -340,7 +325,7 @@ def main(): "Dashboard task in progress", task_progress( "dashboard-task-123", - {"current": 5, "total": 15, "card": "sensorValuesList", "message": "processing sensorValuesList"}, + {"current": 5, "total": 15, "card": "sensorComparisonChart", "message": "processing sensorComparisonChart"}, ), ) register( diff --git a/dashboard_data/migrations/__init__.py b/soile/__init__.py similarity index 100% rename from dashboard_data/migrations/__init__.py rename to soile/__init__.py diff --git a/dashboard_data/cards/anomaly_detection_card.py b/soile/anomaly_detection.py similarity index 100% rename from dashboard_data/cards/anomaly_detection_card.py rename to soile/anomaly_detection.py diff --git a/soile/apps.py b/soile/apps.py new file mode 100644 index 0000000..4a712a8 --- /dev/null +++ b/soile/apps.py @@ -0,0 +1,36 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class SoileConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "soile" + verbose_name = "Soile" + + @cached_property + def soil_moisture_service(self): + from .services import SoilMoistureHeatmapService + + return SoilMoistureHeatmapService() + + def get_soil_moisture_service(self): + return self.soil_moisture_service + + @cached_property + def soil_health_service(self): + from .services import SoilHealthService + + return SoilHealthService() + + def get_soil_health_service(self): + return self.soil_health_service + + @cached_property + def soil_anomaly_service(self): + from .services import SoilAnomalyDetectionService + + return SoilAnomalyDetectionService() + + def get_soil_anomaly_service(self): + return self.soil_anomaly_service diff --git a/soile/health_summary.py b/soile/health_summary.py new file mode 100644 index 0000000..f1ac542 --- /dev/null +++ b/soile/health_summary.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Any + + +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 _safe_number(value: Any, default: float = 0.0) -> float: + return default if value is None else float(value) + + +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(plants: list[Any]) -> tuple[dict[str, dict[str, float]], str]: + 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 = _safe_number(sensor_value, 0) + defaults = DEFAULT_HEALTH_PROFILE.get(metric_type, {}) + ideal_value = float(config.get("ideal_value", defaults.get("ideal_value", 0))) + min_range = float(config.get("min_range", defaults.get("min_range", 0))) + max_range = float(config.get("max_range", defaults.get("max_range", 0))) + weight = float(config.get("weight", defaults.get("weight", 0))) + if weight <= 0: + continue + + normalized_value = _normalize_metric(current_value, ideal_value, min_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) -> dict[str, str]: + 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_soil_health_summary(sensor: Any, plants: list[Any]) -> dict[str, Any]: + profile, profile_source = resolve_plant_profile(plants) + health_score, health_components = compute_health_score(sensor, profile) + moisture = _safe_number(getattr(sensor, "soil_moisture", None), 0) + language = health_language(health_score) + return { + "healthScore": health_score, + "profileSource": profile_source, + "healthScoreDetails": { + "method": "normalized_weighted_average", + "profileSource": profile_source, + "components": health_components, + }, + "healthLanguage": language, + "avgSoilMoisture": round(moisture), + "avgSoilMoistureRaw": round(moisture, 2), + "avgSoilMoistureStatus": "بهینه" if 45 <= moisture <= 75 else "نیازمند بررسی", + } diff --git a/soile/serializers.py b/soile/serializers.py new file mode 100644 index 0000000..e5c81e3 --- /dev/null +++ b/soile/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + + +class SoilMoistureHeatmapRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilMoistureHeatmapResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + location = serializers.JSONField() + current_sensor = serializers.JSONField() + soil_profile = serializers.JSONField() + timestamp = serializers.CharField(allow_null=True) + grid_resolution = serializers.JSONField(allow_null=True) + grid_cells = serializers.JSONField() + sensor_points = serializers.JSONField() + quality_legend = serializers.JSONField() + + +class SoilAnomalyDetectionRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilHealthSummaryRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilHealthSummaryResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + healthScore = serializers.IntegerField() + profileSource = serializers.CharField() + healthScoreDetails = serializers.JSONField() + healthLanguage = serializers.JSONField() + avgSoilMoisture = serializers.IntegerField() + avgSoilMoistureRaw = serializers.FloatField() + avgSoilMoistureStatus = serializers.CharField() + + +class SoilAnomalyDetectionResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + generated_at = serializers.CharField() + anomalies = serializers.JSONField() + interpretation = serializers.JSONField() + knowledge_base = serializers.CharField(allow_null=True, required=False) + tone_file = serializers.CharField(allow_null=True, required=False) + raw_response = serializers.CharField(allow_null=True, required=False) diff --git a/soile/services.py b/soile/services.py new file mode 100644 index 0000000..ad2ab0c --- /dev/null +++ b/soile/services.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +from datetime import datetime +from math import sqrt +from statistics import median +from typing import Any + +from django.utils import timezone + +from farm_data.context import load_farm_context +from farm_data.models import SensorData +from rag.services import get_soil_anomaly_insight + +from .anomaly_detection import build_anomaly_detection_card +from .health_summary import build_soil_health_summary + + +QUALITY_REAL = "REAL" +QUALITY_INTERPOLATED = "INTERPOLATED" +QUALITY_MISSING = "MISSING" +QUALITY_EXTRAPOLATED = "EXTRAPOLATED" + +IDW_POWER = 2 +MAX_GRID_STEPS = 10 +FRESHNESS_HALF_LIFE_HOURS = 24.0 +MAX_SENSOR_INFLUENCE_DISTANCE = 0.08 + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _sensor_time_series(sensor: Any) -> list[dict[str, Any]]: + sensor_block = sensor.get_sensor_block() if hasattr(sensor, "get_sensor_block") else {} + soil_moisture = _safe_float(getattr(sensor, "soil_moisture", None)) + measured_at = sensor_block.get("timestamp") or sensor_block.get("measured_at") + if measured_at is None and getattr(sensor, "updated_at", None): + measured_at = sensor.updated_at.isoformat() + return [ + { + "timestamp": measured_at, + "value": soil_moisture, + "quality_flag": QUALITY_REAL if soil_moisture is not None else QUALITY_MISSING, + } + ] + + +def _parse_timestamp(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if not value: + return None + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + return parsed + return None + + +def _hours_since(timestamp: Any) -> float | None: + parsed = _parse_timestamp(timestamp) + if parsed is None: + return None + delta = timezone.now() - parsed + return max(delta.total_seconds() / 3600.0, 0.0) + + +def _freshness_weight(timestamp: Any) -> float: + age_hours = _hours_since(timestamp) + if age_hours is None: + return 0.65 + return 1.0 / (1.0 + (age_hours / FRESHNESS_HALF_LIFE_HOURS)) + + +def _sensor_anomaly_penalty(value: float | None, network_values: list[float]) -> float: + if value is None or len(network_values) < 3: + return 1.0 + + center = median(network_values) + deviations = [abs(item - center) for item in network_values] + typical_deviation = median(deviations) or 1.0 + normalized_distance = abs(value - center) / typical_deviation + return max(0.45, min(1.0, 1.15 - (normalized_distance * 0.18))) + + +def _boundary_points(sensor: Any) -> list[tuple[float, float]]: + boundary = getattr(sensor.center_location, "farm_boundary", None) or {} + coordinates = [] + if isinstance(boundary, dict) and boundary.get("type") == "Polygon": + coordinates = boundary.get("coordinates") or [] + if coordinates and isinstance(coordinates[0], list): + return [(float(point[1]), float(point[0])) for point in coordinates[0] if len(point) >= 2] + corners = boundary.get("corners") if isinstance(boundary, dict) else boundary if isinstance(boundary, list) else [] + points = [] + for point in corners or []: + if isinstance(point, dict) and point.get("lat") is not None and point.get("lon") is not None: + points.append((float(point["lat"]), float(point["lon"]))) + return points + + +def _point_in_polygon(lat: float, lon: float, polygon: list[tuple[float, float]]) -> bool: + if len(polygon) < 3: + return True + + inside = False + for index in range(len(polygon)): + lat1, lon1 = polygon[index] + lat2, lon2 = polygon[(index + 1) % len(polygon)] + intersects = ((lon1 > lon) != (lon2 > lon)) and ( + lat < ((lat2 - lat1) * (lon - lon1) / max(lon2 - lon1, 1e-12)) + lat1 + ) + if intersects: + inside = not inside + return inside + + +def _latest_sensor_measurement(sensor: Any, network_values: list[float]) -> dict[str, Any]: + series = _sensor_time_series(sensor) + latest = series[-1] if series else {"timestamp": None, "value": None, "quality_flag": QUALITY_MISSING} + reliability = _sensor_anomaly_penalty(latest["value"], network_values) * _freshness_weight(latest["timestamp"]) + 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"], + "freshness_weight": round(_freshness_weight(latest["timestamp"]), 4), + "reliability_score": round(reliability, 4), + } + + +def _spatial_weight(distance: float) -> float: + if distance == 0: + return 1.0 + if distance > MAX_SENSOR_INFLUENCE_DISTANCE: + return 0.0 + return 1 / (distance**IDW_POWER) + + +def _interpolate_cell( + lat: float, + lon: float, + sensor_points: list[dict[str, Any]], +) -> tuple[float | None, str, float]: + weighted_sum = 0.0 + weight_total = 0.0 + min_distance = None + + 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)) + min_distance = distance if min_distance is None else min(min_distance, distance) + if distance == 0: + return round(float(value), 2), point["quality_flag"], 1.0 + + spatial_weight = _spatial_weight(distance) + if spatial_weight == 0.0: + continue + composite_weight = spatial_weight * float(point.get("reliability_score", 1.0)) + weighted_sum += composite_weight * float(value) + weight_total += composite_weight + + if weight_total == 0.0: + return None, QUALITY_MISSING, 0.0 + + uncertainty = 1.0 - min(weight_total / (weight_total + 6.0), 1.0) + quality_flag = QUALITY_INTERPOLATED + if min_distance is not None and min_distance > (MAX_SENSOR_INFLUENCE_DISTANCE / 2): + quality_flag = QUALITY_EXTRAPOLATED + + return round(weighted_sum / weight_total, 2), quality_flag, round(max(0.0, min(1.0, uncertainty)), 4) + + +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", "center_location__depths") + if plant_ids: + queryset = queryset.filter(plants__id__in=plant_ids).distinct() + return list(queryset) + + +def _soil_profile(sensor: Any) -> list[dict[str, Any]]: + depths = sensor.center_location.depths.all() + return [ + { + "depth_label": depth.depth_label, + "field_capacity": depth.wv0033, + "wilting_point": depth.wv1500, + "saturation": depth.wv0010, + "nitrogen": depth.nitrogen, + "ph": depth.phh2o, + "sand": depth.sand, + "silt": depth.silt, + "clay": depth.clay, + } + for depth in depths + ] + + +def _depth_layers(soil_profile: list[dict[str, Any]], grid_cells: list[dict[str, Any]]) -> list[dict[str, Any]]: + layers = [] + if not soil_profile or not grid_cells: + return layers + + for index, depth in enumerate(soil_profile): + depth_factor = max(0.72, 1.0 - (index * 0.08)) + layer_cells = [] + for cell in grid_cells: + if cell["moisture_value"] is None: + moisture_value = None + else: + moisture_value = round(cell["moisture_value"] * depth_factor, 2) + layer_cells.append( + { + "lat": cell["lat"], + "lon": cell["lon"], + "moisture_value": moisture_value, + "quality_flag": cell["quality_flag"], + "uncertainty": cell.get("uncertainty"), + } + ) + layers.append( + { + "depth_label": depth.get("depth_label"), + "estimated_from_surface": True, + "cells": layer_cells, + } + ) + return layers + + +def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[str, Any]]) -> dict[str, Any]: + sensor_values = [point["soil_moisture_value"] for point in sensor_points if point["soil_moisture_value"] is not None] + uncertainties = [cell["uncertainty"] for cell in grid_cells if cell.get("uncertainty") is not None] + return { + "sensor_count": len(sensor_points), + "active_sensor_count": len(sensor_values), + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "uses_freshness_weighting": True, + "uses_boundary_mask": True, + "uses_outlier_penalty": True, + "avg_sensor_moisture": round(sum(sensor_values) / len(sensor_values), 2) if sensor_values else None, + "avg_uncertainty": round(sum(uncertainties) / len(uncertainties), 4) if uncertainties else None, + } + + +class SoilMoistureHeatmapService: + def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: + current_sensor = ( + SensorData.objects.select_related("center_location") + .prefetch_related("plants", "center_location__depths") + .filter(farm_uuid=farm_uuid) + .first() + ) + if current_sensor is None: + raise ValueError("Farm not found.") + + sensors = _load_sensor_network(current_sensor) + raw_network_values = [ + _safe_float(getattr(sensor, "soil_moisture", None)) + for sensor in sensors + if _safe_float(getattr(sensor, "soil_moisture", None)) is not None + ] + sensor_points = [_latest_sensor_measurement(sensor, raw_network_values) for sensor in sensors] + valid_sensor_points = [point for point in sensor_points if point["soil_moisture_value"] is not None] + soil_profile = _soil_profile(current_sensor) + farm_polygon = _boundary_points(current_sensor) + + if not valid_sensor_points: + return { + "farm_uuid": str(current_sensor.farm_uuid), + "location": { + "lat": float(current_sensor.center_location.latitude), + "lon": float(current_sensor.center_location.longitude), + }, + "current_sensor": { + "soil_moisture": current_sensor.soil_moisture, + "soil_temperature": current_sensor.soil_temperature, + "soil_ph": current_sensor.soil_ph, + "electrical_conductivity": current_sensor.electrical_conductivity, + }, + "soil_profile": soil_profile, + "depth_layers": [], + "timestamp": current_sensor.updated_at.isoformat() if getattr(current_sensor, "updated_at", None) else None, + "grid_resolution": None, + "grid_cells": [], + "sensor_points": sensor_points, + "model_metadata": { + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "limitations": [ + "history واقعی سنسورها در مدل حاضر در دسترس نیست", + "depth layers از surface estimate مشتق می‌شوند", + ], + }, + "summary": _heatmap_summary(sensor_points, []), + "quality_legend": { + QUALITY_REAL: "اندازه گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + QUALITY_EXTRAPOLATED: "برآورد در ناحیه دور از سنسورها", + }, + } + + if farm_polygon: + min_lat = min(point[0] for point in farm_polygon) + max_lat = max(point[0] for point in farm_polygon) + min_lon = min(point[1] for point in farm_polygon) + max_lon = max(point[1] for point in farm_polygon) + else: + 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: + if farm_polygon and not _point_in_polygon(lat, lon, farm_polygon): + grid_cells.append( + { + "lat": lat, + "lon": lon, + "moisture_value": None, + "quality_flag": QUALITY_MISSING, + "uncertainty": None, + "inside_farm_boundary": False, + } + ) + continue + + 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"] + uncertainty = round(1.0 - float(direct_sensor.get("reliability_score", 1.0)), 4) + else: + moisture_value, quality_flag, uncertainty = _interpolate_cell(lat, lon, valid_sensor_points) + + grid_cells.append( + { + "lat": lat, + "lon": lon, + "moisture_value": moisture_value, + "quality_flag": quality_flag, + "uncertainty": uncertainty if moisture_value is not None else None, + "inside_farm_boundary": True, + } + ) + + 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 + timestamps = [point["timestamp"] for point in sensor_points if point["timestamp"]] + depth_layers = _depth_layers(soil_profile, [cell for cell in grid_cells if cell["inside_farm_boundary"]]) + + return { + "farm_uuid": str(current_sensor.farm_uuid), + "location": { + "lat": float(current_sensor.center_location.latitude), + "lon": float(current_sensor.center_location.longitude), + }, + "current_sensor": { + "soil_moisture": current_sensor.soil_moisture, + "soil_temperature": current_sensor.soil_temperature, + "soil_ph": current_sensor.soil_ph, + "electrical_conductivity": current_sensor.electrical_conductivity, + "nitrogen": current_sensor.nitrogen, + "phosphorus": current_sensor.phosphorus, + "potassium": current_sensor.potassium, + }, + "soil_profile": soil_profile, + "depth_layers": depth_layers, + "timestamp": max(timestamps) if timestamps else None, + "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, + "model_metadata": { + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "uses_freshness_weighting": True, + "uses_outlier_penalty": True, + "uses_depth_estimation": True, + "uses_boundary_mask": bool(farm_polygon), + "limitations": [ + "history واقعی سنسورها در مدل حاضر ذخیره نشده است", + "depth layers از داده سطحی و پروفایل خاک مشتق شده‌اند", + "uncertainty به صورت heuristic برآورد می‌شود", + ], + }, + "summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]), + "quality_legend": { + QUALITY_REAL: "اندازه گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + QUALITY_EXTRAPOLATED: "برآورد در ناحیه دور از سنسورها", + }, + } + + +class SoilHealthService: + def get_health_summary(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .prefetch_related("plants") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + return { + "farm_uuid": str(sensor.farm_uuid), + **build_soil_health_summary(sensor, list(sensor.plants.all())), + } + + +class SoilAnomalyDetectionService: + def get_anomaly_detection(self, *, farm_uuid: str) -> dict[str, Any]: + context = load_farm_context(farm_uuid) + if context is None: + raise ValueError("Farm not found.") + + anomaly_payload = build_anomaly_detection_card( + sensor_id=farm_uuid, + context=context, + ai_bundle=None, + ) + rag_payload = get_soil_anomaly_insight( + farm_uuid=farm_uuid, + anomaly_payload=anomaly_payload, + ai_bundle=None, + ) + return { + "farm_uuid": farm_uuid, + **anomaly_payload, + **rag_payload, + } diff --git a/soile/test_soil_moisture_heatmap_api.py b/soile/test_soil_moisture_heatmap_api.py new file mode 100644 index 0000000..34d0fac --- /dev/null +++ b/soile/test_soil_moisture_heatmap_api.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from soile.services import SoilMoistureHeatmapService + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilMoistureHeatmapApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_heatmap_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_heatmap=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "location": {"lat": 35.7, "lon": 51.4}, + "current_sensor": {"soil_moisture": 22.5}, + "soil_profile": [{"depth_label": "0-5cm", "field_capacity": 0.34}], + "timestamp": "2026-04-01T00:00:00", + "grid_resolution": {"lat_step": 0.001, "lon_step": 0.001, "rows": 2, "cols": 2}, + "grid_cells": [{"lat": 35.7, "lon": 51.4, "moisture_value": 22.5, "quality_flag": "REAL"}], + "sensor_points": [{"sensor_id": "farm-1", "soil_moisture_value": 22.5}], + "quality_legend": {"REAL": "اندازه گیری واقعی سنسور"}, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_moisture_service=lambda: mock_service + ) + + response = self.client.post( + "/moisture-heatmap/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["current_sensor"]["soil_moisture"], 22.5) + + @patch("soile.views.apps.get_app_config") + def test_heatmap_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_heatmap=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_moisture_service=lambda: mock_service + ) + + response = self.client.post( + "/moisture-heatmap/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilAnomalyDetectionApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "generated_at": "2026-04-01T00:00:00", + "anomalies": [ + { + "metric_type": "soil_moisture", + "label": "رطوبت خاک", + "severity": "high", + "observed_value": 21.4, + } + ], + "interpretation": { + "summary": "ناهنجاري در رطوبت خاک شناسايي شد.", + "explanation": "رطوبت خاک از الگوي معمول فاصله گرفته است.", + "likely_cause": "احتمال اختلال در آبياري يا افزايش تبخير.", + "recommended_action": "آبياري و قرائت سنسور بازبيني شود.", + "monitoring_priority": "urgent", + "confidence": 0.84, + }, + "knowledge_base": "soil_anomaly", + "tone_file": "config/tones/soil_anomaly_tone.txt", + "raw_response": "{\"summary\":\"ok\"}", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["knowledge_base"], "soil_anomaly") + self.assertEqual(payload["interpretation"]["monitoring_priority"], "urgent") + + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + +class SoilMoistureHeatmapServiceTests(TestCase): + @patch("soile.services.SensorData.objects") + def test_heatmap_service_builds_boundary_aware_weighted_output(self, mock_objects): + now = timezone.now() + boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.70], + [51.41, 35.70], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.70], + ] + ], + } + depth = SimpleNamespace( + depth_label="0-5cm", + wv0033=0.34, + wv1500=0.14, + wv0010=0.40, + nitrogen=12.0, + phh2o=7.1, + sand=40.0, + silt=35.0, + clay=25.0, + ) + plants = SimpleNamespace(values_list=lambda *args, **kwargs: [1]) + center_a = SimpleNamespace(latitude=35.70, longitude=51.39, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) + center_b = SimpleNamespace(latitude=35.72, longitude=51.41, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) + sensor_a = SimpleNamespace( + farm_uuid="farm-a", + center_location=center_a, + plants=plants, + sensor_payload={"sensor-1": {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}}, + updated_at=now - timedelta(hours=2), + get_sensor_block=lambda: {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}, + soil_moisture=20.0, + soil_temperature=18.0, + soil_ph=7.0, + electrical_conductivity=1.2, + nitrogen=10.0, + phosphorus=8.0, + potassium=12.0, + ) + sensor_b = SimpleNamespace( + farm_uuid="farm-b", + center_location=center_b, + plants=plants, + sensor_payload={"sensor-1": {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}}, + updated_at=now - timedelta(hours=30), + get_sensor_block=lambda: {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}, + soil_moisture=36.0, + soil_temperature=19.0, + soil_ph=7.2, + electrical_conductivity=1.3, + nitrogen=11.0, + phosphorus=8.5, + potassium=12.5, + ) + + current_first = MagicMock() + current_first.first.return_value = sensor_a + current_filter = MagicMock() + current_filter.filter.return_value = current_first + current_qs = MagicMock() + current_qs.prefetch_related.return_value = current_filter + + network_distinct = MagicMock() + network_distinct.distinct.return_value = [sensor_a, sensor_b] + network_filter = MagicMock() + network_filter.filter.return_value = network_distinct + network_qs = MagicMock() + network_qs.prefetch_related.return_value = network_filter + + mock_objects.select_related.side_effect = [current_qs, network_qs] + + payload = SoilMoistureHeatmapService().get_heatmap(farm_uuid="farm-a") + + self.assertEqual(payload["model_metadata"]["interpolation_model"], "boundary_aware_weighted_idw") + self.assertTrue(payload["model_metadata"]["uses_freshness_weighting"]) + self.assertTrue(payload["model_metadata"]["uses_boundary_mask"]) + self.assertEqual(payload["summary"]["active_sensor_count"], 2) + self.assertEqual(payload["depth_layers"][0]["depth_label"], "0-5cm") + self.assertGreater(payload["sensor_points"][0]["reliability_score"], payload["sensor_points"][1]["reliability_score"]) + outside_cells = [cell for cell in payload["grid_cells"] if not cell["inside_farm_boundary"]] + self.assertTrue(outside_cells) + self.assertTrue(all(cell["moisture_value"] is None for cell in outside_cells)) + self.assertIn("uncertainty", payload["grid_cells"][0]) + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilHealthSummaryApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_health_summary_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_health_summary=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "healthScore": 82, + "profileSource": "گوجه فرنگی", + "healthScoreDetails": {"components": []}, + "healthLanguage": {"short_chip_text": "پایدار"}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 45.8, + "avgSoilMoistureStatus": "بهینه", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_health_service=lambda: mock_service + ) + + response = self.client.post( + "/health-summary/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["healthScore"], 82) + self.assertEqual(payload["avgSoilMoistureStatus"], "بهینه") diff --git a/soile/urls.py b/soile/urls.py new file mode 100644 index 0000000..cf7c93e --- /dev/null +++ b/soile/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import SoilAnomalyDetectionView, SoilHealthSummaryView, SoilMoistureHeatmapView + + +urlpatterns = [ + path("anomaly-detection/", SoilAnomalyDetectionView.as_view(), name="soil-anomaly-detection"), + path("health-summary/", SoilHealthSummaryView.as_view(), name="soil-health-summary"), + path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"), +] diff --git a/soile/views.py b/soile/views.py new file mode 100644 index 0000000..6623090 --- /dev/null +++ b/soile/views.py @@ -0,0 +1,191 @@ +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 ( + SoilAnomalyDetectionRequestSerializer, + SoilAnomalyDetectionResponseSerializer, + SoilHealthSummaryRequestSerializer, + SoilHealthSummaryResponseSerializer, + SoilMoistureHeatmapRequestSerializer, + SoilMoistureHeatmapResponseSerializer, +) + + +SoileHeatmapEnvelopeSerializer = build_envelope_serializer( + "SoileHeatmapEnvelopeSerializer", + SoilMoistureHeatmapResponseSerializer, +) +SoileErrorSerializer = build_envelope_serializer( + "SoileErrorSerializer", + data_required=False, + allow_null=True, +) +SoileAnomalyEnvelopeSerializer = build_envelope_serializer( + "SoileAnomalyEnvelopeSerializer", + SoilAnomalyDetectionResponseSerializer, +) +SoileHealthEnvelopeSerializer = build_envelope_serializer( + "SoileHealthEnvelopeSerializer", + SoilHealthSummaryResponseSerializer, +) + + +class SoilMoistureHeatmapView(APIView): + @extend_schema( + tags=["Soile"], + summary="دریافت heatmap رطوبت خاک مزرعه", + description=( + "با دریافت farm_uuid، heatmap رطوبت خاک را با وزن دهی زمانی/فضایی، " + "mask مرز مزرعه و برآورد عدم قطعیت از app مستقل soile برمی گرداند." + ), + request=SoilMoistureHeatmapRequestSerializer, + responses={ + 200: build_response( + SoileHeatmapEnvelopeSerializer, + "داده heatmap رطوبت خاک مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + SoileErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoileErrorSerializer, + "مزرعه یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست soile", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilMoistureHeatmapRequestSerializer(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("soile").get_soil_moisture_service() + try: + data = service.get_heatmap(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, + ) + + +class SoilHealthSummaryView(APIView): + @extend_schema( + tags=["Soile"], + summary="خلاصه سلامت و رطوبت خاک مزرعه", + description="با دریافت farm_uuid، امتیاز سلامت خاک/سنسور و میانگین رطوبت فعلی خاک را برمی گرداند.", + request=SoilHealthSummaryRequestSerializer, + responses={ + 200: build_response(SoileHealthEnvelopeSerializer, "خلاصه سلامت خاک با موفقیت بازگردانده شد."), + 400: build_response(SoileErrorSerializer, "داده ورودی نامعتبر است."), + 404: build_response(SoileErrorSerializer, "مزرعه یافت نشد."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست soil health", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilHealthSummaryRequestSerializer(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("soile").get_soil_health_service() + try: + data = service.get_health_summary(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) + + +class SoilAnomalyDetectionView(APIView): + @extend_schema( + tags=["Soile"], + summary="تحلیل ناهنجاری خاک با کمک RAG", + description="با دریافت farm_uuid، ناهنجاری های آماری داده های خاک را استخراج می کند و تفسیر تخصصی آن را با پایگاه دانش و tone مستقل برمی گرداند.", + request=SoilAnomalyDetectionRequestSerializer, + responses={ + 200: build_response( + SoileAnomalyEnvelopeSerializer, + "خروجی تحلیل ناهنجاری خاک با موفقیت بازگردانده شد.", + ), + 400: build_response( + SoileErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoileErrorSerializer, + "مزرعه یافت نشد.", + ), + 500: build_response( + SoileErrorSerializer, + "خطا در تحلیل ناهنجاری خاک.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست anomaly", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilAnomalyDetectionRequestSerializer(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("soile").get_soil_anomaly_service() + try: + data = service.get_anomaly_detection( + 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, + ) + 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": data}, + status=status.HTTP_200_OK, + ) diff --git a/weather/apps.py b/weather/apps.py index e9ea881..0f1c14a 100644 --- a/weather/apps.py +++ b/weather/apps.py @@ -1,3 +1,5 @@ +from functools import cached_property + from django.apps import AppConfig @@ -5,3 +7,21 @@ class WeatherConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "weather" verbose_name = "Weather Forecast" + + @cached_property + def farm_weather_service(self): + from .farm_weather import FarmWeatherService + + return FarmWeatherService() + + def get_farm_weather_service(self): + return self.farm_weather_service + + @cached_property + def water_need_service(self): + from .water_need_prediction import WaterNeedPredictionService + + return WaterNeedPredictionService() + + def get_water_need_service(self): + return self.water_need_service diff --git a/weather/farm_weather.py b/weather/farm_weather.py new file mode 100644 index 0000000..953b9ce --- /dev/null +++ b/weather/farm_weather.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from farm_data.models import SensorData + +from .services import get_forecast_for_location + + +WMO_CONDITIONS = { + 0: "صاف", + 1: "عمدتاً صاف", + 2: "نیمه‌ابری", + 3: "ابری", + 45: "مه", + 48: "مه یخ‌زده", + 51: "نم‌نم باران", + 61: "بارش خفیف", + 63: "بارش متوسط", + 65: "بارش شدید", + 71: "برف خفیف", + 80: "رگبار", + 95: "رعد و برق", +} + + +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 _weather_condition(weather_code): + return WMO_CONDITIONS.get(weather_code, "نامشخص") + + +def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]: + 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, + }, + } + + +class FarmWeatherService: + def get_farm_weather_card(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.") + + forecasts = get_forecast_for_location(sensor.center_location, days=7) + return _build_farm_weather_card(forecasts) diff --git a/weather/serializers.py b/weather/serializers.py new file mode 100644 index 0000000..fa97b2a --- /dev/null +++ b/weather/serializers.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + + +class FarmWeatherRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class WeatherChartDataSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField()) + series = serializers.ListField(child=serializers.ListField(child=serializers.FloatField())) + + +class FarmWeatherResponseSerializer(serializers.Serializer): + condition = serializers.CharField() + temperature = serializers.FloatField() + unit = serializers.CharField() + humidity = serializers.FloatField() + windSpeed = serializers.FloatField() + windUnit = serializers.CharField() + chartData = WeatherChartDataSerializer() + + +class WaterNeedPredictionRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class WaterNeedPredictionResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + totalNext7Days = serializers.FloatField() + unit = serializers.CharField() + categories = serializers.ListField(child=serializers.CharField()) + series = serializers.JSONField() + dailyBreakdown = serializers.JSONField() + insight = serializers.JSONField() + knowledge_base = serializers.CharField(allow_null=True, required=False) + tone_file = serializers.CharField(allow_null=True, required=False) + raw_response = serializers.CharField(allow_null=True, required=False) diff --git a/weather/test_farm_weather_api.py b/weather/test_farm_weather_api.py new file mode 100644 index 0000000..eed19b3 --- /dev/null +++ b/weather/test_farm_weather_api.py @@ -0,0 +1,127 @@ +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="weather.urls") +class FarmWeatherApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("weather.views.apps.get_app_config") + def test_farm_weather_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_farm_weather_card=lambda **_kwargs: { + "condition": "صاف", + "temperature": 28.0, + "unit": "°C", + "humidity": 42.0, + "windSpeed": 15.0, + "windUnit": "km/h", + "chartData": { + "labels": ["2026-04-01", "2026-04-02"], + "series": [[28.0, 29.0]], + }, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_farm_weather_service=lambda: mock_service + ) + + response = self.client.post( + "/farm-card/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["condition"], "صاف") + self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01") + + @patch("weather.views.apps.get_app_config") + def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_farm_weather_card=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_farm_weather_service=lambda: mock_service + ) + + response = self.client.post( + "/farm-card/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + +@override_settings(ROOT_URLCONF="weather.urls") +class WaterNeedPredictionApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "totalNext7Days": 24.6, + "unit": "mm", + "categories": ["روز 1", "روز 2"], + "series": [{"name": "نیاز آبی تعدیل‌شده", "data": [3.2, 4.1]}], + "dailyBreakdown": [ + {"forecast_date": "2026-04-01", "gross_irrigation_mm": 3.2}, + {"forecast_date": "2026-04-02", "gross_irrigation_mm": 4.1}, + ], + "insight": { + "summary": "جمع نياز آبي هفته آينده حدود 24.6 ميلي متر است.", + "irrigation_outlook": "نياز آبي در حال افزايش است.", + "recommended_action": "آبياري صبح زود تنظيم شود.", + "risk_note": "در صورت بارش موثر برنامه بازبيني شود.", + "confidence": 0.82, + }, + "knowledge_base": "water_need_prediction", + "tone_file": "config/tones/water_need_prediction_tone.txt", + "raw_response": "{\"summary\":\"ok\"}", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["knowledge_base"], "water_need_prediction") + self.assertEqual(payload["insight"]["confidence"], 0.82) + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") diff --git a/weather/urls.py b/weather/urls.py new file mode 100644 index 0000000..c001424 --- /dev/null +++ b/weather/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import FarmWeatherCardView, WaterNeedPredictionView + + +urlpatterns = [ + path("farm-card/", FarmWeatherCardView.as_view(), name="farm-weather-card"), + path("water-need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"), +] diff --git a/weather/views.py b/weather/views.py new file mode 100644 index 0000000..579c6d4 --- /dev/null +++ b/weather/views.py @@ -0,0 +1,145 @@ +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 ( + FarmWeatherRequestSerializer, + FarmWeatherResponseSerializer, + WaterNeedPredictionRequestSerializer, + WaterNeedPredictionResponseSerializer, +) + + +FarmWeatherEnvelopeSerializer = build_envelope_serializer( + "FarmWeatherEnvelopeSerializer", + FarmWeatherResponseSerializer, +) +WeatherErrorSerializer = build_envelope_serializer( + "WeatherErrorSerializer", + data_required=False, + allow_null=True, +) +WaterNeedPredictionEnvelopeSerializer = build_envelope_serializer( + "WaterNeedPredictionEnvelopeSerializer", + WaterNeedPredictionResponseSerializer, +) + + +class FarmWeatherCardView(APIView): + @extend_schema( + tags=["Weather"], + summary="دریافت کارت آب و هوای مزرعه", + description="با دریافت farm_uuid، داده مستقل کارت آب و هوای مزرعه را از اپ weather برمی گرداند.", + request=FarmWeatherRequestSerializer, + responses={ + 200: build_response( + FarmWeatherEnvelopeSerializer, + "داده کارت آب و هوای مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + WeatherErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + WeatherErrorSerializer, + "مزرعه یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست weather", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = FarmWeatherRequestSerializer(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("weather").get_farm_weather_service() + try: + data = service.get_farm_weather_card( + 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, + ) + + +class WaterNeedPredictionView(APIView): + @extend_schema( + tags=["Weather"], + summary="دریافت پیش بینی نیاز آبی کوتاه مدت مزرعه", + description="با دریافت farm_uuid، محاسبات نیاز آبی 7 روز آینده را از اپ weather برمی گرداند و با RAG تفسیر عملیاتی اضافه می کند.", + request=WaterNeedPredictionRequestSerializer, + responses={ + 200: build_response( + WaterNeedPredictionEnvelopeSerializer, + "داده پیش بینی نیاز آبی مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + WeatherErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + WeatherErrorSerializer, + "مزرعه یافت نشد.", + ), + 500: build_response( + WeatherErrorSerializer, + "خطا در تحلیل نیاز آبی مزرعه.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست water need", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = WaterNeedPredictionRequestSerializer(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("weather").get_water_need_service() + try: + data = service.get_water_need_prediction( + 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, + ) + 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": data}, + status=status.HTTP_200_OK, + ) diff --git a/weather/water_need_prediction.py b/weather/water_need_prediction.py new file mode 100644 index 0000000..48b48d1 --- /dev/null +++ b/weather/water_need_prediction.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile + +from farm_data.models import SensorData +from rag.services import get_water_need_prediction_insight + +from .services import get_forecast_for_location + + +def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]: + location = getattr(sensor, "center_location", None) + plants = list(sensor.plants.all()) if hasattr(sensor, "plants") else [] + plant = plants[0] if plants else None + irrigation_method = getattr(sensor, "irrigation_method", None) + + if not forecasts or location is None: + return { + "totalNext7Days": 0, + "unit": "mm", + "categories": [], + "series": [], + "dailyBreakdown": [], + "cropProfile": {}, + "irrigationEfficiencyPercent": None, + } + + crop_profile = resolve_crop_profile(plant) + efficiency = getattr(irrigation_method, "water_efficiency_percent", None) if irrigation_method 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, + "cropProfile": crop_profile, + "irrigationEfficiencyPercent": efficiency, + } + + +class WaterNeedPredictionService: + def get_water_need_prediction(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location", "irrigation_method") + .prefetch_related("plants") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + + forecasts = get_forecast_for_location(sensor.center_location, days=7) + payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts) + insight = get_water_need_prediction_insight( + farm_uuid=farm_uuid, + prediction_payload=payload, + ) + + return { + "farm_uuid": farm_uuid, + **payload, + "insight": { + "summary": insight.get("summary"), + "irrigation_outlook": insight.get("irrigation_outlook"), + "recommended_action": insight.get("recommended_action"), + "risk_note": insight.get("risk_note"), + "confidence": insight.get("confidence"), + }, + "knowledge_base": insight.get("knowledge_base"), + "tone_file": insight.get("tone_file"), + "raw_response": insight.get("raw_response"), + }