This commit is contained in:
2026-05-13 16:45:54 +03:30
parent 948c062b93
commit 46fe62fa04
96 changed files with 3834 additions and 155 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+16 -25
View File
@@ -6,7 +6,7 @@ from statistics import mean
from typing import Any from typing import Any
from django.apps import apps 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 from crop_simulation.services import CropSimulationService
@@ -47,21 +47,10 @@ def _first_not_none(*values: Any) -> Any:
return None return None
def _sensor_metric(sensor: Any, metric: str) -> float | None: def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
if sensor is None: value = get_ai_snapshot_metric(ai_snapshot, metric)
return None
if hasattr(sensor, metric):
value = getattr(sensor, metric)
return _safe_float(value, default=0.0) if value is not None else None 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 _parse_temperature_range(plant: Any) -> tuple[float, float]: def _parse_temperature_range(plant: Any) -> tuple[float, float]:
raw = (getattr(plant, "temperature", "") or "").replace("تا", "-") raw = (getattr(plant, "temperature", "") or "").replace("تا", "-")
@@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
return records return records
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]: def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
moisture_pct = _sensor_metric(sensor, "soil_moisture") moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture")
center_location = getattr(sensor, "center_location", None) ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34)
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)
wv0033 = ndwi if ndwi > 0 else 0.34 wv0033 = ndwi if ndwi > 0 else 0.34
wv1500 = max(round(wv0033 * 0.45, 3), 0.14) wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
@@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs: list[dict[str, Any]], daily_water_needs: list[dict[str, Any]],
growth_stage: str | None, growth_stage: str | None,
irrigation_method: Any | None, irrigation_method: Any | None,
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if sensor is None or plant is None or not forecasts: if sensor is None or plant is None or not forecasts:
return None return None
@@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs=daily_water_needs, daily_water_needs=daily_water_needs,
growth_stage=growth_stage, growth_stage=growth_stage,
crop_blueprint=crop_blueprint, crop_blueprint=crop_blueprint,
ai_snapshot=ai_snapshot,
) )
if pcse_result is not None: if pcse_result is not None:
return pcse_result return pcse_result
@@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs=daily_water_needs, daily_water_needs=daily_water_needs,
growth_stage=growth_stage, growth_stage=growth_stage,
irrigation_method=irrigation_method, irrigation_method=irrigation_method,
ai_snapshot=ai_snapshot,
) )
def optimize_fertilization( def optimize_fertilization(
@@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer:
plant: Any, plant: Any,
forecasts: list[Any], forecasts: list[Any],
growth_stage: str | None, growth_stage: str | None,
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if sensor is None or plant is None: if sensor is None or plant is None:
return None return None
@@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer:
forecasts=forecasts, forecasts=forecasts,
growth_stage=growth_stage, growth_stage=growth_stage,
crop_blueprint=crop_blueprint, crop_blueprint=crop_blueprint,
ai_snapshot=ai_snapshot,
) )
if pcse_result is not None: if pcse_result is not None:
return pcse_result return pcse_result
@@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer:
plant=plant, plant=plant,
forecasts=forecasts, forecasts=forecasts,
growth_stage=growth_stage, growth_stage=growth_stage,
ai_snapshot=ai_snapshot,
) )
def _optimize_irrigation_with_pcse( def _optimize_irrigation_with_pcse(
@@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer:
daily_water_needs: list[dict[str, Any]], daily_water_needs: list[dict[str, Any]],
growth_stage: str | None, growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]], crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
defaults = apps.get_app_config("irrigation").get_optimizer_defaults() defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
crop_parameters, agromanagement = crop_blueprint 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( weather = _build_weather_records(
forecasts, forecasts,
latitude=_safe_float(sensor.center_location.latitude), latitude=_safe_float(sensor.center_location.latitude),
@@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer:
forecasts: list[Any], forecasts: list[Any],
growth_stage: str | None, growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]], crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
defaults = apps.get_app_config("fertilization").get_optimizer_defaults() defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
crop_parameters, agromanagement = crop_blueprint 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( weather = _build_weather_records(
forecasts, forecasts,
latitude=_safe_float(sensor.center_location.latitude), latitude=_safe_float(sensor.center_location.latitude),
+36 -24
View File
@@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta
from typing import Any from typing import Any
from django.db import transaction 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 from .models import SimulationRun, SimulationScenario
@@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(value, upper)) return max(lower, min(value, upper))
def _sensor_metric(sensor: Any, metric_name: str) -> float | None: def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
if sensor is None: if not isinstance(snapshot, dict):
return None return None
farm_metrics = snapshot.get("farm_metrics") or {}
if hasattr(sensor, metric_name): resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
value = getattr(sensor, metric_name) if not isinstance(resolved_metrics, dict):
if value is not None:
return _safe_float(value)
payload = getattr(sensor, "sensor_payload", None) or {}
if not isinstance(payload, dict):
return None 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)) def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]:
return None 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: 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.") raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name) 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: if weather is not None:
resolved_weather = _normalize_weather_records(weather) resolved_weather = _normalize_weather_records(weather)
@@ -476,8 +479,7 @@ def build_simulation_payload_from_farm(
longitude=float(farm.center_location.longitude), longitude=float(farm.center_location.longitude),
) )
satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {} ndwi = _snapshot_metric(ai_snapshot, "ndwi")
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55) 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)) smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
sm0 = _clamp( sm0 = _clamp(
@@ -485,17 +487,17 @@ def build_simulation_payload_from_farm(
max(smfcf + 0.02, smw + 0.05), max(smfcf + 0.02, smw + 0.05),
0.8, 0.8,
) )
soil_moisture = _sensor_metric(farm, "soil_moisture") soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
wav = ( wav = (
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3) round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
if soil_moisture is not None if soil_moisture is not None
else DEFAULT_WAV else DEFAULT_WAV
) )
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db")) nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db"))
phosphorus = _sensor_metric(farm, "phosphorus") phosphorus = _snapshot_metric(ai_snapshot, "phosphorus")
potassium = _sensor_metric(farm, "potassium") potassium = _snapshot_metric(ai_snapshot, "potassium")
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None) soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None)
ec = _sensor_metric(farm, "electrical_conductivity") ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
resolved_soil = { resolved_soil = {
"SMFCF": round(smfcf, 3), "SMFCF": round(smfcf, 3),
@@ -569,6 +571,13 @@ def build_simulation_payload_from_farm(
"site_parameters": resolved_site, "site_parameters": resolved_site,
"crop_parameters": resolved_crop, "crop_parameters": resolved_crop,
"agromanagement": resolved_agromanagement, "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 {}, "fertilization_recommendation": fertilization_recommendation or {},
"farm_uuid": farm_uuid, "farm_uuid": farm_uuid,
"plant_name": plant_name, "plant_name": plant_name,
"source_metadata": resolved.get("source_metadata") or {},
} }
), ),
) )
@@ -937,6 +947,7 @@ class CropSimulationService:
"crop_parameters": resolved["crop_parameters"], "crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"], "site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement, "agromanagement": merged_agromanagement,
"source_metadata": resolved.get("source_metadata") or {},
} }
], ],
) )
@@ -1205,6 +1216,7 @@ class CropSimulationService:
"crop_parameters": resolved["crop_parameters"], "crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"], "site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement, "agromanagement": merged_agromanagement,
"source_metadata": resolved.get("source_metadata") or {},
} }
) )
return self._execute_scenario(scenario=scenario, run_specs=run_specs) return self._execute_scenario(scenario=scenario, run_specs=run_specs)
@@ -1258,7 +1270,7 @@ class CropSimulationService:
site_parameters=spec["site_parameters"], site_parameters=spec["site_parameters"],
) )
run.status = SimulationScenario.Status.SUCCESS 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"]) run.save(update_fields=["status", "result_payload", "updated_at"])
results.append( results.append(
{ {
+98
View File
@@ -11,6 +11,10 @@ from django.test import TestCase
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from .models import SimulationRun, SimulationScenario 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 .services import CropSimulationService, CropSimulationError, PcseSimulationManager
from .views import PlantGrowthSimulationView from .views import PlantGrowthSimulationView
@@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase):
self.assertEqual(result["result"]["engine"], "pcse") self.assertEqual(result["result"]["engine"], "pcse")
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"]) self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
self.assertIsNotNone(result["result"]["metrics"]["biomass"]) 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")
+11 -7
View File
@@ -10,7 +10,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from farm_data.models import SensorData 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 location_data.models import NdviObservation, SoilLocation
from rag.failure_contract import RAGServiceError from rag.failure_contract import RAGServiceError
@@ -720,13 +720,13 @@ class YieldHarvestSummaryService:
"recent_sensor_averages": {}, "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 center_location = farm.center_location
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {} soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
weather_details = farm_details.get("weather") or {} weather_details = ((farm_details.get("weather") or {}).get("forecast") or {})
recent_sensor_averages = { recent_sensor_averages = {
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None), "soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None),
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None), "soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None),
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), "air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
} }
@@ -744,7 +744,7 @@ class YieldHarvestSummaryService:
"lat": float(center_location.latitude), "lat": float(center_location.latitude),
"lon": float(center_location.longitude), "lon": float(center_location.longitude),
}, },
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"), "farm_boundary": getattr(center_location, "farm_boundary", None),
"soil": { "soil": {
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"), "provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
"soil_type": self._infer_soil_type(soil_details), "soil_type": self._infer_soil_type(soil_details),
@@ -760,6 +760,10 @@ class YieldHarvestSummaryService:
"sensor_data": SensorData.__name__, "sensor_data": SensorData.__name__,
"soil_location": SoilLocation.__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: def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
+677
View File
@@ -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 نگه دار.
+5 -2
View File
@@ -88,7 +88,8 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
fi fi
if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then 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_plants
run_cmd python manage.py seed_weather_data run_cmd python manage.py seed_weather_data
run_cmd python manage.py seed_farm_data run_cmd python manage.py seed_farm_data
@@ -96,7 +97,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
fi fi
echo "Checking openEO authentication..." 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..." echo "Collecting static files..."
run_cmd python manage.py collectstatic --noinput run_cmd python manage.py collectstatic --noinput
+197 -8
View File
@@ -521,13 +521,95 @@ def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
return stats 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: def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {} 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", []) forecasts = context.get("forecasts", [])
history = context.get("history", [])
if sensor is None: if not isinstance(ai_snapshot, dict):
return { return {
"totalAlerts": 0, "totalAlerts": 0,
"alerts": [], "alerts": [],
@@ -537,14 +619,116 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
"prioritizedAlertSummaries": [], "prioritizedAlertSummaries": [],
"recommendedOperationalActions": [], "recommendedOperationalActions": [],
"humanReadableExplanations": [], "humanReadableExplanations": [],
"source_metadata": {"status": "missing", "fallback": "no_ai_snapshot"},
} }
alerts = [] alerts = []
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
alerts.extend(_detect_ph_alert(sensor, history, sensor_id)) moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
alerts.extend(_detect_ec_alert(sensor, history, sensor_id)) if moisture is not None and moisture < 25:
alerts.extend(_detect_frost_alert(forecasts, sensor_id)) alerts.append(
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id)) _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) ordered_alerts = _sort_alerts(alerts)
clusters = _build_clusters(ordered_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], "prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts], "recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
"humanReadableExplanations": [alert["explanation"] 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"),
},
} }
+9 -3
View File
@@ -8,8 +8,8 @@ from typing import Any
from django.apps import apps from django.apps import apps
from django.core.serializers.json import DjangoJSONEncoder 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.context import load_farm_context
from farm_data.services import build_ai_farm_snapshot
from rag.api_provider import get_chat_client from rag.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _complete_audit_log,
@@ -156,12 +156,18 @@ def _build_structured_context(
if context is None: if context is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.") 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 = { structured = {
"farm_profile": _farm_profile(context, farm_uuid), "farm_profile": _farm_profile(context, farm_uuid),
"tracker": tracker, "tracker": tracker,
"forecasts": _forecast_summary(context), "forecasts": _forecast_summary(context),
"incoming_alerts": _normalize_incoming_alerts(incoming_alerts), "incoming_alerts": _normalize_incoming_alerts(incoming_alerts),
"ai_snapshot_source_metadata": (ai_snapshot or {}).get("source_metadata", {}),
} }
return context, structured return context, structured
@@ -321,7 +327,7 @@ def _llm_response(
) -> tuple[dict[str, Any], str, str]: ) -> tuple[dict[str, Any], str, str]:
cfg = load_rag_config() cfg = load_rag_config()
service, service_cfg, model, client = _build_service_config(cfg, service_id) 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( rag_context = build_rag_context(
query=query, query=query,
sensor_uuid=farm_uuid, sensor_uuid=farm_uuid,
+83
View File
@@ -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")
+75
View File
@@ -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,
),
]
+51
View File
@@ -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")] 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): class PlantCatalogSnapshot(models.Model):
""" """
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI. کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
+378 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from numbers import Number from numbers import Number
import logging import logging
import uuid
import warnings import warnings
from django.conf import settings 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.models import BlockSubdivision, SoilLocation
from location_data.satellite_snapshot import ( from location_data.satellite_snapshot import (
build_block_layout_metric_summary, build_block_layout_metric_summary,
build_farmer_block_aggregated_snapshot,
build_location_block_satellite_snapshots, build_location_block_satellite_snapshots,
build_location_satellite_snapshot,
) )
from irrigation.serializers import IrrigationMethodSerializer from irrigation.serializers import IrrigationMethodSerializer
from weather.models import WeatherForecast from weather.models import WeatherForecast
from .models import ( from .models import (
Device,
FarmPlantAssignment, FarmPlantAssignment,
ParameterUpdateLog, ParameterUpdateLog,
PlantCatalogSnapshot, PlantCatalogSnapshot,
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
return synced_parameters 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]]]: 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") parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
if sensor_payload and isinstance(sensor_payload, dict): 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() center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
) )
latest_satellite = build_location_satellite_snapshot(center_location) soil_snapshot = _build_farm_soil_snapshot(
block_metric_snapshots = build_location_block_satellite_snapshots(
center_location, center_location,
sensor_payload=farm.sensor_payload, 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_assignments = get_farm_plant_assignments(farm)
plant_snapshots = [assignment.plant for assignment in plant_assignments] 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, "weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {}, "sensor_payload": farm.sensor_payload or {},
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload), "sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
"soil": { "soil": soil_snapshot,
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"satellite_snapshots": block_metric_snapshots,
},
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots], "plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data, "plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
"plant_assignments": [ "plant_assignments": [
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
), ),
"created_at": farm.created_at, "created_at": farm.created_at,
"updated_at": farm.updated_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( def resolve_center_location_from_boundary(
farm_boundary: dict | list, farm_boundary: dict | list,
block_count: int = 1, block_count: int = 1,
+360 -1
View File
@@ -15,9 +15,10 @@ from location_data.models import (
RemoteSensingSubdivisionResult, RemoteSensingSubdivisionResult,
SoilLocation, SoilLocation,
) )
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.services import ( from farm_data.services import (
assign_farm_plants_from_backend_ids, assign_farm_plants_from_backend_ids,
build_ai_farm_snapshot,
get_canonical_farm_record, get_canonical_farm_record,
get_runtime_plant_for_farm, get_runtime_plant_for_farm,
list_runtime_plants_for_farm, list_runtime_plants_for_farm,
@@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase):
0.6, 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): class FarmDataUpsertApiTests(TestCase):
def setUp(self): def setUp(self):
@@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase):
farm.sensor_payload["sensor-7-1"]["soil_moisture"], farm.sensor_payload["sensor-7-1"]["soil_moisture"],
31.2, 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( self.assertTrue(
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() 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"], ["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): def test_post_requires_farm_uuid_in_request_body(self):
response = self.client.post( response = self.client.post(
"/api/farm-data/", "/api/farm-data/",
+10
View File
@@ -28,6 +28,7 @@ from .services import (
ensure_location_and_weather_data, ensure_location_and_weather_data,
get_farm_details, get_farm_details,
resolve_center_location_from_boundary, resolve_center_location_from_boundary,
sync_devices_from_sensor_data,
sync_sensor_parameters_from_payload, sync_sensor_parameters_from_payload,
sync_plant_catalog_from_backend, sync_plant_catalog_from_backend,
) )
@@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView):
else: else:
farm_data.save() farm_data.save()
sync_devices_from_sensor_data(farm_data)
if plant_ids is not None: if plant_ids is not None:
try: try:
assign_farm_plants_from_backend_ids(farm_data, plant_ids) assign_farm_plants_from_backend_ids(farm_data, plant_ids)
@@ -280,6 +283,13 @@ class FarmDetailView(APIView):
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند " "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند "
"و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند." "و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند."
), ),
examples=[
OpenApiExample(
"نمونه مسیر farm detail",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
parameter_only=("farm_uuid", "path"),
),
],
responses={ responses={
200: build_response( 200: build_response(
FarmDetailEnvelopeSerializer, FarmDetailEnvelopeSerializer,
+8 -3
View File
@@ -32,7 +32,7 @@ def create_or_get_block_subdivision(
اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمیگرداند؛ اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمیگرداند؛
در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره میکند. در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره میکند.
""" """
from .models import BlockSubdivision from .models import BlockSubdivision, build_default_sub_block, ensure_block_layout_defaults
existing = BlockSubdivision.objects.filter( existing = BlockSubdivision.objects.filter(
soil_location=location, soil_location=location,
@@ -244,7 +244,7 @@ def render_elbow_plot(
def sync_block_layout_with_subdivision(location, subdivision) -> None: 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 []) blocks = list(layout.get("blocks") or [])
target_block = None target_block = None
for block in blocks: for block in blocks:
@@ -263,7 +263,12 @@ def sync_block_layout_with_subdivision(location, subdivision) -> None:
blocks.append(target_block) blocks.append(target_block)
target_block["needs_subdivision"] = subdivision.centroid_count > 1 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"] = { target_block["subdivision_summary"] = {
"chunk_size_sqm": subdivision.chunk_size_sqm, "chunk_size_sqm": subdivision.chunk_size_sqm,
"grid_point_count": subdivision.grid_point_count, "grid_point_count": subdivision.grid_point_count,
+415
View File
@@ -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),
},
}
+11 -1
View File
@@ -16,6 +16,8 @@ from django.db import transaction
from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot
from .models import ( from .models import (
build_default_sub_block,
ensure_block_layout_defaults,
AnalysisGridObservation, AnalysisGridObservation,
BlockSubdivision, BlockSubdivision,
RemoteSensingClusterBlock, RemoteSensingClusterBlock,
@@ -1272,7 +1274,7 @@ def sync_location_block_layout_with_result(
result: RemoteSensingSubdivisionResult, result: RemoteSensingSubdivisionResult,
cluster_summaries: list[dict[str, Any]], cluster_summaries: list[dict[str, Any]],
) -> None: ) -> 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 []) blocks = list(layout.get("blocks") or [])
target_block = None target_block = None
for block in blocks: for block in blocks:
@@ -1307,6 +1309,14 @@ def sync_location_block_layout_with_result(
} }
for cluster in cluster_summaries 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"] = { target_block["subdivision_summary"] = {
"type": "data_driven_remote_sensing", "type": "data_driven_remote_sensing",
"cluster_count": result.cluster_count, "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)
+74 -18
View File
@@ -3,24 +3,83 @@ import uuid
from django.db import models 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: def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict:
normalized_blocks = []
if blocks: if blocks:
for index, block in enumerate(blocks): return ensure_block_layout_defaults(
normalized_blocks.append( {
"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(), "block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
"order": int(block.get("order") or index + 1), "order": int(block.get("order") or index + 1),
"source": "input", "source": "input",
"boundary": block.get("boundary") or {}, "boundary": block.get("boundary") or {},
"needs_subdivision": None, "needs_subdivision": None,
"sub_blocks": [], "sub_blocks": [dict(sub_block) for sub_block in (block.get("sub_blocks") or [])],
} }
for index, block in enumerate(blocks)
],
},
block_count=len(blocks),
) )
else:
normalized_count = max(int(block_count or 1), 1) normalized_count = max(int(block_count or 1), 1)
for index in range(normalized_count): return ensure_block_layout_defaults(
normalized_blocks.append( {
"input_block_count": normalized_count,
"default_full_farm": normalized_count == 1,
"algorithm_status": "pending",
"blocks": [
{ {
"block_code": f"block-{index + 1}", "block_code": f"block-{index + 1}",
"order": index + 1, "order": index + 1,
@@ -29,17 +88,12 @@ def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -
"needs_subdivision": None, "needs_subdivision": None,
"sub_blocks": [], "sub_blocks": [],
} }
for index in range(normalized_count)
],
},
block_count=normalized_count,
) )
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,
}
class SoilLocation(models.Model): class SoilLocation(models.Model):
""" """
@@ -122,8 +176,10 @@ class SoilLocation(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.input_block_count: if not self.input_block_count:
self.input_block_count = 1 self.input_block_count = 1
if not self.block_layout: self.block_layout = ensure_block_layout_defaults(
self.block_layout = build_block_layout(self.input_block_count) self.block_layout,
block_count=self.input_block_count,
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
+4 -3
View File
@@ -5,6 +5,7 @@ from typing import Any
from django.db.models import Avg, QuerySet from django.db.models import Avg, QuerySet
from .models import ( from .models import (
ensure_block_layout_defaults,
AnalysisGridObservation, AnalysisGridObservation,
RemoteSensingRun, RemoteSensingRun,
RemoteSensingSubdivisionResult, RemoteSensingSubdivisionResult,
@@ -90,7 +91,7 @@ def build_location_block_satellite_snapshots(
*, *,
sensor_payload: dict[str, Any] | None = None, sensor_payload: dict[str, Any] | None = None,
) -> list[dict[str, Any]]: ) -> 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 [] blocks = block_layout.get("blocks") or []
if not blocks: if not blocks:
return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)] 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, sensor_payload: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> 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 [])] blocks = [dict(block) for block in (layout.get("blocks") or [])]
snapshots_by_block_code = { snapshots_by_block_code = {
str(snapshot.get("block_code") or ""): snapshot 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]: 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_cluster_uuid: dict[str, dict[str, Any]] = {}
by_sub_block_code: dict[str, list[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]] = {} by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {}
+52
View File
@@ -144,6 +144,10 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer):
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100) 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): class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
temporal_start = serializers.DateField(required=False) temporal_start = serializers.DateField(required=False)
temporal_end = serializers.DateField(required=False) temporal_end = serializers.DateField(required=False)
@@ -426,6 +430,54 @@ class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer):
metadata = serializers.JSONField() 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): class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
result_id = serializers.IntegerField() result_id = serializers.IntegerField()
active_requested_k = serializers.IntegerField(allow_null=True) 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 ثبت نشده است.",
)
+19
View File
@@ -57,10 +57,29 @@ class SoilDataApiTests(TestCase):
self.assertEqual(len(payload["block_layout"]["blocks"]), 1) self.assertEqual(len(payload["block_layout"]["blocks"]), 1)
self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary) self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary)
self.assertEqual(payload["block_layout"]["algorithm_status"], "pending") 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(len(payload["block_subdivisions"]), 1)
self.assertEqual(payload["block_subdivisions"][0]["status"], "defined") self.assertEqual(payload["block_subdivisions"][0]["status"], "defined")
self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing") 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): def test_post_updates_block_layout_from_input(self):
SoilLocation.objects.create( SoilLocation.objects.create(
latitude="35.689200", latitude="35.689200",
+6
View File
@@ -4,6 +4,7 @@ from .views import (
NdviHealthView, NdviHealthView,
RemoteSensingAnalysisView, RemoteSensingAnalysisView,
RemoteSensingClusterBlockLiveView, RemoteSensingClusterBlockLiveView,
RemoteSensingClusterRecommendationView,
RemoteSensingSubdivisionOptionActivateView, RemoteSensingSubdivisionOptionActivateView,
RemoteSensingSubdivisionOptionListView, RemoteSensingSubdivisionOptionListView,
RemoteSensingRunStatusView, RemoteSensingRunStatusView,
@@ -18,6 +19,11 @@ urlpatterns = [
RemoteSensingClusterBlockLiveView.as_view(), RemoteSensingClusterBlockLiveView.as_view(),
name="remote-sensing-cluster-block-live", name="remote-sensing-cluster-block-live",
), ),
path(
"remote-sensing/cluster-recommendations/",
RemoteSensingClusterRecommendationView.as_view(),
name="remote-sensing-cluster-recommendations",
),
path( path(
"remote-sensing/results/<int:result_id>/k-options/", "remote-sensing/results/<int:result_id>/k-options/",
RemoteSensingSubdivisionOptionListView.as_view(), RemoteSensingSubdivisionOptionListView.as_view(),
+80
View File
@@ -37,8 +37,15 @@ from farm_data.models import SensorData
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from .data_driven_subdivision import activate_subdivision_option from .data_driven_subdivision import activate_subdivision_option
from .cluster_recommendation import (
ClusterRecommendationNotFound,
ClusterRecommendationValidationError,
build_cluster_crop_recommendations,
)
from .serializers import ( from .serializers import (
BlockSubdivisionSerializer, BlockSubdivisionSerializer,
ClusterCropRecommendationRequestSerializer,
ClusterCropRecommendationResponseSerializer,
NdviHealthRequestSerializer, NdviHealthRequestSerializer,
NdviHealthResponseSerializer, NdviHealthResponseSerializer,
RemoteSensingCellObservationSerializer, RemoteSensingCellObservationSerializer,
@@ -140,6 +147,10 @@ RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingClusterBlockLiveEnvelopeSerializer", "RemoteSensingClusterBlockLiveEnvelopeSerializer",
RemoteSensingClusterBlockLiveResponseSerializer, RemoteSensingClusterBlockLiveResponseSerializer,
) )
ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer(
"ClusterCropRecommendationEnvelopeSerializer",
ClusterCropRecommendationResponseSerializer,
)
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer( RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionListEnvelopeSerializer", "RemoteSensingSubdivisionOptionListEnvelopeSerializer",
RemoteSensingSubdivisionOptionListResponseSerializer, 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): class RemoteSensingSubdivisionOptionListView(APIView):
@extend_schema( @extend_schema(
tags=["Location Data"], tags=["Location Data"],
+15
View File
@@ -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,
}
}
+2 -2
View File
@@ -130,9 +130,9 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
def _format_farm_context(farm_uuid: str) -> str: 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: if not farm_details:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
+11 -1
View File
@@ -13,7 +13,12 @@ from typing import Any
from django.apps import apps from django.apps import apps
from farm_data.models import SensorData 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _complete_audit_log,
@@ -628,6 +633,7 @@ def get_fertilization_recommendation(
.filter(farm_uuid=resolved_farm_uuid) .filter(farm_uuid=resolved_farm_uuid)
.first() .first()
) )
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
plant_config = apps.get_app_config("plant") plant_config = apps.get_app_config("plant")
resolved_plant_name = plant_config.resolve_plant_name(plant_name) resolved_plant_name = plant_config.resolve_plant_name(plant_name)
@@ -662,6 +668,7 @@ def get_fertilization_recommendation(
plant=plant, plant=plant,
forecasts=forecasts, forecasts=forecasts,
growth_stage=resolved_growth_stage, growth_stage=resolved_growth_stage,
ai_snapshot=ai_snapshot,
) )
context = build_rag_context( context = build_rag_context(
@@ -727,6 +734,9 @@ def get_fertilization_recommendation(
growth_stage=resolved_growth_stage, growth_stage=resolved_growth_stage,
forecasts=forecasts, 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 = _validate_fertilization_response(result)
result["raw_response"] = raw result["raw_response"] = raw
result["simulation_optimizer"] = optimized_result result["simulation_optimizer"] = optimized_result
@@ -6,6 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, ValidationError 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _complete_audit_log,
@@ -124,6 +125,13 @@ class FertilizationPlanParserService:
"partial_plan": normalized_partial, "partial_plan": normalized_partial,
"required_core_fields": CORE_FIELDS, "required_core_fields": CORE_FIELDS,
"service": "fertilization_plan_parser", "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( rag_query = self._build_retrieval_query(
+17 -10
View File
@@ -11,7 +11,10 @@ from django.db import transaction
from farm_data.models import SensorData from farm_data.models import SensorData
from farm_data.services import ( from farm_data.services import (
build_ai_farm_snapshot,
clone_snapshot_as_runtime_plant, clone_snapshot_as_runtime_plant,
get_ai_snapshot_metric,
get_ai_snapshot_weather,
get_farm_plant_snapshot_by_name, get_farm_plant_snapshot_by_name,
) )
from irrigation.evapotranspiration import ( from irrigation.evapotranspiration import (
@@ -55,13 +58,15 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
return default return default
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None: def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
if sensor is None or not isinstance(sensor.sensor_payload, dict): value = get_ai_snapshot_metric(ai_snapshot, metric)
return None return _safe_float(value, default=0.0) if value is not None else 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_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]: def _coerce_list(value: Any) -> list[Any]:
@@ -275,9 +280,9 @@ def _build_irrigation_ui_payload(
crop_profile: dict[str, Any], crop_profile: dict[str, Any],
active_kc: float, active_kc: float,
irrigation_method: IrrigationMethod | None, irrigation_method: IrrigationMethod | None,
sensor: SensorData | None, ai_snapshot: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
soil_moisture = _sensor_metric(sensor, "soil_moisture") soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture")
plan = _normalize_plan( plan = _normalize_plan(
llm_result, llm_result,
optimizer_result, optimizer_result,
@@ -380,6 +385,7 @@ def get_irrigation_recommendation(
.filter(farm_uuid=resolved_farm_uuid) .filter(farm_uuid=resolved_farm_uuid)
.first() .first()
) )
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name) irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
_persist_irrigation_method_on_farm(sensor, irrigation_method) _persist_irrigation_method_on_farm(sensor, irrigation_method)
@@ -423,6 +429,7 @@ def get_irrigation_recommendation(
if plant is not None and forecasts: if plant is not None and forecasts:
optimized_result = _get_optimizer().optimize_irrigation( optimized_result = _get_optimizer().optimize_irrigation(
sensor=sensor, sensor=sensor,
ai_snapshot=ai_snapshot,
plant=plant, plant=plant,
forecasts=forecasts, forecasts=forecasts,
daily_water_needs=daily_water_needs, daily_water_needs=daily_water_needs,
@@ -518,7 +525,7 @@ def get_irrigation_recommendation(
crop_profile, crop_profile,
active_kc, active_kc,
irrigation_method, irrigation_method,
sensor, ai_snapshot,
) )
result["raw_response"] = raw result["raw_response"] = raw
result["simulation_optimizer"] = optimized_result result["simulation_optimizer"] = optimized_result
+8
View File
@@ -6,6 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, ValidationError 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _complete_audit_log,
@@ -120,6 +121,13 @@ class IrrigationPlanParserService:
"partial_plan": normalized_partial, "partial_plan": normalized_partial,
"required_core_fields": CORE_FIELDS, "required_core_fields": CORE_FIELDS,
"service": "irrigation_plan_parser", "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( rag_query = self._build_retrieval_query(
+4 -4
View File
@@ -7,7 +7,7 @@ import json
import logging import logging
from typing import Any 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_build_content_parts, _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]: 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: if farm_details is None:
raise RAGServiceError( raise RAGServiceError(
error_code="farm_not_found", 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]: def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
weather = farm_details.get("weather") or {} weather = ((farm_details.get("weather") or {}).get("forecast") or {})
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {} soil = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
humidity = _safe_float(weather.get("humidity_mean"), 55.0) humidity = _safe_float(weather.get("humidity_mean"), 55.0)
temp = _safe_float(weather.get("temperature_mean"), 24.0) temp = _safe_float(weather.get("temperature_mean"), 24.0)
rain = _safe_float(weather.get("precipitation"), 0.0) rain = _safe_float(weather.get("precipitation"), 0.0)
+2 -2
View File
@@ -4,7 +4,7 @@ import json
import logging import logging
from typing import Any 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _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]: 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: if farm_details is None:
raise RAGServiceError( raise RAGServiceError(
error_code="farm_not_found", error_code="farm_not_found",
+22 -2
View File
@@ -4,7 +4,7 @@ import json
import logging import logging
from typing import Any 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _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]: 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: if farm_details is None:
raise RAGServiceError( raise RAGServiceError(
error_code="farm_not_found", error_code="farm_not_found",
@@ -158,6 +158,16 @@ def get_water_need_prediction_insight(
structured_context = { structured_context = {
"farm_uuid": farm_uuid, "farm_uuid": farm_uuid,
"prediction_payload": prediction_payload, "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( rag_context = build_rag_context(
query=user_query, query=user_query,
@@ -208,4 +218,14 @@ def get_water_need_prediction_insight(
parsed["source"] = "llm" parsed["source"] = "llm"
parsed["farm_uuid"] = farm_uuid parsed["farm_uuid"] = farm_uuid
parsed["raw_response"] = raw 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 return parsed
+53
View File
@@ -69,3 +69,56 @@ class ChatContextTests(SimpleTestCase):
self.assertIn("[اطلاعات کامل مزرعه]", context) self.assertIn("[اطلاعات کامل مزرعه]", context)
self.assertIn("soil_moisture", 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")
+36
View File
@@ -86,3 +86,39 @@ class RAGFailureContractTests(SimpleTestCase):
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"}) YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") 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()
+44
View File
@@ -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"]["npk_ratio"]["label"], "20-20-20")
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) 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")
+2 -2
View File
@@ -132,7 +132,7 @@ class ChatView(APIView):
], ],
) )
def post(self, request: Request): 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 from .config import load_rag_config
data = request.data if request.method == "POST" else request.query_params data = request.data if request.method == "POST" else request.query_params
@@ -178,7 +178,7 @@ class ChatView(APIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
cfg = load_rag_config() cfg = load_rag_config()
farm_details = get_farm_details(farm_uuid) farm_details = build_ai_farm_snapshot(farm_uuid)
if farm_details is None: if farm_details is None:
return Response( return Response(
{"code": 404, "msg": "farm پیدا نشد."}, {"code": 404, "msg": "farm پیدا نشد."},
+55
View File
@@ -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()
+12
View File
@@ -281,6 +281,8 @@ def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[
class SoilMoistureHeatmapService: class SoilMoistureHeatmapService:
def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: 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 = ( current_sensor = (
SensorData.objects.select_related("center_location") SensorData.objects.select_related("center_location")
.prefetch_related("plant_assignments__plant") .prefetch_related("plant_assignments__plant")
@@ -329,6 +331,11 @@ class SoilMoistureHeatmapService:
], ],
}, },
"summary": _heatmap_summary(sensor_points, []), "summary": _heatmap_summary(sensor_points, []),
"source_metadata": {
"endpoint_policy": "spatial_first",
"primary_source": "sensor_network_spatial_interpolation",
"agronomic_aggregation": "supplementary_only",
},
"quality_legend": { "quality_legend": {
QUALITY_REAL: "اندازه گیری واقعی سنسور", QUALITY_REAL: "اندازه گیری واقعی سنسور",
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
@@ -438,6 +445,11 @@ class SoilMoistureHeatmapService:
], ],
}, },
"summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]), "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_legend": {
QUALITY_REAL: "اندازه گیری واقعی سنسور", QUALITY_REAL: "اندازه گیری واقعی سنسور",
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
+16 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from farm_data.models import SensorData from farm_data.models import SensorData
from farm_data.services import build_ai_farm_snapshot
from .services import get_forecast_for_location from .services import get_forecast_for_location
@@ -39,7 +40,7 @@ def _weather_condition(weather_code):
return WMO_CONDITIONS.get(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: if not forecasts:
return { return {
"condition": "نامشخص", "condition": "نامشخص",
@@ -49,6 +50,10 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
"windSpeed": 0, "windSpeed": 0,
"windUnit": "km/h", "windUnit": "km/h",
"chartData": {"labels": [], "series": [[]]}, "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] current_forecast = forecasts[0]
@@ -66,6 +71,14 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
"labels": labels, "labels": labels,
"series": series, "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.") raise ValueError("Farm not found.")
forecasts = get_forecast_for_location(sensor.center_location, days=7) 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)
+12
View File
@@ -28,6 +28,10 @@ class FarmWeatherApiTests(TestCase):
"labels": ["2026-04-01", "2026-04-02"], "labels": ["2026-04-01", "2026-04-02"],
"series": [[28.0, 29.0]], "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( mock_get_app_config.return_value = SimpleNamespace(
@@ -44,6 +48,7 @@ class FarmWeatherApiTests(TestCase):
payload = response.json()["data"] payload = response.json()["data"]
self.assertEqual(payload["condition"], "صاف") self.assertEqual(payload["condition"], "صاف")
self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01") 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") @patch("weather.views.apps.get_app_config")
def test_farm_weather_api_returns_404_for_missing_farm(self, mock_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"] payload = response.json()["data"]
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
self.assertEqual(payload["insight"]["confidence"], 0.82) 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") @patch("weather.views.apps.get_app_config")
def test_water_need_api_returns_404_for_missing_farm(self, mock_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.status_code, 502)
self.assertEqual(response.json()["data"]["error_code"], "invalid_json") self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
@override_settings(ROOT_URLCONF="soile.urls")
class SpatialPolicySmokeTests(TestCase):
pass
+15 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any 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 irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
from farm_data.models import SensorData 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 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) location = getattr(sensor, "center_location", None)
plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor)) plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
irrigation_method = getattr(sensor, "irrigation_method", None) irrigation_method = getattr(sensor, "irrigation_method", None)
@@ -46,6 +46,17 @@ def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) ->
"dailyBreakdown": daily, "dailyBreakdown": daily,
"cropProfile": crop_profile, "cropProfile": crop_profile,
"irrigationEfficiencyPercent": efficiency, "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.") raise ValueError("Farm not found.")
forecasts = get_forecast_for_location(sensor.center_location, days=7) 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( insight = get_water_need_prediction_insight(
farm_uuid=farm_uuid, farm_uuid=farm_uuid,
prediction_payload=payload, prediction_payload=payload,