UPDATE
This commit is contained in:
@@ -6,7 +6,7 @@ from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
||||
from farm_data.services import get_ai_snapshot_metric
|
||||
|
||||
from crop_simulation.services import CropSimulationService
|
||||
|
||||
@@ -47,20 +47,9 @@ def _first_not_none(*values: Any) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _sensor_metric(sensor: Any, metric: str) -> float | None:
|
||||
if sensor is None:
|
||||
return None
|
||||
if hasattr(sensor, metric):
|
||||
value = getattr(sensor, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for block in payload.values():
|
||||
if isinstance(block, dict) and block.get(metric) is not None:
|
||||
return _safe_float(block.get(metric), default=0.0)
|
||||
return None
|
||||
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
|
||||
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
|
||||
@@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
|
||||
return records
|
||||
|
||||
|
||||
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
moisture_pct = _sensor_metric(sensor, "soil_moisture")
|
||||
center_location = getattr(sensor, "center_location", None)
|
||||
satellite_metrics = (
|
||||
build_location_satellite_snapshot(center_location).get("resolved_metrics") or {}
|
||||
if center_location is not None
|
||||
else {}
|
||||
)
|
||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.34)
|
||||
def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture")
|
||||
ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34)
|
||||
wv0033 = ndwi if ndwi > 0 else 0.34
|
||||
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
|
||||
|
||||
@@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
growth_stage: str | None,
|
||||
irrigation_method: Any | None,
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if sensor is None or plant is None or not forecasts:
|
||||
return None
|
||||
@@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
crop_blueprint=crop_blueprint,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
if pcse_result is not None:
|
||||
return pcse_result
|
||||
@@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_method=irrigation_method,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
def optimize_fertilization(
|
||||
@@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer:
|
||||
plant: Any,
|
||||
forecasts: list[Any],
|
||||
growth_stage: str | None,
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if sensor is None or plant is None:
|
||||
return None
|
||||
@@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer:
|
||||
forecasts=forecasts,
|
||||
growth_stage=growth_stage,
|
||||
crop_blueprint=crop_blueprint,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
if pcse_result is not None:
|
||||
return pcse_result
|
||||
@@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer:
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=growth_stage,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
def _optimize_irrigation_with_pcse(
|
||||
@@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer:
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
growth_stage: str | None,
|
||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
|
||||
crop_parameters, agromanagement = crop_blueprint
|
||||
soil, site = _build_soil_parameters(sensor)
|
||||
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||
weather = _build_weather_records(
|
||||
forecasts,
|
||||
latitude=_safe_float(sensor.center_location.latitude),
|
||||
@@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer:
|
||||
forecasts: list[Any],
|
||||
growth_stage: str | None,
|
||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||
ai_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
crop_parameters, agromanagement = crop_blueprint
|
||||
soil, site = _build_soil_parameters(sensor)
|
||||
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||
weather = _build_weather_records(
|
||||
forecasts,
|
||||
latitude=_safe_float(sensor.center_location.latitude),
|
||||
|
||||
+36
-24
@@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_weather
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
|
||||
@@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float:
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
|
||||
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
|
||||
if sensor is None:
|
||||
def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
|
||||
if hasattr(sensor, metric_name):
|
||||
value = getattr(sensor, metric_name)
|
||||
if value is not None:
|
||||
return _safe_float(value)
|
||||
|
||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
||||
if not isinstance(payload, dict):
|
||||
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if not isinstance(resolved_metrics, dict):
|
||||
return None
|
||||
return _safe_float(resolved_metrics.get(metric_name))
|
||||
|
||||
for block in payload.values():
|
||||
if isinstance(block, dict) and block.get(metric_name) is not None:
|
||||
return _safe_float(block.get(metric_name))
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(snapshot, dict):
|
||||
return {}
|
||||
source_metadata = snapshot.get("source_metadata") or {}
|
||||
return source_metadata if isinstance(source_metadata, dict) else {}
|
||||
|
||||
|
||||
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
|
||||
@@ -458,6 +458,9 @@ def build_simulation_payload_from_farm(
|
||||
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
||||
|
||||
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
|
||||
ai_snapshot = build_ai_farm_snapshot(str(farm_uuid))
|
||||
if ai_snapshot is None:
|
||||
raise CropSimulationError(f"Canonical AI snapshot for farm uuid={farm_uuid} is missing.")
|
||||
|
||||
if weather is not None:
|
||||
resolved_weather = _normalize_weather_records(weather)
|
||||
@@ -476,8 +479,7 @@ def build_simulation_payload_from_farm(
|
||||
longitude=float(farm.center_location.longitude),
|
||||
)
|
||||
|
||||
satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {}
|
||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
|
||||
ndwi = _snapshot_metric(ai_snapshot, "ndwi")
|
||||
smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55)
|
||||
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
||||
sm0 = _clamp(
|
||||
@@ -485,17 +487,17 @@ def build_simulation_payload_from_farm(
|
||||
max(smfcf + 0.02, smw + 0.05),
|
||||
0.8,
|
||||
)
|
||||
soil_moisture = _sensor_metric(farm, "soil_moisture")
|
||||
soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||
wav = (
|
||||
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
|
||||
if soil_moisture is not None
|
||||
else DEFAULT_WAV
|
||||
)
|
||||
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db"))
|
||||
phosphorus = _sensor_metric(farm, "phosphorus")
|
||||
potassium = _sensor_metric(farm, "potassium")
|
||||
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None)
|
||||
ec = _sensor_metric(farm, "electrical_conductivity")
|
||||
nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db"))
|
||||
phosphorus = _snapshot_metric(ai_snapshot, "phosphorus")
|
||||
potassium = _snapshot_metric(ai_snapshot, "potassium")
|
||||
soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None)
|
||||
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||
|
||||
resolved_soil = {
|
||||
"SMFCF": round(smfcf, 3),
|
||||
@@ -569,6 +571,13 @@ def build_simulation_payload_from_farm(
|
||||
"site_parameters": resolved_site,
|
||||
"crop_parameters": resolved_crop,
|
||||
"agromanagement": resolved_agromanagement,
|
||||
"source_metadata": {
|
||||
"farm_metrics": _snapshot_source_metadata(ai_snapshot).get("farm_metrics", {}),
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -914,6 +923,7 @@ class CropSimulationService:
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
"plant_name": plant_name,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -937,6 +947,7 @@ class CropSimulationService:
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -1205,6 +1216,7 @@ class CropSimulationService:
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"source_metadata": resolved.get("source_metadata") or {},
|
||||
}
|
||||
)
|
||||
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
||||
@@ -1258,7 +1270,7 @@ class CropSimulationService:
|
||||
site_parameters=spec["site_parameters"],
|
||||
)
|
||||
run.status = SimulationScenario.Status.SUCCESS
|
||||
run.result_payload = result
|
||||
run.result_payload = {**result, "source_metadata": spec.get("source_metadata") or {}}
|
||||
run.save(update_fields=["status", "result_payload", "updated_at"])
|
||||
results.append(
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData
|
||||
from irrigation.models import IrrigationMethod
|
||||
from location_data.models import SoilLocation
|
||||
from weather.models import WeatherForecast
|
||||
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||
from .views import PlantGrowthSimulationView
|
||||
|
||||
@@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase):
|
||||
self.assertEqual(result["result"]["engine"], "pcse")
|
||||
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
||||
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
||||
|
||||
|
||||
class CropSimulationCanonicalSnapshotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(latitude="35.700000", longitude="51.400000")
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=24.0,
|
||||
temperature_mean=18.0,
|
||||
humidity_mean=55.0,
|
||||
precipitation=1.0,
|
||||
et0=3.5,
|
||||
)
|
||||
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=401, name="wheat")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="drip")
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
|
||||
center_location=self.location,
|
||||
weather_forecast=self.weather,
|
||||
irrigation_method=self.irrigation_method,
|
||||
)
|
||||
self.farm.plants.add(self.plant)
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot):
|
||||
from crop_simulation.services import build_simulation_payload_from_farm
|
||||
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {
|
||||
"resolved_metrics": {
|
||||
"soil_moisture": 36.0,
|
||||
"ndwi": 0.31,
|
||||
"nitrogen": 21.0,
|
||||
"phosphorus": 11.0,
|
||||
"potassium": 17.0,
|
||||
"soil_ph": 6.8,
|
||||
"electrical_conductivity": 1.4,
|
||||
}
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm_metrics": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||
|
||||
self.assertEqual(payload["soil"]["soil_moisture"], 36.0)
|
||||
self.assertEqual(payload["site_parameters"]["NAVAILI"], 21.0)
|
||||
self.assertEqual(payload["soil"]["phosphorus"], 11.0)
|
||||
self.assertEqual(payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_build_simulation_payload_from_farm_handles_missing_block_metrics(self, mock_snapshot):
|
||||
from crop_simulation.services import build_simulation_payload_from_farm
|
||||
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {"resolved_metrics": {}},
|
||||
"source_metadata": {"farm_metrics": {"status": "missing"}},
|
||||
}
|
||||
|
||||
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||
|
||||
self.assertEqual(payload["site_parameters"]["WAV"], 40.0)
|
||||
self.assertEqual(payload["source_metadata"]["farm_metrics"]["status"], "missing")
|
||||
|
||||
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||
def test_run_single_simulation_stores_weather_provenance(self, mock_snapshot):
|
||||
mock_snapshot.return_value = {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 35.0, "ndwi": 0.3}},
|
||||
"source_metadata": {
|
||||
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||
"weather": {"policy": "center_location_latest_forecast"},
|
||||
},
|
||||
}
|
||||
service = CropSimulationService()
|
||||
|
||||
with patch.object(service.manager, "run_simulation", return_value={"engine": "pcse", "metrics": {}, "daily_output": [], "summary_output": [], "terminal_output": []}):
|
||||
result = service.run_single_simulation(
|
||||
farm_uuid=str(self.farm.farm_uuid),
|
||||
plant_name="wheat",
|
||||
agromanagement=build_agromanagement(),
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"]["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
run = SimulationRun.objects.get()
|
||||
self.assertEqual(run.result_payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from location_data.models import NdviObservation, SoilLocation
|
||||
|
||||
from rag.failure_contract import RAGServiceError
|
||||
@@ -720,13 +720,13 @@ class YieldHarvestSummaryService:
|
||||
"recent_sensor_averages": {},
|
||||
}
|
||||
|
||||
farm_details = get_farm_details(str(farm_uuid)) or {}
|
||||
farm_details = build_ai_farm_snapshot(str(farm_uuid)) or {}
|
||||
center_location = farm.center_location
|
||||
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
weather_details = farm_details.get("weather") or {}
|
||||
soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||
weather_details = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||
recent_sensor_averages = {
|
||||
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None),
|
||||
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None),
|
||||
"soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None),
|
||||
"soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None),
|
||||
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
|
||||
}
|
||||
|
||||
@@ -744,7 +744,7 @@ class YieldHarvestSummaryService:
|
||||
"lat": float(center_location.latitude),
|
||||
"lon": float(center_location.longitude),
|
||||
},
|
||||
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
|
||||
"farm_boundary": getattr(center_location, "farm_boundary", None),
|
||||
"soil": {
|
||||
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
|
||||
"soil_type": self._infer_soil_type(soil_details),
|
||||
@@ -760,6 +760,10 @@ class YieldHarvestSummaryService:
|
||||
"sensor_data": SensorData.__name__,
|
||||
"soil_location": SoilLocation.__name__,
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm_metrics": (farm_details.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||
"weather": ((farm_details.get("weather") or {}).get("source_metadata") or {}),
|
||||
},
|
||||
}
|
||||
|
||||
def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
|
||||
|
||||
Reference in New Issue
Block a user