UPDATE
This commit is contained in:
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user