UPDATE
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 66 KiB |
@@ -6,7 +6,7 @@ from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
||||
from farm_data.services import get_ai_snapshot_metric
|
||||
|
||||
from crop_simulation.services import CropSimulationService
|
||||
|
||||
@@ -47,20 +47,9 @@ def _first_not_none(*values: Any) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _sensor_metric(sensor: Any, metric: str) -> float | None:
|
||||
if sensor is None:
|
||||
return None
|
||||
if hasattr(sensor, metric):
|
||||
value = getattr(sensor, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for block in payload.values():
|
||||
if isinstance(block, dict) and block.get(metric) is not None:
|
||||
return _safe_float(block.get(metric), default=0.0)
|
||||
return None
|
||||
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
|
||||
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
|
||||
@@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
|
||||
return records
|
||||
|
||||
|
||||
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
moisture_pct = _sensor_metric(sensor, "soil_moisture")
|
||||
center_location = getattr(sensor, "center_location", None)
|
||||
satellite_metrics = (
|
||||
build_location_satellite_snapshot(center_location).get("resolved_metrics") or {}
|
||||
if center_location is not None
|
||||
else {}
|
||||
)
|
||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.34)
|
||||
def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture")
|
||||
ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34)
|
||||
wv0033 = ndwi if ndwi > 0 else 0.34
|
||||
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
|
||||
|
||||
@@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
growth_stage: str | None,
|
||||
irrigation_method: Any | None,
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if sensor is None or plant is None or not forecasts:
|
||||
return None
|
||||
@@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
crop_blueprint=crop_blueprint,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
if pcse_result is not None:
|
||||
return pcse_result
|
||||
@@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_method=irrigation_method,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
def optimize_fertilization(
|
||||
@@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer:
|
||||
plant: Any,
|
||||
forecasts: list[Any],
|
||||
growth_stage: str | None,
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if sensor is None or plant is None:
|
||||
return None
|
||||
@@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer:
|
||||
forecasts=forecasts,
|
||||
growth_stage=growth_stage,
|
||||
crop_blueprint=crop_blueprint,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
if pcse_result is not None:
|
||||
return pcse_result
|
||||
@@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer:
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=growth_stage,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
def _optimize_irrigation_with_pcse(
|
||||
@@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
growth_stage: str | None,
|
||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
|
||||
crop_parameters, agromanagement = crop_blueprint
|
||||
soil, site = _build_soil_parameters(sensor)
|
||||
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||
weather = _build_weather_records(
|
||||
forecasts,
|
||||
latitude=_safe_float(sensor.center_location.latitude),
|
||||
@@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer:
|
||||
forecasts: list[Any],
|
||||
growth_stage: str | None,
|
||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
crop_parameters, agromanagement = crop_blueprint
|
||||
soil, site = _build_soil_parameters(sensor)
|
||||
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||
weather = _build_weather_records(
|
||||
forecasts,
|
||||
latitude=_safe_float(sensor.center_location.latitude),
|
||||
|
||||
@@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_weather
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
|
||||
@@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float:
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
|
||||
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
|
||||
if sensor is None:
|
||||
def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
|
||||
if hasattr(sensor, metric_name):
|
||||
value = getattr(sensor, metric_name)
|
||||
if value is not None:
|
||||
return _safe_float(value)
|
||||
|
||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
||||
if not isinstance(payload, dict):
|
||||
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if not isinstance(resolved_metrics, dict):
|
||||
return None
|
||||
return _safe_float(resolved_metrics.get(metric_name))
|
||||
|
||||
for block in payload.values():
|
||||
if isinstance(block, dict) and block.get(metric_name) is not None:
|
||||
return _safe_float(block.get(metric_name))
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(snapshot, dict):
|
||||
return {}
|
||||
source_metadata = snapshot.get("source_metadata") or {}
|
||||
return source_metadata if isinstance(source_metadata, dict) else {}
|
||||
|
||||
|
||||
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
|
||||
@@ -458,6 +458,9 @@ def build_simulation_payload_from_farm(
|
||||
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
||||
|
||||
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
|
||||
ai_snapshot = build_ai_farm_snapshot(str(farm_uuid))
|
||||
if ai_snapshot is None:
|
||||
raise CropSimulationError(f"Canonical AI snapshot for farm uuid={farm_uuid} is missing.")
|
||||
|
||||
if weather is not None:
|
||||
resolved_weather = _normalize_weather_records(weather)
|
||||
@@ -476,8 +479,7 @@ def build_simulation_payload_from_farm(
|
||||
longitude=float(farm.center_location.longitude),
|
||||
)
|
||||
|
||||
satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {}
|
||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
|
||||
ndwi = _snapshot_metric(ai_snapshot, "ndwi")
|
||||
smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55)
|
||||
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
||||
sm0 = _clamp(
|
||||
@@ -485,17 +487,17 @@ def build_simulation_payload_from_farm(
|
||||
max(smfcf + 0.02, smw + 0.05),
|
||||
0.8,
|
||||
)
|
||||
soil_moisture = _sensor_metric(farm, "soil_moisture")
|
||||
soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||
wav = (
|
||||
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
|
||||
if soil_moisture is not None
|
||||
else DEFAULT_WAV
|
||||
)
|
||||
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db"))
|
||||
phosphorus = _sensor_metric(farm, "phosphorus")
|
||||
potassium = _sensor_metric(farm, "potassium")
|
||||
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None)
|
||||
ec = _sensor_metric(farm, "electrical_conductivity")
|
||||
nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db"))
|
||||
phosphorus = _snapshot_metric(ai_snapshot, "phosphorus")
|
||||
potassium = _snapshot_metric(ai_snapshot, "potassium")
|
||||
soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None)
|
||||
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||
|
||||
resolved_soil = {
|
||||
"SMFCF": round(smfcf, 3),
|
||||
@@ -569,6 +571,13 @@ def build_simulation_payload_from_farm(
|
||||
"site_parameters": resolved_site,
|
||||
"crop_parameters": resolved_crop,
|
||||
"agromanagement": resolved_agromanagement,
|
||||
"source_metadata": {
|
||||
"farm_metrics": _snapshot_source_metadata(ai_snapshot).get("farm_metrics", {}),
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -914,6 +923,7 @@ class CropSimulationService:
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
"plant_name": plant_name,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -937,6 +947,7 @@ class CropSimulationService:
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -1205,6 +1216,7 @@ class CropSimulationService:
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
)
|
||||
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
||||
@@ -1258,7 +1270,7 @@ class CropSimulationService:
|
||||
site_parameters=spec["site_parameters"],
|
||||
)
|
||||
run.status = SimulationScenario.Status.SUCCESS
|
||||
run.result_payload = result
|
||||
run.result_payload = {**result, "source_metadata": spec.get("source_metadata") or {}}
|
||||
run.save(update_fields=["status", "result_payload", "updated_at"])
|
||||
results.append(
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData
|
||||
from irrigation.models import IrrigationMethod
|
||||
from location_data.models import SoilLocation
|
||||
from weather.models import WeatherForecast
|
||||
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||
from .views import PlantGrowthSimulationView
|
||||
|
||||
@@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase):
|
||||
self.assertEqual(result["result"]["engine"], "pcse")
|
||||
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
||||
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
||||
|
||||
|
||||
class CropSimulationCanonicalSnapshotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(latitude="35.700000", longitude="51.400000")
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=24.0,
|
||||
temperature_mean=18.0,
|
||||
humidity_mean=55.0,
|
||||
precipitation=1.0,
|
||||
et0=3.5,
|
||||
)
|
||||
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=401, name="wheat")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="drip")
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
|
||||
center_location=self.location,
|
||||
weather_forecast=self.weather,
|
||||
irrigation_method=self.irrigation_method,
|
||||
)
|
||||
self.farm.plants.add(self.plant)
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot):
|
||||
from crop_simulation.services import build_simulation_payload_from_farm
|
||||
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {
|
||||
"resolved_metrics": {
|
||||
"soil_moisture": 36.0,
|
||||
"ndwi": 0.31,
|
||||
"nitrogen": 21.0,
|
||||
"phosphorus": 11.0,
|
||||
"potassium": 17.0,
|
||||
"soil_ph": 6.8,
|
||||
"electrical_conductivity": 1.4,
|
||||
}
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm_metrics": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||
|
||||
self.assertEqual(payload["soil"]["soil_moisture"], 36.0)
|
||||
self.assertEqual(payload["site_parameters"]["NAVAILI"], 21.0)
|
||||
self.assertEqual(payload["soil"]["phosphorus"], 11.0)
|
||||
self.assertEqual(payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_build_simulation_payload_from_farm_handles_missing_block_metrics(self, mock_snapshot):
|
||||
from crop_simulation.services import build_simulation_payload_from_farm
|
||||
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {"resolved_metrics": {}},
|
||||
"source_metadata": {"farm_metrics": {"status": "missing"}},
|
||||
}
|
||||
|
||||
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||
|
||||
self.assertEqual(payload["site_parameters"]["WAV"], 40.0)
|
||||
self.assertEqual(payload["source_metadata"]["farm_metrics"]["status"], "missing")
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_run_single_simulation_stores_weather_provenance(self, mock_snapshot):
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 35.0, "ndwi": 0.3}},
|
||||
"source_metadata": {
|
||||
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||
"weather": {"policy": "center_location_latest_forecast"},
|
||||
},
|
||||
}
|
||||
service = CropSimulationService()
|
||||
|
||||
with patch.object(service.manager, "run_simulation", return_value={"engine": "pcse", "metrics": {}, "daily_output": [], "summary_output": [], "terminal_output": []}):
|
||||
result = service.run_single_simulation(
|
||||
farm_uuid=str(self.farm.farm_uuid),
|
||||
plant_name="wheat",
|
||||
agromanagement=build_agromanagement(),
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"]["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
run = SimulationRun.objects.get()
|
||||
self.assertEqual(run.result_payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from location_data.models import NdviObservation, SoilLocation
|
||||
|
||||
from rag.failure_contract import RAGServiceError
|
||||
@@ -720,13 +720,13 @@ class YieldHarvestSummaryService:
|
||||
"recent_sensor_averages": {},
|
||||
}
|
||||
|
||||
farm_details = get_farm_details(str(farm_uuid)) or {}
|
||||
farm_details = build_ai_farm_snapshot(str(farm_uuid)) or {}
|
||||
center_location = farm.center_location
|
||||
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
weather_details = farm_details.get("weather") or {}
|
||||
soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||
weather_details = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||
recent_sensor_averages = {
|
||||
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None),
|
||||
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None),
|
||||
"soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None),
|
||||
"soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None),
|
||||
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
|
||||
}
|
||||
|
||||
@@ -744,7 +744,7 @@ class YieldHarvestSummaryService:
|
||||
"lat": float(center_location.latitude),
|
||||
"lon": float(center_location.longitude),
|
||||
},
|
||||
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
|
||||
"farm_boundary": getattr(center_location, "farm_boundary", None),
|
||||
"soil": {
|
||||
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
|
||||
"soil_type": self._infer_soil_type(soil_details),
|
||||
@@ -760,6 +760,10 @@ class YieldHarvestSummaryService:
|
||||
"sensor_data": SensorData.__name__,
|
||||
"soil_location": SoilLocation.__name__,
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm_metrics": (farm_details.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||
"weather": ((farm_details.get("weather") or {}).get("source_metadata") or {}),
|
||||
},
|
||||
}
|
||||
|
||||
def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
# سیاست منبع داده و تجمیع کلاستر برای APIهای AI
|
||||
|
||||
این سند مشخص میکند هر API از کجا داده میگیرد، آیا الان خروجی آن بر اساس میانگین داده سنسور کلاسترها و میانگین داده ماهوارهای کلاسترهای `location_data` هست یا نه، و برای یکپارچهسازی چه policyای باید رعایت شود.
|
||||
|
||||
این سند بر اساس کد فعلی پروژه نوشته شده و مخصوص APIهای زیر است:
|
||||
|
||||
- `POST /api/rag/chat/`
|
||||
- `POST /api/soile/anomaly-detection/`
|
||||
- `POST /api/soile/health-summary/`
|
||||
- `POST /api/soile/moisture-heatmap/`
|
||||
- `POST /api/weather/farm-card/`
|
||||
- `POST /api/weather/water-need-prediction/`
|
||||
- `POST /api/pest-disease/detect/`
|
||||
- `POST /api/pest-disease/risk/`
|
||||
- `POST /api/irrigation/plan-from-text/`
|
||||
- `POST /api/irrigation/recommend/`
|
||||
- `POST /api/farm-data/parameters/`
|
||||
- `POST /api/fertilization/plan-from-text/`
|
||||
- `POST /api/fertilization/recommend/`
|
||||
- `POST /api/crop-simulation/current-farm-chart/`
|
||||
- `POST /api/crop-simulation/growth/`
|
||||
- `GET /api/crop-simulation/growth/{task_id}/status/`
|
||||
- `POST /api/crop-simulation/harvest-prediction/`
|
||||
- `GET /api/crop-simulation/yield-harvest-summary/`
|
||||
- `POST /api/crop-simulation/yield-prediction/`
|
||||
- `POST /api/economy/overview/`
|
||||
- `POST /api/farm-alerts/tracker/`
|
||||
|
||||
---
|
||||
|
||||
## 1) قانون هدف کسبوکاری
|
||||
|
||||
قانون مطلوبی که باید برای محاسبههای عمومی AI رعایت شود این است:
|
||||
|
||||
1. برای محاسبههای عمومی AI مثل `RAG`، `crop_simulation`، `irrigation`، `fertilization`، `farm_alerts` و سرویسهای تحلیلی خاک، مبنای داده باید `میانگین داده سنسورهای کلاسترها` و `میانگین داده ماهوارهای کلاسترهای location_data` باشد.
|
||||
2. این تجمیع باید در سطح `بلوکهای بزرگ کشاورز` انجام شود؛ یعنی اول هر block اصلی جداگانه محاسبه شود، بعد در صورت نیاز یک خلاصه کل مزرعه از روی blockهای اصلی ساخته شود.
|
||||
3. اگر کشاورز هنوز block تعریف نکرده باشد، حالت پیشفرض دامنه باید این باشد:
|
||||
- `1 بلوک بزرگ` برای کل مزرعه
|
||||
- `1 بلوک کوچک` داخل همان بلوک بزرگ
|
||||
4. هر API که فقط `farm_uuid` میگیرد و رفتار عمومی مزرعه را تحلیل میکند، باید از snapshot تجمیعشدهی مبتنی بر block/sub-block استفاده کند، نه از یک سنسور خام یا یک location خام.
|
||||
|
||||
---
|
||||
|
||||
## 2) وضعیت فعلی مدل داده
|
||||
|
||||
### 2.1) لایه مزرعه
|
||||
|
||||
رکورد canonical مزرعه در `farm_data.SensorData` نگهداری میشود:
|
||||
|
||||
- شناسه اصلی: `farm_uuid`
|
||||
- اتصال مکانی: `center_location -> location_data.SoilLocation`
|
||||
- سنسورها: `sensor_payload`
|
||||
- آبوهوا: `weather_forecast`
|
||||
- گیاه: `plant_assignments` و `plant_snapshots`
|
||||
- روش آبیاری: `irrigation_method`
|
||||
|
||||
### 2.2) لایه مکانی
|
||||
|
||||
رکورد canonical مکانی در `location_data.SoilLocation` است:
|
||||
|
||||
- `farm_boundary`
|
||||
- `input_block_count`
|
||||
- `block_layout`
|
||||
- `block_subdivisions`
|
||||
- `remote_sensing_runs`
|
||||
|
||||
### 2.3) لایه کلاستر و سنجش از دور
|
||||
|
||||
در `location_data` عملاً دو سطح تجمیع وجود دارد:
|
||||
|
||||
- `block` = بلوک اصلی که کشاورز تعریف کرده
|
||||
- `sub-block / cluster` = بخشهای کوچکتر داخل هر block
|
||||
|
||||
منطق فعلی تجمیع در `location_data/satellite_snapshot.py` پیاده شده است:
|
||||
|
||||
- `build_block_sensor_summary(...)`
|
||||
- داده سنسورهای منتسب به sub-blockها را جمع میکند
|
||||
- ابتدا برای هر sub-block میانگین میسازد
|
||||
- سپس از روی sub-blockها میانگین block را میسازد
|
||||
- `summarize_block_satellite_metrics(...)`
|
||||
- دادههای grid/cell ماهوارهای را برای cluster blockها خلاصه میکند
|
||||
- سپس میانگین block را میسازد
|
||||
- `build_location_block_satellite_snapshots(...)`
|
||||
- برای هر block اصلی یک snapshot میسازد
|
||||
- `build_farmer_block_aggregated_snapshot(...)`
|
||||
- از روی blockهای اصلی یک خلاصه کل مزرعه میسازد
|
||||
|
||||
پس زیرساخت فنی برای policy موردنظر تا حد زیادی در پروژه وجود دارد.
|
||||
|
||||
---
|
||||
|
||||
## 3) رفتار پیشفرض blockها
|
||||
|
||||
### رفتار فعلی در کد
|
||||
|
||||
تابع `build_block_layout()` در `location_data/models.py` وقتی blockی داده نشود، این رفتار را دارد:
|
||||
|
||||
- `input_block_count = 1`
|
||||
- فقط `1 بلوک اصلی` با `block-1` ساخته میشود
|
||||
- `sub_blocks = []`
|
||||
|
||||
یعنی الان بخش دوم rule شما کامل اعمال نشده، چون:
|
||||
|
||||
- `1 بلوک بزرگ` وجود دارد
|
||||
- ولی `1 بلوک کوچک داخل آن` بهصورت default ساخته نمیشود
|
||||
|
||||
### رفتار مطلوب پیشنهادی
|
||||
|
||||
برای سازگاری با خواسته شما، policy پیشنهادی این است:
|
||||
|
||||
- اگر هیچ block و هیچ subdivisionی تعریف نشده بود:
|
||||
- `block_layout.blocks = [block-1]`
|
||||
- داخل `block-1.sub_blocks` یک sub-block پیشفرض ساخته شود
|
||||
- این sub-block پیشفرض میتواند چیزی شبیه این داشته باشد:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub_block_code": "block-1-sub-1",
|
||||
"cluster_label": 0,
|
||||
"source": "default",
|
||||
"boundary": {},
|
||||
"cluster_uuid": null
|
||||
}
|
||||
```
|
||||
|
||||
این policy باعث میشود تمام APIهای downstream همیشه حداقل یک سطح `sub-block` داشته باشند و منطق aggregation یکنواخت بماند.
|
||||
|
||||
---
|
||||
|
||||
## 4) منبع canonical پیشنهادی برای همه APIهای AI
|
||||
|
||||
برای APIهای عمومی AI، منبع canonical باید این ترتیب باشد:
|
||||
|
||||
### 4.1) سنسورها
|
||||
|
||||
منبع اصلی:
|
||||
|
||||
- `farm_data.SensorData.sensor_payload`
|
||||
|
||||
اما نه به صورت خام. باید از مسیر زیر مصرف شود:
|
||||
|
||||
- assign سنسورها به `cluster/sub-block`
|
||||
- میانگینگیری در سطح sub-block
|
||||
- میانگینگیری مجدد در سطح block اصلی
|
||||
- در صورت نیاز میانگینگیری در سطح کل مزرعه
|
||||
|
||||
### 4.2) ماهواره و remote sensing
|
||||
|
||||
منبع اصلی:
|
||||
|
||||
- `location_data.RemoteSensingRun`
|
||||
- `location_data.AnalysisGridObservation`
|
||||
- `location_data.RemoteSensingSubdivisionResult`
|
||||
- `location_data.RemoteSensingClusterBlock`
|
||||
- `location_data.RemoteSensingClusterAssignment`
|
||||
|
||||
و باز هم باید از مسیر زیر مصرف شود:
|
||||
|
||||
- میانگین متریک هر cluster/sub-block
|
||||
- میانگین متریک هر block اصلی
|
||||
- در صورت نیاز میانگین کل مزرعه
|
||||
|
||||
### 4.3) آبوهوا
|
||||
|
||||
منبع اصلی آبوهوا فعلاً cluster-based نیست و از اینجا میآید:
|
||||
|
||||
- `farm.weather_forecast`
|
||||
- یا آخرین `center_location.weather_forecasts`
|
||||
|
||||
یعنی weather فعلاً location-center based است، نه cluster based.
|
||||
|
||||
### 4.4) source of truth نهایی برای APIهای عمومی
|
||||
|
||||
برای APIهایی که `farm_uuid` میگیرند، بهتر است `source of truth` نهایی یکی از این دو باشد:
|
||||
|
||||
- `get_farm_details(farm_uuid)` بعد از ارتقا به policy جدید
|
||||
- یا یک service جدید مثل `build_ai_farm_snapshot(farm_uuid)`
|
||||
|
||||
این snapshot باید شامل این بخشها باشد:
|
||||
|
||||
- `farm_level_aggregated_metrics`
|
||||
- `block_level_metrics`
|
||||
- `sub_block_level_metrics`
|
||||
- `weather`
|
||||
- `plants`
|
||||
- `irrigation_method`
|
||||
- `source_metadata`
|
||||
|
||||
---
|
||||
|
||||
## 5) وضعیت فعلی هر API
|
||||
|
||||
در این بخش برای هر API مشخص شده:
|
||||
|
||||
- آیا الان مبتنی بر میانگین سنسور کلاسترها و میانگین ماهوارهای کلاسترها هست یا نه
|
||||
- اگر نیست، الان داده را از کجا میگیرد
|
||||
- وضعیت انطباق آن با policy جدید
|
||||
|
||||
---
|
||||
|
||||
## 5.1) RAG
|
||||
|
||||
### `POST /api/rag/chat/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- متن خاک کاربر در `rag/user_data.py -> build_user_soil_text()`
|
||||
- از `SensorData` و `center_location` میخواند
|
||||
- از `build_farmer_block_aggregated_snapshot(...)` استفاده میکند
|
||||
- از `build_location_block_satellite_snapshots(...)` هم برای blockهای اصلی استفاده میکند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- برای summary کلی مزرعه، از aggregation مبتنی بر block استفاده میکند
|
||||
- برای satellite و sensor summary تا حدی با policy شما سازگار است
|
||||
- اما RAG chat هنوز یک contract رسمی و واحد برای `cluster mean only` ندارد و متن embed شده ممکن است ترکیبی از دادههای سطح farm و block باشد
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- canonical snapshot باید صریحاً از `block mean` و `sub-block mean` ساخته شود
|
||||
- prompt/context باید روشن بگوید که اعداد از `cluster averages` آمدهاند
|
||||
|
||||
### `POST /api/soile/anomaly-detection/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق مستقیم، منطبق غیرمستقیم`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- anomaly payload از request میآید
|
||||
- اطلاعات مزرعه از `farm_data.services.get_farm_details()`
|
||||
- سپس به `rag/services/soil_anomaly.py` داده میشود
|
||||
|
||||
نتیجه:
|
||||
|
||||
- خود anomaly ممکن است از هر منبعی آمده باشد و API آن را enforce نمیکند
|
||||
- اما farm context از `get_farm_details()` میآید که فعلاً `latest_satellite + sensor_payload` را ترکیب میکند
|
||||
- `get_farm_details()` هنوز خلاصه soil را از `build_location_satellite_snapshot(center_location)` میگیرد، نه از `build_farmer_block_aggregated_snapshot()`
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- `get_farm_details()` باید خلاصه soil اصلی را از `farmer-level aggregated snapshot` بگیرد
|
||||
- anomaly input هم اگر داخلی تولید میشود، باید بر پایه cluster mean باشد
|
||||
|
||||
---
|
||||
|
||||
## 5.2) Soile
|
||||
|
||||
### `POST /api/soile/health-summary/`
|
||||
|
||||
وضعیت فعلی: `احتمالاً نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- از `farm_data.context.load_farm_context()` و سرویسهای soil استفاده میکند
|
||||
- `load_farm_context()` از `build_location_block_satellite_snapshots(location)` استفاده میکند
|
||||
- بخشی از context block-based است
|
||||
|
||||
اما:
|
||||
|
||||
- اگر در summary نهایی از metrics خام سنسور یا latest weather/location استفاده شود، خروجی کاملاً cluster-mean-only نیست
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- health summary باید صریحاً از `block_level` و `farm_level` aggregated metrics استفاده کند
|
||||
- هیچ metric خام سنسور بدون عبور از منطق sub-block mean وارد پاسخ نشود
|
||||
|
||||
### `POST /api/soile/moisture-heatmap/`
|
||||
|
||||
وضعیت فعلی: `منطبق نیست`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- heatmap ذاتاً spatial است و معمولاً از grid/cell یا location-level moisture data ساخته میشود
|
||||
- برای heatmap، میانگینگیری کامل روی clusterها کافی نیست چون heatmap نیاز به توزیع مکانی دارد
|
||||
|
||||
نتیجه:
|
||||
|
||||
- این API نباید به خروجی تکعددی farm average تقلیل داده شود
|
||||
- بلکه باید داده خام grid/cell یا cluster-level map را نگه دارد
|
||||
|
||||
policy صحیح:
|
||||
|
||||
- برای کارت summary یا average moisture بله، از cluster mean استفاده شود
|
||||
- اما برای heatmap خودِ خروجی باید `cluster map` یا `grid map` باشد، نه صرفاً میانگین کل مزرعه
|
||||
|
||||
---
|
||||
|
||||
## 5.3) Weather
|
||||
|
||||
### `POST /api/weather/farm-card/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- از `weather_forecast` مرتبط با `center_location`
|
||||
- یا آخرین forecast همان location
|
||||
|
||||
نتیجه:
|
||||
|
||||
- weather فعلاً بر اساس clusterهای `location_data` نیست
|
||||
- چون مدل weather cluster-based ندارد
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- اگر قرار است cluster-based شود، باید weather در سطح block/sub-block تعریف شود
|
||||
- در غیر این صورت، این API باید در سند بهعنوان `location-centered weather` ثبت شود
|
||||
|
||||
### `POST /api/weather/water-need-prediction/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- در `rag/services/water_need_prediction.py` از `get_farm_details(farm_uuid)` استفاده میشود
|
||||
- همچنین از weather forecast مزرعه استفاده میکند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- بخش خاک/سنسور میتواند از snapshot مزرعه بیاید
|
||||
- اما چون `get_farm_details()` هنوز fully farmer-block-aggregated نیست، خروجی کامل منطبق نیست
|
||||
- بخش weather هم location-level است
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- خاک و ماهواره از farmer aggregated snapshot
|
||||
- weather تا زمان توسعه مدل cluster-weather، بهصورت location-level باقی بماند ولی source آن شفاف اعلام شود
|
||||
|
||||
---
|
||||
|
||||
## 5.4) Pest & Disease
|
||||
|
||||
### `POST /api/pest-disease/detect/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق مفهومی`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- ورودی اصلی: تصویر
|
||||
- context کمکی: `farm_uuid` و داده مزرعه از `get_farm_details()`
|
||||
|
||||
نتیجه:
|
||||
|
||||
- هسته این API image-based است، نه cluster average based
|
||||
- context مزرعه میتواند cluster-based شود، اما خروجی نهایی وابسته به تصویر است
|
||||
|
||||
policy صحیح:
|
||||
|
||||
- context مزرعه برای کمک به تشخیص از snapshot تجمیعی بیاید
|
||||
- اما این API ذاتاً APIِ میانگینمحور نیست
|
||||
|
||||
### `POST /api/pest-disease/risk/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- `rag/services/pest_disease.py`
|
||||
- اطلاعات مزرعه از `get_farm_details()`
|
||||
- داده RAG و پایگاه دانش تخصصی
|
||||
|
||||
نتیجه:
|
||||
|
||||
- risk API باید از context مزرعه استفاده کند
|
||||
- اگر `get_farm_details()` اصلاح شود، این API هم خودبهخود نزدیک به policy مطلوب میشود
|
||||
|
||||
---
|
||||
|
||||
## 5.5) Irrigation
|
||||
|
||||
### `POST /api/irrigation/plan-from-text/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق ذاتی`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- ورودی اصلی: متن آزاد
|
||||
- مدل parser متنی
|
||||
|
||||
نتیجه:
|
||||
|
||||
- این API parser است، نه تحلیل agronomic مبتنی بر sensor/satellite
|
||||
- بنابراین لازم نیست خروجیاش بر پایه cluster average باشد
|
||||
|
||||
policy صحیح:
|
||||
|
||||
- فقط اگر farm context کمکی به parser اضافه شود، آن context باید از snapshot تجمیعی بیاید
|
||||
|
||||
### `POST /api/irrigation/recommend/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- از farm context و سرویس RAG irrigation استفاده میکند
|
||||
- بخشی از سنسور را ممکن است مستقیماً از `sensor.sensor_payload` بخواند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- اگر بعضی metricها مستقیم از payload خام خوانده شوند، با policy شما ناسازگار است
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- recommendation فقط باید از aggregated block/sub-block metrics تغذیه شود
|
||||
- fallback مستقیم به اولین سنسور یا payload خام باید حذف یا محدود شود
|
||||
|
||||
---
|
||||
|
||||
## 5.6) Farm Parameters
|
||||
|
||||
### `POST /api/farm-data/parameters/`
|
||||
|
||||
وضعیت فعلی: `خارج از دامنه این policy`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- این API داده را ایجاد/ویرایش میکند
|
||||
- `farm_boundary` را میگیرد
|
||||
- `center_location` را resolve میکند
|
||||
- `sensor_payload` را ذخیره میکند
|
||||
- weather/location sync را trigger میکند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- این API تولیدکننده داده است، نه consumer تحلیلی
|
||||
- بنابراین قرار نیست خروجی analytic آن بر پایه cluster mean باشد
|
||||
|
||||
اما نقش مهم:
|
||||
|
||||
- باید sensorها را طوری ذخیره کند که assignment به `cluster/sub-block` ممکن باشد
|
||||
- اگر cluster_uuid یا sub_block_code در payload نیاید، aggregation دقیق محدود میشود
|
||||
|
||||
---
|
||||
|
||||
## 5.7) Fertilization
|
||||
|
||||
### `POST /api/fertilization/plan-from-text/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق ذاتی`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- parser متن آزاد
|
||||
|
||||
نتیجه:
|
||||
|
||||
- مانند irrigation text parser، این API ماهیتاً cluster-average consumer نیست
|
||||
|
||||
### `POST /api/fertilization/recommend/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- context مزرعه و RAG fertilization
|
||||
- معمولاً از `get_farm_details()` و contextهای وابسته استفاده میکند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- با اصلاح منبع canonical مزرعه، این API هم میتواند کاملاً منطبق شود
|
||||
|
||||
---
|
||||
|
||||
## 5.8) Crop Simulation
|
||||
|
||||
### `POST /api/crop-simulation/current-farm-chart/`
|
||||
### `POST /api/crop-simulation/growth/`
|
||||
### `POST /api/crop-simulation/harvest-prediction/`
|
||||
### `GET /api/crop-simulation/yield-harvest-summary/`
|
||||
### `POST /api/crop-simulation/yield-prediction/`
|
||||
|
||||
وضعیت فعلی: `غیرمنطبق تا نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- سرویسهای `crop_simulation/services.py`
|
||||
- بخشی از دادهها از farm context و بخشی از سنسور یا weather فعلی میآیند
|
||||
- در بعضی helperها متریکها ممکن است مستقیماً از `sensor_payload` یا propertyهای سنسور resolve شوند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- با rule شما که گفته بودی برای `crop_simulation` باید از داده کل بلوکهای بزرگ استفاده شود، وضعیت فعلی هنوز کامل نیست
|
||||
- چون منبع canonical شبیهسازی هنوز بهصورت صریح farmer-block aggregation enforced نشده
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- ورودی تمام simulation endpointها باید از farm aggregated snapshot بیاید
|
||||
- یعنی متریکهای soil moisture, temperature, ndvi, ndwi, slope, nitrogen, phosphorus, potassium از `cluster mean -> block mean -> farm mean` ساخته شوند
|
||||
- weather فعلاً جداگانه location-level میماند مگر بعداً block-weather اضافه شود
|
||||
|
||||
### `GET /api/crop-simulation/growth/{task_id}/status/`
|
||||
|
||||
وضعیت فعلی: `وابسته به منبع run اولیه`
|
||||
|
||||
نتیجه:
|
||||
|
||||
- این endpoint فقط status/result job را برمیگرداند
|
||||
- اگر `growth` هنگام شروع از داده غیرمنطبق استفاده کرده باشد، status endpoint هم همان خروجی را reflect میکند
|
||||
|
||||
---
|
||||
|
||||
## 5.9) Economy
|
||||
|
||||
### `POST /api/economy/overview/`
|
||||
|
||||
وضعیت فعلی: `منطبق نیست و حتی منبع واقعی ندارد`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- `economy/services.py`
|
||||
- پیام صریح دارد که منبع واقعی تنظیم نشده است
|
||||
|
||||
نتیجه:
|
||||
|
||||
- این API فعلاً نه cluster-based است و نه حتی data-backed
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- اگر قرار است فعال شود، باید از snapshot مزرعه + هزینهها + yield prediction + irrigation/fertilization plan استفاده کند
|
||||
|
||||
---
|
||||
|
||||
## 5.10) Farm Alerts
|
||||
|
||||
### `POST /api/farm-alerts/tracker/`
|
||||
|
||||
وضعیت فعلی: `نیمهمنطبق`
|
||||
|
||||
منبع داده فعلی:
|
||||
|
||||
- `farm_alerts/services.py`
|
||||
- از `load_farm_context(farm_uuid)` و `get_farm_details(farm_uuid)` استفاده میکند
|
||||
|
||||
نتیجه:
|
||||
|
||||
- چون farm context بخشی از snapshotهای block-based را میخواند، تا حدی با policy سازگار است
|
||||
- اما اگر context نهایی هنوز metric خام یا latest non-aggregated را ترجیح بدهد، کامل منطبق نیست
|
||||
|
||||
نیاز به اصلاح:
|
||||
|
||||
- همه alert ruleها باید روی block/farm aggregated metrics سوار شوند
|
||||
- در صورت نیاز، هشدارهای spatial باید روی cluster-level breakdown هم نمایش داده شوند
|
||||
|
||||
---
|
||||
|
||||
## 6) جمعبندی سریع انطباق APIها
|
||||
|
||||
### APIهایی که ماهیتاً باید cluster-aggregated باشند
|
||||
|
||||
اینها باید به policy جدید مهاجرت کنند:
|
||||
|
||||
- `POST /api/rag/chat/`
|
||||
- `POST /api/soile/anomaly-detection/`
|
||||
- `POST /api/soile/health-summary/`
|
||||
- `POST /api/weather/water-need-prediction/`
|
||||
- `POST /api/pest-disease/risk/`
|
||||
- `POST /api/irrigation/recommend/`
|
||||
- `POST /api/fertilization/recommend/`
|
||||
- `POST /api/crop-simulation/current-farm-chart/`
|
||||
- `POST /api/crop-simulation/growth/`
|
||||
- `POST /api/crop-simulation/harvest-prediction/`
|
||||
- `GET /api/crop-simulation/yield-harvest-summary/`
|
||||
- `POST /api/crop-simulation/yield-prediction/`
|
||||
- `POST /api/farm-alerts/tracker/`
|
||||
|
||||
### APIهایی که ماهیتاً parser/input/image/weather هستند و کامل cluster-based نیستند
|
||||
|
||||
- `POST /api/pest-disease/detect/` -> image-first
|
||||
- `POST /api/irrigation/plan-from-text/` -> text parser
|
||||
- `POST /api/fertilization/plan-from-text/` -> text parser
|
||||
- `POST /api/farm-data/parameters/` -> data ingestion/upsert
|
||||
- `POST /api/weather/farm-card/` -> location-centered weather
|
||||
- `POST /api/economy/overview/` -> فعلاً منبع واقعی ندارد
|
||||
|
||||
### APIهایی که spatial map هستند و نباید صرفاً average شوند
|
||||
|
||||
- `POST /api/soile/moisture-heatmap/`
|
||||
|
||||
این API باید cluster/grid-aware بماند، نه فقط average-based.
|
||||
|
||||
---
|
||||
|
||||
## 7) مهمترین mismatch فعلی در کد
|
||||
|
||||
مهمترین mismatch فعلی این است که `farm_data.services.get_farm_details()` برای `soil.resolved_metrics` این کار را میکند:
|
||||
|
||||
- `latest_satellite = build_location_satellite_snapshot(center_location)`
|
||||
- سپس `latest_satellite.resolved_metrics` را به عنوان soil_metrics میگیرد
|
||||
|
||||
این یعنی:
|
||||
|
||||
- خلاصه اصلی soil بر اساس یک snapshot location/block واحد ساخته میشود
|
||||
- نه بر اساس `build_farmer_block_aggregated_snapshot(center_location, sensor_payload=...)`
|
||||
|
||||
در حالی که برای policy موردنظر شما بهتر است این کار انجام شود:
|
||||
|
||||
- `farm_level_snapshot = build_farmer_block_aggregated_snapshot(...)`
|
||||
- `soil.resolved_metrics = farm_level_snapshot.resolved_metrics`
|
||||
- `soil.satellite_snapshots = block-level snapshots`
|
||||
- در صورت نیاز `sub_block snapshots` هم در context بمانند
|
||||
|
||||
---
|
||||
|
||||
## 8) پیشنهاد ساختار استاندارد پاسخ داده برای AI
|
||||
|
||||
برای همه سرویسهای AI بهتر است یک snapshot واحد با این ساختار وجود داشته باشد:
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": "...",
|
||||
"aggregation_policy": {
|
||||
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"weather": "center_location_latest_forecast",
|
||||
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"
|
||||
},
|
||||
"farm_metrics": {},
|
||||
"block_metrics": [],
|
||||
"sub_block_metrics": [],
|
||||
"weather": {},
|
||||
"plants": [],
|
||||
"irrigation_method": {}
|
||||
}
|
||||
```
|
||||
|
||||
مزیت این طراحی:
|
||||
|
||||
- همه APIها source of truth یکسان دارند
|
||||
- اختلاف بین RAG، crop simulation، irrigation و alerts کم میشود
|
||||
- تستپذیری سادهتر میشود
|
||||
|
||||
---
|
||||
|
||||
## 9) نتیجه نهایی
|
||||
|
||||
### الان کدام APIها دقیقاً مطابق خواسته شما نیستند؟
|
||||
|
||||
تقریباً همه APIهای تحلیلی هنوز `کاملاً` مطابق policy شما نیستند، چون منبع canonical یکپارچهای که صریحاً بر پایه `cluster mean sensor + cluster mean satellite` باشد هنوز همهجا enforce نشده است.
|
||||
|
||||
بیشترین فاصله با policy مطلوب در این بخشهاست:
|
||||
|
||||
- `crop_simulation`
|
||||
- `irrigation/recommend`
|
||||
- `fertilization/recommend`
|
||||
- `weather/farm-card`
|
||||
- `economy/overview`
|
||||
- بخشی از `soile` و `farm_alerts`
|
||||
|
||||
### الان کدام APIها از همین حالا تا حدی نزدیک هستند؟
|
||||
|
||||
- `rag/chat`
|
||||
- `farm_alerts/tracker`
|
||||
- `soile/health-summary`
|
||||
- `pest-disease/risk`
|
||||
|
||||
چون بهصورت مستقیم یا غیرمستقیم از snapshotهای block-based و `get_farm_details()` استفاده میکنند.
|
||||
|
||||
### الان کدام APIها ماهیتاً نباید صرفاً average-based شوند؟
|
||||
|
||||
- `soile/moisture-heatmap`
|
||||
- `pest-disease/detect`
|
||||
- parserهای `plan-from-text`
|
||||
|
||||
---
|
||||
|
||||
## 10) پیشنهاد اجرای فنی در کد
|
||||
|
||||
ترتیب پیشنهادی برای پیادهسازی:
|
||||
|
||||
1. `get_farm_details()` را به `farmer aggregated snapshot` مهاجرت بده.
|
||||
2. یک service جدید مثل `build_ai_farm_snapshot()` بساز.
|
||||
3. default block policy را به `1 main block + 1 default sub-block` ارتقا بده.
|
||||
4. همه APIهای تحلیلی را وادار کن فقط از snapshot جدید بخوانند.
|
||||
5. برای APIهای spatial مثل heatmap، علاوه بر average summary، خروجی cluster/grid breakdown نگه دار.
|
||||
|
||||
@@ -88,7 +88,8 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
|
||||
fi
|
||||
|
||||
if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
|
||||
echo "DEVELOP is set. Seeding demo plant, weather_data, and farm_data..."
|
||||
echo "DEVELOP is set. Seeding demo location_data, plant, weather_data, and farm_data..."
|
||||
run_cmd python manage.py seed_location_data
|
||||
run_cmd python manage.py seed_plants
|
||||
run_cmd python manage.py seed_weather_data
|
||||
run_cmd python manage.py seed_farm_data
|
||||
@@ -96,7 +97,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
|
||||
fi
|
||||
|
||||
echo "Checking openEO authentication..."
|
||||
run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured
|
||||
if ! run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured; then
|
||||
echo "openEO authentication failed; continuing startup with degraded openEO-dependent features." >&2
|
||||
fi
|
||||
|
||||
echo "Collecting static files..."
|
||||
run_cmd python manage.py collectstatic --noinput
|
||||
|
||||
@@ -521,13 +521,95 @@ def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return stats
|
||||
|
||||
|
||||
def _snapshot_metric(ai_snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return None
|
||||
farm_metrics = ai_snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if not isinstance(resolved_metrics, dict):
|
||||
return None
|
||||
return safe_number(resolved_metrics.get(metric_name), None)
|
||||
|
||||
|
||||
|
||||
def _snapshot_weather(ai_snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return {}
|
||||
weather = ai_snapshot.get("weather") or {}
|
||||
forecast = weather.get("forecast") if isinstance(weather, dict) else None
|
||||
return forecast if isinstance(forecast, dict) else {}
|
||||
|
||||
|
||||
|
||||
def _block_metric_alerts(ai_snapshot: dict[str, Any] | None, sensor_id: str) -> list[dict[str, Any]]:
|
||||
alerts: list[dict[str, Any]] = []
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return alerts
|
||||
for block in ai_snapshot.get("block_metrics") or []:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_code = str(block.get("block_code") or "default-block")
|
||||
metrics = block.get("resolved_metrics") or {}
|
||||
moisture = safe_number(metrics.get("soil_moisture"), None)
|
||||
if moisture is not None and moisture < 25:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="moisture",
|
||||
current_value=moisture,
|
||||
threshold_value=25.0,
|
||||
severity="warning" if moisture >= 18 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
zone_id=block_code,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "block",
|
||||
"affected_blocks": [block_code],
|
||||
"source": "block_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
|
||||
def _sub_block_support(ai_snapshot: dict[str, Any] | None, metric_type: str) -> list[dict[str, Any]]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return evidence
|
||||
metric_key = {
|
||||
"moisture": "soil_moisture",
|
||||
"ph": "soil_ph",
|
||||
"ec": "electrical_conductivity",
|
||||
}.get(metric_type)
|
||||
if not metric_key:
|
||||
return evidence
|
||||
for sub_block in ai_snapshot.get("sub_block_metrics") or []:
|
||||
if not isinstance(sub_block, dict):
|
||||
continue
|
||||
metrics = sub_block.get("resolved_metrics") or {}
|
||||
value = metrics.get(metric_key)
|
||||
if value is None:
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"block_code": sub_block.get("block_code") or "default-block",
|
||||
"sub_block_code": sub_block.get("sub_block_code") or "default-sub-block",
|
||||
"metric": metric_key,
|
||||
"value": round(float(value), 2),
|
||||
}
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
|
||||
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||
context = context or {}
|
||||
sensor = context.get("sensor")
|
||||
ai_snapshot = (ai_bundle or {}).get("ai_snapshot") if isinstance(ai_bundle, dict) else None
|
||||
forecasts = context.get("forecasts", [])
|
||||
history = context.get("history", [])
|
||||
|
||||
if sensor is None:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return {
|
||||
"totalAlerts": 0,
|
||||
"alerts": [],
|
||||
@@ -537,14 +619,116 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
||||
"prioritizedAlertSummaries": [],
|
||||
"recommendedOperationalActions": [],
|
||||
"humanReadableExplanations": [],
|
||||
"source_metadata": {"status": "missing", "fallback": "no_ai_snapshot"},
|
||||
}
|
||||
|
||||
alerts = []
|
||||
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_ph_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_ec_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_frost_alert(forecasts, sensor_id))
|
||||
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id))
|
||||
|
||||
moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||
if moisture is not None and moisture < 25:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="moisture",
|
||||
current_value=moisture,
|
||||
threshold_value=25.0,
|
||||
severity="warning" if moisture >= 18 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"affected_blocks": [item.get("block_code") for item in (ai_snapshot.get("block_metrics") or [])],
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
soil_ph = _snapshot_metric(ai_snapshot, "soil_ph")
|
||||
if soil_ph is not None and not (6.0 <= soil_ph <= 7.8):
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="ph",
|
||||
current_value=soil_ph,
|
||||
threshold_value="6.0-7.8",
|
||||
severity="warning" if 5.5 <= soil_ph <= 8.2 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below" if soil_ph < 6.0 else "above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ph"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||
if ec is not None and ec > 2.5:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="ec",
|
||||
current_value=ec,
|
||||
threshold_value=2.5,
|
||||
severity="warning" if ec <= 3.2 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ec"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
weather = _snapshot_weather(ai_snapshot)
|
||||
if weather:
|
||||
temp_min = safe_number(weather.get("temperature_min"), None)
|
||||
if temp_min is not None and temp_min < 0:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="temperature",
|
||||
current_value=temp_min,
|
||||
threshold_value=0.0,
|
||||
severity="warning" if temp_min >= -2 else "danger",
|
||||
duration_hours=24.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"source": "weather_forecast",
|
||||
"weather_policy": "center_location_latest_forecast",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
humidity = safe_number(weather.get("humidity_mean"), None)
|
||||
if humidity is not None and moisture is not None and humidity > 75 and moisture > 60:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="fungal_risk",
|
||||
current_value=humidity,
|
||||
threshold_value=75.0,
|
||||
severity="warning" if humidity <= 85 else "danger",
|
||||
duration_hours=24.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||
"source": "farm_metrics+weather_forecast",
|
||||
"soil_moisture": round(moisture, 2),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
alerts.extend(_block_metric_alerts(ai_snapshot, sensor_id))
|
||||
|
||||
ordered_alerts = _sort_alerts(alerts)
|
||||
clusters = _build_clusters(ordered_alerts)
|
||||
@@ -559,4 +743,9 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
||||
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
|
||||
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
|
||||
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
|
||||
"source_metadata": {
|
||||
"farm_metrics": (ai_snapshot.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||
"weather": ((ai_snapshot.get("weather") or {}).get("source_metadata") or {}),
|
||||
"default_block_policy": (ai_snapshot.get("aggregation_policy") or {}).get("default_block_policy"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ from typing import Any
|
||||
from django.apps import apps
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.context import load_farm_context
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -156,12 +156,18 @@ def _build_structured_context(
|
||||
if context is None:
|
||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
|
||||
|
||||
tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None)
|
||||
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id=farm_uuid,
|
||||
context=context,
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
structured = {
|
||||
"farm_profile": _farm_profile(context, farm_uuid),
|
||||
"tracker": tracker,
|
||||
"forecasts": _forecast_summary(context),
|
||||
"incoming_alerts": _normalize_incoming_alerts(incoming_alerts),
|
||||
"ai_snapshot_source_metadata": (ai_snapshot or {}).get("source_metadata", {}),
|
||||
}
|
||||
return context, structured
|
||||
|
||||
@@ -321,7 +327,7 @@ def _llm_response(
|
||||
) -> tuple[dict[str, Any], str, str]:
|
||||
cfg = load_rag_config()
|
||||
service, service_cfg, model, client = _build_service_config(cfg, service_id)
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
rag_context = build_rag_context(
|
||||
query=query,
|
||||
sensor_uuid=farm_uuid,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from farm_alerts.alerts_tracker import build_farm_alerts_tracker
|
||||
|
||||
|
||||
class FarmAlertsTrackerCanonicalTests(SimpleTestCase):
|
||||
def test_whole_farm_alert_uses_aggregated_metrics(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 16.0}},
|
||||
"block_metrics": [{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 14.0}}],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 12.0}}
|
||||
],
|
||||
"weather": {"forecast": {"temperature_min": 8.0, "humidity_mean": 60.0}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
self.assertEqual(tracker["totalAlerts"], 2)
|
||||
self.assertEqual(tracker["alerts"][0]["metadata"]["evaluation_level"], "farm")
|
||||
self.assertEqual(tracker["alerts"][0]["metadata"]["source"], "farm_metrics")
|
||||
self.assertEqual(tracker["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
def test_block_specific_alert_includes_affected_block(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 30.0}},
|
||||
"block_metrics": [
|
||||
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 31.0}},
|
||||
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 17.0}},
|
||||
],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 15.0}}
|
||||
],
|
||||
"weather": {"forecast": {}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
block_alerts = [alert for alert in tracker["alerts"] if alert.get("zone_id") == "block-2"]
|
||||
self.assertEqual(len(block_alerts), 1)
|
||||
self.assertEqual(block_alerts[0]["metadata"]["evaluation_level"], "block")
|
||||
self.assertEqual(block_alerts[0]["metadata"]["affected_blocks"], ["block-2"])
|
||||
|
||||
def test_default_block_sub_block_policy_is_reported(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 22.0}},
|
||||
"block_metrics": [{"block_code": "default-block", "resolved_metrics": {"soil_moisture": 22.0}}],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "default-block", "sub_block_code": "default-sub-block", "resolved_metrics": {"soil_moisture": 22.0}}
|
||||
],
|
||||
"weather": {"forecast": {}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
tracker["source_metadata"]["default_block_policy"],
|
||||
"1_main_block + 1_default_sub_block_when_missing",
|
||||
)
|
||||
|
||||
def test_missing_snapshot_uses_explicit_fallback_metadata(self):
|
||||
tracker = build_farm_alerts_tracker(sensor_id="farm-1", context={"forecasts": []}, ai_bundle={})
|
||||
|
||||
self.assertEqual(tracker["totalAlerts"], 0)
|
||||
self.assertEqual(tracker["source_metadata"]["fallback"], "no_ai_snapshot")
|
||||
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_devices_from_sensor_payload(apps, schema_editor):
|
||||
SensorData = apps.get_model("sensor_data", "SensorData")
|
||||
Device = apps.get_model("sensor_data", "Device")
|
||||
|
||||
for farm in SensorData.objects.all().iterator():
|
||||
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||
location_id = getattr(farm, "center_location_id", None)
|
||||
if location_id is None:
|
||||
continue
|
||||
|
||||
for sensor_name, payload in sensor_payload.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
cluster_uuid = None
|
||||
raw_cluster_uuid = payload.get("cluster_uuid")
|
||||
if raw_cluster_uuid not in (None, ""):
|
||||
try:
|
||||
cluster_uuid = uuid.UUID(str(raw_cluster_uuid))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
cluster_uuid = None
|
||||
|
||||
Device.objects.update_or_create(
|
||||
farm_id=farm.pk,
|
||||
sensor_name=sensor_name,
|
||||
defaults={
|
||||
"location_id": location_id,
|
||||
"payload": payload,
|
||||
"cluster_uuid": cluster_uuid,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0019_cluster_block_centers"),
|
||||
("sensor_data", "0012_plant_catalog_snapshot_and_assignment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("sensor_name", models.CharField(db_index=True, help_text='نام سنسور مثل "sensor-7-1"', max_length=64)),
|
||||
("payload", models.JSONField(blank=True, default=dict, help_text="payload همان سنسور")),
|
||||
("cluster_uuid", models.UUIDField(blank=True, db_index=True, help_text="uuid کلاستر داخل location برای این سنسور", null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="sensor_data.sensordata")),
|
||||
("location", models.ForeignKey(db_column="location_id", help_text="location مرتبط با این device", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="location_data.soillocation")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "device",
|
||||
"verbose_name_plural": "devices",
|
||||
"db_table": "farm_data_device",
|
||||
"ordering": ["sensor_name", "id"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="device",
|
||||
constraint=models.UniqueConstraint(fields=("farm", "sensor_name"), name="farm_data_unique_device_per_farm_sensor"),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_devices_from_sensor_payload,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -169,6 +169,57 @@ class SensorData(SensorPayloadMixin, models.Model):
|
||||
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
"""نسخه نرمالشده هر سنسور داخل farm_data_sensordata."""
|
||||
|
||||
farm = models.ForeignKey(
|
||||
SensorData,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="devices",
|
||||
db_column="farm_uuid",
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
"location_data.SoilLocation",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="devices",
|
||||
db_column="location_id",
|
||||
help_text="location مرتبط با این device",
|
||||
)
|
||||
sensor_name = models.CharField(
|
||||
max_length=64,
|
||||
db_index=True,
|
||||
help_text='نام سنسور مثل "sensor-7-1"',
|
||||
)
|
||||
payload = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="payload همان سنسور",
|
||||
)
|
||||
cluster_uuid = models.UUIDField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="uuid کلاستر داخل location برای این سنسور",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_device"
|
||||
ordering = ["sensor_name", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["farm", "sensor_name"],
|
||||
name="farm_data_unique_device_per_farm_sensor",
|
||||
)
|
||||
]
|
||||
verbose_name = "device"
|
||||
verbose_name_plural = "devices"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.farm_id}::{self.sensor_name}"
|
||||
|
||||
|
||||
class PlantCatalogSnapshot(models.Model):
|
||||
"""
|
||||
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from numbers import Number
|
||||
import logging
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
@@ -17,13 +18,14 @@ 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_farmer_block_aggregated_snapshot,
|
||||
build_location_block_satellite_snapshots,
|
||||
build_location_satellite_snapshot,
|
||||
)
|
||||
from irrigation.serializers import IrrigationMethodSerializer
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from .models import (
|
||||
Device,
|
||||
FarmPlantAssignment,
|
||||
ParameterUpdateLog,
|
||||
PlantCatalogSnapshot,
|
||||
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
|
||||
return synced_parameters
|
||||
|
||||
|
||||
def _parse_cluster_uuid(value: object) -> uuid.UUID | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]:
|
||||
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||
location = farm.center_location
|
||||
synced_devices: list[Device] = []
|
||||
|
||||
with transaction.atomic():
|
||||
active_sensor_names: list[str] = []
|
||||
for sensor_name, payload in sensor_payload.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
active_sensor_names.append(sensor_name)
|
||||
device, _created = Device.objects.update_or_create(
|
||||
farm=farm,
|
||||
sensor_name=sensor_name,
|
||||
defaults={
|
||||
"location": location,
|
||||
"payload": payload,
|
||||
"cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")),
|
||||
},
|
||||
)
|
||||
synced_devices.append(device)
|
||||
|
||||
stale_devices = Device.objects.filter(farm=farm)
|
||||
if active_sensor_names:
|
||||
stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names)
|
||||
stale_devices.delete()
|
||||
|
||||
return synced_devices
|
||||
|
||||
|
||||
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
|
||||
if sensor_payload and isinstance(sensor_payload, dict):
|
||||
@@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str):
|
||||
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||
)
|
||||
|
||||
latest_satellite = build_location_satellite_snapshot(center_location)
|
||||
block_metric_snapshots = build_location_block_satellite_snapshots(
|
||||
soil_snapshot = _build_farm_soil_snapshot(
|
||||
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)
|
||||
|
||||
resolved_metrics = dict(soil_metrics)
|
||||
metric_sources = {key: "remote_sensing" for key in soil_metrics}
|
||||
for key, value in sensor_metrics.items():
|
||||
resolved_metrics[key] = value
|
||||
metric_sources[key] = sensor_metric_sources[key]
|
||||
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
plant_snapshots = [assignment.plant for assignment in plant_assignments]
|
||||
@@ -501,11 +528,7 @@ def get_farm_details(farm_uuid: str):
|
||||
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||
"sensor_payload": farm.sensor_payload or {},
|
||||
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
|
||||
"soil": {
|
||||
"resolved_metrics": resolved_metrics,
|
||||
"metric_sources": metric_sources,
|
||||
"satellite_snapshots": block_metric_snapshots,
|
||||
},
|
||||
"soil": soil_snapshot,
|
||||
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
||||
"plant_assignments": [
|
||||
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
|
||||
),
|
||||
"created_at": farm.created_at,
|
||||
"updated_at": farm.updated_at,
|
||||
"source_metadata": {
|
||||
"soil": soil_snapshot.get("source_metadata", {}),
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"note": "Weather remains tied to the farm center location.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_farm_soil_snapshot(
|
||||
center_location: SoilLocation,
|
||||
*,
|
||||
sensor_payload: dict | None,
|
||||
) -> dict[str, object]:
|
||||
# Canonical farm soil metrics now come from farmer-level block aggregation.
|
||||
aggregated_snapshot = build_farmer_block_aggregated_snapshot(
|
||||
center_location,
|
||||
sensor_payload=sensor_payload,
|
||||
)
|
||||
block_snapshots = build_location_block_satellite_snapshots(
|
||||
center_location,
|
||||
sensor_payload=sensor_payload,
|
||||
)
|
||||
if all(
|
||||
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
|
||||
for snapshot in block_snapshots
|
||||
):
|
||||
block_snapshots = []
|
||||
|
||||
has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks"))
|
||||
resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {})
|
||||
metric_sources = dict(aggregated_snapshot.get("metric_sources") or {})
|
||||
compatibility_sensor_overlay_applied = False
|
||||
|
||||
if not has_explicit_blocks:
|
||||
compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing(
|
||||
resolved_metrics,
|
||||
metric_sources,
|
||||
sensor_payload,
|
||||
)
|
||||
|
||||
cluster_breakdown = _build_cluster_breakdown(block_snapshots)
|
||||
return {
|
||||
"resolved_metrics": resolved_metrics,
|
||||
"metric_sources": metric_sources,
|
||||
"block_snapshots": block_snapshots,
|
||||
"satellite_snapshots": block_snapshots,
|
||||
"cluster_breakdown": cluster_breakdown,
|
||||
"source_metadata": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing",
|
||||
"status": aggregated_snapshot.get("status") or "missing",
|
||||
"block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)),
|
||||
"has_explicit_blocks": has_explicit_blocks,
|
||||
"compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied,
|
||||
"policy": {
|
||||
"sensor": "cluster_mean -> block_mean -> farm_mean",
|
||||
"satellite": "cluster_mean -> block_mean -> farm_mean",
|
||||
"weather": "location_center_based",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _merge_legacy_sensor_metrics_if_missing(
|
||||
resolved_metrics: dict,
|
||||
metric_sources: dict,
|
||||
sensor_payload: dict | None,
|
||||
) -> bool:
|
||||
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload)
|
||||
applied = False
|
||||
for metric_name, metric_value in sensor_metrics.items():
|
||||
if metric_name in resolved_metrics:
|
||||
continue
|
||||
resolved_metrics[metric_name] = metric_value
|
||||
metric_sources[metric_name] = sensor_metric_sources[metric_name]
|
||||
applied = True
|
||||
return applied
|
||||
|
||||
|
||||
def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
cluster_breakdown: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip()
|
||||
for sub_block in snapshot.get("satellite_sub_blocks") or []:
|
||||
cluster_breakdown.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"source": "satellite",
|
||||
**dict(sub_block),
|
||||
}
|
||||
)
|
||||
for sub_block in snapshot.get("sensor_sub_blocks") or []:
|
||||
cluster_breakdown.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"source": "sensor",
|
||||
**dict(sub_block),
|
||||
}
|
||||
)
|
||||
return cluster_breakdown
|
||||
|
||||
|
||||
AI_FARM_AGGREGATION_POLICY = {
|
||||
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"weather": "center_location_latest_forecast",
|
||||
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing",
|
||||
}
|
||||
|
||||
|
||||
def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
return None
|
||||
|
||||
sync_sensor_parameters_from_payload(farm.sensor_payload)
|
||||
|
||||
center_location = farm.center_location
|
||||
weather = farm.weather_forecast
|
||||
if weather is None:
|
||||
weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||
|
||||
soil_snapshot = _build_farm_soil_snapshot(
|
||||
center_location,
|
||||
sensor_payload=farm.sensor_payload,
|
||||
)
|
||||
block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||
sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
|
||||
return {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY),
|
||||
"farm_metrics": {
|
||||
"resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}),
|
||||
"metric_sources": dict(soil_snapshot.get("metric_sources") or {}),
|
||||
"status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"),
|
||||
"aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get(
|
||||
"aggregation_strategy", "missing"
|
||||
),
|
||||
},
|
||||
"block_metrics": block_metrics,
|
||||
"sub_block_metrics": sub_block_metrics,
|
||||
"weather": {
|
||||
"forecast": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||
"source_metadata": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"status": "completed" if weather else "missing",
|
||||
},
|
||||
},
|
||||
"plants": [
|
||||
{
|
||||
"plant_id": assignment.plant.backend_plant_id,
|
||||
"position": assignment.position,
|
||||
"stage": assignment.stage,
|
||||
"metadata": assignment.metadata,
|
||||
"assigned_at": assignment.assigned_at,
|
||||
"updated_at": assignment.updated_at,
|
||||
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
|
||||
}
|
||||
for assignment in plant_assignments
|
||||
],
|
||||
"irrigation_method": {
|
||||
"id": farm.irrigation_method_id,
|
||||
"details": (
|
||||
IrrigationMethodSerializer(farm.irrigation_method).data
|
||||
if farm.irrigation_method
|
||||
else None
|
||||
),
|
||||
"source_metadata": {
|
||||
"source": "farm_record",
|
||||
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||
},
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm": {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"center_location_id": center_location.id,
|
||||
"has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")),
|
||||
},
|
||||
"farm_metrics": dict(soil_snapshot.get("source_metadata") or {}),
|
||||
"block_metrics": {
|
||||
"source": "build_location_block_satellite_snapshots",
|
||||
"block_count": len(block_metrics),
|
||||
"status": "completed" if block_metrics else "missing",
|
||||
},
|
||||
"sub_block_metrics": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"sub_block_count": len(sub_block_metrics),
|
||||
"status": "completed" if sub_block_metrics else "missing",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"note": "Weather remains tied to the farm center location.",
|
||||
},
|
||||
"plants": {
|
||||
"source": "farm_plant_assignments",
|
||||
"count": len(plant_assignments),
|
||||
},
|
||||
"irrigation_method": {
|
||||
"source": "farm_record",
|
||||
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None:
|
||||
"""Return the canonical AI snapshot, or fall back to farm details for older consumers."""
|
||||
snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return snapshot
|
||||
|
||||
|
||||
def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None:
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if isinstance(resolved_metrics, dict):
|
||||
return resolved_metrics.get(metric_name)
|
||||
return None
|
||||
|
||||
|
||||
def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]:
|
||||
if not isinstance(snapshot, dict):
|
||||
return {}
|
||||
weather_section = snapshot.get("weather") or {}
|
||||
forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None
|
||||
return forecast if isinstance(forecast, dict) else {}
|
||||
|
||||
|
||||
def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
block_metrics: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||
block_metrics.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||
"metric_sources": dict(snapshot.get("metric_sources") or {}),
|
||||
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
|
||||
"sub_block_count": int(snapshot.get("sub_block_count") or 0),
|
||||
"temporal_extent": snapshot.get("temporal_extent"),
|
||||
"source_metadata": {
|
||||
"source": "build_location_block_satellite_snapshots",
|
||||
"block_code": block_code,
|
||||
"run_id": snapshot.get("run_id"),
|
||||
"cell_count": snapshot.get("cell_count"),
|
||||
},
|
||||
}
|
||||
)
|
||||
return block_metrics
|
||||
|
||||
|
||||
def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
sub_block_metrics: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||
satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or []
|
||||
sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or []
|
||||
if not satellite_sub_blocks and not sensor_sub_blocks:
|
||||
sub_block_metrics.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": "default-sub-block",
|
||||
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "default_sub_block_compatibility",
|
||||
"scope": "future_default_policy",
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
sub_blocks_by_code: dict[str, dict[str, object]] = {}
|
||||
for sub_block in satellite_sub_blocks:
|
||||
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||
entry = sub_blocks_by_code.setdefault(
|
||||
sub_block_code,
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": sub_block_code,
|
||||
"resolved_metrics": {},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {},
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"satellite_present": False,
|
||||
"sensor_present": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["satellite_metrics"])
|
||||
entry["source_metadata"]["satellite_present"] = True
|
||||
for sub_block in sensor_sub_blocks:
|
||||
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||
entry = sub_blocks_by_code.setdefault(
|
||||
sub_block_code,
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": sub_block_code,
|
||||
"resolved_metrics": {},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {},
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"satellite_present": False,
|
||||
"sensor_present": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["sensor_metrics"])
|
||||
entry["source_metadata"]["sensor_present"] = True
|
||||
sub_block_metrics.extend(sub_blocks_by_code.values())
|
||||
return sub_block_metrics
|
||||
|
||||
|
||||
def resolve_center_location_from_boundary(
|
||||
farm_boundary: dict | list,
|
||||
block_count: int = 1,
|
||||
|
||||
@@ -15,9 +15,10 @@ from location_data.models import (
|
||||
RemoteSensingSubdivisionResult,
|
||||
SoilLocation,
|
||||
)
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
from farm_data.services import (
|
||||
assign_farm_plants_from_backend_ids,
|
||||
build_ai_farm_snapshot,
|
||||
get_canonical_farm_record,
|
||||
get_runtime_plant_for_farm,
|
||||
list_runtime_plants_for_farm,
|
||||
@@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase):
|
||||
0.6,
|
||||
)
|
||||
|
||||
def test_detail_uses_farmer_aggregated_snapshot_as_canonical_soil_source(self):
|
||||
self.location.block_layout = {
|
||||
"blocks": [
|
||||
{"block_code": "block-a", "order": 1},
|
||||
{"block_code": "block-b", "order": 2},
|
||||
]
|
||||
}
|
||||
self.location.save(update_fields=["block_layout", "updated_at"])
|
||||
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 2,
|
||||
"resolved_metrics": {"nitrogen": 42.0, "ndvi": 0.61},
|
||||
"metric_sources": {
|
||||
"nitrogen": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-a",
|
||||
"resolved_metrics": {"nitrogen": 20.0, "ndvi": 0.5},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [{"sub_block_code": "cluster-a"}],
|
||||
"sensor_sub_blocks": [],
|
||||
},
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-b",
|
||||
"resolved_metrics": {"nitrogen": 64.0, "ndvi": 0.72},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [{"sub_block_code": "cluster-b"}],
|
||||
"sensor_sub_blocks": [],
|
||||
},
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 42.0)
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.61)
|
||||
self.assertEqual(payload["soil"]["source_metadata"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
self.assertEqual(payload["soil"]["source_metadata"]["policy"]["sensor"], "cluster_mean -> block_mean -> farm_mean")
|
||||
self.assertEqual(payload["source_metadata"]["weather"]["scope"], "location_center_based")
|
||||
self.assertEqual(len(payload["soil"]["block_snapshots"]), 2)
|
||||
self.assertEqual(len(payload["soil"]["cluster_breakdown"]), 2)
|
||||
aggregated_mock.assert_called_once()
|
||||
block_mock.assert_called_once()
|
||||
|
||||
def test_detail_without_explicit_blocks_keeps_aggregated_snapshot_and_marks_compatibility_policy(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {
|
||||
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "",
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.55)
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 99.0)
|
||||
self.assertFalse(payload["soil"]["source_metadata"]["has_explicit_blocks"])
|
||||
self.assertTrue(payload["soil"]["source_metadata"]["compatibility_sensor_overlay_applied"])
|
||||
self.assertEqual(payload["soil"]["metric_sources"]["nitrogen"]["type"], "sensor")
|
||||
|
||||
def test_detail_canonical_soil_metrics_do_not_come_from_single_raw_location_snapshot(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"soil_moisture": 12.0},
|
||||
"metric_sources": {
|
||||
"soil_moisture": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"soil_moisture": 77.0},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["soil_moisture"], 12.0)
|
||||
self.assertNotEqual(
|
||||
payload["soil"]["resolved_metrics"]["soil_moisture"],
|
||||
payload["soil"]["block_snapshots"][0]["resolved_metrics"]["soil_moisture"],
|
||||
)
|
||||
|
||||
|
||||
class BuildAiFarmSnapshotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.700000",
|
||||
longitude="51.400000",
|
||||
farm_boundary={"type": "Polygon", "coordinates": []},
|
||||
)
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=23.0,
|
||||
temperature_mean=18.0,
|
||||
precipitation=1.2,
|
||||
humidity_mean=52.0,
|
||||
)
|
||||
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=201, name="ذرت")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="تیپ")
|
||||
self.farm_uuid = uuid.uuid4()
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid=self.farm_uuid,
|
||||
center_location=self.location,
|
||||
weather_forecast=self.weather,
|
||||
irrigation_method=self.irrigation_method,
|
||||
sensor_payload={"sensor-1": {"soil_moisture": 30.0}},
|
||||
)
|
||||
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
|
||||
|
||||
def test_build_ai_farm_snapshot_returns_normalized_block_and_sub_block_metrics(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}, "soil_moisture": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"aggregation_strategy": "sub_block_mean",
|
||||
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}, "soil_moisture": {"type": "sensor"}},
|
||||
"satellite_metrics": {"ndvi": 0.6},
|
||||
"sensor_metrics": {"soil_moisture": 24.0},
|
||||
"sub_block_count": 2,
|
||||
"run_id": 91,
|
||||
"cell_count": 8,
|
||||
"temporal_extent": {"start_date": "2026-04-01", "end_date": "2026-04-30"},
|
||||
"satellite_sub_blocks": [
|
||||
{"sub_block_code": "cluster-a", "resolved_metrics": {"ndvi": 0.5}},
|
||||
{"sub_block_code": "cluster-b", "resolved_metrics": {"ndvi": 0.7}},
|
||||
],
|
||||
"sensor_sub_blocks": [
|
||||
{"sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 20.0}},
|
||||
{"sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 28.0}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertIsNotNone(snapshot)
|
||||
self.assertEqual(snapshot["farm_uuid"], str(self.farm_uuid))
|
||||
self.assertEqual(snapshot["aggregation_policy"]["sensor"], "cluster_mean_then_block_mean_then_farm_mean")
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 24.0)
|
||||
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "block-1")
|
||||
self.assertEqual(snapshot["block_metrics"][0]["source_metadata"]["run_id"], 91)
|
||||
self.assertEqual(len(snapshot["sub_block_metrics"]), 2)
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["satellite_present"], True)
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["sensor_present"], True)
|
||||
self.assertEqual(snapshot["weather"]["source_metadata"]["scope"], "location_center_based")
|
||||
self.assertEqual(len(snapshot["plants"]), 1)
|
||||
self.assertEqual(snapshot["irrigation_method"]["details"]["name"], self.irrigation_method.name)
|
||||
|
||||
def test_build_ai_farm_snapshot_without_explicit_subdivisions_uses_default_compatibility_shape(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "",
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||
"satellite_metrics": {"ndvi": 0.55},
|
||||
"sensor_metrics": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "default-block")
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["sub_block_code"], "default-sub-block")
|
||||
self.assertEqual(
|
||||
snapshot["sub_block_metrics"][0]["source_metadata"]["source"],
|
||||
"default_sub_block_compatibility",
|
||||
)
|
||||
self.assertEqual(
|
||||
snapshot["aggregation_policy"]["default_block_policy"],
|
||||
"1_main_block + 1_default_sub_block_when_missing",
|
||||
)
|
||||
|
||||
def test_build_ai_farm_snapshot_handles_missing_sensor_data(self):
|
||||
self.farm.sensor_payload = None
|
||||
self.farm.save(update_fields=["sensor_payload"])
|
||||
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.49},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"ndvi": 0.49},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||
"satellite_metrics": {"ndvi": 0.49},
|
||||
"sensor_metrics": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"], {"ndvi": 0.49})
|
||||
self.assertEqual(snapshot["block_metrics"][0]["sensor_metrics"], {})
|
||||
|
||||
def test_build_ai_farm_snapshot_handles_missing_remote_sensing_data(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"soil_moisture": 30.0},
|
||||
"metric_sources": {"soil_moisture": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"soil_moisture": 30.0},
|
||||
"metric_sources": {"soil_moisture": {"type": "sensor"}},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {"soil_moisture": 30.0},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [
|
||||
{"sub_block_code": "cluster-1", "resolved_metrics": {"soil_moisture": 30.0}}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 30.0)
|
||||
self.assertEqual(snapshot["block_metrics"][0]["satellite_metrics"], {})
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["sensor_metrics"]["soil_moisture"], 30.0)
|
||||
|
||||
|
||||
class FarmDataUpsertApiTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
|
||||
31.2,
|
||||
)
|
||||
device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||
self.assertEqual(device.location_id, self.location.id)
|
||||
self.assertEqual(device.payload["soil_moisture"], 31.2)
|
||||
self.assertIsNone(device.cluster_uuid)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
||||
)
|
||||
@@ -444,6 +752,57 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
|
||||
)
|
||||
|
||||
def test_post_syncs_device_rows_from_sensor_payload(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
cluster_uuid = uuid.uuid4()
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"cluster_uuid": str(cluster_uuid),
|
||||
"soil_moisture": 31.2,
|
||||
},
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 10.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertEqual(Device.objects.filter(farm=farm).count(), 2)
|
||||
soil_device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||
self.assertEqual(str(soil_device.cluster_uuid), str(cluster_uuid))
|
||||
self.assertEqual(soil_device.payload["soil_moisture"], 31.2)
|
||||
|
||||
update_response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"cluster_uuid": str(cluster_uuid),
|
||||
"soil_moisture": 33.8,
|
||||
"nitrogen": 20.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(update_response.status_code, 200)
|
||||
soil_device.refresh_from_db()
|
||||
self.assertEqual(soil_device.payload["soil_moisture"], 33.8)
|
||||
self.assertEqual(soil_device.payload["nitrogen"], 20.5)
|
||||
self.assertEqual(Device.objects.filter(farm=farm, sensor_name="leaf-sensor").count(), 1)
|
||||
|
||||
def test_post_requires_farm_uuid_in_request_body(self):
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
|
||||
@@ -28,6 +28,7 @@ from .services import (
|
||||
ensure_location_and_weather_data,
|
||||
get_farm_details,
|
||||
resolve_center_location_from_boundary,
|
||||
sync_devices_from_sensor_data,
|
||||
sync_sensor_parameters_from_payload,
|
||||
sync_plant_catalog_from_backend,
|
||||
)
|
||||
@@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView):
|
||||
else:
|
||||
farm_data.save()
|
||||
|
||||
sync_devices_from_sensor_data(farm_data)
|
||||
|
||||
if plant_ids is not None:
|
||||
try:
|
||||
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
|
||||
@@ -280,6 +283,13 @@ class FarmDetailView(APIView):
|
||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
||||
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
||||
),
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه مسیر farm detail",
|
||||
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||
parameter_only=("farm_uuid", "path"),
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: build_response(
|
||||
FarmDetailEnvelopeSerializer,
|
||||
|
||||
@@ -32,7 +32,7 @@ def create_or_get_block_subdivision(
|
||||
اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمیگرداند؛
|
||||
در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره میکند.
|
||||
"""
|
||||
from .models import BlockSubdivision
|
||||
from .models import BlockSubdivision, build_default_sub_block, ensure_block_layout_defaults
|
||||
|
||||
existing = BlockSubdivision.objects.filter(
|
||||
soil_location=location,
|
||||
@@ -244,7 +244,7 @@ def render_elbow_plot(
|
||||
|
||||
|
||||
def sync_block_layout_with_subdivision(location, subdivision) -> None:
|
||||
layout = location.block_layout or {}
|
||||
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||
blocks = list(layout.get("blocks") or [])
|
||||
target_block = None
|
||||
for block in blocks:
|
||||
@@ -263,7 +263,12 @@ def sync_block_layout_with_subdivision(location, subdivision) -> None:
|
||||
blocks.append(target_block)
|
||||
|
||||
target_block["needs_subdivision"] = subdivision.centroid_count > 1
|
||||
target_block["sub_blocks"] = list(subdivision.centroid_points or [])
|
||||
target_block["sub_blocks"] = list(subdivision.centroid_points or []) or [
|
||||
build_default_sub_block(
|
||||
str(target_block.get("block_code") or "block-1"),
|
||||
boundary=target_block.get("boundary") or {},
|
||||
)
|
||||
]
|
||||
target_block["subdivision_summary"] = {
|
||||
"chunk_size_sqm": subdivision.chunk_size_sqm,
|
||||
"grid_point_count": subdivision.grid_point_count,
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Avg
|
||||
|
||||
from crop_simulation.growth_simulation import GrowthSimulationContext, _run_projection_engine
|
||||
from crop_simulation.services import PcseSimulationManager, build_simulation_payload_from_farm
|
||||
from farm_data.services import get_canonical_farm_record, get_farm_plant_assignments
|
||||
from .models import AnalysisGridObservation, RemoteSensingClusterBlock
|
||||
from .satellite_snapshot import build_location_block_satellite_snapshots
|
||||
|
||||
|
||||
class ClusterRecommendationNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ClusterRecommendationValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterPlantCandidate:
|
||||
plant_id: int | None
|
||||
plant_name: str
|
||||
position: int | None
|
||||
stage: str
|
||||
score: float
|
||||
predicted_yield: float | None
|
||||
predicted_yield_tons: float | None
|
||||
biomass: float | None
|
||||
max_lai: float | None
|
||||
simulation_engine: str | None
|
||||
simulation_model_name: str | None
|
||||
simulation_warning: str | None
|
||||
supporting_metrics: dict[str, Any]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"plant_id": self.plant_id,
|
||||
"plant_name": self.plant_name,
|
||||
"position": self.position,
|
||||
"stage": self.stage,
|
||||
"score": self.score,
|
||||
"predicted_yield": self.predicted_yield,
|
||||
"predicted_yield_tons": self.predicted_yield_tons,
|
||||
"biomass": self.biomass,
|
||||
"max_lai": self.max_lai,
|
||||
"simulation_engine": self.simulation_engine,
|
||||
"simulation_model_name": self.simulation_model_name,
|
||||
"simulation_warning": self.simulation_warning,
|
||||
"supporting_metrics": self.supporting_metrics,
|
||||
}
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp(value: float, minimum: float, maximum: float) -> float:
|
||||
if minimum > maximum:
|
||||
minimum, maximum = maximum, minimum
|
||||
return max(minimum, min(value, maximum))
|
||||
|
||||
|
||||
def _build_cluster_entries(
|
||||
snapshots: list[dict[str, Any]],
|
||||
*,
|
||||
cluster_blocks_by_uuid: dict[str, RemoteSensingClusterBlock],
|
||||
) -> list[dict[str, Any]]:
|
||||
entries_by_key: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for snapshot in snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip()
|
||||
temporal_extent = snapshot.get("temporal_extent")
|
||||
for satellite_sub_block in snapshot.get("satellite_sub_blocks") or []:
|
||||
cluster_uuid = str(satellite_sub_block.get("cluster_uuid") or "").strip()
|
||||
sub_block_code = str(satellite_sub_block.get("sub_block_code") or "").strip()
|
||||
cluster_label = satellite_sub_block.get("cluster_label")
|
||||
if cluster_uuid:
|
||||
entry_key = cluster_uuid
|
||||
elif sub_block_code:
|
||||
entry_key = f"{block_code}::{sub_block_code}"
|
||||
else:
|
||||
entry_key = f"{block_code}::cluster-{cluster_label}"
|
||||
entry = entries_by_key.setdefault(
|
||||
entry_key,
|
||||
{
|
||||
"block_code": block_code,
|
||||
"cluster_uuid": cluster_uuid or None,
|
||||
"sub_block_code": sub_block_code,
|
||||
"cluster_label": cluster_label,
|
||||
"temporal_extent": temporal_extent,
|
||||
"cluster_block": None,
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {},
|
||||
"resolved_metrics": {},
|
||||
"source_metadata": {
|
||||
"block_status": snapshot.get("status") or "missing",
|
||||
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
|
||||
"has_satellite_metrics": False,
|
||||
"has_sensor_metrics": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
entry["satellite_metrics"] = dict(satellite_sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["satellite_metrics"])
|
||||
entry["source_metadata"]["has_satellite_metrics"] = True
|
||||
if cluster_uuid and cluster_uuid in cluster_blocks_by_uuid:
|
||||
entry["cluster_block"] = cluster_blocks_by_uuid[cluster_uuid]
|
||||
|
||||
for sensor_sub_block in snapshot.get("sensor_sub_blocks") or []:
|
||||
cluster_uuid = str(sensor_sub_block.get("cluster_uuid") or "").strip()
|
||||
sub_block_code = str(sensor_sub_block.get("sub_block_code") or "").strip()
|
||||
cluster_label = sensor_sub_block.get("cluster_label")
|
||||
candidate_keys = [
|
||||
cluster_uuid,
|
||||
f"{block_code}::{sub_block_code}" if sub_block_code else "",
|
||||
f"{block_code}::cluster-{cluster_label}" if cluster_label is not None else "",
|
||||
]
|
||||
entry = None
|
||||
for candidate_key in candidate_keys:
|
||||
if candidate_key and candidate_key in entries_by_key:
|
||||
entry = entries_by_key[candidate_key]
|
||||
break
|
||||
if entry is None:
|
||||
continue
|
||||
entry["sensor_metrics"] = dict(sensor_sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["sensor_metrics"])
|
||||
entry["source_metadata"]["has_sensor_metrics"] = True
|
||||
|
||||
return list(entries_by_key.values())
|
||||
|
||||
|
||||
def _attach_missing_satellite_metrics(cluster_entries: list[dict[str, Any]]) -> None:
|
||||
for cluster_entry in cluster_entries:
|
||||
cluster_block = cluster_entry.get("cluster_block")
|
||||
if cluster_block is None:
|
||||
continue
|
||||
needs_soil_vv = "soil_vv" not in (cluster_entry.get("resolved_metrics") or {})
|
||||
if not needs_soil_vv:
|
||||
continue
|
||||
observation_summary = AnalysisGridObservation.objects.filter(
|
||||
cell__cell_code__in=list(cluster_block.cell_codes or []),
|
||||
temporal_start=cluster_block.result.temporal_start,
|
||||
temporal_end=cluster_block.result.temporal_end,
|
||||
).aggregate(soil_vv_mean=Avg("soil_vv"))
|
||||
soil_vv_mean = _safe_float(observation_summary.get("soil_vv_mean"))
|
||||
if soil_vv_mean is None:
|
||||
continue
|
||||
rounded_soil_vv = round(soil_vv_mean, 6)
|
||||
cluster_entry.setdefault("satellite_metrics", {})["soil_vv"] = rounded_soil_vv
|
||||
cluster_entry.setdefault("resolved_metrics", {})["soil_vv"] = rounded_soil_vv
|
||||
|
||||
|
||||
def _build_cluster_overrides(
|
||||
base_payload: dict[str, Any],
|
||||
*,
|
||||
cluster_metrics: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
soil_parameters = deepcopy(base_payload.get("soil") or {})
|
||||
site_parameters = deepcopy(base_payload.get("site_parameters") or {})
|
||||
|
||||
ndwi = _safe_float(cluster_metrics.get("ndwi"))
|
||||
if ndwi is not None:
|
||||
smfcf = _clamp(ndwi, 0.2, 0.55)
|
||||
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
||||
sm0 = _clamp(
|
||||
min(max(smfcf + 0.08, smw + 0.12), 0.6),
|
||||
max(smfcf + 0.02, smw + 0.05),
|
||||
0.8,
|
||||
)
|
||||
soil_parameters["SMFCF"] = round(smfcf, 3)
|
||||
soil_parameters["SMW"] = round(smw, 3)
|
||||
soil_parameters["SM0"] = round(sm0, 3)
|
||||
site_parameters["SMLIM"] = round(_clamp(smfcf, smw, sm0), 3)
|
||||
|
||||
soil_moisture = _safe_float(cluster_metrics.get("soil_moisture"))
|
||||
if soil_moisture is not None:
|
||||
soil_parameters["soil_moisture"] = soil_moisture
|
||||
site_parameters["WAV"] = round(max(soil_moisture, 0.0), 3)
|
||||
|
||||
nutrient_mappings = (
|
||||
("nitrogen", "NAVAILI", "nitrogen"),
|
||||
("phosphorus", "P_STATUS", "phosphorus"),
|
||||
("potassium", "K_STATUS", "potassium"),
|
||||
("soil_ph", "SOIL_PH", "soil_ph"),
|
||||
("electrical_conductivity", "EC", "electrical_conductivity"),
|
||||
)
|
||||
for metric_name, site_key, soil_key in nutrient_mappings:
|
||||
value = _safe_float(cluster_metrics.get(metric_name))
|
||||
if value is None:
|
||||
continue
|
||||
soil_parameters[soil_key] = value
|
||||
site_parameters[site_key] = value
|
||||
|
||||
return soil_parameters, site_parameters
|
||||
|
||||
|
||||
def _serialize_cluster_block(cluster_block: RemoteSensingClusterBlock | None) -> dict[str, Any] | None:
|
||||
if cluster_block is None:
|
||||
return None
|
||||
return {
|
||||
"uuid": str(cluster_block.uuid),
|
||||
"sub_block_code": cluster_block.sub_block_code,
|
||||
"cluster_label": cluster_block.cluster_label,
|
||||
"chunk_size_sqm": cluster_block.chunk_size_sqm,
|
||||
"centroid_lat": cluster_block.centroid_lat,
|
||||
"centroid_lon": cluster_block.centroid_lon,
|
||||
"center_cell_code": cluster_block.center_cell_code,
|
||||
"center_cell_lat": cluster_block.center_cell_lat,
|
||||
"center_cell_lon": cluster_block.center_cell_lon,
|
||||
"cell_count": cluster_block.cell_count,
|
||||
"cell_codes": list(cluster_block.cell_codes or []),
|
||||
"geometry": cluster_block.geometry,
|
||||
"metadata": dict(cluster_block.metadata or {}),
|
||||
"created_at": cluster_block.created_at,
|
||||
"updated_at": cluster_block.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _simulate_candidate(
|
||||
*,
|
||||
base_payload: dict[str, Any],
|
||||
soil_parameters: dict[str, Any],
|
||||
site_parameters: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
manager = PcseSimulationManager()
|
||||
try:
|
||||
return (
|
||||
manager.run_simulation(
|
||||
weather=base_payload.get("weather") or [],
|
||||
soil=soil_parameters,
|
||||
crop_parameters=base_payload.get("crop_parameters") or {},
|
||||
agromanagement=base_payload.get("agromanagement") or [],
|
||||
site_parameters=site_parameters,
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception as exc:
|
||||
context = GrowthSimulationContext(
|
||||
farm_uuid=None,
|
||||
plant_name=str((base_payload.get("crop_parameters") or {}).get("crop_name") or ""),
|
||||
plant=base_payload.get("plant"),
|
||||
dynamic_parameters=[],
|
||||
weather=base_payload.get("weather") or [],
|
||||
crop_parameters=base_payload.get("crop_parameters") or {},
|
||||
soil_parameters=soil_parameters,
|
||||
site_parameters=site_parameters,
|
||||
agromanagement=base_payload.get("agromanagement") or [],
|
||||
page_size=10,
|
||||
)
|
||||
fallback_result = _run_projection_engine(context)
|
||||
return fallback_result, f"simulation_fallback:{exc}"
|
||||
|
||||
|
||||
def _rank_cluster_plants(
|
||||
cluster_entry: dict[str, Any],
|
||||
*,
|
||||
plant_assignments: list[Any],
|
||||
base_payloads: dict[str, dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
candidates: list[ClusterPlantCandidate] = []
|
||||
for assignment in plant_assignments:
|
||||
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
|
||||
if not plant_name:
|
||||
continue
|
||||
base_payload = base_payloads[plant_name]
|
||||
soil_parameters, site_parameters = _build_cluster_overrides(
|
||||
base_payload,
|
||||
cluster_metrics=dict(cluster_entry.get("resolved_metrics") or {}),
|
||||
)
|
||||
simulation_result, simulation_warning = _simulate_candidate(
|
||||
base_payload=base_payload,
|
||||
soil_parameters=soil_parameters,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
metrics = dict(simulation_result.get("metrics") or {})
|
||||
predicted_yield = _safe_float(metrics.get("yield_estimate"))
|
||||
biomass = _safe_float(metrics.get("biomass"))
|
||||
max_lai = _safe_float(metrics.get("max_lai"))
|
||||
predicted_yield_tons = None if predicted_yield is None else round(max(predicted_yield, 0.0) / 1000.0, 4)
|
||||
score = round(predicted_yield if predicted_yield is not None else -1.0, 4)
|
||||
candidates.append(
|
||||
ClusterPlantCandidate(
|
||||
plant_id=getattr(assignment.plant, "backend_plant_id", None),
|
||||
plant_name=plant_name,
|
||||
position=getattr(assignment, "position", None),
|
||||
stage=str(getattr(assignment, "stage", "") or ""),
|
||||
score=score,
|
||||
predicted_yield=round(predicted_yield, 4) if predicted_yield is not None else None,
|
||||
predicted_yield_tons=predicted_yield_tons,
|
||||
biomass=round(biomass, 4) if biomass is not None else None,
|
||||
max_lai=round(max_lai, 4) if max_lai is not None else None,
|
||||
simulation_engine=simulation_result.get("engine"),
|
||||
simulation_model_name=simulation_result.get("model_name"),
|
||||
simulation_warning=simulation_warning,
|
||||
supporting_metrics=metrics,
|
||||
)
|
||||
)
|
||||
|
||||
ranked_candidates = sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
item.score,
|
||||
item.biomass if item.biomass is not None else float("-inf"),
|
||||
-1 * (item.position if item.position is not None else 10_000),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return [candidate.as_dict() for candidate in ranked_candidates]
|
||||
|
||||
|
||||
def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
raise ClusterRecommendationNotFound("مزرعه پیدا نشد.")
|
||||
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
if not plant_assignments:
|
||||
raise ClusterRecommendationValidationError("برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.")
|
||||
|
||||
location = farm.center_location
|
||||
snapshots = build_location_block_satellite_snapshots(
|
||||
location,
|
||||
sensor_payload=farm.sensor_payload,
|
||||
)
|
||||
cluster_uuids = {
|
||||
str(sub_block.get("cluster_uuid") or "").strip()
|
||||
for snapshot in snapshots
|
||||
for sub_block in (snapshot.get("satellite_sub_blocks") or [])
|
||||
if str(sub_block.get("cluster_uuid") or "").strip()
|
||||
}
|
||||
if not cluster_uuids:
|
||||
raise ClusterRecommendationNotFound("برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.")
|
||||
|
||||
cluster_blocks_by_uuid = {
|
||||
str(cluster_block.uuid): cluster_block
|
||||
for cluster_block in RemoteSensingClusterBlock.objects.filter(uuid__in=list(cluster_uuids)).select_related("result")
|
||||
}
|
||||
cluster_entries = _build_cluster_entries(
|
||||
snapshots,
|
||||
cluster_blocks_by_uuid=cluster_blocks_by_uuid,
|
||||
)
|
||||
_attach_missing_satellite_metrics(cluster_entries)
|
||||
if not cluster_entries:
|
||||
raise ClusterRecommendationNotFound("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.")
|
||||
|
||||
base_payloads: dict[str, dict[str, Any]] = {}
|
||||
for assignment in plant_assignments:
|
||||
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
|
||||
if not plant_name or plant_name in base_payloads:
|
||||
continue
|
||||
try:
|
||||
base_payloads[plant_name] = build_simulation_payload_from_farm(
|
||||
farm_uuid=str(farm.farm_uuid),
|
||||
plant_name=plant_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ClusterRecommendationValidationError(
|
||||
f"مقایسه گیاهها با crop_simulation انجام نشد: {exc}"
|
||||
) from exc
|
||||
|
||||
response_clusters: list[dict[str, Any]] = []
|
||||
for cluster_entry in cluster_entries:
|
||||
candidate_plants = _rank_cluster_plants(
|
||||
cluster_entry,
|
||||
plant_assignments=plant_assignments,
|
||||
base_payloads=base_payloads,
|
||||
)
|
||||
response_clusters.append(
|
||||
{
|
||||
"block_code": cluster_entry.get("block_code") or "",
|
||||
"cluster_uuid": cluster_entry.get("cluster_uuid"),
|
||||
"sub_block_code": cluster_entry.get("sub_block_code") or "",
|
||||
"cluster_label": cluster_entry.get("cluster_label"),
|
||||
"temporal_extent": cluster_entry.get("temporal_extent"),
|
||||
"cluster_block": _serialize_cluster_block(cluster_entry.get("cluster_block")),
|
||||
"satellite_metrics": dict(cluster_entry.get("satellite_metrics") or {}),
|
||||
"sensor_metrics": dict(cluster_entry.get("sensor_metrics") or {}),
|
||||
"resolved_metrics": dict(cluster_entry.get("resolved_metrics") or {}),
|
||||
"candidate_plants": candidate_plants,
|
||||
"suggested_plant": candidate_plants[0] if candidate_plants else None,
|
||||
"source_metadata": dict(cluster_entry.get("source_metadata") or {}),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"location_id": location.id,
|
||||
"evaluated_plant_count": len(base_payloads),
|
||||
"cluster_count": len(response_clusters),
|
||||
"registered_plants": [
|
||||
{
|
||||
"plant_id": assignment.plant.backend_plant_id,
|
||||
"plant_name": assignment.plant.name,
|
||||
"position": assignment.position,
|
||||
"stage": assignment.stage,
|
||||
}
|
||||
for assignment in plant_assignments
|
||||
],
|
||||
"clusters": response_clusters,
|
||||
"source_metadata": {
|
||||
"source": "location_data+kmeans+farm_data+crop_simulation",
|
||||
"location_id": location.id,
|
||||
"snapshot_block_count": len(snapshots),
|
||||
},
|
||||
}
|
||||
@@ -16,6 +16,8 @@ from django.db import transaction
|
||||
|
||||
from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot
|
||||
from .models import (
|
||||
build_default_sub_block,
|
||||
ensure_block_layout_defaults,
|
||||
AnalysisGridObservation,
|
||||
BlockSubdivision,
|
||||
RemoteSensingClusterBlock,
|
||||
@@ -1272,7 +1274,7 @@ def sync_location_block_layout_with_result(
|
||||
result: RemoteSensingSubdivisionResult,
|
||||
cluster_summaries: list[dict[str, Any]],
|
||||
) -> None:
|
||||
layout = dict(location.block_layout or {})
|
||||
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||
blocks = list(layout.get("blocks") or [])
|
||||
target_block = None
|
||||
for block in blocks:
|
||||
@@ -1307,6 +1309,14 @@ def sync_location_block_layout_with_result(
|
||||
}
|
||||
for cluster in cluster_summaries
|
||||
]
|
||||
if not target_block["sub_blocks"]:
|
||||
target_block["sub_blocks"] = [
|
||||
build_default_sub_block(
|
||||
str(target_block.get("block_code") or "block-1"),
|
||||
boundary=target_block.get("boundary") or {},
|
||||
)
|
||||
]
|
||||
|
||||
target_block["subdivision_summary"] = {
|
||||
"type": "data_driven_remote_sensing",
|
||||
"cluster_count": result.cluster_count,
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from location_data.models import (
|
||||
AnalysisGridCell,
|
||||
AnalysisGridObservation,
|
||||
BlockSubdivision,
|
||||
NdviObservation,
|
||||
RemoteSensingClusterAssignment,
|
||||
RemoteSensingClusterBlock,
|
||||
RemoteSensingRun,
|
||||
RemoteSensingSubdivisionOption,
|
||||
RemoteSensingSubdivisionOptionAssignment,
|
||||
RemoteSensingSubdivisionOptionBlock,
|
||||
RemoteSensingSubdivisionResult,
|
||||
SoilLocation,
|
||||
)
|
||||
|
||||
SEED_DATA = {
|
||||
"soillocations": [
|
||||
{
|
||||
"id": 1,
|
||||
"latitude": "50.000000",
|
||||
"longitude": "50.000000",
|
||||
"task_id": "",
|
||||
"farm_boundary": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[49.9995, 49.9995], [50.0005, 49.9995], [50.0005, 50.0005], [49.9995, 50.0005], [49.9995, 49.9995]]],
|
||||
},
|
||||
"input_block_count": 1,
|
||||
"block_layout": {
|
||||
"blocks": [
|
||||
{
|
||||
"order": 1,
|
||||
"source": "default",
|
||||
"boundary": {},
|
||||
"block_code": "block-1",
|
||||
"sub_blocks": [],
|
||||
"needs_subdivision": None,
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"source": "remote_sensing",
|
||||
"block_code": "",
|
||||
"sub_blocks": [
|
||||
{
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
|
||||
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0004993, "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "center_mean_distance": 0.00029732}, "cell_geometry_type": "Polygon"},
|
||||
"cell_count": 4,
|
||||
"centroid_lat": 49.99977,
|
||||
"centroid_lon": 49.99992,
|
||||
"cluster_uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
|
||||
"cluster_label": 0,
|
||||
"sub_block_code": "cluster-0",
|
||||
"center_cell_lat": 49.999635,
|
||||
"center_cell_lon": 49.99971,
|
||||
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
|
||||
},
|
||||
{
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
|
||||
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0006827, "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "center_mean_distance": 0.00041092}, "cell_geometry_type": "Polygon"},
|
||||
"cell_count": 8,
|
||||
"centroid_lat": 50.000174,
|
||||
"centroid_lon": 50.000235,
|
||||
"cluster_uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
|
||||
"cluster_label": 1,
|
||||
"sub_block_code": "cluster-1",
|
||||
"center_cell_lat": 50.000174,
|
||||
"center_cell_lon": 50.00013,
|
||||
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
|
||||
},
|
||||
],
|
||||
"needs_subdivision": True,
|
||||
"subdivision_summary": {
|
||||
"type": "data_driven_remote_sensing",
|
||||
"run_id": 1,
|
||||
"cluster_count": 2,
|
||||
"used_cell_count": 12,
|
||||
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
|
||||
"skipped_cell_count": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
"algorithm_status": "completed",
|
||||
"default_full_farm": True,
|
||||
"input_block_count": 1,
|
||||
"analysis_grid_summary": {"cell_count": 12, "chunk_size_sqm": 900},
|
||||
},
|
||||
"created_at": "2026-05-11T14:41:43.319380+00:00",
|
||||
"updated_at": "2026-05-12T12:37:38.239904+00:00",
|
||||
}
|
||||
],
|
||||
"blocksubdivisions": [],
|
||||
"remotesensingruns": [
|
||||
{
|
||||
"id": 1,
|
||||
"soil_location_id": 1,
|
||||
"block_subdivision_id": None,
|
||||
"block_code": "",
|
||||
"provider": "openeo",
|
||||
"chunk_size_sqm": 900,
|
||||
"temporal_start": "2026-04-11",
|
||||
"temporal_end": "2026-05-11",
|
||||
"status": "success",
|
||||
"metadata": {"farm_uuid": "11111111-1111-1111-1111-111111111111", "requested_via": "api", "scope": "all_blocks"},
|
||||
"error_message": "",
|
||||
"started_at": "2026-05-12T12:19:03.911826+00:00",
|
||||
"finished_at": "2026-05-12T12:37:39.018428+00:00",
|
||||
"created_at": "2026-05-12T12:19:03.912346+00:00",
|
||||
"updated_at": "2026-05-12T12:37:39.019007+00:00",
|
||||
}
|
||||
],
|
||||
"remotesensingsubdivisionresults": [
|
||||
{
|
||||
"id": 1,
|
||||
"soil_location_id": 1,
|
||||
"run_id": 1,
|
||||
"block_subdivision_id": None,
|
||||
"block_code": "",
|
||||
"chunk_size_sqm": 900,
|
||||
"temporal_start": "2026-04-11",
|
||||
"temporal_end": "2026-05-11",
|
||||
"cluster_count": 2,
|
||||
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
|
||||
"skipped_cell_codes": [],
|
||||
"metadata": {"selection_strategy": "elbow", "used_cell_count": 12},
|
||||
"created_at": "2026-05-12T12:37:27.897155+00:00",
|
||||
"updated_at": "2026-05-12T12:37:27.897180+00:00",
|
||||
}
|
||||
],
|
||||
"remotesensingclusterblocks": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
|
||||
"result_id": 1,
|
||||
"soil_location_id": 1,
|
||||
"block_subdivision_id": None,
|
||||
"block_code": "",
|
||||
"sub_block_code": "cluster-0",
|
||||
"cluster_label": 0,
|
||||
"chunk_size_sqm": 900,
|
||||
"centroid_lat": "49.999770",
|
||||
"centroid_lon": "49.999920",
|
||||
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
|
||||
"center_cell_lat": "49.999635",
|
||||
"center_cell_lon": "49.999710",
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
|
||||
"cell_count": 4,
|
||||
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0000", "loc-1__block-farm__chunk-900__r0000c0001", "loc-1__block-farm__chunk-900__r0001c0000", "loc-1__block-farm__chunk-900__r0001c0001"],
|
||||
"metadata": {"source": "analysis_grid_cells"},
|
||||
"created_at": "2026-05-12T12:37:27.899874+00:00",
|
||||
"updated_at": "2026-05-12T12:37:27.899899+00:00",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
|
||||
"result_id": 1,
|
||||
"soil_location_id": 1,
|
||||
"block_subdivision_id": None,
|
||||
"block_code": "",
|
||||
"sub_block_code": "cluster-1",
|
||||
"cluster_label": 1,
|
||||
"chunk_size_sqm": 900,
|
||||
"centroid_lat": "50.000174",
|
||||
"centroid_lon": "50.000235",
|
||||
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
|
||||
"center_cell_lat": "50.000174",
|
||||
"center_cell_lon": "50.000130",
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
|
||||
"cell_count": 8,
|
||||
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0002", "loc-1__block-farm__chunk-900__r0001c0002", "loc-1__block-farm__chunk-900__r0002c0000", "loc-1__block-farm__chunk-900__r0002c0001", "loc-1__block-farm__chunk-900__r0002c0002", "loc-1__block-farm__chunk-900__r0003c0000", "loc-1__block-farm__chunk-900__r0003c0001", "loc-1__block-farm__chunk-900__r0003c0002"],
|
||||
"metadata": {"source": "analysis_grid_cells"},
|
||||
"created_at": "2026-05-12T12:37:27.901926+00:00",
|
||||
"updated_at": "2026-05-12T12:37:27.901945+00:00",
|
||||
},
|
||||
],
|
||||
"analysisgridcells": [
|
||||
{"id": 1, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [49.99992, 49.99977], [49.9995, 49.99977], [49.9995, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.930590+00:00", "updated_at": "2026-05-12T12:19:03.930609+00:00"},
|
||||
{"id": 2, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [49.99992, 49.99977], [49.99992, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931797+00:00", "updated_at": "2026-05-12T12:19:03.931817+00:00"},
|
||||
{"id": 3, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000339, 49.99977], [50.000339, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931857+00:00", "updated_at": "2026-05-12T12:19:03.931864+00:00"},
|
||||
{"id": 4, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.99977], [49.99992, 49.99977], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.931899+00:00", "updated_at": "2026-05-12T12:19:03.931906+00:00"},
|
||||
{"id": 5, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.99977], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.99992, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931939+00:00", "updated_at": "2026-05-12T12:19:03.931945+00:00"},
|
||||
{"id": 6, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.99977], [50.000759, 49.99977], [50.000759, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931978+00:00", "updated_at": "2026-05-12T12:19:03.931985+00:00"},
|
||||
{"id": 7, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.00004], [49.99992, 50.00004], [49.99992, 50.000309], [49.9995, 50.000309], [49.9995, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932017+00:00", "updated_at": "2026-05-12T12:19:03.932024+00:00"},
|
||||
{"id": 8, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 50.000309], [49.99992, 50.000309], [49.99992, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932056+00:00", "updated_at": "2026-05-12T12:19:03.932063+00:00"},
|
||||
{"id": 9, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.00004], [50.000759, 50.00004], [50.000759, 50.000309], [50.000339, 50.000309], [50.000339, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932100+00:00", "updated_at": "2026-05-12T12:19:03.932107+00:00"},
|
||||
{"id": 10, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.000309], [49.99992, 50.000309], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932145+00:00", "updated_at": "2026-05-12T12:19:03.932152+00:00"},
|
||||
{"id": 11, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.000309], [50.000339, 50.000309], [50.000339, 50.000579], [49.99992, 50.000579], [49.99992, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932191+00:00", "updated_at": "2026-05-12T12:19:03.932198+00:00"},
|
||||
{"id": 12, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.000309], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [50.000339, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932234+00:00", "updated_at": "2026-05-12T12:19:03.932241+00:00"},
|
||||
],
|
||||
"analysisgridobservations": [
|
||||
{"id": 1, "cell_id": 1, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv": -15.369688, "soil_vv_db": -15.369688, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.614257+00:00", "updated_at": "2026-05-12T12:37:26.614281+00:00"},
|
||||
{"id": 2, "cell_id": 2, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv": -14.043169, "soil_vv_db": -14.043169, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615124+00:00", "updated_at": "2026-05-12T12:37:26.615143+00:00"},
|
||||
{"id": 3, "cell_id": 3, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv": -13.727797, "soil_vv_db": -13.727797, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615867+00:00", "updated_at": "2026-05-12T12:37:26.615885+00:00"},
|
||||
{"id": 4, "cell_id": 4, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv": -13.127913, "soil_vv_db": -13.127913, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.616668+00:00", "updated_at": "2026-05-12T12:37:26.616687+00:00"},
|
||||
{"id": 5, "cell_id": 5, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv": -12.400669, "soil_vv_db": -12.400669, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.617453+00:00", "updated_at": "2026-05-12T12:37:26.617472+00:00"},
|
||||
{"id": 6, "cell_id": 6, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv": -12.273758, "soil_vv_db": -12.273758, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618181+00:00", "updated_at": "2026-05-12T12:37:26.618199+00:00"},
|
||||
{"id": 7, "cell_id": 7, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv": -12.147284, "soil_vv_db": -12.147284, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618964+00:00", "updated_at": "2026-05-12T12:37:26.618982+00:00"},
|
||||
{"id": 8, "cell_id": 8, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv": -13.170681, "soil_vv_db": -13.170681, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.619733+00:00", "updated_at": "2026-05-12T12:37:26.619750+00:00"},
|
||||
{"id": 9, "cell_id": 9, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv": -13.873331, "soil_vv_db": -13.873331, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.620504+00:00", "updated_at": "2026-05-12T12:37:26.620522+00:00"},
|
||||
{"id": 10, "cell_id": 10, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv": -14.09151, "soil_vv_db": -14.09151, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621257+00:00", "updated_at": "2026-05-12T12:37:26.621275+00:00"},
|
||||
{"id": 11, "cell_id": 11, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv": -13.272252, "soil_vv_db": -13.272252, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621989+00:00", "updated_at": "2026-05-12T12:37:26.622008+00:00"},
|
||||
{"id": 12, "cell_id": 12, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv": -12.991811, "soil_vv_db": -12.991811, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.622751+00:00", "updated_at": "2026-05-12T12:37:26.622769+00:00"},
|
||||
],
|
||||
"remotesensingclusterassignments": [
|
||||
{"id": 1, "result_id": 1, "cell_id": 1, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv_db": -15.369688}, "scaled_feature_values": {"ndvi": -1.630738, "ndwi": 1.792215, "soil_vv_db": -2.274005}, "created_at": "2026-05-12T12:37:27.901986+00:00", "updated_at": "2026-05-12T12:37:27.902003+00:00"},
|
||||
{"id": 2, "result_id": 1, "cell_id": 2, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv_db": -14.043169}, "scaled_feature_values": {"ndvi": -1.094298, "ndwi": 1.109414, "soil_vv_db": -0.762373}, "created_at": "2026-05-12T12:37:27.902768+00:00", "updated_at": "2026-05-12T12:37:27.902786+00:00"},
|
||||
{"id": 3, "result_id": 1, "cell_id": 3, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv_db": -13.727797}, "scaled_feature_values": {"ndvi": 0.736534, "ndwi": -0.8832, "soil_vv_db": -0.402992}, "created_at": "2026-05-12T12:37:27.903479+00:00", "updated_at": "2026-05-12T12:37:27.903497+00:00"},
|
||||
{"id": 4, "result_id": 1, "cell_id": 4, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv_db": -13.127913}, "scaled_feature_values": {"ndvi": -0.614307, "ndwi": 0.732429, "soil_vv_db": 0.280605}, "created_at": "2026-05-12T12:37:27.904197+00:00", "updated_at": "2026-05-12T12:37:27.904214+00:00"},
|
||||
{"id": 5, "result_id": 1, "cell_id": 5, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv_db": -12.400669}, "scaled_feature_values": {"ndvi": -0.818023, "ndwi": 1.232666, "soil_vv_db": 1.109334}, "created_at": "2026-05-12T12:37:27.904909+00:00", "updated_at": "2026-05-12T12:37:27.904926+00:00"},
|
||||
{"id": 6, "result_id": 1, "cell_id": 6, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv_db": -12.273758}, "scaled_feature_values": {"ndvi": 1.296436, "ndwi": -0.703617, "soil_vv_db": 1.253955}, "created_at": "2026-05-12T12:37:27.905606+00:00", "updated_at": "2026-05-12T12:37:27.905623+00:00"},
|
||||
{"id": 7, "result_id": 1, "cell_id": 7, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv_db": -12.147284}, "scaled_feature_values": {"ndvi": 0.11252, "ndwi": -0.229749, "soil_vv_db": 1.398079}, "created_at": "2026-05-12T12:37:27.906325+00:00", "updated_at": "2026-05-12T12:37:27.906343+00:00"},
|
||||
{"id": 8, "result_id": 1, "cell_id": 8, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv_db": -13.170681}, "scaled_feature_values": {"ndvi": 0.788741, "ndwi": -0.715232, "soil_vv_db": 0.231869}, "created_at": "2026-05-12T12:37:27.907052+00:00", "updated_at": "2026-05-12T12:37:27.907069+00:00"},
|
||||
{"id": 9, "result_id": 1, "cell_id": 9, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv_db": -13.873331}, "scaled_feature_values": {"ndvi": 1.485916, "ndwi": -1.526247, "soil_vv_db": -0.568835}, "created_at": "2026-05-12T12:37:27.907735+00:00", "updated_at": "2026-05-12T12:37:27.907752+00:00"},
|
||||
{"id": 10, "result_id": 1, "cell_id": 10, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv_db": -14.09151}, "scaled_feature_values": {"ndvi": -0.194631, "ndwi": -0.300985, "soil_vv_db": -0.81746}, "created_at": "2026-05-12T12:37:27.908441+00:00", "updated_at": "2026-05-12T12:37:27.908458+00:00"},
|
||||
{"id": 11, "result_id": 1, "cell_id": 11, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv_db": -13.272252}, "scaled_feature_values": {"ndvi": -1.031701, "ndwi": 0.492105, "soil_vv_db": 0.116124}, "created_at": "2026-05-12T12:37:27.909117+00:00", "updated_at": "2026-05-12T12:37:27.909134+00:00"},
|
||||
{"id": 12, "result_id": 1, "cell_id": 12, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv_db": -12.991811}, "scaled_feature_values": {"ndvi": 0.963553, "ndwi": -0.999798, "soil_vv_db": 0.4357}, "created_at": "2026-05-12T12:37:27.909828+00:00", "updated_at": "2026-05-12T12:37:27.909845+00:00"},
|
||||
],
|
||||
"remotesensingsubdivisionoptions": [],
|
||||
"remotesensingsubdivisionoptionblocks": [],
|
||||
"remotesensingsubdivisionoptionassignments": [],
|
||||
"ndviobservations": [],
|
||||
}
|
||||
|
||||
|
||||
def _dt(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
parsed = parse_datetime(value)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _date(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def _decimal(value):
|
||||
if value is None:
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _uuid(value):
|
||||
if not value:
|
||||
return None
|
||||
return UUID(str(value))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed the current location_data database snapshot into local tables."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--flush-existing",
|
||||
action="store_true",
|
||||
help="Delete existing location_data seeded tables before inserting the snapshot.",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
if options["flush_existing"]:
|
||||
self._flush_existing()
|
||||
|
||||
self._seed_soil_locations()
|
||||
self._seed_block_subdivisions()
|
||||
self._seed_remote_sensing_runs()
|
||||
self._seed_subdivision_results()
|
||||
self._seed_cluster_blocks()
|
||||
self._seed_analysis_grid_cells()
|
||||
self._seed_analysis_grid_observations()
|
||||
self._seed_cluster_assignments()
|
||||
self._seed_subdivision_options()
|
||||
self._seed_subdivision_option_blocks()
|
||||
self._seed_subdivision_option_assignments()
|
||||
self._seed_ndvi_observations()
|
||||
self.stdout.write(self.style.SUCCESS("location_data seed snapshot applied successfully."))
|
||||
|
||||
def _flush_existing(self):
|
||||
RemoteSensingSubdivisionOptionAssignment.objects.all().delete()
|
||||
RemoteSensingSubdivisionOptionBlock.objects.all().delete()
|
||||
RemoteSensingSubdivisionOption.objects.all().delete()
|
||||
RemoteSensingClusterAssignment.objects.all().delete()
|
||||
AnalysisGridObservation.objects.all().delete()
|
||||
RemoteSensingClusterBlock.objects.all().delete()
|
||||
RemoteSensingSubdivisionResult.objects.all().delete()
|
||||
RemoteSensingRun.objects.all().delete()
|
||||
AnalysisGridCell.objects.all().delete()
|
||||
BlockSubdivision.objects.all().delete()
|
||||
NdviObservation.objects.all().delete()
|
||||
SoilLocation.objects.all().delete()
|
||||
|
||||
def _seed_soil_locations(self):
|
||||
for row in SEED_DATA["soillocations"]:
|
||||
obj, _ = SoilLocation.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"latitude": _decimal(row["latitude"]),
|
||||
"longitude": _decimal(row["longitude"]),
|
||||
"task_id": row["task_id"],
|
||||
"farm_boundary": row["farm_boundary"],
|
||||
"input_block_count": row["input_block_count"],
|
||||
"block_layout": row["block_layout"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_block_subdivisions(self):
|
||||
for row in SEED_DATA["blocksubdivisions"]:
|
||||
obj, _ = BlockSubdivision.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"soil_location_id": row["soil_location_id"],
|
||||
"block_code": row["block_code"],
|
||||
"source_boundary": row["source_boundary"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"grid_points": row["grid_points"],
|
||||
"centroid_points": row["centroid_points"],
|
||||
"grid_point_count": row["grid_point_count"],
|
||||
"centroid_count": row["centroid_count"],
|
||||
"elbow_plot": row.get("elbow_plot", ""),
|
||||
"subdivision_summary": row.get("subdivision_summary", {}),
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_remote_sensing_runs(self):
|
||||
for row in SEED_DATA["remotesensingruns"]:
|
||||
obj, _ = RemoteSensingRun.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"soil_location_id": row["soil_location_id"],
|
||||
"block_subdivision_id": row["block_subdivision_id"],
|
||||
"block_code": row["block_code"],
|
||||
"provider": row["provider"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"temporal_start": _date(row["temporal_start"]),
|
||||
"temporal_end": _date(row["temporal_end"]),
|
||||
"status": row["status"],
|
||||
"metadata": row["metadata"],
|
||||
"error_message": row["error_message"],
|
||||
"started_at": _dt(row["started_at"]),
|
||||
"finished_at": _dt(row["finished_at"]),
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_subdivision_results(self):
|
||||
for row in SEED_DATA["remotesensingsubdivisionresults"]:
|
||||
obj, _ = RemoteSensingSubdivisionResult.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"soil_location_id": row["soil_location_id"],
|
||||
"run_id": row["run_id"],
|
||||
"block_subdivision_id": row["block_subdivision_id"],
|
||||
"block_code": row["block_code"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"temporal_start": _date(row["temporal_start"]),
|
||||
"temporal_end": _date(row["temporal_end"]),
|
||||
"cluster_count": row["cluster_count"],
|
||||
"selected_features": row["selected_features"],
|
||||
"skipped_cell_codes": row["skipped_cell_codes"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_cluster_blocks(self):
|
||||
for row in SEED_DATA["remotesensingclusterblocks"]:
|
||||
obj, _ = RemoteSensingClusterBlock.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"uuid": _uuid(row["uuid"]),
|
||||
"result_id": row["result_id"],
|
||||
"soil_location_id": row["soil_location_id"],
|
||||
"block_subdivision_id": row["block_subdivision_id"],
|
||||
"block_code": row["block_code"],
|
||||
"sub_block_code": row["sub_block_code"],
|
||||
"cluster_label": row["cluster_label"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||
"center_cell_code": row["center_cell_code"],
|
||||
"center_cell_lat": _decimal(row["center_cell_lat"]),
|
||||
"center_cell_lon": _decimal(row["center_cell_lon"]),
|
||||
"geometry": row["geometry"],
|
||||
"cell_count": row["cell_count"],
|
||||
"cell_codes": row["cell_codes"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_analysis_grid_cells(self):
|
||||
for row in SEED_DATA["analysisgridcells"]:
|
||||
obj, _ = AnalysisGridCell.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"soil_location_id": row["soil_location_id"],
|
||||
"block_subdivision_id": row["block_subdivision_id"],
|
||||
"block_code": row["block_code"],
|
||||
"cell_code": row["cell_code"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"geometry": row["geometry"],
|
||||
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_analysis_grid_observations(self):
|
||||
for row in SEED_DATA["analysisgridobservations"]:
|
||||
obj, _ = AnalysisGridObservation.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"cell_id": row["cell_id"],
|
||||
"run_id": row["run_id"],
|
||||
"temporal_start": _date(row["temporal_start"]),
|
||||
"temporal_end": _date(row["temporal_end"]),
|
||||
"ndvi": row["ndvi"],
|
||||
"ndwi": row["ndwi"],
|
||||
"soil_vv": row["soil_vv"],
|
||||
"soil_vv_db": row["soil_vv_db"],
|
||||
"dem_m": row["dem_m"],
|
||||
"slope_deg": row["slope_deg"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_cluster_assignments(self):
|
||||
for row in SEED_DATA["remotesensingclusterassignments"]:
|
||||
obj, _ = RemoteSensingClusterAssignment.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"result_id": row["result_id"],
|
||||
"cell_id": row["cell_id"],
|
||||
"cluster_label": row["cluster_label"],
|
||||
"raw_feature_values": row["raw_feature_values"],
|
||||
"scaled_feature_values": row["scaled_feature_values"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_subdivision_options(self):
|
||||
for row in SEED_DATA["remotesensingsubdivisionoptions"]:
|
||||
obj, _ = RemoteSensingSubdivisionOption.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"result_id": row["result_id"],
|
||||
"requested_k": row["requested_k"],
|
||||
"effective_cluster_count": row["effective_cluster_count"],
|
||||
"is_active": row["is_active"],
|
||||
"is_recommended": row["is_recommended"],
|
||||
"selection_source": row["selection_source"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_subdivision_option_blocks(self):
|
||||
for row in SEED_DATA["remotesensingsubdivisionoptionblocks"]:
|
||||
obj, _ = RemoteSensingSubdivisionOptionBlock.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"option_id": row["option_id"],
|
||||
"cluster_label": row["cluster_label"],
|
||||
"sub_block_code": row["sub_block_code"],
|
||||
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||
"center_cell_code": row["center_cell_code"],
|
||||
"center_cell_lat": _decimal(row["center_cell_lat"]),
|
||||
"center_cell_lon": _decimal(row["center_cell_lon"]),
|
||||
"geometry": row["geometry"],
|
||||
"cell_count": row["cell_count"],
|
||||
"cell_codes": row["cell_codes"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_subdivision_option_assignments(self):
|
||||
for row in SEED_DATA["remotesensingsubdivisionoptionassignments"]:
|
||||
obj, _ = RemoteSensingSubdivisionOptionAssignment.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"option_id": row["option_id"],
|
||||
"cell_id": row["cell_id"],
|
||||
"cluster_label": row["cluster_label"],
|
||||
"raw_feature_values": row["raw_feature_values"],
|
||||
"scaled_feature_values": row["scaled_feature_values"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row)
|
||||
|
||||
def _seed_ndvi_observations(self):
|
||||
for row in SEED_DATA["ndviobservations"]:
|
||||
obj, _ = NdviObservation.objects.update_or_create(
|
||||
id=row["id"],
|
||||
defaults={
|
||||
"location_id": row["location_id"],
|
||||
"observation_date": _date(row["observation_date"]),
|
||||
"mean_ndvi": row["mean_ndvi"],
|
||||
"ndvi_map": row["ndvi_map"],
|
||||
"vegetation_health_class": row["vegetation_health_class"],
|
||||
"satellite_source": row["satellite_source"],
|
||||
"cloud_cover": row["cloud_cover"],
|
||||
"metadata": row["metadata"],
|
||||
},
|
||||
)
|
||||
self._touch(obj, row, created_field=False)
|
||||
|
||||
def _touch(self, obj, row, *, created_field=True):
|
||||
updates = []
|
||||
if created_field and hasattr(obj, "created_at") and row.get("created_at"):
|
||||
obj.created_at = _dt(row["created_at"])
|
||||
updates.append("created_at")
|
||||
if hasattr(obj, "updated_at") and row.get("updated_at"):
|
||||
obj.updated_at = _dt(row["updated_at"])
|
||||
updates.append("updated_at")
|
||||
if updates:
|
||||
obj.save(update_fields=updates)
|
||||
@@ -3,24 +3,83 @@ import uuid
|
||||
from django.db import models
|
||||
|
||||
|
||||
def build_default_sub_block(block_code: str, *, boundary: dict | None = None) -> dict:
|
||||
normalized_block_code = str(block_code or "block-1").strip() or "block-1"
|
||||
return {
|
||||
"sub_block_code": f"{normalized_block_code}-sub-1",
|
||||
"cluster_label": 0,
|
||||
"source": "default",
|
||||
"boundary": boundary or {},
|
||||
"cluster_uuid": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def ensure_block_layout_defaults(layout: dict | None, *, block_count: int | None = None) -> dict:
|
||||
raw_layout = dict(layout or {})
|
||||
raw_blocks = list(raw_layout.get("blocks") or [])
|
||||
normalized_count = len(raw_blocks) if raw_blocks else max(int(block_count or raw_layout.get("input_block_count") or 1), 1)
|
||||
normalized_blocks: list[dict] = []
|
||||
|
||||
for index in range(normalized_count):
|
||||
raw_block = raw_blocks[index] if index < len(raw_blocks) else {}
|
||||
block_code = str(raw_block.get("block_code") or f"block-{index + 1}").strip() or f"block-{index + 1}"
|
||||
boundary = raw_block.get("boundary") or {}
|
||||
sub_blocks = [dict(sub_block) for sub_block in (raw_block.get("sub_blocks") or []) if isinstance(sub_block, dict)]
|
||||
if not sub_blocks:
|
||||
sub_blocks = [build_default_sub_block(block_code, boundary=boundary)]
|
||||
|
||||
normalized_block = {
|
||||
"block_code": block_code,
|
||||
"order": int(raw_block.get("order") or index + 1),
|
||||
"source": raw_block.get("source") or ("input" if raw_blocks or normalized_count > 1 else "default"),
|
||||
"boundary": boundary,
|
||||
"needs_subdivision": raw_block.get("needs_subdivision"),
|
||||
"sub_blocks": sub_blocks,
|
||||
}
|
||||
for extra_key in ("subdivision_summary", "analysis_grid_summary", "aggregated_metrics"):
|
||||
if extra_key in raw_block:
|
||||
normalized_block[extra_key] = raw_block[extra_key]
|
||||
normalized_blocks.append(normalized_block)
|
||||
|
||||
return {
|
||||
"input_block_count": normalized_count,
|
||||
"default_full_farm": raw_layout.get("default_full_farm", normalized_count == 1),
|
||||
"algorithm_status": raw_layout.get("algorithm_status") or "pending",
|
||||
"blocks": normalized_blocks,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict:
|
||||
normalized_blocks = []
|
||||
if blocks:
|
||||
for index, block in enumerate(blocks):
|
||||
normalized_blocks.append(
|
||||
{
|
||||
"block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
|
||||
"order": int(block.get("order") or index + 1),
|
||||
"source": "input",
|
||||
"boundary": block.get("boundary") or {},
|
||||
"needs_subdivision": None,
|
||||
"sub_blocks": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
normalized_count = max(int(block_count or 1), 1)
|
||||
for index in range(normalized_count):
|
||||
normalized_blocks.append(
|
||||
return ensure_block_layout_defaults(
|
||||
{
|
||||
"input_block_count": len(blocks),
|
||||
"default_full_farm": len(blocks) == 1,
|
||||
"algorithm_status": "pending",
|
||||
"blocks": [
|
||||
{
|
||||
"block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
|
||||
"order": int(block.get("order") or index + 1),
|
||||
"source": "input",
|
||||
"boundary": block.get("boundary") or {},
|
||||
"needs_subdivision": None,
|
||||
"sub_blocks": [dict(sub_block) for sub_block in (block.get("sub_blocks") or [])],
|
||||
}
|
||||
for index, block in enumerate(blocks)
|
||||
],
|
||||
},
|
||||
block_count=len(blocks),
|
||||
)
|
||||
|
||||
normalized_count = max(int(block_count or 1), 1)
|
||||
return ensure_block_layout_defaults(
|
||||
{
|
||||
"input_block_count": normalized_count,
|
||||
"default_full_farm": normalized_count == 1,
|
||||
"algorithm_status": "pending",
|
||||
"blocks": [
|
||||
{
|
||||
"block_code": f"block-{index + 1}",
|
||||
"order": index + 1,
|
||||
@@ -29,16 +88,11 @@ def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -
|
||||
"needs_subdivision": None,
|
||||
"sub_blocks": [],
|
||||
}
|
||||
)
|
||||
|
||||
normalized_count = len(normalized_blocks) if normalized_blocks else max(int(block_count or 1), 1)
|
||||
|
||||
return {
|
||||
"input_block_count": normalized_count,
|
||||
"default_full_farm": normalized_count == 1,
|
||||
"algorithm_status": "pending",
|
||||
"blocks": normalized_blocks,
|
||||
}
|
||||
for index in range(normalized_count)
|
||||
],
|
||||
},
|
||||
block_count=normalized_count,
|
||||
)
|
||||
|
||||
|
||||
class SoilLocation(models.Model):
|
||||
@@ -122,8 +176,10 @@ class SoilLocation(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.input_block_count:
|
||||
self.input_block_count = 1
|
||||
if not self.block_layout:
|
||||
self.block_layout = build_block_layout(self.input_block_count)
|
||||
self.block_layout = ensure_block_layout_defaults(
|
||||
self.block_layout,
|
||||
block_count=self.input_block_count,
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from django.db.models import Avg, QuerySet
|
||||
|
||||
from .models import (
|
||||
ensure_block_layout_defaults,
|
||||
AnalysisGridObservation,
|
||||
RemoteSensingRun,
|
||||
RemoteSensingSubdivisionResult,
|
||||
@@ -90,7 +91,7 @@ def build_location_block_satellite_snapshots(
|
||||
*,
|
||||
sensor_payload: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
block_layout = location.block_layout or {}
|
||||
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||
blocks = block_layout.get("blocks") or []
|
||||
if not blocks:
|
||||
return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)]
|
||||
@@ -112,7 +113,7 @@ def build_block_layout_metric_summary(
|
||||
*,
|
||||
sensor_payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
layout = dict(location.block_layout or {})
|
||||
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||
blocks = [dict(block) for block in (layout.get("blocks") or [])]
|
||||
snapshots_by_block_code = {
|
||||
str(snapshot.get("block_code") or ""): snapshot
|
||||
@@ -461,7 +462,7 @@ def build_block_sensor_summary(
|
||||
|
||||
|
||||
def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]:
|
||||
block_layout = dict(location.block_layout or {})
|
||||
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||
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]] = {}
|
||||
|
||||
@@ -144,6 +144,10 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer):
|
||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100)
|
||||
|
||||
|
||||
class ClusterCropRecommendationRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
|
||||
|
||||
|
||||
class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
|
||||
temporal_start = serializers.DateField(required=False)
|
||||
temporal_end = serializers.DateField(required=False)
|
||||
@@ -426,6 +430,54 @@ class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer):
|
||||
metadata = serializers.JSONField()
|
||||
|
||||
|
||||
class ClusterCropRegisteredPlantSerializer(serializers.Serializer):
|
||||
plant_id = serializers.IntegerField()
|
||||
plant_name = serializers.CharField()
|
||||
position = serializers.IntegerField(allow_null=True)
|
||||
stage = serializers.CharField(allow_blank=True)
|
||||
|
||||
|
||||
class ClusterCropCandidateSerializer(serializers.Serializer):
|
||||
plant_id = serializers.IntegerField(allow_null=True)
|
||||
plant_name = serializers.CharField()
|
||||
position = serializers.IntegerField(allow_null=True)
|
||||
stage = serializers.CharField(allow_blank=True)
|
||||
score = serializers.FloatField()
|
||||
predicted_yield = serializers.FloatField(allow_null=True)
|
||||
predicted_yield_tons = serializers.FloatField(allow_null=True)
|
||||
biomass = serializers.FloatField(allow_null=True)
|
||||
max_lai = serializers.FloatField(allow_null=True)
|
||||
simulation_engine = serializers.CharField(allow_null=True)
|
||||
simulation_model_name = serializers.CharField(allow_null=True)
|
||||
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
supporting_metrics = serializers.JSONField()
|
||||
|
||||
|
||||
class ClusterCropRecommendationClusterSerializer(serializers.Serializer):
|
||||
block_code = serializers.CharField(allow_blank=True)
|
||||
cluster_uuid = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
sub_block_code = serializers.CharField()
|
||||
cluster_label = serializers.IntegerField(allow_null=True)
|
||||
temporal_extent = serializers.JSONField(allow_null=True)
|
||||
cluster_block = RemoteSensingClusterBlockSerializer(allow_null=True)
|
||||
satellite_metrics = serializers.JSONField()
|
||||
sensor_metrics = serializers.JSONField()
|
||||
resolved_metrics = serializers.JSONField()
|
||||
candidate_plants = ClusterCropCandidateSerializer(many=True)
|
||||
suggested_plant = ClusterCropCandidateSerializer(allow_null=True)
|
||||
source_metadata = serializers.JSONField()
|
||||
|
||||
|
||||
class ClusterCropRecommendationResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.CharField()
|
||||
location_id = serializers.IntegerField()
|
||||
evaluated_plant_count = serializers.IntegerField()
|
||||
cluster_count = serializers.IntegerField()
|
||||
registered_plants = ClusterCropRegisteredPlantSerializer(many=True)
|
||||
clusters = ClusterCropRecommendationClusterSerializer(many=True)
|
||||
source_metadata = serializers.JSONField()
|
||||
|
||||
|
||||
class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
|
||||
result_id = serializers.IntegerField()
|
||||
active_requested_k = serializers.IntegerField(allow_null=True)
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from farm_data.models import FarmPlantAssignment, PlantCatalogSnapshot, SensorData
|
||||
from location_data.models import (
|
||||
AnalysisGridCell,
|
||||
AnalysisGridObservation,
|
||||
BlockSubdivision,
|
||||
RemoteSensingClusterAssignment,
|
||||
RemoteSensingClusterBlock,
|
||||
RemoteSensingRun,
|
||||
RemoteSensingSubdivisionResult,
|
||||
SoilLocation,
|
||||
)
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="location_data.urls")
|
||||
class RemoteSensingClusterRecommendationApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.boundary = {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.3890, 35.6890],
|
||||
[51.3900, 35.6890],
|
||||
[51.3900, 35.6900],
|
||||
[51.3890, 35.6900],
|
||||
[51.3890, 35.6890],
|
||||
]
|
||||
],
|
||||
}
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.689200",
|
||||
longitude="51.389000",
|
||||
farm_boundary=self.boundary,
|
||||
)
|
||||
self.location.set_input_block_count(1)
|
||||
self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"])
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid="11111111-1111-1111-1111-111111111111",
|
||||
center_location=self.location,
|
||||
sensor_payload={},
|
||||
)
|
||||
for day_index in range(1, 5):
|
||||
WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2025, 2, day_index),
|
||||
temperature_min=12.0,
|
||||
temperature_max=24.0,
|
||||
temperature_mean=18.0,
|
||||
precipitation=1.0,
|
||||
precipitation_probability=25.0,
|
||||
humidity_mean=55.0,
|
||||
wind_speed_max=10.0,
|
||||
et0=3.0,
|
||||
weather_code=1,
|
||||
)
|
||||
self.subdivision = BlockSubdivision.objects.create(
|
||||
soil_location=self.location,
|
||||
block_code="block-1",
|
||||
source_boundary=self.boundary,
|
||||
chunk_size_sqm=900,
|
||||
status="subdivided",
|
||||
)
|
||||
self.run = RemoteSensingRun.objects.create(
|
||||
soil_location=self.location,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
chunk_size_sqm=900,
|
||||
temporal_start=date(2025, 1, 1),
|
||||
temporal_end=date(2025, 1, 31),
|
||||
status=RemoteSensingRun.STATUS_SUCCESS,
|
||||
metadata={"stage": "completed"},
|
||||
)
|
||||
self.result = RemoteSensingSubdivisionResult.objects.create(
|
||||
soil_location=self.location,
|
||||
run=self.run,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
chunk_size_sqm=900,
|
||||
temporal_start=date(2025, 1, 1),
|
||||
temporal_end=date(2025, 1, 31),
|
||||
cluster_count=2,
|
||||
selected_features=["ndvi", "ndwi", "soil_vv_db"],
|
||||
metadata={"used_cell_count": 2, "skipped_cell_count": 0},
|
||||
)
|
||||
|
||||
self.cell_1 = AnalysisGridCell.objects.create(
|
||||
soil_location=self.location,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
cell_code="cell-1",
|
||||
chunk_size_sqm=900,
|
||||
geometry=self.boundary,
|
||||
centroid_lat="35.689250",
|
||||
centroid_lon="51.389250",
|
||||
)
|
||||
self.cell_2 = AnalysisGridCell.objects.create(
|
||||
soil_location=self.location,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
cell_code="cell-2",
|
||||
chunk_size_sqm=900,
|
||||
geometry=self.boundary,
|
||||
centroid_lat="35.689750",
|
||||
centroid_lon="51.389750",
|
||||
)
|
||||
AnalysisGridObservation.objects.create(
|
||||
cell=self.cell_1,
|
||||
run=self.run,
|
||||
temporal_start=date(2025, 1, 1),
|
||||
temporal_end=date(2025, 1, 31),
|
||||
ndvi=0.51,
|
||||
ndwi=0.24,
|
||||
soil_vv=0.13,
|
||||
soil_vv_db=-10.0,
|
||||
metadata={"backend_name": "openeo"},
|
||||
)
|
||||
AnalysisGridObservation.objects.create(
|
||||
cell=self.cell_2,
|
||||
run=self.run,
|
||||
temporal_start=date(2025, 1, 1),
|
||||
temporal_end=date(2025, 1, 31),
|
||||
ndvi=0.71,
|
||||
ndwi=0.48,
|
||||
soil_vv=0.19,
|
||||
soil_vv_db=-7.5,
|
||||
metadata={"backend_name": "openeo"},
|
||||
)
|
||||
RemoteSensingClusterAssignment.objects.create(
|
||||
result=self.result,
|
||||
cell=self.cell_1,
|
||||
cluster_label=0,
|
||||
raw_feature_values={"ndvi": 0.51, "ndwi": 0.24, "soil_vv_db": -10.0},
|
||||
scaled_feature_values={"ndvi": -1.0, "ndwi": -1.0, "soil_vv_db": -1.0},
|
||||
)
|
||||
RemoteSensingClusterAssignment.objects.create(
|
||||
result=self.result,
|
||||
cell=self.cell_2,
|
||||
cluster_label=1,
|
||||
raw_feature_values={"ndvi": 0.71, "ndwi": 0.48, "soil_vv_db": -7.5},
|
||||
scaled_feature_values={"ndvi": 1.0, "ndwi": 1.0, "soil_vv_db": 1.0},
|
||||
)
|
||||
self.cluster_0 = RemoteSensingClusterBlock.objects.create(
|
||||
result=self.result,
|
||||
soil_location=self.location,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
sub_block_code="cluster-0",
|
||||
cluster_label=0,
|
||||
chunk_size_sqm=900,
|
||||
centroid_lat="35.689250",
|
||||
centroid_lon="51.389250",
|
||||
center_cell_code="cell-1",
|
||||
cell_count=1,
|
||||
cell_codes=["cell-1"],
|
||||
geometry=self.boundary,
|
||||
)
|
||||
self.cluster_1 = RemoteSensingClusterBlock.objects.create(
|
||||
result=self.result,
|
||||
soil_location=self.location,
|
||||
block_subdivision=self.subdivision,
|
||||
block_code="block-1",
|
||||
sub_block_code="cluster-1",
|
||||
cluster_label=1,
|
||||
chunk_size_sqm=900,
|
||||
centroid_lat="35.689750",
|
||||
centroid_lon="51.389750",
|
||||
center_cell_code="cell-2",
|
||||
cell_count=1,
|
||||
cell_codes=["cell-2"],
|
||||
geometry=self.boundary,
|
||||
)
|
||||
|
||||
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": self.boundary,
|
||||
"needs_subdivision": True,
|
||||
"sub_blocks": [
|
||||
{
|
||||
"sub_block_code": "cluster-0",
|
||||
"cluster_label": 0,
|
||||
"cluster_uuid": str(self.cluster_0.uuid),
|
||||
},
|
||||
{
|
||||
"sub_block_code": "cluster-1",
|
||||
"cluster_label": 1,
|
||||
"cluster_uuid": str(self.cluster_1.uuid),
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
self.location.save(update_fields=["block_layout", "updated_at"])
|
||||
|
||||
self.tomato = PlantCatalogSnapshot.objects.create(
|
||||
backend_plant_id=101,
|
||||
name="Tomato",
|
||||
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Tomato", "MAX_BIOMASS": 14000.0}}},
|
||||
)
|
||||
self.wheat = PlantCatalogSnapshot.objects.create(
|
||||
backend_plant_id=102,
|
||||
name="Wheat",
|
||||
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Wheat", "MAX_BIOMASS": 11000.0}}},
|
||||
)
|
||||
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.tomato, position=0, stage="vegetative")
|
||||
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.wheat, position=1, stage="vegetative")
|
||||
|
||||
@patch("location_data.cluster_recommendation._simulate_candidate")
|
||||
def test_cluster_recommendations_return_ranked_plants_for_each_cluster(self, simulate_mock):
|
||||
def fake_simulation(*, base_payload, soil_parameters, site_parameters):
|
||||
plant_name = base_payload["crop_parameters"]["crop_name"]
|
||||
smfcf = float(soil_parameters["SMFCF"])
|
||||
if plant_name == "Tomato":
|
||||
yield_estimate = 150.0 if smfcf >= 0.4 else 80.0
|
||||
else:
|
||||
yield_estimate = 110.0 if smfcf >= 0.4 else 120.0
|
||||
return (
|
||||
{
|
||||
"engine": "pcse",
|
||||
"model_name": "Wofost81_NWLP_CWB_CNB",
|
||||
"metrics": {
|
||||
"yield_estimate": yield_estimate,
|
||||
"biomass": yield_estimate * 2,
|
||||
"max_lai": 4.2,
|
||||
},
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
simulate_mock.side_effect = fake_simulation
|
||||
|
||||
response = self.client.get(
|
||||
"/remote-sensing/cluster-recommendations/",
|
||||
data={"farm_uuid": str(self.farm.farm_uuid)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["cluster_count"], 2)
|
||||
self.assertEqual(payload["evaluated_plant_count"], 2)
|
||||
self.assertEqual(len(payload["registered_plants"]), 2)
|
||||
|
||||
clusters = {item["cluster_label"]: item for item in payload["clusters"]}
|
||||
self.assertEqual(clusters[0]["resolved_metrics"]["ndvi"], 0.51)
|
||||
self.assertEqual(clusters[0]["resolved_metrics"]["ndwi"], 0.24)
|
||||
self.assertEqual(clusters[0]["resolved_metrics"]["soil_vv"], 0.13)
|
||||
self.assertEqual(clusters[1]["resolved_metrics"]["ndwi"], 0.48)
|
||||
|
||||
self.assertEqual(clusters[0]["suggested_plant"]["plant_name"], "Wheat")
|
||||
self.assertEqual(clusters[1]["suggested_plant"]["plant_name"], "Tomato")
|
||||
self.assertEqual(clusters[0]["candidate_plants"][0]["score"], 120.0)
|
||||
self.assertEqual(clusters[1]["candidate_plants"][0]["score"], 150.0)
|
||||
self.assertEqual(clusters[0]["cluster_block"]["uuid"], str(self.cluster_0.uuid))
|
||||
self.assertEqual(clusters[1]["cluster_block"]["uuid"], str(self.cluster_1.uuid))
|
||||
|
||||
def test_cluster_recommendations_return_400_when_no_plants_registered(self):
|
||||
FarmPlantAssignment.objects.all().delete()
|
||||
|
||||
response = self.client.get(
|
||||
"/remote-sensing/cluster-recommendations/",
|
||||
data={"farm_uuid": str(self.farm.farm_uuid)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response.json()["msg"],
|
||||
"برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.",
|
||||
)
|
||||
@@ -57,10 +57,29 @@ class SoilDataApiTests(TestCase):
|
||||
self.assertEqual(len(payload["block_layout"]["blocks"]), 1)
|
||||
self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary)
|
||||
self.assertEqual(payload["block_layout"]["algorithm_status"], "pending")
|
||||
self.assertEqual(len(payload["block_layout"]["blocks"][0]["sub_blocks"]), 1)
|
||||
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["sub_block_code"], "block-1-sub-1")
|
||||
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["cluster_label"], 0)
|
||||
self.assertEqual(len(payload["block_subdivisions"]), 1)
|
||||
self.assertEqual(payload["block_subdivisions"][0]["status"], "defined")
|
||||
self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing")
|
||||
|
||||
def test_model_default_layout_includes_default_sub_block_when_missing(self):
|
||||
location = SoilLocation.objects.create(
|
||||
latitude="35.689201",
|
||||
longitude="51.389001",
|
||||
)
|
||||
|
||||
self.assertEqual(location.input_block_count, 1)
|
||||
self.assertEqual(location.block_layout["blocks"][0]["block_code"], "block-1")
|
||||
self.assertEqual(len(location.block_layout["blocks"][0]["sub_blocks"]), 1)
|
||||
self.assertEqual(
|
||||
location.block_layout["blocks"][0]["sub_blocks"][0]["sub_block_code"],
|
||||
"block-1-sub-1",
|
||||
)
|
||||
self.assertEqual(location.block_layout["blocks"][0]["sub_blocks"][0]["source"], "default")
|
||||
|
||||
|
||||
def test_post_updates_block_layout_from_input(self):
|
||||
SoilLocation.objects.create(
|
||||
latitude="35.689200",
|
||||
|
||||
@@ -4,6 +4,7 @@ from .views import (
|
||||
NdviHealthView,
|
||||
RemoteSensingAnalysisView,
|
||||
RemoteSensingClusterBlockLiveView,
|
||||
RemoteSensingClusterRecommendationView,
|
||||
RemoteSensingSubdivisionOptionActivateView,
|
||||
RemoteSensingSubdivisionOptionListView,
|
||||
RemoteSensingRunStatusView,
|
||||
@@ -18,6 +19,11 @@ urlpatterns = [
|
||||
RemoteSensingClusterBlockLiveView.as_view(),
|
||||
name="remote-sensing-cluster-block-live",
|
||||
),
|
||||
path(
|
||||
"remote-sensing/cluster-recommendations/",
|
||||
RemoteSensingClusterRecommendationView.as_view(),
|
||||
name="remote-sensing-cluster-recommendations",
|
||||
),
|
||||
path(
|
||||
"remote-sensing/results/<int:result_id>/k-options/",
|
||||
RemoteSensingSubdivisionOptionListView.as_view(),
|
||||
|
||||
@@ -37,8 +37,15 @@ from farm_data.models import SensorData
|
||||
|
||||
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
|
||||
from .data_driven_subdivision import activate_subdivision_option
|
||||
from .cluster_recommendation import (
|
||||
ClusterRecommendationNotFound,
|
||||
ClusterRecommendationValidationError,
|
||||
build_cluster_crop_recommendations,
|
||||
)
|
||||
from .serializers import (
|
||||
BlockSubdivisionSerializer,
|
||||
ClusterCropRecommendationRequestSerializer,
|
||||
ClusterCropRecommendationResponseSerializer,
|
||||
NdviHealthRequestSerializer,
|
||||
NdviHealthResponseSerializer,
|
||||
RemoteSensingCellObservationSerializer,
|
||||
@@ -140,6 +147,10 @@ RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
|
||||
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
|
||||
RemoteSensingClusterBlockLiveResponseSerializer,
|
||||
)
|
||||
ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer(
|
||||
"ClusterCropRecommendationEnvelopeSerializer",
|
||||
ClusterCropRecommendationResponseSerializer,
|
||||
)
|
||||
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
|
||||
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
|
||||
RemoteSensingSubdivisionOptionListResponseSerializer,
|
||||
@@ -843,6 +854,75 @@ class RemoteSensingClusterBlockLiveView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class RemoteSensingClusterRecommendationView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Location Data"],
|
||||
summary="پیشنهاد گیاه برای هر کلاستر KMeans",
|
||||
description=(
|
||||
"با دریافت farm_uuid، داده هر کلاستر KMeans location_data بههمراه "
|
||||
"ndvi، ndwi، soil_vv، soil_vv_db و مقایسه گیاههای ثبتشده در farm_data "
|
||||
"با crop_simulation برگردانده میشود و برای هر زیربلاک یک گیاه پیشنهادی ارائه میشود."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="farm_uuid",
|
||||
type=str,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="شناسه یکتای مزرعه",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: build_response(
|
||||
ClusterCropRecommendationEnvelopeSerializer,
|
||||
"داده کلاسترها و پیشنهاد گیاه برای هر زیربلاک بازگردانده شد.",
|
||||
),
|
||||
400: build_response(
|
||||
SoilErrorResponseSerializer,
|
||||
"پیشنیازهای مقایسه نامعتبر یا ناقص است.",
|
||||
),
|
||||
404: build_response(
|
||||
SoilErrorResponseSerializer,
|
||||
"مزرعه یا خروجی KMeans یافت نشد.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست پیشنهاد گیاه برای کلاسترها",
|
||||
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||
parameter_only=("farm_uuid", "query"),
|
||||
)
|
||||
],
|
||||
)
|
||||
def get(self, request):
|
||||
serializer = ClusterCropRecommendationRequestSerializer(data=request.query_params)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = build_cluster_crop_recommendations(
|
||||
str(serializer.validated_data["farm_uuid"])
|
||||
)
|
||||
except ClusterRecommendationNotFound as exc:
|
||||
return Response(
|
||||
{"code": 404, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ClusterRecommendationValidationError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": payload},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class RemoteSensingSubdivisionOptionListView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Location Data"],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
feature_decision(feature) := result if {
|
||||
has_feature_rule(feature)
|
||||
rule := feature_rule(feature)
|
||||
matched := [matched_rule | matched_rule := rule; action_match(matched_rule)]
|
||||
deny_rules := [matched_rule | matched_rule := matched[_]; not object.get(matched_rule, "allow", false)]
|
||||
allow_rules := [matched_rule | matched_rule := matched[_]; object.get(matched_rule, "allow", false)]
|
||||
count(deny_rules) > 0
|
||||
result := {
|
||||
"allow": false,
|
||||
"matched_rules": matched,
|
||||
"deny_rules": deny_rules,
|
||||
"allow_rules": allow_rules,
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,9 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
|
||||
|
||||
|
||||
def _format_farm_context(farm_uuid: str) -> str:
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if not farm_details:
|
||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any
|
||||
from django.apps import apps
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
|
||||
from farm_data.services import (
|
||||
build_ai_farm_snapshot,
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_ai_snapshot_weather,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -628,6 +633,7 @@ def get_fertilization_recommendation(
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||
|
||||
plant_config = apps.get_app_config("plant")
|
||||
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
||||
@@ -662,6 +668,7 @@ def get_fertilization_recommendation(
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=resolved_growth_stage,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
@@ -727,6 +734,9 @@ def get_fertilization_recommendation(
|
||||
growth_stage=resolved_growth_stage,
|
||||
forecasts=forecasts,
|
||||
)
|
||||
result.setdefault("source_metadata", {})
|
||||
result["source_metadata"]["farm_metrics"] = (ai_snapshot or {}).get("source_metadata", {}).get("farm_metrics", {})
|
||||
result["source_metadata"]["weather"] = {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}
|
||||
result = _validate_fertilization_response(result)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -124,7 +125,14 @@ class FertilizationPlanParserService:
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "fertilization_plan_parser",
|
||||
"endpoint_policy": "parser_first",
|
||||
}
|
||||
if farm_uuid:
|
||||
# Parser-first endpoint: farm context is optional enrichment only.
|
||||
structured_context["farm_context_source_metadata"] = {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"optional": True,
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
|
||||
@@ -11,7 +11,10 @@ from django.db import transaction
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import (
|
||||
build_ai_farm_snapshot,
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_ai_snapshot_metric,
|
||||
get_ai_snapshot_weather,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from irrigation.evapotranspiration import (
|
||||
@@ -55,13 +58,15 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
||||
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
||||
return None
|
||||
for payload in sensor.sensor_payload.values():
|
||||
if isinstance(payload, dict) and payload.get(metric) is not None:
|
||||
return _safe_float(payload.get(metric), default=0.0)
|
||||
return None
|
||||
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
|
||||
|
||||
def _aggregated_metric_fallback(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
"""Limited fallback for missing aggregated metrics only; raw payload is intentionally not consulted."""
|
||||
return _aggregated_metric(ai_snapshot, metric)
|
||||
|
||||
|
||||
def _coerce_list(value: Any) -> list[Any]:
|
||||
@@ -275,9 +280,9 @@ def _build_irrigation_ui_payload(
|
||||
crop_profile: dict[str, Any],
|
||||
active_kc: float,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
sensor: SensorData | None,
|
||||
ai_snapshot: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
soil_moisture = _sensor_metric(sensor, "soil_moisture")
|
||||
soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture")
|
||||
plan = _normalize_plan(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
@@ -380,6 +385,7 @@ def get_irrigation_recommendation(
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
||||
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
||||
|
||||
@@ -423,6 +429,7 @@ def get_irrigation_recommendation(
|
||||
if plant is not None and forecasts:
|
||||
optimized_result = _get_optimizer().optimize_irrigation(
|
||||
sensor=sensor,
|
||||
ai_snapshot=ai_snapshot,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
daily_water_needs=daily_water_needs,
|
||||
@@ -518,7 +525,7 @@ def get_irrigation_recommendation(
|
||||
crop_profile,
|
||||
active_kc,
|
||||
irrigation_method,
|
||||
sensor,
|
||||
ai_snapshot,
|
||||
)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -120,7 +121,14 @@ class IrrigationPlanParserService:
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "irrigation_plan_parser",
|
||||
"endpoint_policy": "parser_first",
|
||||
}
|
||||
if farm_uuid:
|
||||
# Parser-first endpoint: farm context is optional enrichment only.
|
||||
structured_context["farm_context_source_metadata"] = {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"optional": True,
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_build_content_parts,
|
||||
@@ -106,7 +106,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
@@ -134,8 +134,8 @@ def _build_service_client(cfg: RAGConfig):
|
||||
|
||||
|
||||
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
||||
weather = farm_details.get("weather") or {}
|
||||
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
weather = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||
soil = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
||||
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
||||
rain = _safe_float(weather.get("precipitation"), 0.0)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -73,7 +73,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_metric, get_ai_snapshot_weather
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -71,7 +71,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
@@ -158,6 +158,16 @@ def get_water_need_prediction_insight(
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"prediction_payload": prediction_payload,
|
||||
"source_metadata": {
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
},
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
@@ -208,4 +218,14 @@ def get_water_need_prediction_insight(
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
parsed["source_metadata"] = {
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
}
|
||||
return parsed
|
||||
|
||||
@@ -69,3 +69,56 @@ class ChatContextTests(SimpleTestCase):
|
||||
|
||||
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
|
||||
|
||||
class CanonicalFarmContextTests(SimpleTestCase):
|
||||
@patch("rag.chat.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-123", "farm_metrics": {"resolved_metrics": {"soil_moisture": 41.0}}})
|
||||
def test_format_farm_context_uses_canonical_ai_snapshot(self, mock_snapshot):
|
||||
from rag.chat import _format_farm_context
|
||||
|
||||
context = _format_farm_context("farm-123")
|
||||
|
||||
self.assertIn("farm_metrics", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
mock_snapshot.assert_called_once_with("farm-123")
|
||||
|
||||
|
||||
class CanonicalUserSoilTextTests(SimpleTestCase):
|
||||
@patch(
|
||||
"rag.user_data.build_ai_farm_snapshot",
|
||||
return_value={
|
||||
"farm_uuid": "farm-123",
|
||||
"aggregation_policy": {
|
||||
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"weather": "center_location_latest_forecast",
|
||||
},
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 42.0, "nitrogen": 18.0}},
|
||||
"block_metrics": [
|
||||
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 40.0}},
|
||||
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 44.0}},
|
||||
],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 39.0}},
|
||||
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 45.0}},
|
||||
],
|
||||
"source_metadata": {
|
||||
"farm_metrics": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_build_user_soil_text_uses_only_canonical_snapshot(self, mock_snapshot):
|
||||
from rag.user_data import build_user_soil_text
|
||||
|
||||
text = build_user_soil_text("farm-123")
|
||||
|
||||
self.assertIn("سیاست تجمیع خاک", text)
|
||||
self.assertIn("خلاصه تجمیعشده مزرعه", text)
|
||||
self.assertIn("بلوک block-1", text)
|
||||
self.assertIn("زیر-بلوک cluster-a", text)
|
||||
self.assertIn("canonical_source: farmer_block_aggregated_snapshot", text)
|
||||
self.assertNotIn("sensor_payload", text)
|
||||
mock_snapshot.assert_called_once_with("farm-123")
|
||||
|
||||
@@ -86,3 +86,39 @@ class RAGFailureContractTests(SimpleTestCase):
|
||||
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
|
||||
|
||||
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
|
||||
|
||||
|
||||
@patch("rag.services.soil_anomaly.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||
def test_soil_anomaly_loads_canonical_snapshot(self, mock_snapshot):
|
||||
from rag.services.soil_anomaly import _load_farm_or_error
|
||||
|
||||
payload = _load_farm_or_error("farm-1")
|
||||
|
||||
self.assertEqual(payload["farm_uuid"], "farm-1")
|
||||
mock_snapshot.assert_called_once_with("farm-1")
|
||||
|
||||
@patch(
|
||||
"rag.services.pest_disease.build_ai_farm_snapshot",
|
||||
return_value={
|
||||
"farm_uuid": "farm-1",
|
||||
"weather": {"forecast": {"humidity_mean": 75.0, "temperature_mean": 31.0, "precipitation": 3.0}},
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 66.0, "electrical_conductivity": 2.8, "soil_ph": 7.9}},
|
||||
},
|
||||
)
|
||||
def test_pest_risk_context_reads_canonical_snapshot_shape(self, mock_snapshot):
|
||||
from rag.services.pest_disease import _build_risk_context, _load_farm_or_error
|
||||
|
||||
farm_details = _load_farm_or_error("farm-1")
|
||||
risk = _build_risk_context(farm_details, plant_name=None, growth_stage=None)
|
||||
|
||||
self.assertEqual(risk["overall_risk"], "high")
|
||||
self.assertIn("EC بالا", risk["key_drivers"])
|
||||
mock_snapshot.assert_called_once_with("farm-1")
|
||||
|
||||
@patch("rag.services.pest_disease.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||
def test_pest_detection_remains_image_first_with_optional_farm_context(self, mock_snapshot):
|
||||
with self.assertRaises(RAGServiceError) as exc_info:
|
||||
get_pest_disease_detection(farm_uuid="farm-1", images=[])
|
||||
|
||||
self.assertEqual(exc_info.exception.contract.error_code, "missing_images")
|
||||
mock_snapshot.assert_not_called()
|
||||
|
||||
@@ -385,3 +385,47 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
|
||||
|
||||
class RecommendationCanonicalSnapshotTests(TestCase):
|
||||
@patch("rag.services.irrigation.get_chat_client")
|
||||
@patch("rag.services.irrigation.build_rag_context", return_value="")
|
||||
@patch("rag.services.irrigation.build_ai_farm_snapshot")
|
||||
def test_irrigation_ui_payload_uses_aggregated_snapshot_metrics(self, mock_snapshot, _mock_context, mock_client):
|
||||
from rag.services.irrigation import _build_irrigation_ui_payload
|
||||
|
||||
mock_snapshot.return_value = {"farm_metrics": {"resolved_metrics": {"soil_moisture": 44.0}}}
|
||||
payload = _build_irrigation_ui_payload(
|
||||
llm_result={"plan": {}, "timeline": [], "sections": []},
|
||||
optimizer_result=None,
|
||||
daily_water_needs=[],
|
||||
crop_profile={},
|
||||
active_kc=0.9,
|
||||
irrigation_method=None,
|
||||
ai_snapshot=mock_snapshot.return_value,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["plan"]["moistureLevel"], 44)
|
||||
|
||||
@patch("rag.services.fertilization._get_optimizer")
|
||||
@patch("rag.services.fertilization.get_chat_client")
|
||||
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
||||
@patch("rag.services.fertilization.build_ai_farm_snapshot")
|
||||
def test_fertilization_recommendation_includes_snapshot_provenance(self, mock_snapshot, _mock_context, mock_client, mock_optimizer):
|
||||
from rag.services.fertilization import get_fertilization_recommendation
|
||||
|
||||
client = mock_client.return_value
|
||||
client.chat.completions.create.return_value = type("Resp", (), {"choices": [type("Choice", (), {"message": type("Msg", (), {"content": '{"status": "success", "data": {}}'})()})()]})()
|
||||
mock_snapshot.return_value = {
|
||||
"source_metadata": {
|
||||
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||
}
|
||||
}
|
||||
mock_optimizer.return_value.optimize_fertilization.return_value = None
|
||||
|
||||
with patch("rag.services.fertilization.SensorData.objects.select_related") as mock_select:
|
||||
mock_select.return_value.prefetch_related.return_value.filter.return_value.first.return_value = None
|
||||
result = get_fertilization_recommendation(farm_uuid="farm-1")
|
||||
|
||||
self.assertEqual(result["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
self.assertEqual(result["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
|
||||
@@ -132,7 +132,7 @@ class ChatView(APIView):
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from .config import load_rag_config
|
||||
|
||||
data = request.data if request.method == "POST" else request.query_params
|
||||
@@ -178,7 +178,7 @@ class ChatView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
cfg = load_rag_config()
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
return Response(
|
||||
{"code": 404, "msg": "farm پیدا نشد."},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import MySQLdb
|
||||
from dotenv import dotenv_values
|
||||
|
||||
TABLES = [
|
||||
"location_data_soillocation",
|
||||
"location_data_blocksubdivision",
|
||||
"location_data_remotesensingrun",
|
||||
"location_data_remotesensingsubdivisionresult",
|
||||
"location_data_remotesensingclusterblock",
|
||||
"location_data_remotesensingclusterassignment",
|
||||
"location_data_analysisgridcell",
|
||||
"location_data_analysisgridobservation",
|
||||
"location_data_remotesensingsubdivisionoption",
|
||||
"location_data_remotesensingsubdivisionoptionblock",
|
||||
"location_data_remotesensingsubdivisionoptionassignment",
|
||||
"dashboard_data_ndviobservation",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env = dotenv_values(Path(__file__).resolve().parent.parent / ".env")
|
||||
conn = MySQLdb.connect(
|
||||
host=env.get("DB_HOST", "127.0.0.1"),
|
||||
port=int(env.get("DB_PORT", 3306)),
|
||||
user=env.get("DB_USER", ""),
|
||||
passwd=env.get("DB_PASSWORD", ""),
|
||||
db=env.get("DB_NAME", ""),
|
||||
charset="utf8mb4",
|
||||
)
|
||||
out: dict[str, list[dict]] = {}
|
||||
try:
|
||||
with conn as cursor:
|
||||
for table in TABLES:
|
||||
cursor.execute(f"SELECT * FROM {table}")
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = []
|
||||
for raw_row in cursor.fetchall():
|
||||
row = {}
|
||||
for key, value in zip(columns, raw_row):
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
value = value.decode("utf-8")
|
||||
row[key] = value
|
||||
rows.append(row)
|
||||
out[table] = rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -281,6 +281,8 @@ def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[
|
||||
|
||||
class SoilMoistureHeatmapService:
|
||||
def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]:
|
||||
# Spatial-first endpoint: preserve grid/cluster-aware output and never flatten the
|
||||
# heatmap to a single farm-average metric. Aggregated summaries are supplementary only.
|
||||
current_sensor = (
|
||||
SensorData.objects.select_related("center_location")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
@@ -329,6 +331,11 @@ class SoilMoistureHeatmapService:
|
||||
],
|
||||
},
|
||||
"summary": _heatmap_summary(sensor_points, []),
|
||||
"source_metadata": {
|
||||
"endpoint_policy": "spatial_first",
|
||||
"primary_source": "sensor_network_spatial_interpolation",
|
||||
"agronomic_aggregation": "supplementary_only",
|
||||
},
|
||||
"quality_legend": {
|
||||
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
||||
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
||||
@@ -438,6 +445,11 @@ class SoilMoistureHeatmapService:
|
||||
],
|
||||
},
|
||||
"summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]),
|
||||
"source_metadata": {
|
||||
"endpoint_policy": "spatial_first",
|
||||
"primary_source": "sensor_network_spatial_interpolation",
|
||||
"agronomic_aggregation": "supplementary_only",
|
||||
},
|
||||
"quality_legend": {
|
||||
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
||||
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
|
||||
from .services import get_forecast_for_location
|
||||
|
||||
@@ -39,7 +40,7 @@ def _weather_condition(weather_code):
|
||||
return WMO_CONDITIONS.get(weather_code, "نامشخص")
|
||||
|
||||
|
||||
def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
||||
def _build_farm_weather_card(forecasts: list[Any], *, farm_uuid: str | None = None, ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if not forecasts:
|
||||
return {
|
||||
"condition": "نامشخص",
|
||||
@@ -49,6 +50,10 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
||||
"windSpeed": 0,
|
||||
"windUnit": "km/h",
|
||||
"chartData": {"labels": [], "series": [[]]},
|
||||
"source_metadata": {
|
||||
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||
"agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated"},
|
||||
},
|
||||
}
|
||||
|
||||
current_forecast = forecasts[0]
|
||||
@@ -66,6 +71,14 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
||||
"labels": labels,
|
||||
"series": series,
|
||||
},
|
||||
"source_metadata": {
|
||||
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
"available": bool(ai_snapshot),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -80,4 +93,5 @@ class FarmWeatherService:
|
||||
raise ValueError("Farm not found.")
|
||||
|
||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||
return _build_farm_weather_card(forecasts)
|
||||
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
return _build_farm_weather_card(forecasts, farm_uuid=farm_uuid, ai_snapshot=ai_snapshot)
|
||||
|
||||
@@ -28,6 +28,10 @@ class FarmWeatherApiTests(TestCase):
|
||||
"labels": ["2026-04-01", "2026-04-02"],
|
||||
"series": [[28.0, 29.0]],
|
||||
},
|
||||
"source_metadata": {
|
||||
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||
"agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated", "available": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
@@ -44,6 +48,7 @@ class FarmWeatherApiTests(TestCase):
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["condition"], "صاف")
|
||||
self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01")
|
||||
self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||
@@ -106,6 +111,8 @@ class WaterNeedPredictionApiTests(TestCase):
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
||||
self.assertEqual(payload["insight"]["confidence"], 0.82)
|
||||
self.assertEqual(payload["source_metadata"]["agronomic_metrics"]["policy"], "cluster_block_farm_aggregated")
|
||||
self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||
@@ -150,3 +157,8 @@ class WaterNeedPredictionApiTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="soile.urls")
|
||||
class SpatialPolicySmokeTests(TestCase):
|
||||
pass
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot
|
||||
from farm_data.services import build_ai_farm_snapshot, clone_snapshot_as_runtime_plant, get_primary_plant_snapshot, get_ai_snapshot_weather
|
||||
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
|
||||
|
||||
from farm_data.models import SensorData
|
||||
@@ -11,7 +11,7 @@ from rag.services import get_water_need_prediction_insight
|
||||
from .services import get_forecast_for_location
|
||||
|
||||
|
||||
def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]:
|
||||
def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any], ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
location = getattr(sensor, "center_location", None)
|
||||
plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
|
||||
irrigation_method = getattr(sensor, "irrigation_method", None)
|
||||
@@ -46,6 +46,17 @@ def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) ->
|
||||
"dailyBreakdown": daily,
|
||||
"cropProfile": crop_profile,
|
||||
"irrigationEfficiencyPercent": efficiency,
|
||||
"source_metadata": {
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
"available": bool(ai_snapshot),
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +72,8 @@ class WaterNeedPredictionService:
|
||||
raise ValueError("Farm not found.")
|
||||
|
||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||
payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts)
|
||||
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts, ai_snapshot=ai_snapshot)
|
||||
insight = get_water_need_prediction_insight(
|
||||
farm_uuid=farm_uuid,
|
||||
prediction_payload=payload,
|
||||
|
||||