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
+75
View File
@@ -0,0 +1,75 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
def backfill_devices_from_sensor_payload(apps, schema_editor):
SensorData = apps.get_model("sensor_data", "SensorData")
Device = apps.get_model("sensor_data", "Device")
for farm in SensorData.objects.all().iterator():
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
location_id = getattr(farm, "center_location_id", None)
if location_id is None:
continue
for sensor_name, payload in sensor_payload.items():
if not isinstance(payload, dict):
continue
cluster_uuid = None
raw_cluster_uuid = payload.get("cluster_uuid")
if raw_cluster_uuid not in (None, ""):
try:
cluster_uuid = uuid.UUID(str(raw_cluster_uuid))
except (TypeError, ValueError, AttributeError):
cluster_uuid = None
Device.objects.update_or_create(
farm_id=farm.pk,
sensor_name=sensor_name,
defaults={
"location_id": location_id,
"payload": payload,
"cluster_uuid": cluster_uuid,
},
)
class Migration(migrations.Migration):
dependencies = [
("location_data", "0019_cluster_block_centers"),
("sensor_data", "0012_plant_catalog_snapshot_and_assignment"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sensor_name", models.CharField(db_index=True, help_text='نام سنسور مثل "sensor-7-1"', max_length=64)),
("payload", models.JSONField(blank=True, default=dict, help_text="payload همان سنسور")),
("cluster_uuid", models.UUIDField(blank=True, db_index=True, help_text="uuid کلاستر داخل location برای این سنسور", null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="sensor_data.sensordata")),
("location", models.ForeignKey(db_column="location_id", help_text="location مرتبط با این device", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="location_data.soillocation")),
],
options={
"verbose_name": "device",
"verbose_name_plural": "devices",
"db_table": "farm_data_device",
"ordering": ["sensor_name", "id"],
},
),
migrations.AddConstraint(
model_name="device",
constraint=models.UniqueConstraint(fields=("farm", "sensor_name"), name="farm_data_unique_device_per_farm_sensor"),
),
migrations.RunPython(
backfill_devices_from_sensor_payload,
migrations.RunPython.noop,
),
]
+51
View File
@@ -169,6 +169,57 @@ class SensorData(SensorPayloadMixin, models.Model):
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
class Device(models.Model):
"""نسخه نرمال‌شده هر سنسور داخل farm_data_sensordata."""
farm = models.ForeignKey(
SensorData,
on_delete=models.CASCADE,
related_name="devices",
db_column="farm_uuid",
)
location = models.ForeignKey(
"location_data.SoilLocation",
on_delete=models.CASCADE,
related_name="devices",
db_column="location_id",
help_text="location مرتبط با این device",
)
sensor_name = models.CharField(
max_length=64,
db_index=True,
help_text='نام سنسور مثل "sensor-7-1"',
)
payload = models.JSONField(
default=dict,
blank=True,
help_text="payload همان سنسور",
)
cluster_uuid = models.UUIDField(
null=True,
blank=True,
db_index=True,
help_text="uuid کلاستر داخل location برای این سنسور",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_data_device"
ordering = ["sensor_name", "id"]
constraints = [
models.UniqueConstraint(
fields=["farm", "sensor_name"],
name="farm_data_unique_device_per_farm_sensor",
)
]
verbose_name = "device"
verbose_name_plural = "devices"
def __str__(self):
return f"{self.farm_id}::{self.sensor_name}"
class PlantCatalogSnapshot(models.Model):
"""
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژول‌های AI.
+378 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from numbers import Number
import logging
import uuid
import warnings
from django.conf import settings
@@ -17,13 +18,14 @@ from location_data.block_subdivision import create_or_get_block_subdivision
from location_data.models import BlockSubdivision, SoilLocation
from location_data.satellite_snapshot import (
build_block_layout_metric_summary,
build_farmer_block_aggregated_snapshot,
build_location_block_satellite_snapshots,
build_location_satellite_snapshot,
)
from irrigation.serializers import IrrigationMethodSerializer
from weather.models import WeatherForecast
from .models import (
Device,
FarmPlantAssignment,
ParameterUpdateLog,
PlantCatalogSnapshot,
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
return synced_parameters
def _parse_cluster_uuid(value: object) -> uuid.UUID | None:
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]:
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
location = farm.center_location
synced_devices: list[Device] = []
with transaction.atomic():
active_sensor_names: list[str] = []
for sensor_name, payload in sensor_payload.items():
if not isinstance(payload, dict):
continue
active_sensor_names.append(sensor_name)
device, _created = Device.objects.update_or_create(
farm=farm,
sensor_name=sensor_name,
defaults={
"location": location,
"payload": payload,
"cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")),
},
)
synced_devices.append(device)
stale_devices = Device.objects.filter(farm=farm)
if active_sensor_names:
stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names)
stale_devices.delete()
return synced_devices
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
if sensor_payload and isinstance(sensor_payload, dict):
@@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str):
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
)
latest_satellite = build_location_satellite_snapshot(center_location)
block_metric_snapshots = build_location_block_satellite_snapshots(
soil_snapshot = _build_farm_soil_snapshot(
center_location,
sensor_payload=farm.sensor_payload,
)
if all(
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
for snapshot in block_metric_snapshots
):
block_metric_snapshots = []
soil_metrics = dict(latest_satellite.get("resolved_metrics") or {})
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
resolved_metrics = dict(soil_metrics)
metric_sources = {key: "remote_sensing" for key in soil_metrics}
for key, value in sensor_metrics.items():
resolved_metrics[key] = value
metric_sources[key] = sensor_metric_sources[key]
plant_assignments = get_farm_plant_assignments(farm)
plant_snapshots = [assignment.plant for assignment in plant_assignments]
@@ -501,11 +528,7 @@ def get_farm_details(farm_uuid: str):
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {},
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
"soil": {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"satellite_snapshots": block_metric_snapshots,
},
"soil": soil_snapshot,
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
"plant_assignments": [
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
),
"created_at": farm.created_at,
"updated_at": farm.updated_at,
"source_metadata": {
"soil": soil_snapshot.get("source_metadata", {}),
"weather": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"note": "Weather remains tied to the farm center location.",
},
},
}
def _build_farm_soil_snapshot(
center_location: SoilLocation,
*,
sensor_payload: dict | None,
) -> dict[str, object]:
# Canonical farm soil metrics now come from farmer-level block aggregation.
aggregated_snapshot = build_farmer_block_aggregated_snapshot(
center_location,
sensor_payload=sensor_payload,
)
block_snapshots = build_location_block_satellite_snapshots(
center_location,
sensor_payload=sensor_payload,
)
if all(
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
for snapshot in block_snapshots
):
block_snapshots = []
has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks"))
resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {})
metric_sources = dict(aggregated_snapshot.get("metric_sources") or {})
compatibility_sensor_overlay_applied = False
if not has_explicit_blocks:
compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing(
resolved_metrics,
metric_sources,
sensor_payload,
)
cluster_breakdown = _build_cluster_breakdown(block_snapshots)
return {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"block_snapshots": block_snapshots,
"satellite_snapshots": block_snapshots,
"cluster_breakdown": cluster_breakdown,
"source_metadata": {
"canonical_source": "farmer_block_aggregated_snapshot",
"aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing",
"status": aggregated_snapshot.get("status") or "missing",
"block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)),
"has_explicit_blocks": has_explicit_blocks,
"compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied,
"policy": {
"sensor": "cluster_mean -> block_mean -> farm_mean",
"satellite": "cluster_mean -> block_mean -> farm_mean",
"weather": "location_center_based",
},
},
}
def _merge_legacy_sensor_metrics_if_missing(
resolved_metrics: dict,
metric_sources: dict,
sensor_payload: dict | None,
) -> bool:
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload)
applied = False
for metric_name, metric_value in sensor_metrics.items():
if metric_name in resolved_metrics:
continue
resolved_metrics[metric_name] = metric_value
metric_sources[metric_name] = sensor_metric_sources[metric_name]
applied = True
return applied
def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
cluster_breakdown: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip()
for sub_block in snapshot.get("satellite_sub_blocks") or []:
cluster_breakdown.append(
{
"block_code": block_code,
"source": "satellite",
**dict(sub_block),
}
)
for sub_block in snapshot.get("sensor_sub_blocks") or []:
cluster_breakdown.append(
{
"block_code": block_code,
"source": "sensor",
**dict(sub_block),
}
)
return cluster_breakdown
AI_FARM_AGGREGATION_POLICY = {
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
"weather": "center_location_latest_forecast",
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing",
}
def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None:
farm = get_canonical_farm_record(farm_uuid)
if farm is None:
return None
sync_sensor_parameters_from_payload(farm.sensor_payload)
center_location = farm.center_location
weather = farm.weather_forecast
if weather is None:
weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
soil_snapshot = _build_farm_soil_snapshot(
center_location,
sensor_payload=farm.sensor_payload,
)
block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or [])
sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or [])
plant_assignments = get_farm_plant_assignments(farm)
return {
"farm_uuid": str(farm.farm_uuid),
"aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY),
"farm_metrics": {
"resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}),
"metric_sources": dict(soil_snapshot.get("metric_sources") or {}),
"status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"),
"aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get(
"aggregation_strategy", "missing"
),
},
"block_metrics": block_metrics,
"sub_block_metrics": sub_block_metrics,
"weather": {
"forecast": WeatherForecastDetailSerializer(weather).data if weather else None,
"source_metadata": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"status": "completed" if weather else "missing",
},
},
"plants": [
{
"plant_id": assignment.plant.backend_plant_id,
"position": assignment.position,
"stage": assignment.stage,
"metadata": assignment.metadata,
"assigned_at": assignment.assigned_at,
"updated_at": assignment.updated_at,
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
}
for assignment in plant_assignments
],
"irrigation_method": {
"id": farm.irrigation_method_id,
"details": (
IrrigationMethodSerializer(farm.irrigation_method).data
if farm.irrigation_method
else None
),
"source_metadata": {
"source": "farm_record",
"status": "completed" if farm.irrigation_method_id else "missing",
},
},
"source_metadata": {
"farm": {
"farm_uuid": str(farm.farm_uuid),
"center_location_id": center_location.id,
"has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")),
},
"farm_metrics": dict(soil_snapshot.get("source_metadata") or {}),
"block_metrics": {
"source": "build_location_block_satellite_snapshots",
"block_count": len(block_metrics),
"status": "completed" if block_metrics else "missing",
},
"sub_block_metrics": {
"source": "block_snapshot_sub_blocks",
"sub_block_count": len(sub_block_metrics),
"status": "completed" if sub_block_metrics else "missing",
},
"weather": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"note": "Weather remains tied to the farm center location.",
},
"plants": {
"source": "farm_plant_assignments",
"count": len(plant_assignments),
},
"irrigation_method": {
"source": "farm_record",
"status": "completed" if farm.irrigation_method_id else "missing",
},
},
}
def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None:
"""Return the canonical AI snapshot, or fall back to farm details for older consumers."""
snapshot = build_ai_farm_snapshot(farm_uuid)
if snapshot is None:
return None
return snapshot
def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None:
if not isinstance(snapshot, dict):
return None
farm_metrics = snapshot.get("farm_metrics") or {}
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
if isinstance(resolved_metrics, dict):
return resolved_metrics.get(metric_name)
return None
def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]:
if not isinstance(snapshot, dict):
return {}
weather_section = snapshot.get("weather") or {}
forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None
return forecast if isinstance(forecast, dict) else {}
def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
block_metrics: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
block_metrics.append(
{
"block_code": block_code,
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
"metric_sources": dict(snapshot.get("metric_sources") or {}),
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
"status": snapshot.get("status") or "missing",
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
"sub_block_count": int(snapshot.get("sub_block_count") or 0),
"temporal_extent": snapshot.get("temporal_extent"),
"source_metadata": {
"source": "build_location_block_satellite_snapshots",
"block_code": block_code,
"run_id": snapshot.get("run_id"),
"cell_count": snapshot.get("cell_count"),
},
}
)
return block_metrics
def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
sub_block_metrics: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or []
sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or []
if not satellite_sub_blocks and not sensor_sub_blocks:
sub_block_metrics.append(
{
"block_code": block_code,
"sub_block_code": "default-sub-block",
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "default_sub_block_compatibility",
"scope": "future_default_policy",
},
}
)
continue
sub_blocks_by_code: dict[str, dict[str, object]] = {}
for sub_block in satellite_sub_blocks:
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
entry = sub_blocks_by_code.setdefault(
sub_block_code,
{
"block_code": block_code,
"sub_block_code": sub_block_code,
"resolved_metrics": {},
"satellite_metrics": {},
"sensor_metrics": {},
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "block_snapshot_sub_blocks",
"satellite_present": False,
"sensor_present": False,
},
},
)
entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["satellite_metrics"])
entry["source_metadata"]["satellite_present"] = True
for sub_block in sensor_sub_blocks:
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
entry = sub_blocks_by_code.setdefault(
sub_block_code,
{
"block_code": block_code,
"sub_block_code": sub_block_code,
"resolved_metrics": {},
"satellite_metrics": {},
"sensor_metrics": {},
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "block_snapshot_sub_blocks",
"satellite_present": False,
"sensor_present": False,
},
},
)
entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["sensor_metrics"])
entry["source_metadata"]["sensor_present"] = True
sub_block_metrics.extend(sub_blocks_by_code.values())
return sub_block_metrics
def resolve_center_location_from_boundary(
farm_boundary: dict | list,
block_count: int = 1,
+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/",
+10
View File
@@ -28,6 +28,7 @@ from .services import (
ensure_location_and_weather_data,
get_farm_details,
resolve_center_location_from_boundary,
sync_devices_from_sensor_data,
sync_sensor_parameters_from_payload,
sync_plant_catalog_from_backend,
)
@@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView):
else:
farm_data.save()
sync_devices_from_sensor_data(farm_data)
if plant_ids is not None:
try:
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
@@ -280,6 +283,13 @@ class FarmDetailView(APIView):
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند "
"و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند."
),
examples=[
OpenApiExample(
"نمونه مسیر farm detail",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
parameter_only=("farm_uuid", "path"),
),
],
responses={
200: build_response(
FarmDetailEnvelopeSerializer,