Files
Ai/farm_data/tests/test_farm_detail_api.py
T
2026-05-11 15:31:10 +03:30

578 lines
23 KiB
Python

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 PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.services import (
assign_farm_plants_from_backend_ids,
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_reconciles_legacy_relation_for_transition(self):
self.assertEqual(list(self.farm.plants.values_list("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,
)
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,
)
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_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)