from datetime import date from unittest.mock import patch import uuid from django.test import TestCase from rest_framework.test import APIClient from location_data.models import ( AnalysisGridCell, AnalysisGridObservation, BlockSubdivision, RemoteSensingClusterAssignment, RemoteSensingClusterBlock, RemoteSensingRun, RemoteSensingSubdivisionResult, SoilLocation, ) 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, ) from irrigation.models import IrrigationMethod from weather.models import WeatherForecast from farm_data.services import resolve_center_location_from_boundary def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict: return { "type": "Polygon", "coordinates": [ [ [lon - delta, lat - delta], [lon + delta, lat - delta], [lon + delta, lat + delta], [lon - delta, lat + delta], [lon - delta, lat - delta], ] ], } class FarmDetailApiTests(TestCase): def setUp(self): self.client = APIClient() 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.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, 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-7-1": { "soil_moisture": 33.5, "nitrogen": 99.0, } }, ) assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self): farm = get_canonical_farm_record(str(self.farm_uuid)) self.assertIsNotNone(farm) self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجه‌فرنگی"]) self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار") def test_assignment_sync_uses_backend_snapshots_as_canonical_source(self): self.assertEqual( list(self.farm.plant_assignments.values_list("plant__name", flat=True)), ["خیار", "گوجه‌فرنگی"], ) def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self): farm = get_canonical_farm_record(str(self.farm_uuid)) resolved = get_runtime_plant_for_farm(farm, plant_name="گوجه‌فرنگی") self.assertIsNotNone(resolved) self.assertEqual(resolved.name, "گوجه‌فرنگی") self.assertEqual(resolved.id, self.plant1.backend_plant_id) def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertNotIn("farm_uuid", payload) self.assertEqual(payload["center_location"]["id"], self.location.id) self.assertEqual(payload["weather"]["id"], self.weather.id) self.assertEqual( payload["sensor_payload"]["sensor-7-1"]["soil_moisture"], 33.5, ) self.assertIn("sensor_schema", payload) self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen") resolved_metrics = payload["soil"]["resolved_metrics"] metric_sources = payload["soil"]["metric_sources"] self.assertEqual(resolved_metrics["nitrogen"], 99.0) self.assertEqual(metric_sources["nitrogen"]["type"], "sensor") self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value") self.assertEqual(payload["soil"]["satellite_snapshots"], []) self.assertCountEqual(payload["plant_ids"], [self.plant1.backend_plant_id, self.plant2.backend_plant_id]) self.assertEqual(len(payload["plants"]), 2) returned_plants = {item["id"]: item for item in payload["plants"]} self.assertEqual(returned_plants[self.plant1.backend_plant_id]["name"], self.plant1.name) self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name) self.assertIn("light", returned_plants[self.plant1.backend_plant_id]) self.assertEqual(len(payload["plant_assignments"]), 2) self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id) self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) def test_returns_404_when_farm_is_missing(self): response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "farm یافت نشد.") def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self): self.farm.sensor_payload = { "sensor-a": { "soil_moisture": 20.0, "nitrogen": 90.0, "status": "ok", }, "sensor-b": { "soil_moisture": 40.0, "nitrogen": 110.0, "status": "needs-check", }, } self.farm.save(update_fields=["sensor_payload"]) response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] resolved_metrics = payload["soil"]["resolved_metrics"] metric_sources = payload["soil"]["metric_sources"] self.assertEqual(resolved_metrics["soil_moisture"], 30.0) self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average") self.assertCountEqual( metric_sources["soil_moisture"]["sensor_keys"], ["sensor-a", "sensor-b"], ) self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0]) self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"]) self.assertEqual(metric_sources["status"]["strategy"], "distinct_values") def test_detail_auto_registers_unknown_sensor_parameters(self): self.farm.sensor_payload = { "leaf-sensor": { "leaf_wetness": 11.0, "leaf_temperature": 19.8, } } self.farm.save(update_fields=["sensor_payload"]) response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] leaf_schema = payload["sensor_schema"]["leaf-sensor"] self.assertCountEqual( [item["code"] for item in leaf_schema], ["leaf_temperature", "leaf_wetness"], ) self.assertTrue( SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() ) def test_detail_aggregates_satellite_and_sensor_metrics_from_kmeans_sub_blocks_to_main_block(self): subdivision = BlockSubdivision.objects.create( soil_location=self.location, block_code="block-1", source_boundary=square_boundary_for_center(35.7, 51.4, delta=0.002), chunk_size_sqm=900, status="subdivided", ) run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=subdivision, block_code="block-1", chunk_size_sqm=900, temporal_start=date(2026, 4, 1), temporal_end=date(2026, 4, 30), status=RemoteSensingRun.STATUS_SUCCESS, ) result = RemoteSensingSubdivisionResult.objects.create( soil_location=self.location, run=run, block_subdivision=subdivision, block_code="block-1", chunk_size_sqm=900, temporal_start=run.temporal_start, temporal_end=run.temporal_end, cluster_count=2, selected_features=["ndvi", "ndwi", "soil_vv_db"], metadata={"used_cell_count": 3, "cluster_summaries": []}, ) cell_payloads = [ ("cell-1", 0, 0.2, 10.0), ("cell-2", 0, 0.4, 12.0), ("cell-3", 1, 0.9, 20.0), ] created_cells = [] for index, (cell_code, cluster_label, ndvi, ndwi) in enumerate(cell_payloads): cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=subdivision, block_code="block-1", cell_code=cell_code, chunk_size_sqm=900, geometry=square_boundary_for_center(35.7 + (index * 0.0001), 51.4 + (index * 0.0001), delta=0.00005), centroid_lat=f"{35.7000 + (index * 0.0001):.6f}", centroid_lon=f"{51.4000 + (index * 0.0001):.6f}", ) created_cells.append((cell, cluster_label)) AnalysisGridObservation.objects.create( cell=cell, run=run, temporal_start=run.temporal_start, temporal_end=run.temporal_end, ndvi=ndvi, ndwi=ndwi, soil_vv_db=-8.0 - index, ) RemoteSensingClusterAssignment.objects.create( result=result, cell=cell, cluster_label=cluster_label, raw_feature_values={}, scaled_feature_values={}, ) cluster_0 = RemoteSensingClusterBlock.objects.create( result=result, soil_location=self.location, block_subdivision=subdivision, block_code="block-1", sub_block_code="cluster-0", cluster_label=0, chunk_size_sqm=900, centroid_lat="35.700050", centroid_lon="51.400050", cell_count=2, cell_codes=["cell-1", "cell-2"], geometry=square_boundary_for_center(35.70005, 51.40005, delta=0.00008), metadata={}, ) cluster_1 = RemoteSensingClusterBlock.objects.create( result=result, soil_location=self.location, block_subdivision=subdivision, block_code="block-1", sub_block_code="cluster-1", cluster_label=1, chunk_size_sqm=900, centroid_lat="35.700200", centroid_lon="51.400200", cell_count=1, cell_codes=["cell-3"], geometry=square_boundary_for_center(35.7002, 51.4002, delta=0.00008), metadata={}, ) 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": square_boundary_for_center(35.7, 51.4, delta=0.002), "needs_subdivision": True, "sub_blocks": [ { "cluster_uuid": str(cluster_0.uuid), "sub_block_code": "cluster-0", "cluster_label": 0, }, { "cluster_uuid": str(cluster_1.uuid), "sub_block_code": "cluster-1", "cluster_label": 1, }, ], } ], } self.location.save(update_fields=["block_layout", "updated_at"]) self.farm.sensor_payload = { "sensor-a": { "cluster_uuid": str(cluster_0.uuid), "soil_moisture": 10.0, "nitrogen": 100.0, }, "sensor-b": { "cluster_uuid": str(cluster_0.uuid), "soil_moisture": 20.0, "nitrogen": 80.0, }, "sensor-c": { "cluster_uuid": str(cluster_1.uuid), "soil_moisture": 30.0, "nitrogen": 60.0, }, } self.farm.save(update_fields=["sensor_payload", "updated_at"]) response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] block_snapshot = payload["soil"]["satellite_snapshots"][0] self.assertEqual(block_snapshot["block_code"], "block-1") self.assertEqual(block_snapshot["sub_block_count"], 2) self.assertEqual(block_snapshot["satellite_metrics"]["ndvi"], 0.6) self.assertEqual(block_snapshot["satellite_metrics"]["ndwi"], 15.5) self.assertEqual(block_snapshot["sensor_metrics"]["soil_moisture"], 22.5) self.assertEqual(block_snapshot["sensor_metrics"]["nitrogen"], 75.0) self.assertEqual(block_snapshot["resolved_metrics"]["soil_moisture"], 22.5) self.assertEqual(block_snapshot["metric_sources"]["ndvi"]["strategy"], "sub_block_mean_average") self.assertEqual(block_snapshot["metric_sources"]["soil_moisture"]["strategy"], "sub_block_mean_average") self.assertEqual(len(block_snapshot["satellite_sub_blocks"]), 2) self.assertEqual(len(block_snapshot["sensor_sub_blocks"]), 2) block_layout = payload["center_location"]["block_layout"] self.assertEqual( block_layout["blocks"][0]["aggregated_metrics"]["resolved_metrics"]["soil_moisture"], 22.5, ) self.assertEqual( block_layout["blocks"][0]["aggregated_metrics"]["satellite_metrics"]["ndvi"], 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): self.client = APIClient() self.location = SoilLocation.objects.create( latitude="35.710000", longitude="51.410000", ) self.boundary = square_boundary_for_center(35.71, 51.41) self.weather = WeatherForecast.objects.create( location=self.location, forecast_date=date(2026, 4, 11), temperature_min=11.0, temperature_max=24.0, temperature_mean=17.5, ) self.irrigation_method = IrrigationMethod.objects.create(name="بارانی") def test_post_creates_farm_data_with_explicit_farm_uuid(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": self.boundary, "sensor_payload": { "sensor-7-1": { "soil_moisture": 31.2, "nitrogen": 18.0, } }, "irrigation_method_id": self.irrigation_method.id, }, format="json", ) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid)) self.assertEqual(response.json()["data"]["center_location_id"], self.location.id) self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertEqual(farm.center_location_id, self.location.id) self.assertEqual(farm.weather_forecast_id, self.weather.id) self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id) self.assertEqual( 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() ) def test_post_auto_registers_new_sensor_without_manual_parameter_creation(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": self.boundary, "sensor_payload": { "canopy-sensor-v2": { "leaf_wetness": 12.4, "leaf_temperature": 21.6, "disease_pressure_index": 0.41, } }, }, format="json", ) self.assertEqual(response.status_code, 201) self.assertTrue( SensorParameter.objects.filter( sensor_key="canopy-sensor-v2", code="leaf_wetness", ).exists() ) detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") self.assertEqual(detail_response.status_code, 200) schema = detail_response.json()["data"]["sensor_schema"]["canopy-sensor-v2"] self.assertCountEqual( [item["code"] for item in schema], ["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/", data={ "farm_boundary": self.boundary, "sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}}, }, format="json", ) self.assertEqual(response.status_code, 400) self.assertIn("farm_uuid", response.json()["data"]) def test_post_creates_center_location_from_boundary_when_missing(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": { "corners": [ {"lat": 50.0, "lon": 50.0}, {"lat": 50.0, "lon": 50.02}, {"lat": 50.02, "lon": 50.02}, {"lat": 50.02, "lon": 50.0}, ] }, "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, }, format="json", ) self.assertEqual(response.status_code, 201) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertIsNotNone(farm.center_location_id) self.assertEqual(str(farm.center_location.latitude), "50.010000") self.assertEqual(str(farm.center_location.longitude), "50.010000") self.assertIsNone(farm.weather_forecast_id) self.assertEqual(farm.center_location.input_block_count, 1) self.assertEqual(len(farm.center_location.block_layout["blocks"]), 1) subdivision = BlockSubdivision.objects.get(soil_location=farm.center_location, block_code="block-1") self.assertGreater(subdivision.grid_point_count, 0) self.assertEqual(subdivision.grid_point_count, subdivision.centroid_count) def test_post_persists_requested_block_count_on_center_location(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": self.boundary, "block_count": 3, "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, }, format="json", ) self.assertEqual(response.status_code, 201) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertEqual(farm.center_location.input_block_count, 3) self.assertEqual(len(farm.center_location.block_layout["blocks"]), 3) self.assertFalse( BlockSubdivision.objects.filter(soil_location=farm.center_location).exists() ) def test_resolve_center_location_runs_subdivision_only_on_creation(self): boundary = square_boundary_for_center(35.75, 51.45) first_location = resolve_center_location_from_boundary(boundary, block_count=1) first_subdivision = BlockSubdivision.objects.get( soil_location=first_location, block_code="block-1", ) second_location = resolve_center_location_from_boundary(boundary, block_count=1) self.assertEqual(first_location.id, second_location.id) self.assertEqual( BlockSubdivision.objects.filter( soil_location=second_location, block_code="block-1", ).count(), 1, ) self.assertEqual( BlockSubdivision.objects.get( soil_location=second_location, block_code="block-1", ).id, first_subdivision.id, ) def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self): location = resolve_center_location_from_boundary( { "corners": [ {"lat": 0.0, "lon": 0.0}, {"lat": 0.0, "lon": 4.0}, {"lat": 4.0, "lon": 4.0}, {"lat": 4.0, "lon": 0.0}, {"lat": 1.0, "lon": 0.0}, {"lat": 1.0, "lon": 3.0}, {"lat": 3.0, "lon": 3.0}, {"lat": 3.0, "lon": 1.0}, {"lat": 0.0, "lon": 1.0}, ] } ) self.assertEqual(str(location.latitude), "2.078947") self.assertEqual(str(location.longitude), "2.078947") def test_post_keeps_missing_location_without_external_sync(self): missing_boundary = square_boundary_for_center(36.0, 52.0) farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": missing_boundary, "sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}}, }, format="json", ) self.assertEqual(response.status_code, 201) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertIsNone(farm.weather_forecast_id)