UPDATE
This commit is contained in:
@@ -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 داخلی` باشد.
|
||||
|
||||
+15
-2
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-4
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user