This commit is contained in:
2026-04-25 17:22:41 +03:30
parent 569d520a5c
commit aa24fc22b0
124 changed files with 8491 additions and 2582 deletions
+11
View File
@@ -1,3 +1,5 @@
from functools import cached_property
from django.apps import AppConfig
@@ -5,3 +7,12 @@ class SoilDataConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "location_data"
verbose_name = "Soil Data (SoilGrids)"
@cached_property
def ndvi_health_service(self):
from .ndvi import NdviHealthService
return NdviHealthService()
def get_ndvi_health_service(self):
return self.ndvi_health_service
+31
View File
@@ -117,3 +117,34 @@ class SoilDepthData(models.Model):
def __str__(self):
return f"SoilDepthData({self.soil_location_id}, {self.depth_label})"
class NdviObservation(models.Model):
location = models.ForeignKey(
SoilLocation,
on_delete=models.CASCADE,
related_name="ndvi_observations",
)
observation_date = models.DateField(db_index=True)
mean_ndvi = models.FloatField()
ndvi_map = models.JSONField(default=dict, blank=True)
vegetation_health_class = models.CharField(max_length=64)
satellite_source = models.CharField(max_length=64, default="sentinel-2")
cloud_cover = models.FloatField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = "dashboard_data_ndviobservation"
ordering = ["-observation_date", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=["location", "observation_date", "satellite_source"],
name="ndvi_unique_location_date_source",
)
]
verbose_name = "NDVI Observation"
verbose_name_plural = "NDVI Observations"
def __str__(self):
return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}"
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from farm_data.models import SensorData
from .remote_sensing import fetch_or_get_ndvi_observation
def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str:
ai_bundle = ai_bundle or {}
ai_payload = ai_bundle.get("ndviHealthCard", {}) if isinstance(ai_bundle, dict) else {}
explanation = ai_payload.get("explanation")
if isinstance(explanation, str) and explanation.strip():
return explanation.strip()
return (
f"میانگین NDVI مزرعه {observation.mean_ndvi} ثبت شده و کلاس سلامت پوشش گیاهی "
f"در وضعیت {observation.vegetation_health_class} قرار دارد."
)
def _build_ndvi_health_card(location: Any, ai_bundle: dict | None = None) -> dict[str, Any]:
if location is None:
return {
"mean_ndvi": None,
"ndvi_map": {},
"vegetation_health_class": None,
"observation_date": None,
"satellite_source": None,
"healthData": [],
}
observation = fetch_or_get_ndvi_observation(location)
if observation is None:
return {
"mean_ndvi": None,
"ndvi_map": {},
"vegetation_health_class": "Unavailable",
"observation_date": None,
"satellite_source": None,
"healthData": [
{
"title": "وضعیت NDVI",
"value": "داده ماهواره‌ای موجود نیست",
"color": "warning",
"icon": "tabler-satellite-off",
},
],
}
mean_value = round(observation.mean_ndvi, 2)
vegetation_class = observation.vegetation_health_class
return {
"ndviIndex": mean_value,
"mean_ndvi": mean_value,
"ndvi_map": observation.ndvi_map,
"vegetation_health_class": vegetation_class,
"observation_date": observation.observation_date.isoformat(),
"satellite_source": observation.satellite_source,
"healthData": [
{
"title": "سلامت پوشش گیاهی",
"value": vegetation_class,
"color": "success" if mean_value > 0.6 else "warning" if mean_value >= 0.4 else "error",
"icon": "tabler-plant",
},
{
"title": "تاریخ مشاهده",
"value": observation.observation_date.isoformat(),
"color": "info",
"icon": "tabler-calendar",
},
{
"title": "تفسیر",
"value": _ndvi_explanation(observation, ai_bundle=ai_bundle),
"color": "primary",
"icon": "tabler-message-2",
},
],
}
class NdviHealthService:
def get_ndvi_health(self, *, farm_uuid: str) -> dict[str, Any]:
sensor = (
SensorData.objects.select_related("center_location")
.filter(farm_uuid=farm_uuid)
.first()
)
if sensor is None:
raise ValueError("Farm not found.")
return _build_ndvi_health_card(sensor.center_location, ai_bundle=None)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
import requests
from .models import NdviObservation
DEFAULT_SATELLITE_SOURCE = "sentinel-2"
DEFAULT_CLOUD_COVER = 20.0
def classify_ndvi(mean_ndvi: float) -> str:
if mean_ndvi < 0.2:
return "Bare soil"
if mean_ndvi < 0.4:
return "Weak vegetation"
if mean_ndvi < 0.6:
return "Moderate vegetation"
return "Healthy vegetation"
def calculate_ndvi(red: float, nir: float) -> float | None:
denominator = nir + red
if denominator == 0:
return None
return round((nir - red) / denominator, 4)
def calculate_ndvi_grid(red_band: list[list[float]], nir_band: list[list[float]]) -> list[list[float | None]]:
grid: list[list[float | None]] = []
for red_row, nir_row in zip(red_band, nir_band):
row: list[float | None] = []
for red, nir in zip(red_row, nir_row):
row.append(calculate_ndvi(float(red), float(nir)))
grid.append(row)
return grid
def mean_ndvi(grid: list[list[float | None]]) -> float:
values = [value for row in grid for value in row if value is not None]
if not values:
return 0.0
return round(sum(values) / len(values), 4)
def _default_bbox(location: Any, delta: float = 0.001) -> list[float]:
lat = float(location.latitude)
lon = float(location.longitude)
return [lon - delta, lat - delta, lon + delta, lat + delta]
def _geometry_payload(location: Any) -> dict:
boundary = getattr(location, "farm_boundary", None) or {}
if boundary:
return boundary
return {"bbox": _default_bbox(location)}
@dataclass
class SatelliteNdviResult:
observation_date: str
mean_ndvi: float
ndvi_map: list[list[float | None]]
vegetation_health_class: str
satellite_source: str
cloud_cover: float | None
metadata: dict[str, Any]
class SentinelCompatibleNdviClient:
def __init__(self) -> None:
self.endpoint = os.environ.get("SATELLITE_NDVI_ENDPOINT")
self.api_key = os.environ.get("SATELLITE_NDVI_API_KEY")
self.source = os.environ.get("SATELLITE_SOURCE", DEFAULT_SATELLITE_SOURCE)
@property
def is_configured(self) -> bool:
return bool(self.endpoint and self.api_key)
def fetch_red_nir(
self,
geometry: dict,
date_from: date,
date_to: date,
cloud_cover: float,
) -> dict[str, Any] | None:
if not self.is_configured:
return None
response = requests.post(
self.endpoint,
json={
"geometry": geometry,
"date_from": date_from.isoformat(),
"date_to": date_to.isoformat(),
"cloud_cover_max": cloud_cover,
"source": self.source,
"bands": ["B04", "B08"],
},
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
timeout=30,
)
response.raise_for_status()
return response.json()
def fetch_or_get_ndvi_observation(
location: Any,
days_back: int = 7,
cloud_cover: float = DEFAULT_CLOUD_COVER,
) -> NdviObservation | None:
observation = location.ndvi_observations.order_by("-observation_date", "-created_at").first()
if observation is not None:
return observation
client = SentinelCompatibleNdviClient()
payload = client.fetch_red_nir(
geometry=_geometry_payload(location),
date_from=date.today() - timedelta(days=days_back),
date_to=date.today(),
cloud_cover=cloud_cover,
)
if not payload:
return None
red_band = payload.get("red_band") or []
nir_band = payload.get("nir_band") or []
observation_date = payload.get("observation_date") or date.today().isoformat()
ndvi_grid = calculate_ndvi_grid(red_band=red_band, nir_band=nir_band)
ndvi_mean = mean_ndvi(ndvi_grid)
return NdviObservation.objects.create(
location=location,
observation_date=date.fromisoformat(observation_date),
mean_ndvi=ndvi_mean,
ndvi_map={
"grid": ndvi_grid,
"red_band_source": "B04",
"nir_band_source": "B08",
},
vegetation_health_class=classify_ndvi(ndvi_mean),
satellite_source=payload.get("satellite_source", client.source),
cloud_cover=payload.get("cloud_cover"),
metadata={
"geometry": _geometry_payload(location),
"raw_payload_meta": payload.get("metadata", {}),
},
)
+21
View File
@@ -74,3 +74,24 @@ class SoilDataTaskResponseSerializer(serializers.Serializer):
lon = serializers.FloatField(source="longitude")
lat = serializers.FloatField(source="latitude")
status_url = serializers.CharField(required=False)
class NdviHealthRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
class NdviHealthDataItemSerializer(serializers.Serializer):
title = serializers.CharField()
value = serializers.JSONField()
color = serializers.CharField()
icon = serializers.CharField()
class NdviHealthResponseSerializer(serializers.Serializer):
ndviIndex = serializers.FloatField(allow_null=True, required=False)
mean_ndvi = serializers.FloatField(allow_null=True)
ndvi_map = serializers.JSONField()
vegetation_health_class = serializers.CharField(allow_null=True)
observation_date = serializers.CharField(allow_null=True)
satellite_source = serializers.CharField(allow_null=True)
healthData = NdviHealthDataItemSerializer(many=True)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="location_data.urls")
class NdviHealthApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@patch("location_data.views.apps.get_app_config")
def test_ndvi_health_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_ndvi_health=lambda **_kwargs: {
"ndviIndex": 0.68,
"mean_ndvi": 0.68,
"ndvi_map": {"grid": [[0.61, 0.7]]},
"vegetation_health_class": "Healthy vegetation",
"observation_date": "2026-04-02",
"satellite_source": "sentinel-2",
"healthData": [
{
"title": "سلامت پوشش گیاهی",
"value": "Healthy vegetation",
"color": "success",
"icon": "tabler-plant",
}
],
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_ndvi_health_service=lambda: mock_service
)
response = self.client.post(
"/ndvi-health/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["mean_ndvi"], 0.68)
self.assertEqual(payload["vegetation_health_class"], "Healthy vegetation")
@patch("location_data.views.apps.get_app_config")
def test_ndvi_health_api_returns_404_for_missing_farm(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_ndvi_health=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
)
mock_get_app_config.return_value = SimpleNamespace(
get_ndvi_health_service=lambda: mock_service
)
response = self.client.post(
"/ndvi-health/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "Farm not found.")
+2 -1
View File
@@ -1,8 +1,9 @@
from django.urls import path
from .views import SoilDataTaskStatusView, SoilDataView
from .views import NdviHealthView, SoilDataTaskStatusView, SoilDataView
urlpatterns = [
path("", SoilDataView.as_view(), name="soil-data"),
path("ndvi-health/", NdviHealthView.as_view(), name="ndvi-health"),
path("tasks/<str:task_id>/status/", SoilDataTaskStatusView.as_view(), name="soil-data-task-status"),
]
+60
View File
@@ -1,3 +1,4 @@
from django.apps import apps
from rest_framework import status
from drf_spectacular.utils import (
OpenApiExample,
@@ -16,6 +17,8 @@ from config.openapi import (
)
from .models import SoilLocation
from .serializers import (
NdviHealthRequestSerializer,
NdviHealthResponseSerializer,
SoilDataRequestSerializer,
SoilDepthDataSerializer,
SoilDataTaskResponseSerializer,
@@ -51,6 +54,10 @@ SoilTaskStatusResponseSerializer = build_envelope_serializer(
"SoilTaskStatusResponseSerializer",
build_task_status_data_serializer("SoilTaskStatusDataSerializer"),
)
NdviHealthEnvelopeSerializer = build_envelope_serializer(
"NdviHealthEnvelopeSerializer",
NdviHealthResponseSerializer,
)
class SoilDataView(APIView):
@@ -233,3 +240,56 @@ class SoilDataTaskStatusView(APIView):
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
class NdviHealthView(APIView):
@extend_schema(
tags=["Soil Data"],
summary="دریافت NDVI سلامت مزرعه",
description="با دریافت farm_uuid، داده NDVI سلامت پوشش گیاهی مزرعه را به صورت مستقل از dashboard برمی گرداند.",
request=NdviHealthRequestSerializer,
responses={
200: build_response(
NdviHealthEnvelopeSerializer,
"داده NDVI مزرعه با موفقیت بازگردانده شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"داده ورودی نامعتبر است.",
),
404: build_response(
SoilErrorResponseSerializer,
"مزرعه یافت نشد.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست NDVI",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
request_only=True,
)
],
)
def post(self, request):
serializer = NdviHealthRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("location_data").get_ndvi_health_service()
try:
data = service.get_ndvi_health(
farm_uuid=str(serializer.validated_data["farm_uuid"])
)
except ValueError as exc:
return Response(
{"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)