diff --git a/docs/farm_location_data_relationships.md b/docs/farm_location_data_relationships.md new file mode 100644 index 0000000..9bdb206 --- /dev/null +++ b/docs/farm_location_data_relationships.md @@ -0,0 +1,941 @@ +# ارتباط `farm_data`، `location_data` و فیلدهای وابسته + +این سند ساختار فعلی داده‌ها در پروژه را توضیح می‌دهد و هم‌زمان دو قاعده کسب‌وکاری مورد تایید را به‌عنوان مبنای توسعه ثبت می‌کند: + +1. برای محاسبه‌های عمومی هوش مصنوعی مثل `RAG` و `crop_simulation` باید از داده‌های تجمیع‌شده کل بلوک‌های بزرگِ تعریف‌شده توسط کشاورز استفاده شود. +2. اگر برای یک مزرعه هیچ بلاکی تعریف نشده باشد، حالت پیش‌فرض باید شامل `1 بلوک بزرگ` و `1 بلوک کوچک‌تر داخل همان بلوک بزرگ` باشد. + +این سند بر اساس ساختار فعلی کد در `farm_data/`, `location_data/`, `weather/`, `rag/`, `crop_simulation/`, `irrigation/` و `plant/` نوشته شده است. + +--- + +## 1) نقش هر app در معماری داده + +### `farm_data` + +این app رکورد canonical مزرعه برای مصرف لایه AI را نگه می‌دارد. مهم‌ترین رکورد آن `SensorData` است. + +وظیفه‌های اصلی: + +- نگه‌داری شناسه مزرعه (`farm_uuid`) +- اتصال مزرعه به `SoilLocation` +- نگه‌داری payload سنسورها +- نگه‌داری snapshot گیاه‌ها برای مصرف AI +- اتصال به روش آبیاری و آب‌وهوا +- ساخت خروجی تجمیعی مزرعه با `get_farm_details()` + +### `location_data` + +این app مدل مکانی مزرعه و ساختار بلاک‌ها را نگه می‌دارد. + +وظیفه‌های اصلی: + +- نگه‌داری مرکز هندسی مزرعه (`SoilLocation`) +- نگه‌داری مرز کل مزرعه (`farm_boundary`) +- نگه‌داری بلاک‌های اصلی کشاورز (`block_layout.blocks`) +- نگه‌داری subdivision هر بلاک (`BlockSubdivision`) +- نگه‌داری grid سلول‌ها و داده‌های سنجش‌ازدور +- ساخت snapshotهای بلاکی و تجمیعی برای مصرف downstream + +### `weather` + +پیش‌بینی آب‌وهوا را برای هر `SoilLocation` نگه می‌دارد. مزرعه از طریق `center_location` به forecast متصل می‌شود. + +### `plant` + +مدل اصلی گیاه‌ها را نگه می‌دارد، اما برای لایه AI در `farm_data` یک read-model به نام `PlantCatalogSnapshot` ساخته شده است. + +### `irrigation` + +جدول مرجع روش‌های آبیاری را نگه می‌دارد و `farm_data.SensorData` به یکی از آن‌ها متصل می‌شود. + +### `rag` و `crop_simulation` + +این دو app مصرف‌کننده داده‌اند، نه مالک اصلی داده. یعنی داده اصلی را از `farm_data`, `location_data`, `weather`, `plant` و snapshotهای تجمیعی می‌گیرند. + +--- + +## 2) موجودیت‌های اصلی و ارتباط بین آن‌ها + +نمای کلی: + +```text +Farm (business id = farm_uuid) + | + v +farm_data.SensorData + |-- FK --> location_data.SoilLocation + |-- FK --> weather.WeatherForecast (optional cached/latest link) + |-- FK --> irrigation.IrrigationMethod (optional) + |-- JSON --> sensor_payload + |-- M2M legacy --> plant.Plant + |-- 1:N canonical --> farm_data.FarmPlantAssignment --> farm_data.PlantCatalogSnapshot + +location_data.SoilLocation + |-- JSON --> farm_boundary + |-- int --> input_block_count + |-- JSON --> block_layout + |-- 1:N --> BlockSubdivision + |-- 1:N --> RemoteSensingRun + |-- 1:N --> AnalysisGridCell + |-- 1:N --> WeatherForecast + |-- 1:N --> NdviObservation + +BlockSubdivision + |-- belongs to --> SoilLocation + |-- identifies --> one main block (block_code) + |-- stores --> source_boundary / centroid_points / subdivision_summary + +RemoteSensingRun + |-- belongs to --> SoilLocation + |-- scoped by --> block_code + |-- produces --> AnalysisGridObservation + RemoteSensingSubdivisionResult + +RemoteSensingSubdivisionResult + |-- belongs to --> RemoteSensingRun + |-- scoped by --> block_code + |-- produces --> RemoteSensingClusterBlock + RemoteSensingClusterAssignment +``` + +--- + +## 3) تعریف دقیق موجودیت‌ها + +## 3.1) `farm_data.SensorData` + +این مدل در عمل رکورد اصلی مزرعه برای مصرف AI است. + +فیلدهای مهم: + +- `farm_uuid` + - شناسه یکتای مزرعه + - primary key + - شناسه business-level بین appها + +- `center_location` + - `ForeignKey` به `location_data.SoilLocation` + - هر مزرعه دقیقاً به یک location مرکزی وصل است + - نام legacy آن در بعضی جاها هنوز به‌صورت `location` دیده می‌شود، ولی canonical همان `center_location` است + +- `weather_forecast` + - `ForeignKey` اختیاری به `weather.WeatherForecast` + - آخرین forecast مرتبط با location را cache می‌کند + - اگر خالی باشد، سرویس‌ها معمولاً از روی `center_location.weather_forecasts` آخرین رکورد را پیدا می‌کنند + +- `sensor_payload` + - `JSONField` + - ساختار چندسنسوری دارد + - نمونه: + +```json +{ + "sensor-7-1": { + "soil_moisture": 22.4, + "soil_temperature": 18.1, + "nitrogen": 31.0 + }, + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 19.3 + } +} +``` + +- `plants` + - relation قدیمی به `plant.Plant` + - برای سازگاری عقب‌رو نگه داشته شده + - canonical برای AI نیست + +- `irrigation_method` + - `ForeignKey` اختیاری به `irrigation.IrrigationMethod` + +- `created_at`, `updated_at` + - زمان ساخت و آخرین به‌روزرسانی رکورد مزرعه + +نکته مهم: + +- `SensorData` مالک geometry مزرعه نیست. +- geometry و بلاک‌ها در `location_data` نگه‌داری می‌شوند. +- `SensorData` فقط به `SoilLocation` وصل می‌شود. + +--- + +## 3.2) `farm_data.PlantCatalogSnapshot` + +این مدل snapshot خواندنی از کاتالوگ گیاه Backend است تا سرویس‌های AI مستقیم به app اصلی گیاه وابسته نباشند. + +فیلدهای مهم: + +- `backend_plant_id` +- `name` +- `slug` +- `icon` +- `description` +- `metadata` +- `health_profile` +- `irrigation_profile` +- `growth_profile` +- `growth_stage`, `growth_stages` +- `is_active` +- `source_updated_at` + +این مدل source of truth اصلی گیاه در کل سیستم نیست، ولی source of truth لایه AI برای گیاهِ sync‌شده است. + +--- + +## 3.3) `farm_data.FarmPlantAssignment` + +رابط canonical بین مزرعه و snapshot گیاه است. + +فیلدهای مهم: + +- `farm` -> `SensorData` +- `plant` -> `PlantCatalogSnapshot` +- `position` +- `stage` +- `metadata` + +کاربرد: + +- تعیین ترتیب گیاه‌های مزرعه +- تعیین stage اختصاصی برای گیاه در همان مزرعه +- حذف وابستگی مستقیم AI به `SensorData.plants` + +--- + +## 3.4) `farm_data.SensorParameter` + +تعریف metadata هر پارامتر سنسوری است. + +فیلدهای مهم: + +- `sensor_key` +- `code` +- `name_fa` +- `unit` +- `data_type` +- `metadata` + +کاربرد: + +- ساخت schema داینامیک برای `sensor_payload` +- ثبت سنسورهای جدید بدون migration + +--- + +## 3.5) `location_data.SoilLocation` + +این مدل نقطه مرکزی و ساختار فضایی مزرعه را نگه می‌دارد. + +فیلدهای مهم: + +- `latitude` +- `longitude` + - مرکز هندسی مزرعه + - روی این دو فیلد constraint یکتایی وجود دارد + +- `task_id` + - شناسه taskهای async + +- `farm_boundary` + - مرز کل مزرعه + - معمولاً به شکل GeoJSON polygon ذخیره می‌شود + +- `input_block_count` + - تعداد بلاک‌های اصلی تعریف‌شده توسط کشاورز + +- `block_layout` + - ساختار کامل بلاک‌های اصلی و زیر‌بلاک‌های داخلی + - مهم‌ترین فیلد spatial-read-model برای AI + +- `created_at`, `updated_at` + +نکته مهم: + +- `SoilLocation` خود مزرعه نیست. +- `SoilLocation` نمای مکانی مزرعه است. +- مزرعه business-level با `farm_uuid` در `SensorData` شناسایی می‌شود. + +--- + +## 3.6) `location_data.BlockSubdivision` + +این مدل subdivision یک بلاک اصلی را نگه می‌دارد. + +فیلدهای مهم: + +- `soil_location` +- `block_code` +- `source_boundary` +- `chunk_size_sqm` +- `grid_points` +- `centroid_points` +- `grid_point_count` +- `centroid_count` +- `status` +- `metadata` +- `elbow_plot` + +تفسیر: + +- هر رکورد `BlockSubdivision` به یک `main block` تعلق دارد. +- `block_code` همان شناسه بلاک اصلی کشاورز است. +- `centroid_points` معمولاً نماینده زیر‌بلاک‌های داخلی است. + +--- + +## 3.7) `location_data.RemoteSensingRun` + +هر run سنجش‌ازدور برای یک location و معمولاً برای یک `block_code` اجرا می‌شود. + +فیلدهای مهم: + +- `soil_location` +- `block_subdivision` +- `block_code` +- `provider` +- `chunk_size_sqm` +- `temporal_start` +- `temporal_end` +- `status` +- `metadata` +- `error_message` + +اگر `block_code` خالی باشد، run در سطح کل farm/location تفسیر می‌شود. +اگر `block_code` مقدار داشته باشد، run مربوط به همان بلاک اصلی است. + +--- + +## 3.8) `location_data.AnalysisGridCell` + +سلول‌های شبکه تحلیلی برای سنجش‌ازدور هستند. + +فیلدهای مهم: + +- `soil_location` +- `block_subdivision` +- `block_code` +- `cell_code` +- `chunk_size_sqm` +- `geometry` +- `centroid_lat` +- `centroid_lon` + +این‌ها لایه پایین‌تر از main block هستند و برای محاسبات remote sensing استفاده می‌شوند. + +--- + +## 3.9) `location_data.AnalysisGridObservation` + +خروجی متریک‌های سنجش‌ازدور برای هر cell در یک بازه زمانی است. + +فیلدهای مهم: + +- `cell` +- `run` +- `temporal_start` +- `temporal_end` +- `ndvi` +- `ndwi` +- `soil_vv` +- `soil_vv_db` +- `dem_m` +- `slope_deg` +- `metadata` + +این داده‌ها raw یا نیمه‌تجمیع‌شده‌اند و هنوز در سطح مزرعه نیستند. + +--- + +## 3.10) `location_data.RemoteSensingSubdivisionResult` + +نتیجه خوشه‌بندی داده‌محور برای یک run و یک بلاک است. + +فیلدهای مهم: + +- `run` +- `soil_location` +- `block_subdivision` +- `block_code` +- `chunk_size_sqm` +- `cluster_count` +- `selected_features` +- `metadata` + +--- + +## 3.11) `location_data.RemoteSensingClusterBlock` + +این مدل زیر‌بلاک‌های KMeans/cluster-based را نگه می‌دارد. + +فیلدهای مهم: + +- `uuid` +- `result` +- `soil_location` +- `block_subdivision` +- `block_code` +- `sub_block_code` +- `cluster_label` +- `centroid_lat` +- `centroid_lon` +- `geometry` +- `cell_count` +- `cell_codes` +- `metadata` + +نکته مهم: + +- این زیر‌بلاک‌ها با `main block` فرق دارند. +- `block_code` = بلاک اصلی والد +- `sub_block_code` = زیر‌بلاک داخلی ساخته‌شده با خوشه‌بندی + +--- + +## 3.12) `weather.WeatherForecast` + +پیش‌بینی هواشناسی برای یک `SoilLocation` است. + +فیلدهای مهم: + +- `location` +- `forecast_date` +- `temperature_min` +- `temperature_max` +- `temperature_mean` +- `precipitation` +- `precipitation_probability` +- `humidity_mean` +- `wind_speed_max` +- `et0` +- `weather_code` +- `fetched_at` + +نکته: + +- آب‌وهوا به location وصل است، نه مستقیم به farm_uuid. +- `SensorData.weather_forecast` فقط shortcut/cache است. + +--- + +## 3.13) `irrigation.IrrigationMethod` + +مدل مرجع روش آبیاری است. + +فیلدهای مهم: + +- `name` +- `category` +- `description` +- `water_efficiency_percent` +- `water_pressure_required` +- `flow_rate` +- `coverage_area` +- `soil_type` +- `climate_suitability` + +هر مزرعه می‌تواند صفر یا یک روش آبیاری انتخاب‌شده داشته باشد. + +--- + +## 4) منبع اصلی هر نوع داده + +| نوع داده | مالک اصلی | فیلد/مدل canonical | توضیح | +|---|---|---|---| +| شناسه مزرعه | `farm_data` | `SensorData.farm_uuid` | شناسه business-level | +| مرکز مکانی مزرعه | `location_data` | `SoilLocation.latitude/longitude` | centroid هندسی | +| مرز کل مزرعه | `location_data` | `SoilLocation.farm_boundary` | شکل کل زمین | +| تعداد بلاک‌های اصلی | `location_data` | `SoilLocation.input_block_count` | تعداد بلاک‌های کشاورز | +| ساختار بلاک‌ها | `location_data` | `SoilLocation.block_layout` | بلاک‌های اصلی + sub-block metadata | +| تعریف subdivision هر بلاک | `location_data` | `BlockSubdivision` | state و مرز هر بلاک | +| داده سنسور | `farm_data` | `SensorData.sensor_payload` | source مستقیم از مزرعه/سنسور | +| schema پارامترهای سنسور | `farm_data` | `SensorParameter` | metadata فیلدهای sensor_payload | +| گیاه‌های مزرعه | `farm_data` | `FarmPlantAssignment` | canonical برای AI | +| catalog گیاه | `farm_data` | `PlantCatalogSnapshot` | snapshot sync شده | +| forecast هوا | `weather` | `WeatherForecast` | در سطح location | +| داده سنجش‌ازدور سلولی | `location_data` | `AnalysisGridObservation` | خام/نیمه‌خام | +| تجمیع بلاک اصلی | `location_data` | snapshotهای `satellite_snapshot.py` | برای AI | +| روش آبیاری | `irrigation` | `IrrigationMethod` | جدول مرجع | + +--- + +## 5) دو سطح بلاک که نباید با هم قاطی شوند + +در این پروژه دو سطح جدا داریم: + +### سطح اول: `main block` + +این همان بلاک بزرگی است که کشاورز تعریف می‌کند. + +محل نگه‌داری: + +- `SoilLocation.block_layout["blocks"]` +- `BlockSubdivision.block_code` +- `RemoteSensingRun.block_code` + +مثال: + +- `block-1` +- `north-field` +- `greenhouse-a` + +### سطح دوم: `sub block` + +این زیر‌بلاک داخلی است که یا: + +- از subdivision اولیه ساخته می‌شود +- یا از خوشه‌بندی داده‌محور remote sensing/KMeans ساخته می‌شود + +محل نگه‌داری: + +- `BlockSubdivision.centroid_points` +- `block_layout["blocks"][i]["sub_blocks"]` +- `RemoteSensingClusterBlock` +- `satellite_snapshot["satellite_sub_blocks"]` +- `satellite_snapshot["sensor_sub_blocks"]` + +مثال: + +- `sub-block-1` +- `cluster-0` +- `cluster-1` + +قانون مهم: + +- `main block` سطح تصمیم‌گیری کشاورز است. +- `sub block` سطح تحلیل داخلی سیستم است. +- برای AI عمومی باید جمع‌بندی روی `main block`ها انجام شود، نه این‌که مستقیماً یک `sub block` به‌عنوان نماینده کل مزرعه در نظر گرفته شود. + +--- + +## 6) ساختار `block_layout` + +`SoilLocation.block_layout` مهم‌ترین read-model فضایی برای کل سیستم است. + +شکل عمومی: + +```json +{ + "input_block_count": 1, + "default_full_farm": true, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "boundary": {}, + "needs_subdivision": null, + "sub_blocks": [] + } + ] +} +``` + +کلیدهای مهم: + +- `input_block_count` + - تعداد بلاک‌های اصلی کشاورز + +- `default_full_farm` + - اگر فقط یک بلاک اصلی وجود داشته باشد معمولاً `true` است + +- `algorithm_status` + - وضعیت محاسبات بعدی روی layout + - معمولاً `pending` یا `completed` + +- `blocks` + - لیست بلاک‌های اصلی + +هر آیتم `blocks`: + +- `block_code` + - شناسه یکتای بلاک اصلی + +- `order` + - ترتیب نمایش/تحلیل + +- `source` + - معمولاً `input` یا `default` + +- `boundary` + - مرز همان بلاک اصلی + +- `needs_subdivision` + - آیا این بلاک نیاز به subdivision داخلی دارد + +- `sub_blocks` + - لیست زیر‌بلاک‌های داخلی + +در بعضی مرحله‌ها این layout با فیلدهای تکمیلی enrich می‌شود: + +- `subdivision_summary` +- `analysis_grid_summary` +- `aggregated_metrics` + +--- + +## 7) جریان ساخت و به‌روزرسانی داده + +## 7.1) وقتی `POST /api/farm-data/` صدا زده می‌شود + +این endpoint مزرعه را از دید AI upsert می‌کند. + +جریان: + +1. `farm_uuid` و `farm_boundary` دریافت می‌شود. +2. در `resolve_center_location_from_boundary()` centroid مزرعه محاسبه می‌شود. +3. یک `SoilLocation` بر اساس centroid ساخته یا پیدا می‌شود. +4. `input_block_count` و `block_layout` اولیه تنظیم می‌شوند. +5. اگر ایجاد جدید باشد و فقط یک بلاک وجود داشته باشد، برای `block-1` یک subdivision اولیه هم می‌تواند ساخته شود. +6. forecast آب‌وهوا از روی location resolve می‌شود. +7. رکورد `SensorData` ساخته یا آپدیت می‌شود. +8. payload سنسورها merge می‌شود. +9. plant assignmentها و irrigation method در صورت ارسال sync می‌شوند. + +نکته: + +- این endpoint بیشتر مزرعه را به `SoilLocation` وصل می‌کند. +- تعریف دقیق مرز هر main block معمولاً از مسیر `location_data` می‌آید، نه از `farm_data`. + +--- + +## 7.2) وقتی `POST /api/location-data/` صدا زده می‌شود + +این endpoint ساختار مزرعه از دید spatial را ذخیره می‌کند. + +جریان: + +1. `lat`, `lon`, `farm_boundary`, `blocks` دریافت می‌شود. +2. `SoilLocation` بر اساس همان lat/lon ذخیره یا پیدا می‌شود. +3. `input_block_count` و `block_layout` با لیست `blocks` به‌روزرسانی می‌شوند. +4. `_sync_defined_blocks()` برای هر `main block` یک `BlockSubdivision` با `status="defined"` می‌سازد یا به‌روزرسانی می‌کند. +5. اگر بلاکی حذف شده باشد، subdivision و state تحلیل قبلی آن پاک می‌شود. +6. اگر boundary بلاکی تغییر کند، state تحلیل سنجش‌ازدور آن invalidate می‌شود. + +پس: + +- `location_data` مالک تعریف بلاک‌های اصلی است. +- `farm_data` مالک رکورد مزرعه برای AI است. + +--- + +## 7.3) وقتی `get_farm_details()` ساخته می‌شود + +این تابع خروجی canonical مزرعه را برای appهای دیگر تولید می‌کند. + +خروجی شامل این بخش‌هاست: + +- `center_location` +- `weather` +- `sensor_payload` +- `sensor_schema` +- `soil` +- `plant_ids` +- `plants` +- `plant_assignments` +- `irrigation_method` +- `created_at`, `updated_at` + +بخش `soil` از ادغام این دو منبع ساخته می‌شود: + +- snapshotهای سنجش‌ازدور +- sensor_payload + +قاعده فعلی merge: + +- اگر برای یک metric داده سنسور وجود داشته باشد، روی داده remote sensing override می‌شود. +- اگر چند سنسور مقدار متعارض بدهند: + - برای داده عددی average گرفته می‌شود + - برای داده غیرعددی distinct values برمی‌گردد + +--- + +## 8) snapshotهای مکانی و معنای آن‌ها + +در `location_data/satellite_snapshot.py` چند نوع snapshot مهم ساخته می‌شود: + +### `build_location_satellite_snapshot(location, block_code="")` + +یک snapshot برای یک scope خاص می‌سازد: + +- اگر `block_code` خالی باشد: snapshot عمومی location/farm +- اگر `block_code` پر باشد: snapshot همان main block + +### `build_location_block_satellite_snapshots(location)` + +برای همه `main block`های ثبت‌شده snapshot می‌سازد. + +خروجی هر بلاک شامل این‌هاست: + +- `resolved_metrics` +- `metric_sources` +- `satellite_metrics` +- `sensor_metrics` +- `sensor_sub_blocks` +- `satellite_sub_blocks` +- `sub_block_count` + +### `build_farmer_block_aggregated_snapshot(location)` + +خروجی تجمیعی سطح مزرعه بر اساس همه `main block`های کشاورز است. + +این مهم‌ترین تابع برای قانون کسب‌وکاری تو است، چون: + +- اگر چند main block وجود داشته باشد، میانگین آن‌ها را در سطح farmer-block می‌سازد +- `aggregation_strategy` آن `farmer_block_mean` است +- برای AI عمومی از نظر مفهومی این همان سطح درست مصرف داده است + +--- + +## 9) قانون canonical برای محاسبه‌های عمومی AI + +برای سرویس‌های عمومی هوش مصنوعی مثل: + +- `RAG` +- `crop_simulation` +- `fertilization` +- `irrigation` +- `farm_alerts` +- هر سرویسی که قرار است از کل وضعیت مزرعه حرف بزند + +باید سطح داده canonical این باشد: + +### سطح مجاز + +- کل مزرعه بر اساس تجمیع `main block`های کشاورز + +### تابع پیشنهادی canonical + +- `build_farmer_block_aggregated_snapshot(location, sensor_payload=...)` + +### دلیل + +- این تابع داده‌ها را از سطح `main block` بالا می‌آورد +- اگر مزرعه چند بلاک اصلی داشته باشد، یک بلاک یا یک sub-block به اشتباه نماینده کل مزرعه نمی‌شود +- با خواسته کسب‌وکاری تو هم‌راستا است + +### سطحی که نباید مبنای AI عمومی باشد + +- یک `sub_block` تکی +- یک `cluster-0` یا `cluster-1` به‌تنهایی +- snapshot خام location بدون درنظرگرفتن بلاک‌های اصلی کشاورز، مگر فقط به‌عنوان fallback + +--- + +## 10) وضعیت پیش‌فرض وقتی بلاک تعریف نشده است + +قاعده مورد تایید: + +- اگر کشاورز هنوز بلاک‌ها را تعریف نکرده باشد: + - یک `main block` پیش‌فرض وجود دارد + - داخل آن هم یک `sub block` پیش‌فرض وجود دارد + +### نمایش منطقی مورد انتظار + +```json +{ + "input_block_count": 1, + "default_full_farm": true, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "boundary": {}, + "needs_subdivision": false, + "sub_blocks": [ + { + "sub_block_code": "sub-block-1" + } + ] + } + ] +} +``` + +### تفسیر این قانون + +- `block-1` نماینده کل مزرعه است +- `sub-block-1` حداقل واحد داخلی برای این‌که downstreamها همیشه ساختار یکنواخت ببینند + +### نکته درباره وضعیت فعلی کد + +کد فعلی به‌صورت پیش‌فرض `1 main block` را به‌خوبی می‌سازد، اما وجود `1 sub-block` پیش‌فرض باید به‌عنوان قانون توسعه حفظ و در همه entry pointها یکدست شود. + +--- + +## 11) ارتباط این داده‌ها با appهای دیگر + +## 11.1) `rag` + +`rag` معمولاً context مزرعه را از `farm_data` می‌گیرد. + +نقاط مهم: + +- `rag.chat.build_rag_context()` از `get_farm_details()` استفاده می‌کند +- `rag.user_data.build_user_soil_text()` علاوه بر داده‌های مزرعه، از: + - `build_farmer_block_aggregated_snapshot()` + - `build_location_block_satellite_snapshots()` + استفاده می‌کند + +نتیجه: + +- برای RAG عمومی، سطح درست context باید تجمیع `main block`ها باشد +- جزئیات بلاکی و زیر‌بلاکی فقط برای explanation تکمیلی مناسب‌اند + +## 11.2) `crop_simulation` + +`crop_simulation` از این داده‌ها استفاده می‌کند: + +- `SensorData` +- `center_location` +- forecastهای هوا +- snapshotهای خاک/سنجش‌ازدور +- plant profile +- irrigation method + +قاعده مورد انتظار: + +- اگر خروجی برای کل مزرعه است، ورودی خاک/سنسور باید از تجمیع `main block`های کشاورز بیاید +- نه از یک location snapshot ساده یا یک sub-block خاص + +## 11.3) `weather` + +سرویس‌های هواشناسی به `SensorData.center_location` متکی هستند و forecast را از `WeatherForecast`های همان location می‌خوانند. + +## 11.4) `soile` + +تحلیل‌های خاک و anomaly detection از `load_farm_context()` و snapshotهای location استفاده می‌کنند. برای use-caseهای farm-wide، باید همان rule تجمیع `main block`ها رعایت شود. + +## 11.5) `farm_alerts` + +این app از `load_farm_context()` و `get_farm_details()` استفاده می‌کند. بنابراین هر تغییری در canonical farm context مستقیماً روی alertها اثر می‌گذارد. + +--- + +## 12) تفاوت `farm_boundary` با `block boundary` + +این دو نباید با هم اشتباه شوند: + +### `farm_boundary` + +- مرز کل زمین +- در `SoilLocation.farm_boundary` +- فقط یکی برای هر location + +### `blocks[i].boundary` + +- مرز هر بلاک اصلی کشاورز +- در `SoilLocation.block_layout["blocks"]` +- به‌ازای هر main block یک boundary + +نتیجه: + +- `farm_boundary` = outer shell کل مزرعه +- `block boundary` = تقسیم داخلی همان مزرعه + +--- + +## 13) تفاوت `center_location` با `farm_uuid` + +### `farm_uuid` + +- شناسه business-level مزرعه +- در `SensorData` +- چیزی است که APIهای AI بیشتر با آن کار می‌کنند + +### `center_location` + +- شناسه مکانی centroid-based +- در `SoilLocation` +- چیزی است که weather, remote sensing, block layout و geometry به آن وصل‌اند + +یک `farm_uuid` به یک `center_location` وصل می‌شود، اما معنا و مسئولیتشان متفاوت است: + +- `farm_uuid` = هویت مزرعه +- `center_location` = هویت مکانی مزرعه + +--- + +## 14) فیلدهایی که downstreamها باید canonical از آن‌ها بخوانند + +اگر سرویسی بخواهد داده مزرعه را بخواند، اولویت canonical این‌طور است: + +### هویت مزرعه + +- `SensorData.farm_uuid` + +### geometry و ساختار بلاک + +- `SensorData.center_location` +- `SensorData.center_location.farm_boundary` +- `SensorData.center_location.block_layout` + +### داده سنسور + +- `SensorData.sensor_payload` + +### schema سنسور + +- `farm_data.SensorParameter` + +### گیاه + +- `FarmPlantAssignment` + `PlantCatalogSnapshot` + +### آب‌وهوا + +- `SensorData.weather_forecast` اگر موجود بود +- در غیر این صورت `center_location.weather_forecasts` + +### summary خاک/remote sensing برای کل مزرعه + +- `build_farmer_block_aggregated_snapshot(...)` + +### summary برای هر main block + +- `build_location_block_satellite_snapshots(...)` + +### summary برای زیر‌بلاک‌ها + +- `satellite_sub_blocks` و `sensor_sub_blocks` + +--- + +## 15) نمونه خلاصه مفهومی برای یک مزرعه + +```text +farm_uuid = شناسه اصلی مزرعه +center_location = centroid و ساختار spatial مزرعه +farm_boundary = مرز کل زمین +block_layout = بلاک‌های اصلی تعریف‌شده توسط کشاورز +block_subdivisions = وضعیت subdivision هر بلاک اصلی +analysis_grid = سلول‌های داخلی برای سنجش‌ازدور +remote_sensing = متریک‌های سلولی و تجمیع‌شده +sensor_payload = سنسورهای واقعی نصب‌شده در مزرعه +plants = گیاه‌های sync شده برای AI +weather = forecastهای location +irrigation_method = روش آبیاری انتخاب‌شده +AI general context = farmer-block aggregated snapshot + sensor payload + weather + plant + irrigation +``` + +--- + +## 16) جمع‌بندی نهایی + +اگر بخواهیم یک قانون ساده و پایدار برای کل سیستم تعریف کنیم: + +- `farm_data` مالک رکورد AI-facing مزرعه است. +- `location_data` مالک geometry، بلاک‌ها، subdivision و remote sensing است. +- `weather` مالک forecastهای location است. +- `plant` و snapshotهای آن مالک context گیاهی مزرعه‌اند. +- `irrigation` مالک روش آبیاری است. + +و از نظر محاسبات عمومی AI: + +- سطح تصمیم‌گیری باید `کل main block`های کشاورز باشد. +- `sub_block`ها فقط جزئیات داخلی و تحلیلی هستند. +- اگر بلاکی تعریف نشده بود، مدل ذهنی و داده‌ای پیش‌فرض باید `1 main block + 1 sub-block داخلی` باشد. + diff --git a/farm_data/services.py b/farm_data/services.py index e93ac90..98f5833 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -16,6 +16,7 @@ import requests from location_data.block_subdivision import create_or_get_block_subdivision from location_data.models import BlockSubdivision, SoilLocation from location_data.satellite_snapshot import ( + build_block_layout_metric_summary, build_location_block_satellite_snapshots, build_location_satellite_snapshot, ) @@ -464,6 +465,15 @@ def get_farm_details(farm_uuid: str): ) latest_satellite = build_location_satellite_snapshot(center_location) + block_metric_snapshots = build_location_block_satellite_snapshots( + center_location, + sensor_payload=farm.sensor_payload, + ) + if all( + snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics") + for snapshot in block_metric_snapshots + ): + block_metric_snapshots = [] soil_metrics = dict(latest_satellite.get("resolved_metrics") or {}) sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload) @@ -483,7 +493,10 @@ def get_farm_details(farm_uuid: str): "lon": center_location.longitude, "farm_boundary": center_location.farm_boundary, "input_block_count": center_location.input_block_count, - "block_layout": center_location.block_layout, + "block_layout": build_block_layout_metric_summary( + center_location, + sensor_payload=farm.sensor_payload, + ), }, "weather": WeatherForecastDetailSerializer(weather).data if weather else None, "sensor_payload": farm.sensor_payload or {}, @@ -491,7 +504,7 @@ def get_farm_details(farm_uuid: str): "soil": { "resolved_metrics": resolved_metrics, "metric_sources": metric_sources, - "satellite_snapshots": build_location_block_satellite_snapshots(center_location), + "satellite_snapshots": block_metric_snapshots, }, "plant_ids": [plant.backend_plant_id for plant in plant_snapshots], "plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data, diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index b0aed8e..9dff7f4 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -5,7 +5,16 @@ import uuid from django.test import TestCase from rest_framework.test import APIClient -from location_data.models import BlockSubdivision, SoilLocation +from location_data.models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingClusterAssignment, + RemoteSensingClusterBlock, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter from farm_data.services import ( assign_farm_plants_from_backend_ids, @@ -181,6 +190,172 @@ class FarmDetailApiTests(TestCase): SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() ) + def test_detail_aggregates_satellite_and_sensor_metrics_from_kmeans_sub_blocks_to_main_block(self): + subdivision = BlockSubdivision.objects.create( + soil_location=self.location, + block_code="block-1", + source_boundary=square_boundary_for_center(35.7, 51.4, delta=0.002), + chunk_size_sqm=900, + status="subdivided", + ) + run = RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2026, 4, 1), + temporal_end=date(2026, 4, 30), + status=RemoteSensingRun.STATUS_SUCCESS, + ) + result = RemoteSensingSubdivisionResult.objects.create( + soil_location=self.location, + run=run, + block_subdivision=subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=run.temporal_start, + temporal_end=run.temporal_end, + cluster_count=2, + selected_features=["ndvi", "ndwi", "soil_vv_db"], + metadata={"used_cell_count": 3, "cluster_summaries": []}, + ) + cell_payloads = [ + ("cell-1", 0, 0.2, 10.0), + ("cell-2", 0, 0.4, 12.0), + ("cell-3", 1, 0.9, 20.0), + ] + created_cells = [] + for index, (cell_code, cluster_label, ndvi, ndwi) in enumerate(cell_payloads): + cell = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=subdivision, + block_code="block-1", + cell_code=cell_code, + chunk_size_sqm=900, + geometry=square_boundary_for_center(35.7 + (index * 0.0001), 51.4 + (index * 0.0001), delta=0.00005), + centroid_lat=f"{35.7000 + (index * 0.0001):.6f}", + centroid_lon=f"{51.4000 + (index * 0.0001):.6f}", + ) + created_cells.append((cell, cluster_label)) + AnalysisGridObservation.objects.create( + cell=cell, + run=run, + temporal_start=run.temporal_start, + temporal_end=run.temporal_end, + ndvi=ndvi, + ndwi=ndwi, + soil_vv_db=-8.0 - index, + ) + RemoteSensingClusterAssignment.objects.create( + result=result, + cell=cell, + cluster_label=cluster_label, + raw_feature_values={}, + scaled_feature_values={}, + ) + + cluster_0 = RemoteSensingClusterBlock.objects.create( + result=result, + soil_location=self.location, + block_subdivision=subdivision, + block_code="block-1", + sub_block_code="cluster-0", + cluster_label=0, + chunk_size_sqm=900, + centroid_lat="35.700050", + centroid_lon="51.400050", + cell_count=2, + cell_codes=["cell-1", "cell-2"], + geometry=square_boundary_for_center(35.70005, 51.40005, delta=0.00008), + metadata={}, + ) + cluster_1 = RemoteSensingClusterBlock.objects.create( + result=result, + soil_location=self.location, + block_subdivision=subdivision, + block_code="block-1", + sub_block_code="cluster-1", + cluster_label=1, + chunk_size_sqm=900, + centroid_lat="35.700200", + centroid_lon="51.400200", + cell_count=1, + cell_codes=["cell-3"], + geometry=square_boundary_for_center(35.7002, 51.4002, delta=0.00008), + metadata={}, + ) + self.location.block_layout = { + "input_block_count": 1, + "default_full_farm": True, + "algorithm_status": "completed", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "input", + "boundary": square_boundary_for_center(35.7, 51.4, delta=0.002), + "needs_subdivision": True, + "sub_blocks": [ + { + "cluster_uuid": str(cluster_0.uuid), + "sub_block_code": "cluster-0", + "cluster_label": 0, + }, + { + "cluster_uuid": str(cluster_1.uuid), + "sub_block_code": "cluster-1", + "cluster_label": 1, + }, + ], + } + ], + } + self.location.save(update_fields=["block_layout", "updated_at"]) + self.farm.sensor_payload = { + "sensor-a": { + "cluster_uuid": str(cluster_0.uuid), + "soil_moisture": 10.0, + "nitrogen": 100.0, + }, + "sensor-b": { + "cluster_uuid": str(cluster_0.uuid), + "soil_moisture": 20.0, + "nitrogen": 80.0, + }, + "sensor-c": { + "cluster_uuid": str(cluster_1.uuid), + "soil_moisture": 30.0, + "nitrogen": 60.0, + }, + } + self.farm.save(update_fields=["sensor_payload", "updated_at"]) + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + block_snapshot = payload["soil"]["satellite_snapshots"][0] + self.assertEqual(block_snapshot["block_code"], "block-1") + self.assertEqual(block_snapshot["sub_block_count"], 2) + self.assertEqual(block_snapshot["satellite_metrics"]["ndvi"], 0.6) + self.assertEqual(block_snapshot["satellite_metrics"]["ndwi"], 15.5) + self.assertEqual(block_snapshot["sensor_metrics"]["soil_moisture"], 22.5) + self.assertEqual(block_snapshot["sensor_metrics"]["nitrogen"], 75.0) + self.assertEqual(block_snapshot["resolved_metrics"]["soil_moisture"], 22.5) + self.assertEqual(block_snapshot["metric_sources"]["ndvi"]["strategy"], "sub_block_mean_average") + self.assertEqual(block_snapshot["metric_sources"]["soil_moisture"]["strategy"], "sub_block_mean_average") + self.assertEqual(len(block_snapshot["satellite_sub_blocks"]), 2) + self.assertEqual(len(block_snapshot["sensor_sub_blocks"]), 2) + block_layout = payload["center_location"]["block_layout"] + self.assertEqual( + block_layout["blocks"][0]["aggregated_metrics"]["resolved_metrics"]["soil_moisture"], + 22.5, + ) + self.assertEqual( + block_layout["blocks"][0]["aggregated_metrics"]["satellite_metrics"]["ndvi"], + 0.6, + ) + class FarmDataUpsertApiTests(TestCase): def setUp(self): diff --git a/location_data/satellite_snapshot.py b/location_data/satellite_snapshot.py index 18462db..19efcd2 100644 --- a/location_data/satellite_snapshot.py +++ b/location_data/satellite_snapshot.py @@ -4,7 +4,12 @@ from typing import Any from django.db.models import Avg, QuerySet -from .models import AnalysisGridObservation, RemoteSensingRun, SoilLocation +from .models import ( + AnalysisGridObservation, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) SATELLITE_METRIC_FIELDS = ( @@ -20,21 +25,45 @@ def build_location_satellite_snapshot( location: SoilLocation, *, block_code: str = "", + sensor_payload: dict[str, Any] | None = None, ) -> dict[str, Any]: run = get_latest_completed_remote_sensing_run(location, block_code=block_code) + sensor_summary = build_block_sensor_summary( + location, + block_code=block_code, + sensor_payload=sensor_payload, + ) if run is None: + resolved_metrics = dict(sensor_summary["resolved_metrics"]) return { - "status": "missing", + "status": "completed" if resolved_metrics else "missing", "block_code": block_code, "run_id": None, "temporal_extent": None, "cell_count": 0, - "resolved_metrics": {}, - "metric_sources": {}, + "sub_block_count": int(sensor_summary["sub_block_count"]), + "aggregation_strategy": "sub_block_mean" if sensor_summary["sub_block_count"] else "missing", + "satellite_metrics": {}, + "sensor_metrics": sensor_summary["resolved_metrics"], + "sensor_metric_sources": sensor_summary["metric_sources"], + "sensor_sub_blocks": sensor_summary["sub_blocks"], + "satellite_sub_blocks": [], + "resolved_metrics": resolved_metrics, + "metric_sources": dict(sensor_summary["metric_sources"]), } observations = get_run_observations(run) - summary = summarize_observations(observations) + subdivision_result = get_latest_subdivision_result(location, block_code=block_code, run=run) + satellite_summary = summarize_block_satellite_metrics( + run=run, + observations=observations, + subdivision_result=subdivision_result, + ) + resolved_metrics = dict(satellite_summary["resolved_metrics"]) + metric_sources = dict(satellite_summary["metric_sources"]) + for metric_name, metric_value in sensor_summary["resolved_metrics"].items(): + resolved_metrics[metric_name] = metric_value + metric_sources[metric_name] = sensor_summary["metric_sources"].get(metric_name, {}) return { "status": "completed", "block_code": run.block_code, @@ -44,30 +73,124 @@ def build_location_satellite_snapshot( "end_date": run.temporal_end.isoformat() if run.temporal_end else None, }, "cell_count": observations.count(), - "resolved_metrics": summary, - "metric_sources": { - metric_name: "remote_sensing" - for metric_name in summary - }, + "sub_block_count": int(max(satellite_summary["sub_block_count"], sensor_summary["sub_block_count"])), + "aggregation_strategy": satellite_summary["aggregation_strategy"], + "satellite_metrics": satellite_summary["resolved_metrics"], + "sensor_metrics": sensor_summary["resolved_metrics"], + "sensor_metric_sources": sensor_summary["metric_sources"], + "sensor_sub_blocks": sensor_summary["sub_blocks"], + "satellite_sub_blocks": satellite_summary["sub_blocks"], + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, } -def build_location_block_satellite_snapshots(location: SoilLocation) -> list[dict[str, Any]]: +def build_location_block_satellite_snapshots( + location: SoilLocation, + *, + sensor_payload: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: block_layout = location.block_layout or {} blocks = block_layout.get("blocks") or [] if not blocks: - return [build_location_satellite_snapshot(location)] + return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)] + snapshots = [] for block in blocks: snapshots.append( build_location_satellite_snapshot( location, block_code=str(block.get("block_code") or "").strip(), + sensor_payload=sensor_payload, ) ) return snapshots +def build_block_layout_metric_summary( + location: SoilLocation, + *, + sensor_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + layout = dict(location.block_layout or {}) + blocks = [dict(block) for block in (layout.get("blocks") or [])] + snapshots_by_block_code = { + str(snapshot.get("block_code") or ""): snapshot + for snapshot in build_location_block_satellite_snapshots( + location, + sensor_payload=sensor_payload, + ) + } + for block in blocks: + snapshot = snapshots_by_block_code.get(str(block.get("block_code") or "").strip(), {}) + block["aggregated_metrics"] = { + "resolved_metrics": snapshot.get("resolved_metrics", {}), + "metric_sources": snapshot.get("metric_sources", {}), + "satellite_metrics": snapshot.get("satellite_metrics", {}), + "sensor_metrics": snapshot.get("sensor_metrics", {}), + "sub_block_count": snapshot.get("sub_block_count", 0), + "satellite_sub_blocks": snapshot.get("satellite_sub_blocks", []), + "sensor_sub_blocks": snapshot.get("sensor_sub_blocks", []), + } + layout["blocks"] = blocks + return layout + + +def build_farmer_block_aggregated_snapshot( + location: SoilLocation, + *, + sensor_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + block_snapshots = build_location_block_satellite_snapshots( + location, + sensor_payload=sensor_payload, + ) + usable_snapshots = [ + snapshot + for snapshot in block_snapshots + if isinstance(snapshot.get("resolved_metrics"), dict) and snapshot.get("resolved_metrics") + ] + if not usable_snapshots: + fallback_snapshot = build_location_satellite_snapshot( + location, + sensor_payload=sensor_payload, + ) + return { + "status": fallback_snapshot.get("status", "missing"), + "aggregation_strategy": "farmer_block_mean" if fallback_snapshot.get("resolved_metrics") else "missing", + "block_count": len(block_snapshots), + "resolved_metrics": dict(fallback_snapshot.get("resolved_metrics") or {}), + "metric_sources": dict(fallback_snapshot.get("metric_sources") or {}), + "blocks": block_snapshots, + } + + resolved_metrics = average_metric_maps( + [snapshot.get("resolved_metrics") or {} for snapshot in usable_snapshots] + ) + metric_sources = { + metric_name: { + "type": "farmer_block", + "strategy": "average_of_main_blocks", + "block_count": len( + [ + snapshot + for snapshot in usable_snapshots + if metric_name in (snapshot.get("resolved_metrics") or {}) + ] + ), + } + for metric_name in resolved_metrics + } + return { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": len(usable_snapshots), + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + "blocks": block_snapshots, + } + + def get_latest_completed_remote_sensing_run( location: SoilLocation, *, @@ -97,6 +220,26 @@ def get_run_observations(run: RemoteSensingRun) -> QuerySet[AnalysisGridObservat ) +def get_latest_subdivision_result( + location: SoilLocation, + *, + block_code: str = "", + run: RemoteSensingRun | None = None, +) -> RemoteSensingSubdivisionResult | None: + queryset = ( + RemoteSensingSubdivisionResult.objects.filter( + soil_location=location, + block_code=block_code or "", + ) + .select_related("run") + .prefetch_related("cluster_blocks", "assignments__cell") + .order_by("-temporal_end", "-created_at", "-id") + ) + if run is not None: + queryset = queryset.filter(run=run) + return queryset.first() + + def summarize_observations( observations: QuerySet[AnalysisGridObservation], ) -> dict[str, float]: @@ -113,3 +256,365 @@ def summarize_observations( continue summary[metric_name] = round(float(value), 6) return summary + + +def summarize_block_satellite_metrics( + *, + run: RemoteSensingRun, + observations: QuerySet[AnalysisGridObservation], + subdivision_result: RemoteSensingSubdivisionResult | None, +) -> dict[str, Any]: + _ = run + if subdivision_result is None or not subdivision_result.cluster_blocks.exists(): + resolved_metrics = summarize_observations(observations) + return { + "resolved_metrics": resolved_metrics, + "metric_sources": { + metric_name: { + "type": "remote_sensing", + "strategy": "cell_mean", + "sub_block_count": 0, + } + for metric_name in resolved_metrics + }, + "sub_blocks": [], + "sub_block_count": 0, + "aggregation_strategy": "cell_mean", + } + + observation_by_cell_id = { + observation.cell_id: observation + for observation in observations + } + assignments_by_label: dict[int, list[int]] = {} + for assignment in subdivision_result.assignments.all(): + assignments_by_label.setdefault(int(assignment.cluster_label), []).append(int(assignment.cell_id)) + + sub_block_snapshots: list[dict[str, Any]] = [] + for cluster_block in subdivision_result.cluster_blocks.all().order_by("cluster_label", "id"): + relevant_observations = [ + observation_by_cell_id[cell_id] + for cell_id in assignments_by_label.get(int(cluster_block.cluster_label), []) + if cell_id in observation_by_cell_id + ] + metric_map = summarize_observation_list(relevant_observations) + sub_block_snapshots.append( + { + "cluster_uuid": str(cluster_block.uuid), + "sub_block_code": cluster_block.sub_block_code, + "cluster_label": int(cluster_block.cluster_label), + "cell_count": len(relevant_observations), + "resolved_metrics": metric_map, + } + ) + + resolved_metrics = average_metric_maps( + [sub_block_snapshot["resolved_metrics"] for sub_block_snapshot in sub_block_snapshots] + ) + return { + "resolved_metrics": resolved_metrics, + "metric_sources": { + metric_name: { + "type": "remote_sensing", + "strategy": "sub_block_mean_average", + "sub_block_count": len( + [ + sub_block_snapshot + for sub_block_snapshot in sub_block_snapshots + if metric_name in sub_block_snapshot["resolved_metrics"] + ] + ), + } + for metric_name in resolved_metrics + }, + "sub_blocks": sub_block_snapshots, + "sub_block_count": len(sub_block_snapshots), + "aggregation_strategy": "sub_block_mean", + } + + +def summarize_observation_list( + observations: list[AnalysisGridObservation], +) -> dict[str, float]: + metric_lists: dict[str, list[float]] = { + metric_name: [] + for metric_name in SATELLITE_METRIC_FIELDS + } + for observation in observations: + for metric_name in SATELLITE_METRIC_FIELDS: + numeric_value = _coerce_numeric(getattr(observation, metric_name, None)) + if numeric_value is not None: + metric_lists[metric_name].append(numeric_value) + + summary: dict[str, float] = {} + for metric_name, values in metric_lists.items(): + if not values: + continue + summary[metric_name] = round(sum(values) / len(values), 6) + return summary + + +def average_metric_maps(metric_maps: list[dict[str, Any]]) -> dict[str, float]: + values_by_metric: dict[str, list[float]] = {} + for metric_map in metric_maps: + for metric_name, metric_value in metric_map.items(): + numeric_value = _coerce_numeric(metric_value) + if numeric_value is None: + continue + values_by_metric.setdefault(metric_name, []).append(numeric_value) + + return { + metric_name: round(sum(values) / len(values), 6) + for metric_name, values in values_by_metric.items() + if values + } + + +def build_block_sensor_summary( + location: SoilLocation, + *, + block_code: str, + sensor_payload: dict[str, Any] | None, +) -> dict[str, Any]: + if not isinstance(sensor_payload, dict): + return { + "resolved_metrics": {}, + "metric_sources": {}, + "sub_blocks": [], + "sub_block_count": 0, + } + + active_lookup = _build_active_sub_block_lookup(location) + sensors_by_sub_block: dict[str, dict[str, Any]] = {} + for sensor_key, sensor_values in sorted(sensor_payload.items()): + if not isinstance(sensor_values, dict): + continue + resolved_assignment = _resolve_sensor_sub_block_assignment( + sensor_values=sensor_values, + active_lookup=active_lookup, + ) + if resolved_assignment is None or resolved_assignment["block_code"] != (block_code or ""): + continue + + sub_block_identifier = str( + resolved_assignment.get("cluster_uuid") + or resolved_assignment.get("sub_block_code") + or f"cluster-{resolved_assignment.get('cluster_label')}" + ) + sub_block_entry = sensors_by_sub_block.setdefault( + sub_block_identifier, + { + "cluster_uuid": resolved_assignment.get("cluster_uuid"), + "sub_block_code": resolved_assignment.get("sub_block_code"), + "cluster_label": resolved_assignment.get("cluster_label"), + "sensor_keys": [], + "readings_by_metric": {}, + }, + ) + sub_block_entry["sensor_keys"].append(sensor_key) + for metric_name, metric_value in _extract_sensor_metric_values(sensor_values).items(): + sub_block_entry["readings_by_metric"].setdefault(metric_name, []).append((sensor_key, metric_value)) + + sub_block_snapshots: list[dict[str, Any]] = [] + for sub_block_identifier, sub_block_entry in sorted(sensors_by_sub_block.items()): + resolved_metrics: dict[str, Any] = {} + metric_sources: dict[str, Any] = {} + for metric_name, readings in sub_block_entry["readings_by_metric"].items(): + resolved_value, source = _resolve_metric_readings(readings) + resolved_metrics[metric_name] = resolved_value + metric_sources[metric_name] = source + sub_block_snapshots.append( + { + "id": sub_block_identifier, + "cluster_uuid": sub_block_entry.get("cluster_uuid"), + "sub_block_code": sub_block_entry.get("sub_block_code"), + "cluster_label": sub_block_entry.get("cluster_label"), + "sensor_keys": sub_block_entry["sensor_keys"], + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + } + ) + + resolved_metrics = average_metric_maps( + [sub_block_snapshot["resolved_metrics"] for sub_block_snapshot in sub_block_snapshots] + ) + metric_sources = { + metric_name: { + "type": "sensor", + "strategy": "sub_block_mean_average", + "sub_block_count": len( + [ + sub_block_snapshot + for sub_block_snapshot in sub_block_snapshots + if metric_name in sub_block_snapshot["resolved_metrics"] + ] + ), + } + for metric_name in resolved_metrics + } + return { + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + "sub_blocks": sub_block_snapshots, + "sub_block_count": len(sub_block_snapshots), + } + + +def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]: + block_layout = dict(location.block_layout or {}) + by_cluster_uuid: dict[str, dict[str, Any]] = {} + by_sub_block_code: dict[str, list[dict[str, Any]]] = {} + by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {} + for block in block_layout.get("blocks") or []: + block_code = str(block.get("block_code") or "").strip() + for sub_block in block.get("sub_blocks") or []: + record = { + "block_code": block_code, + "cluster_uuid": str(sub_block.get("cluster_uuid") or "").strip(), + "sub_block_code": str(sub_block.get("sub_block_code") or "").strip(), + "cluster_label": _coerce_int(sub_block.get("cluster_label")), + } + if record["cluster_uuid"]: + by_cluster_uuid[record["cluster_uuid"]] = record + if record["sub_block_code"]: + by_sub_block_code.setdefault(record["sub_block_code"], []).append(record) + if record["cluster_label"] is not None: + by_block_and_cluster_label[(block_code, int(record["cluster_label"]))] = record + return { + "by_cluster_uuid": by_cluster_uuid, + "by_sub_block_code": by_sub_block_code, + "by_block_and_cluster_label": by_block_and_cluster_label, + } + + +def _resolve_sensor_sub_block_assignment( + *, + sensor_values: dict[str, Any], + active_lookup: dict[str, Any], +) -> dict[str, Any] | None: + assignment_payloads = [ + sensor_values, + sensor_values.get("assignment"), + sensor_values.get("sub_block"), + sensor_values.get("metadata"), + ] + candidate: dict[str, Any] = { + "block_code": "", + "cluster_uuid": "", + "sub_block_code": "", + "cluster_label": None, + } + for payload in assignment_payloads: + if not isinstance(payload, dict): + continue + if not candidate["block_code"]: + candidate["block_code"] = str(payload.get("block_code") or "").strip() + if not candidate["cluster_uuid"]: + candidate["cluster_uuid"] = str(payload.get("cluster_uuid") or "").strip() + if not candidate["sub_block_code"]: + candidate["sub_block_code"] = str(payload.get("sub_block_code") or "").strip() + if candidate["cluster_label"] is None: + candidate["cluster_label"] = _coerce_int(payload.get("cluster_label")) + + if candidate["cluster_uuid"]: + resolved = active_lookup["by_cluster_uuid"].get(candidate["cluster_uuid"]) + if resolved is not None: + return resolved + if candidate["block_code"] and candidate["cluster_label"] is not None: + resolved = active_lookup["by_block_and_cluster_label"].get( + (candidate["block_code"], int(candidate["cluster_label"])) + ) + if resolved is not None: + return resolved + if candidate["sub_block_code"]: + matches = active_lookup["by_sub_block_code"].get(candidate["sub_block_code"], []) + if candidate["block_code"]: + for match in matches: + if match["block_code"] == candidate["block_code"]: + return match + if len(matches) == 1: + return matches[0] + if candidate["block_code"] and candidate["cluster_label"] is not None: + return { + "block_code": candidate["block_code"], + "cluster_uuid": candidate["cluster_uuid"], + "sub_block_code": candidate["sub_block_code"], + "cluster_label": candidate["cluster_label"], + } + return None + + +def _extract_sensor_metric_values(sensor_values: dict[str, Any]) -> dict[str, Any]: + ignored_keys = { + "assignment", + "metadata", + "sub_block", + "cluster_uuid", + "sub_block_code", + "cluster_label", + "block_code", + } + metric_values: dict[str, Any] = {} + for key, value in sensor_values.items(): + if key in ignored_keys or isinstance(value, dict): + continue + metric_values[str(key)] = value + return metric_values + + +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) + return round(float(average), 6), { + "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: Any) -> float | None: + if isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _coerce_int(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None diff --git a/rag/user_data.py b/rag/user_data.py index 3dab7d4..e7e76d6 100644 --- a/rag/user_data.py +++ b/rag/user_data.py @@ -44,7 +44,10 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: متن متنی قابل چانک، یا None اگر سنسور یافت نشد. """ from farm_data.models import SensorData - from location_data.satellite_snapshot import build_location_block_satellite_snapshots + from location_data.satellite_snapshot import ( + build_farmer_block_aggregated_snapshot, + build_location_block_satellite_snapshots, + ) try: sensor = SensorData.objects.select_related("center_location").get( @@ -72,7 +75,19 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: sensor_lines = [f" {k}: {v}" for k, v in sorted(sensor_fields.items())] parts.append("خوانش‌های سنسور:\n" + "\n".join(sensor_lines)) - snapshots = build_location_block_satellite_snapshots(loc) + aggregated_snapshot = build_farmer_block_aggregated_snapshot( + loc, + sensor_payload=sensor.sensor_payload, + ) + aggregated_metrics = aggregated_snapshot.get("resolved_metrics") or {} + if aggregated_metrics: + lines = [f" {k}: {v}" for k, v in sorted(aggregated_metrics.items())] + parts.append("خلاصه تجمیع‌شده بلوک‌های اصلی:\n" + "\n".join(lines)) + + snapshots = build_location_block_satellite_snapshots( + loc, + sensor_payload=sensor.sensor_payload, + ) if snapshots: snapshot_lines = [] for snapshot in snapshots: @@ -81,10 +96,10 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: continue lines = [f" {k}: {v}" for k, v in sorted(metrics.items())] snapshot_lines.append( - f" بلوک {snapshot.get('block_code') or 'farm'}:\n" + "\n".join(lines) + f" بلوک اصلی {snapshot.get('block_code') or 'farm'}:\n" + "\n".join(lines) ) if snapshot_lines: - parts.append("داده‌های ماهواره‌ای:\n" + "\n".join(snapshot_lines)) + parts.append("داده‌های تجمیع‌شده بلوک‌های اصلی:\n" + "\n".join(snapshot_lines)) return "\n\n".join(parts) if len(parts) > 1 else None