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
+360 -1
View File
@@ -15,9 +15,10 @@ from location_data.models import (
RemoteSensingSubdivisionResult,
SoilLocation,
)
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.services import (
assign_farm_plants_from_backend_ids,
build_ai_farm_snapshot,
get_canonical_farm_record,
get_runtime_plant_for_farm,
list_runtime_plants_for_farm,
@@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase):
0.6,
)
def test_detail_uses_farmer_aggregated_snapshot_as_canonical_soil_source(self):
self.location.block_layout = {
"blocks": [
{"block_code": "block-a", "order": 1},
{"block_code": "block-b", "order": 2},
]
}
self.location.save(update_fields=["block_layout", "updated_at"])
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 2,
"resolved_metrics": {"nitrogen": 42.0, "ndvi": 0.61},
"metric_sources": {
"nitrogen": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "block-a",
"resolved_metrics": {"nitrogen": 20.0, "ndvi": 0.5},
"metric_sources": {},
"satellite_sub_blocks": [{"sub_block_code": "cluster-a"}],
"sensor_sub_blocks": [],
},
{
"status": "completed",
"block_code": "block-b",
"resolved_metrics": {"nitrogen": 64.0, "ndvi": 0.72},
"metric_sources": {},
"satellite_sub_blocks": [{"sub_block_code": "cluster-b"}],
"sensor_sub_blocks": [],
},
]
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 42.0)
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.61)
self.assertEqual(payload["soil"]["source_metadata"]["canonical_source"], "farmer_block_aggregated_snapshot")
self.assertEqual(payload["soil"]["source_metadata"]["policy"]["sensor"], "cluster_mean -> block_mean -> farm_mean")
self.assertEqual(payload["source_metadata"]["weather"]["scope"], "location_center_based")
self.assertEqual(len(payload["soil"]["block_snapshots"]), 2)
self.assertEqual(len(payload["soil"]["cluster_breakdown"]), 2)
aggregated_mock.assert_called_once()
block_mock.assert_called_once()
def test_detail_without_explicit_blocks_keeps_aggregated_snapshot_and_marks_compatibility_policy(self):
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"ndvi": 0.55},
"metric_sources": {
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "",
"resolved_metrics": {"ndvi": 0.55},
"metric_sources": {},
"satellite_sub_blocks": [],
"sensor_sub_blocks": [],
}
]
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.55)
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 99.0)
self.assertFalse(payload["soil"]["source_metadata"]["has_explicit_blocks"])
self.assertTrue(payload["soil"]["source_metadata"]["compatibility_sensor_overlay_applied"])
self.assertEqual(payload["soil"]["metric_sources"]["nitrogen"]["type"], "sensor")
def test_detail_canonical_soil_metrics_do_not_come_from_single_raw_location_snapshot(self):
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"soil_moisture": 12.0},
"metric_sources": {
"soil_moisture": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "block-1",
"resolved_metrics": {"soil_moisture": 77.0},
"metric_sources": {},
"satellite_sub_blocks": [],
"sensor_sub_blocks": [],
}
]
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["soil"]["resolved_metrics"]["soil_moisture"], 12.0)
self.assertNotEqual(
payload["soil"]["resolved_metrics"]["soil_moisture"],
payload["soil"]["block_snapshots"][0]["resolved_metrics"]["soil_moisture"],
)
class BuildAiFarmSnapshotTests(TestCase):
def setUp(self):
self.location = SoilLocation.objects.create(
latitude="35.700000",
longitude="51.400000",
farm_boundary={"type": "Polygon", "coordinates": []},
)
self.weather = WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 10),
temperature_min=12.0,
temperature_max=23.0,
temperature_mean=18.0,
precipitation=1.2,
humidity_mean=52.0,
)
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=201, name="ذرت")
self.irrigation_method = IrrigationMethod.objects.create(name="تیپ")
self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create(
farm_uuid=self.farm_uuid,
center_location=self.location,
weather_forecast=self.weather,
irrigation_method=self.irrigation_method,
sensor_payload={"sensor-1": {"soil_moisture": 30.0}},
)
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
def test_build_ai_farm_snapshot_returns_normalized_block_and_sub_block_metrics(self):
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
"metric_sources": {"ndvi": {"type": "farmer_block"}, "soil_moisture": {"type": "farmer_block"}},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "block-1",
"aggregation_strategy": "sub_block_mean",
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
"metric_sources": {"ndvi": {"type": "satellite"}, "soil_moisture": {"type": "sensor"}},
"satellite_metrics": {"ndvi": 0.6},
"sensor_metrics": {"soil_moisture": 24.0},
"sub_block_count": 2,
"run_id": 91,
"cell_count": 8,
"temporal_extent": {"start_date": "2026-04-01", "end_date": "2026-04-30"},
"satellite_sub_blocks": [
{"sub_block_code": "cluster-a", "resolved_metrics": {"ndvi": 0.5}},
{"sub_block_code": "cluster-b", "resolved_metrics": {"ndvi": 0.7}},
],
"sensor_sub_blocks": [
{"sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 20.0}},
{"sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 28.0}},
],
}
]
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
self.assertIsNotNone(snapshot)
self.assertEqual(snapshot["farm_uuid"], str(self.farm_uuid))
self.assertEqual(snapshot["aggregation_policy"]["sensor"], "cluster_mean_then_block_mean_then_farm_mean")
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 24.0)
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "block-1")
self.assertEqual(snapshot["block_metrics"][0]["source_metadata"]["run_id"], 91)
self.assertEqual(len(snapshot["sub_block_metrics"]), 2)
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["satellite_present"], True)
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["sensor_present"], True)
self.assertEqual(snapshot["weather"]["source_metadata"]["scope"], "location_center_based")
self.assertEqual(len(snapshot["plants"]), 1)
self.assertEqual(snapshot["irrigation_method"]["details"]["name"], self.irrigation_method.name)
def test_build_ai_farm_snapshot_without_explicit_subdivisions_uses_default_compatibility_shape(self):
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"ndvi": 0.55},
"metric_sources": {"ndvi": {"type": "farmer_block"}},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "",
"resolved_metrics": {"ndvi": 0.55},
"metric_sources": {"ndvi": {"type": "satellite"}},
"satellite_metrics": {"ndvi": 0.55},
"sensor_metrics": {},
"satellite_sub_blocks": [],
"sensor_sub_blocks": [],
}
]
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "default-block")
self.assertEqual(snapshot["sub_block_metrics"][0]["sub_block_code"], "default-sub-block")
self.assertEqual(
snapshot["sub_block_metrics"][0]["source_metadata"]["source"],
"default_sub_block_compatibility",
)
self.assertEqual(
snapshot["aggregation_policy"]["default_block_policy"],
"1_main_block + 1_default_sub_block_when_missing",
)
def test_build_ai_farm_snapshot_handles_missing_sensor_data(self):
self.farm.sensor_payload = None
self.farm.save(update_fields=["sensor_payload"])
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"ndvi": 0.49},
"metric_sources": {"ndvi": {"type": "farmer_block"}},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "block-1",
"resolved_metrics": {"ndvi": 0.49},
"metric_sources": {"ndvi": {"type": "satellite"}},
"satellite_metrics": {"ndvi": 0.49},
"sensor_metrics": {},
"satellite_sub_blocks": [],
"sensor_sub_blocks": [],
}
]
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"], {"ndvi": 0.49})
self.assertEqual(snapshot["block_metrics"][0]["sensor_metrics"], {})
def test_build_ai_farm_snapshot_handles_missing_remote_sensing_data(self):
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
"farm_data.services.build_location_block_satellite_snapshots"
) as block_mock:
aggregated_mock.return_value = {
"status": "completed",
"aggregation_strategy": "farmer_block_mean",
"block_count": 1,
"resolved_metrics": {"soil_moisture": 30.0},
"metric_sources": {"soil_moisture": {"type": "farmer_block"}},
}
block_mock.return_value = [
{
"status": "completed",
"block_code": "block-1",
"resolved_metrics": {"soil_moisture": 30.0},
"metric_sources": {"soil_moisture": {"type": "sensor"}},
"satellite_metrics": {},
"sensor_metrics": {"soil_moisture": 30.0},
"satellite_sub_blocks": [],
"sensor_sub_blocks": [
{"sub_block_code": "cluster-1", "resolved_metrics": {"soil_moisture": 30.0}}
],
}
]
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 30.0)
self.assertEqual(snapshot["block_metrics"][0]["satellite_metrics"], {})
self.assertEqual(snapshot["sub_block_metrics"][0]["sensor_metrics"]["soil_moisture"], 30.0)
class FarmDataUpsertApiTests(TestCase):
def setUp(self):
@@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase):
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
31.2,
)
device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
self.assertEqual(device.location_id, self.location.id)
self.assertEqual(device.payload["soil_moisture"], 31.2)
self.assertIsNone(device.cluster_uuid)
self.assertTrue(
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
)
@@ -444,6 +752,57 @@ class FarmDataUpsertApiTests(TestCase):
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
)
def test_post_syncs_device_rows_from_sensor_payload(self):
farm_uuid = uuid.uuid4()
cluster_uuid = uuid.uuid4()
create_response = self.client.post(
"/api/farm-data/",
data={
"farm_uuid": str(farm_uuid),
"farm_boundary": self.boundary,
"sensor_payload": {
"sensor-7-1": {
"cluster_uuid": str(cluster_uuid),
"soil_moisture": 31.2,
},
"leaf-sensor": {
"leaf_wetness": 10.0,
},
},
},
format="json",
)
self.assertEqual(create_response.status_code, 201)
farm = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertEqual(Device.objects.filter(farm=farm).count(), 2)
soil_device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
self.assertEqual(str(soil_device.cluster_uuid), str(cluster_uuid))
self.assertEqual(soil_device.payload["soil_moisture"], 31.2)
update_response = self.client.post(
"/api/farm-data/",
data={
"farm_uuid": str(farm_uuid),
"farm_boundary": self.boundary,
"sensor_payload": {
"sensor-7-1": {
"cluster_uuid": str(cluster_uuid),
"soil_moisture": 33.8,
"nitrogen": 20.5,
},
},
},
format="json",
)
self.assertEqual(update_response.status_code, 200)
soil_device.refresh_from_db()
self.assertEqual(soil_device.payload["soil_moisture"], 33.8)
self.assertEqual(soil_device.payload["nitrogen"], 20.5)
self.assertEqual(Device.objects.filter(farm=farm, sensor_name="leaf-sensor").count(), 1)
def test_post_requires_farm_uuid_in_request_body(self):
response = self.client.post(
"/api/farm-data/",