This commit is contained in:
2026-05-11 04:38:44 +03:30
parent 17628f503f
commit c2b6052e5c
69 changed files with 3073 additions and 57 deletions
+336 -1
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from django.apps import apps
@@ -26,13 +27,16 @@ from .models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
RemoteSensingSubdivisionOption,
SoilLocation,
)
from farm_data.models import SensorData
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from .data_driven_subdivision import activate_subdivision_option
from .serializers import (
BlockSubdivisionSerializer,
NdviHealthRequestSerializer,
@@ -42,12 +46,25 @@ from .serializers import (
RemoteSensingFarmRequestSerializer,
RemoteSensingRunSerializer,
RemoteSensingRunStatusResponseSerializer,
RemoteSensingClusterBlockLiveRequestSerializer,
RemoteSensingClusterBlockLiveResponseSerializer,
RemoteSensingClusterBlockSerializer,
RemoteSensingSubdivisionOptionActivateResponseSerializer,
RemoteSensingSubdivisionOptionActivateSerializer,
RemoteSensingSubdivisionOptionListResponseSerializer,
RemoteSensingSubdivisionOptionSerializer,
RemoteSensingSummarySerializer,
RemoteSensingSubdivisionResultSerializer,
SoilDataRequestSerializer,
SoilLocationResponseSerializer,
)
from .tasks import run_remote_sensing_analysis_task
from .openeo_service import (
OpenEOAuthenticationError,
OpenEOExecutionError,
OpenEOServiceError,
compute_remote_sensing_metrics,
)
MAX_REMOTE_SENSING_PAGE_SIZE = 200
REMOTE_SENSING_RUN_STAGE_ORDER = (
@@ -119,6 +136,18 @@ RemoteSensingRunStatusEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingRunStatusEnvelopeSerializer",
RemoteSensingRunStatusResponseSerializer,
)
RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
RemoteSensingClusterBlockLiveResponseSerializer,
)
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
RemoteSensingSubdivisionOptionListResponseSerializer,
)
RemoteSensingSubdivisionOptionActivateEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionActivateEnvelopeSerializer",
RemoteSensingSubdivisionOptionActivateResponseSerializer,
)
class SoilDataView(APIView):
"""
ثبت مختصات گوشه‌های مزرعه و بلوک‌های تعریف‌شده توسط کشاورز.
@@ -679,6 +708,256 @@ class RemoteSensingRunStatusView(APIView):
)
class RemoteSensingClusterBlockLiveView(APIView):
@extend_schema(
tags=["Location Data"],
summary="دریافت زنده remote sensing برای زیر‌بلاک KMeans",
description="با دریافت UUID زیر‌بلاک ساخته‌شده توسط KMeans، هندسه همان زیر‌بلاک از دیتابیس خوانده می‌شود و داده تازه ماهواره‌ای از openEO برگردانده می‌شود.",
parameters=[
OpenApiParameter(
name="cluster_uuid",
type={"type": "string", "format": "uuid"},
location=OpenApiParameter.PATH,
required=True,
description="شناسه UUID زیر‌بلاک KMeans.",
),
OpenApiParameter(
name="temporal_start",
type={"type": "string", "format": "date"},
location=OpenApiParameter.QUERY,
required=False,
description="شروع بازه سفارشی. اگر ست شود، temporal_end هم باید ارسال شود.",
),
OpenApiParameter(
name="temporal_end",
type={"type": "string", "format": "date"},
location=OpenApiParameter.QUERY,
required=False,
description="پایان بازه سفارشی. اگر ست شود، temporal_start هم باید ارسال شود.",
),
OpenApiParameter(
name="days",
type=int,
location=OpenApiParameter.QUERY,
required=False,
default=30,
description="اگر بازه سفارشی ارسال نشود، از yesterday backfill با این تعداد روز استفاده می‌شود.",
),
],
responses={
200: build_response(
RemoteSensingClusterBlockLiveEnvelopeSerializer,
"داده زنده openEO برای زیر‌بلاک KMeans بازگردانده شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"پارامترهای ورودی یا هندسه زیر‌بلاک نامعتبر است.",
),
404: build_response(
SoilErrorResponseSerializer,
"زیر‌بلاک KMeans پیدا نشد.",
),
502: build_response(
SoilErrorResponseSerializer,
"خواندن داده از openEO ناموفق بود.",
),
},
)
def get(self, request, cluster_uuid):
serializer = RemoteSensingClusterBlockLiveRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
cluster_block = (
RemoteSensingClusterBlock.objects.select_related("soil_location", "block_subdivision", "result")
.filter(uuid=cluster_uuid)
.first()
)
if cluster_block is None:
return Response(
{"code": 404, "msg": "زیر‌بلاک KMeans پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
geometry = _resolve_cluster_block_geometry(cluster_block)
if not geometry:
return Response(
{
"code": 400,
"msg": "هندسه زیر‌بلاک KMeans نامعتبر است.",
"data": {"cluster_uuid": [str(cluster_block.uuid)]},
},
status=status.HTTP_400_BAD_REQUEST,
)
temporal_start, temporal_end = _resolve_live_remote_sensing_window(serializer.validated_data)
virtual_cell = _build_virtual_cluster_block_cell(cluster_block=cluster_block, geometry=geometry)
try:
remote_payload = compute_remote_sensing_metrics(
[virtual_cell],
temporal_start=temporal_start,
temporal_end=temporal_end,
selected_features=list(DEFAULT_CLUSTER_FEATURES),
)
except (OpenEOAuthenticationError, OpenEOExecutionError, OpenEOServiceError) as exc:
return Response(
{"code": 502, "msg": "خواندن داده از openEO ناموفق بود.", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
metrics = dict(remote_payload.get("results", {}).get(virtual_cell.cell_code, {}) or {})
response_payload = {
"status": "success",
"source": "openeo",
"cluster_block": RemoteSensingClusterBlockSerializer(cluster_block).data,
"temporal_extent": {
"start_date": temporal_start.isoformat(),
"end_date": temporal_end.isoformat(),
},
"selected_features": list(DEFAULT_CLUSTER_FEATURES),
"summary": {
"cell_count": int(cluster_block.cell_count or 0),
"ndvi_mean": _round_or_none(metrics.get("ndvi")),
"ndwi_mean": _round_or_none(metrics.get("ndwi")),
"soil_vv_db_mean": _round_or_none(metrics.get("soil_vv_db")),
},
"metrics": {
"ndvi": _round_or_none(metrics.get("ndvi")),
"ndwi": _round_or_none(metrics.get("ndwi")),
"soil_vv": _round_or_none(metrics.get("soil_vv")),
"soil_vv_db": _round_or_none(metrics.get("soil_vv_db")),
},
"metadata": {
**dict(remote_payload.get("metadata") or {}),
"requested_cluster_uuid": str(cluster_block.uuid),
"block_code": cluster_block.block_code,
"sub_block_code": cluster_block.sub_block_code,
},
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
class RemoteSensingSubdivisionOptionListView(APIView):
@extend_schema(
tags=["Location Data"],
summary="فهرست همه گزینه‌های K ذخیره‌شده برای یک subdivision result",
description="همه ترکیب‌های K که برای این run/result محاسبه و ذخیره شده‌اند را برمی‌گرداند و مشخص می‌کند کدام‌یک active و کدام‌یک recommended است.",
responses={
200: build_response(
RemoteSensingSubdivisionOptionListEnvelopeSerializer,
"فهرست گزینه‌های K بازگردانده شد.",
),
404: build_response(
SoilErrorResponseSerializer,
"subdivision result موردنظر پیدا نشد.",
),
},
)
def get(self, request, result_id):
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result_id)
.prefetch_related("options__cluster_blocks")
.first()
)
if result is None:
return Response(
{"code": 404, "msg": "subdivision result پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
options = list(result.options.all().order_by("requested_k"))
response_payload = {
"result_id": result.id,
"active_requested_k": next((option.requested_k for option in options if option.is_active), None),
"recommended_requested_k": next((option.requested_k for option in options if option.is_recommended), None),
"options": RemoteSensingSubdivisionOptionSerializer(options, many=True).data,
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
class RemoteSensingSubdivisionOptionActivateView(APIView):
@extend_schema(
tags=["Location Data"],
summary="فعال‌سازی یک K ذخیره‌شده برای subdivision result",
description="کاربر می‌تواند یکی از Kهای از قبل محاسبه‌شده و ذخیره‌شده را انتخاب کند تا active شود و خروجی اصلی subdivision بر همان مبنا sync شود.",
request=RemoteSensingSubdivisionOptionActivateSerializer,
responses={
200: build_response(
RemoteSensingSubdivisionOptionActivateEnvelopeSerializer,
"K انتخابی فعال شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"درخواست نامعتبر است یا K انتخابی موجود نیست.",
),
404: build_response(
SoilErrorResponseSerializer,
"subdivision result موردنظر پیدا نشد.",
),
},
)
def post(self, request, result_id):
serializer = RemoteSensingSubdivisionOptionActivateSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result_id)
.select_related("soil_location", "block_subdivision")
.prefetch_related("options__cluster_blocks", "options__assignments__cell")
.first()
)
if result is None:
return Response(
{"code": 404, "msg": "subdivision result پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
requested_k = serializer.validated_data["requested_k"]
option = next(
(candidate for candidate in result.options.all() if candidate.requested_k == requested_k),
None,
)
if option is None:
return Response(
{
"code": 400,
"msg": "K انتخابی برای این subdivision result موجود نیست.",
"data": {"requested_k": [requested_k]},
},
status=status.HTTP_400_BAD_REQUEST,
)
activate_subdivision_option(option=option, selection_source="user")
result.refresh_from_db()
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result.pk)
.prefetch_related("assignments__cell", "cluster_blocks", "options__cluster_blocks")
.first()
)
response_payload = {
"result_id": result.id,
"activated_requested_k": requested_k,
"subdivision_result": RemoteSensingSubdivisionResultSerializer(result).data,
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int, page_size: int) -> dict:
run_data = RemoteSensingRunSerializer(run).data
task_id = (run.metadata or {}).get("task_id")
@@ -1069,6 +1348,62 @@ def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) ->
return 900
def _resolve_live_remote_sensing_window(payload: dict[str, Any]):
temporal_start = payload.get("temporal_start")
temporal_end = payload.get("temporal_end")
if temporal_start and temporal_end:
return temporal_start, temporal_end
days = int(payload.get("days") or 30)
end_date = timezone.localdate() - timedelta(days=1)
start_date = end_date - timedelta(days=days - 1)
return start_date, end_date
def _resolve_cluster_block_geometry(cluster_block: RemoteSensingClusterBlock) -> dict[str, Any]:
geometry = dict(cluster_block.geometry or {})
if geometry.get("type") and geometry.get("coordinates"):
return geometry
cell_codes = list(cluster_block.cell_codes or [])
if not cell_codes:
return {}
cell_geometries = list(
AnalysisGridCell.objects.filter(
soil_location=cluster_block.soil_location,
cell_code__in=cell_codes,
)
.order_by("cell_code")
.values_list("geometry", flat=True)
)
polygon_coordinates: list[list[list[list[float]]]] = []
for cell_geometry in cell_geometries:
cell_geometry = dict(cell_geometry or {})
coordinates = cell_geometry.get("coordinates") or []
if cell_geometry.get("type") == "Polygon" and coordinates:
polygon_coordinates.append(coordinates)
elif cell_geometry.get("type") == "MultiPolygon" and coordinates:
polygon_coordinates.extend(coordinates)
if not polygon_coordinates:
return {}
if len(polygon_coordinates) == 1:
return {"type": "Polygon", "coordinates": polygon_coordinates[0]}
return {"type": "MultiPolygon", "coordinates": polygon_coordinates}
def _build_virtual_cluster_block_cell(
*,
cluster_block: RemoteSensingClusterBlock,
geometry: dict[str, Any],
):
return SimpleNamespace(
cell_code=f"cluster-{cluster_block.uuid}",
block_code=cluster_block.block_code,
soil_location_id=cluster_block.soil_location_id,
chunk_size_sqm=cluster_block.chunk_size_sqm,
geometry=geometry,
)
def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date):
queryset = (
AnalysisGridObservation.objects.select_related("cell", "run")
@@ -1104,7 +1439,7 @@ def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_d
temporal_end=end_date,
)
.select_related("run")
.prefetch_related("assignments__cell")
.prefetch_related("assignments__cell", "cluster_blocks")
.order_by("-created_at", "-id")
.first()
)