This commit is contained in:
2026-05-09 16:55:06 +03:30
parent 1679825ae2
commit cead7dafe2
51 changed files with 7514 additions and 1221 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import date
def load_farm_context(sensor_id: str) -> dict | None:
from irrigation.models import IrrigationMethod
from location_data.models import SoilDepthData
from location_data.satellite_snapshot import build_location_block_satellite_snapshots
from farm_data.models import SensorData
from farm_data.services import get_farm_plant_snapshots
from weather.models import WeatherForecast
@@ -16,7 +16,7 @@ def load_farm_context(sensor_id: str) -> dict | None:
return None
location = sensor.center_location
depths = list(SoilDepthData.objects.filter(soil_location=location).order_by("depth_label"))
satellite_snapshots = build_location_block_satellite_snapshots(location)
forecasts = list(
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7]
)
@@ -26,7 +26,7 @@ def load_farm_context(sensor_id: str) -> dict | None:
return {
"sensor": sensor,
"location": location,
"depths": depths,
"satellite_snapshots": satellite_snapshots,
"forecasts": forecasts,
"history": [],
"plants": plants,
+5 -2
View File
@@ -1,6 +1,5 @@
from rest_framework import serializers
from location_data.serializers import SoilDepthDataSerializer
from irrigation.models import IrrigationMethod
from irrigation.serializers import IrrigationMethodSerializer
from weather.models import WeatherForecast
@@ -19,6 +18,7 @@ class SensorDataUpdateSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
farm_boundary = serializers.JSONField(required=True)
block_count = serializers.IntegerField(required=False, min_value=1, default=1)
sensor_key = serializers.CharField(required=False, default=DEFAULT_SENSOR_KEY)
sensor_payload = serializers.JSONField(required=False)
plant_ids = serializers.ListField(
@@ -40,6 +40,7 @@ class SensorDataUpdateSerializer(serializers.Serializer):
known_fields = {
"farm_uuid",
"farm_boundary",
"block_count",
"sensor_key",
"sensor_payload",
"plant_ids",
@@ -150,6 +151,8 @@ class FarmCenterLocationSerializer(serializers.Serializer):
lat = serializers.DecimalField(max_digits=9, decimal_places=6)
lon = serializers.DecimalField(max_digits=9, decimal_places=6)
farm_boundary = serializers.JSONField()
input_block_count = serializers.IntegerField()
block_layout = serializers.JSONField()
class WeatherForecastDetailSerializer(serializers.ModelSerializer):
@@ -173,7 +176,7 @@ class WeatherForecastDetailSerializer(serializers.ModelSerializer):
class FarmSoilPayloadSerializer(serializers.Serializer):
resolved_metrics = serializers.JSONField()
metric_sources = serializers.JSONField()
depths = SoilDepthDataSerializer(many=True)
satellite_snapshots = serializers.JSONField()
class PlantCatalogSnapshotSerializer(serializers.ModelSerializer):
+55 -68
View File
@@ -12,11 +12,13 @@ from django.utils.dateparse import parse_datetime
import requests
from location_data.models import SoilLocation
from location_data.serializers import SoilDepthDataSerializer
from location_data.tasks import fetch_soil_data_for_coordinates
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_location_block_satellite_snapshots,
build_location_satellite_snapshot,
)
from irrigation.serializers import IrrigationMethodSerializer
from weather.services import update_weather_for_location
from weather.models import WeatherForecast
from .models import (
@@ -29,7 +31,6 @@ from .models import (
from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer
DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
DECIMAL_PRECISION = Decimal("0.000001")
logger = logging.getLogger(__name__)
@@ -231,7 +232,7 @@ def get_canonical_farm_record(farm_uuid: str) -> SensorData | None:
"weather_forecast",
"irrigation_method",
)
.prefetch_related("plant_assignments__plant", "center_location__depths")
.prefetch_related("plant_assignments__plant")
.filter(farm_uuid=farm_uuid)
.first()
)
@@ -461,14 +462,12 @@ def get_farm_details(farm_uuid: str):
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
)
depths = list(center_location.depths.all())
depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99)
soil_metrics = _surface_soil_metrics(depths)
latest_satellite = build_location_satellite_snapshot(center_location)
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: "soil" for key in 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]
@@ -482,6 +481,8 @@ def get_farm_details(farm_uuid: str):
"lat": center_location.latitude,
"lon": center_location.longitude,
"farm_boundary": center_location.farm_boundary,
"input_block_count": center_location.input_block_count,
"block_layout": center_location.block_layout,
},
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {},
@@ -489,7 +490,7 @@ def get_farm_details(farm_uuid: str):
"soil": {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"depths": SoilDepthDataSerializer(depths, many=True).data,
"satellite_snapshots": build_location_block_satellite_snapshots(center_location),
},
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
@@ -516,7 +517,10 @@ def get_farm_details(farm_uuid: str):
}
def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLocation:
def resolve_center_location_from_boundary(
farm_boundary: dict | list,
block_count: int = 1,
) -> SoilLocation:
"""
مرز مزرعه را می‌گیرد، مرکز را محاسبه می‌کند و رکورد SoilLocation را
ایجاد/به‌روزرسانی می‌کند.
@@ -530,13 +534,35 @@ def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLoc
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
center_lat, center_lon = _compute_polygon_centroid(normalized_points)
serialized_boundary = _serialize_boundary(farm_boundary)
normalized_block_count = max(int(block_count or 1), 1)
with transaction.atomic():
location, _ = SoilLocation.objects.update_or_create(
location, created = SoilLocation.objects.get_or_create(
latitude=center_lat,
longitude=center_lon,
defaults={"farm_boundary": _serialize_boundary(farm_boundary)},
defaults={
"farm_boundary": serialized_boundary,
"input_block_count": normalized_block_count,
},
)
if created:
location.set_input_block_count(normalized_block_count)
location.farm_boundary = serialized_boundary
location.save(update_fields=["farm_boundary", "input_block_count", "block_layout", "updated_at"])
if normalized_block_count == 1:
_create_initial_block_subdivision(location, serialized_boundary)
else:
changed_fields = []
if location.farm_boundary != serialized_boundary:
location.farm_boundary = serialized_boundary
changed_fields.append("farm_boundary")
if location.input_block_count != normalized_block_count:
location.set_input_block_count(normalized_block_count)
changed_fields.extend(["input_block_count", "block_layout"])
if changed_fields:
changed_fields.append("updated_at")
location.save(update_fields=changed_fields)
return location
@@ -550,36 +576,25 @@ def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | No
def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]:
"""
اگر داده خاک یا آب‌وهوا برای location موجود نباشد، از سرویس مربوطه
واکشی و در دیتابیس ذخیره می‌شود.
در فاز فعلی برای location_data و بلوک‌ها هیچ ریکوئست خارجی زده نمی‌شود
و فقط داده‌های محلی موجود برگردانده می‌شوند.
"""
if not location.is_complete:
try:
soil_result = fetch_soil_data_for_coordinates(
latitude=float(location.latitude),
longitude=float(location.longitude),
)
except Exception as exc:
raise ExternalDataSyncError(f"خطا در واکشی داده خاک: {exc}") from exc
if soil_result.get("status") != "completed":
raise ExternalDataSyncError(
soil_result.get("error") or "واکشی داده خاک کامل نشد."
)
location.refresh_from_db()
weather_forecast = resolve_weather_for_location(location)
if weather_forecast is None:
weather_result = update_weather_for_location(location)
if weather_result.get("status") not in {"success", "no_data"}:
raise ExternalDataSyncError(
weather_result.get("error") or "واکشی داده آب‌وهوا کامل نشد."
)
weather_forecast = resolve_weather_for_location(location)
return location, weather_forecast
def _create_initial_block_subdivision(
location: SoilLocation,
block_boundary: dict | list,
) -> BlockSubdivision:
subdivision, _created = create_or_get_block_subdivision(
location=location,
block_code="block-1",
boundary=block_boundary,
)
return subdivision
def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
if not isinstance(sensor_payload, dict):
return {}, {}
@@ -659,34 +674,6 @@ def _normalize_numeric_result(value: float, source_values: list[object]) -> int
return float(Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP))
def _surface_soil_metrics(depths) -> dict:
if not depths:
return {}
primary_depth = depths[0]
fields = [
"bdod",
"cec",
"cfvo",
"clay",
"nitrogen",
"ocd",
"ocs",
"phh2o",
"sand",
"silt",
"soc",
"wv0010",
"wv0033",
"wv1500",
]
return {
field: getattr(primary_depth, field)
for field in fields
if getattr(primary_depth, field) is not None
}
def _extract_boundary_points(boundary: dict | list) -> list:
if isinstance(boundary, dict):
if boundary.get("type") == "Polygon":
+59 -91
View File
@@ -5,7 +5,7 @@ import uuid
from django.test import TestCase
from rest_framework.test import APIClient
from location_data.models import SoilDepthData, SoilLocation
from location_data.models import BlockSubdivision, SoilLocation
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.services import (
assign_farm_plants_from_backend_ids,
@@ -42,19 +42,6 @@ class FarmDetailApiTests(TestCase):
longitude="51.400000",
farm_boundary={"type": "Polygon", "coordinates": []},
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=22.0,
nitrogen=10.0,
sand=40.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
nitrogen=8.0,
)
self.weather = WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 10),
@@ -123,9 +110,7 @@ class FarmDetailApiTests(TestCase):
self.assertEqual(resolved_metrics["nitrogen"], 99.0)
self.assertEqual(metric_sources["nitrogen"]["type"], "sensor")
self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value")
self.assertEqual(resolved_metrics["clay"], 22.0)
self.assertEqual(metric_sources["clay"], "soil")
self.assertEqual(len(payload["soil"]["depths"]), 2)
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"]}
@@ -204,21 +189,6 @@ class FarmDataUpsertApiTests(TestCase):
latitude="35.710000",
longitude="51.410000",
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=20.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="15-30cm",
clay=16.0,
)
self.boundary = square_boundary_for_center(35.71, 51.41)
self.weather = WeatherForecast.objects.create(
location=self.location,
@@ -312,16 +282,7 @@ class FarmDataUpsertApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("farm_uuid", response.json()["data"])
@patch("farm_data.services.update_weather_for_location", return_value={"status": "no_data"})
@patch(
"farm_data.services.fetch_soil_data_for_coordinates",
return_value={"status": "completed", "depths": []},
)
def test_post_creates_center_location_from_boundary_when_missing(
self,
_mock_fetch_soil_data_for_coordinates,
_mock_update_weather_for_location,
):
def test_post_creates_center_location_from_boundary_when_missing(self):
farm_uuid = uuid.uuid4()
response = self.client.post(
@@ -347,6 +308,60 @@ class FarmDataUpsertApiTests(TestCase):
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(
@@ -368,53 +383,10 @@ class FarmDataUpsertApiTests(TestCase):
self.assertEqual(str(location.latitude), "2.078947")
self.assertEqual(str(location.longitude), "2.078947")
@patch("farm_data.services.update_weather_for_location")
@patch("farm_data.services.fetch_soil_data_for_coordinates")
def test_post_fetches_missing_location_and_weather_data(
self,
mock_fetch_soil_data_for_coordinates,
mock_update_weather_for_location,
):
def test_post_keeps_missing_location_without_external_sync(self):
missing_boundary = square_boundary_for_center(36.0, 52.0)
farm_uuid = uuid.uuid4()
def soil_side_effect(latitude, longitude, task_id="", progress_callback=None):
location = SoilLocation.objects.get(
latitude="36.000000",
longitude="52.000000",
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="0-5cm",
defaults={"clay": 20.0},
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="5-15cm",
defaults={"clay": 18.0},
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="15-30cm",
defaults={"clay": 16.0},
)
return {"status": "completed", "location_id": location.id, "depths": ["0-5cm", "5-15cm", "15-30cm"]}
def weather_side_effect(location):
WeatherForecast.objects.update_or_create(
location=location,
forecast_date=date(2026, 4, 12),
defaults={
"temperature_min": 10.0,
"temperature_max": 20.0,
"temperature_mean": 15.0,
},
)
return {"status": "success", "location_id": location.id, "days_updated": 1}
mock_fetch_soil_data_for_coordinates.side_effect = soil_side_effect
mock_update_weather_for_location.side_effect = weather_side_effect
response = self.client.post(
"/api/farm-data/",
data={
@@ -426,9 +398,5 @@ class FarmDataUpsertApiTests(TestCase):
)
self.assertEqual(response.status_code, 201)
mock_fetch_soil_data_for_coordinates.assert_called_once()
mock_update_weather_for_location.assert_called_once()
farm = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertEqual(farm.center_location.depths.count(), 3)
self.assertIsNotNone(farm.weather_forecast_id)
self.assertIsNone(farm.weather_forecast_id)
+8 -1
View File
@@ -83,6 +83,7 @@ class FarmDataUpsertView(APIView):
"`farm_uuid` باید از API ارسال شود و هرگز خودکار ساخته نمی‌شود. "
"مرز مزرعه را می‌گیرد، مرکز زمین را خودش محاسبه و در location_data ذخیره می‌کند. "
"رکورد آب‌وهوا هم از همان مرکز زمین به‌صورت خودکار پیدا می‌شود. "
"در این مرحله برای location_data هیچ ریکوئست خارجی برای بلوک‌ها زده نمی‌شود. "
'خوانش‌ها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگه‌داری می‌شوند.'
),
request=SensorDataUpdateSerializer,
@@ -121,6 +122,7 @@ class FarmDataUpsertView(APIView):
]
],
},
"block_count": 3,
"sensor_payload": {
"sensor-7-1": {
"soil_moisture": 45.2,
@@ -147,6 +149,7 @@ class FarmDataUpsertView(APIView):
{"lat": 35.7200, "lon": 51.3900},
]
},
"block_count": 2,
"sensor_payload": {
"sensor-7-1": {
"soil_moisture": 45.2,
@@ -172,11 +175,15 @@ class FarmDataUpsertView(APIView):
farm_uuid = serializer.validated_data["farm_uuid"]
farm_boundary = serializer.validated_data["farm_boundary"]
block_count = serializer.validated_data.get("block_count", 1)
plant_ids = serializer.validated_data.get("plant_ids")
irrigation_method_id = serializer.validated_data.get("irrigation_method_id")
sensor_payload = serializer.validated_data.get("sensor_payload", {})
try:
center_location = resolve_center_location_from_boundary(farm_boundary)
center_location = resolve_center_location_from_boundary(
farm_boundary,
block_count=block_count,
)
except ValueError as exc:
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},