UPDATE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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", {}),
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user