from datetime import timedelta from types import SimpleNamespace from typing import Any from uuid import uuid4 from django.apps import apps from django.core.paginator import EmptyPage, Paginator from django.db.models import Avg from django.db import transaction from django.utils import timezone from rest_framework import status from drf_spectacular.utils import ( OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema, inline_serializer, ) from rest_framework import serializers as drf_serializers from rest_framework.response import Response from rest_framework.views import APIView from config.openapi import ( build_envelope_serializer, build_response, ) 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 .cluster_recommendation import ( ClusterRecommendationNotFound, ClusterRecommendationValidationError, build_cluster_crop_recommendations, ) from .serializers import ( BlockSubdivisionSerializer, ClusterCropRecommendationRequestSerializer, ClusterCropRecommendationResponseSerializer, NdviHealthRequestSerializer, NdviHealthResponseSerializer, RemoteSensingCellObservationSerializer, RemoteSensingResponseSerializer, 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 = ( "queued", "running", "preparing_analysis_grid", "analysis_grid_ready", "analysis_cells_selected", "using_cached_observations", "fetching_remote_metrics", "remote_metrics_fetched", "observations_persisted", "clustering_completed", "completed", "failed", "retrying", ) SoilLocationPayloadSerializer = inline_serializer( name="SoilLocationPayloadSerializer", fields={ "source": drf_serializers.CharField(), "id": drf_serializers.IntegerField(), "lon": drf_serializers.DecimalField(max_digits=9, decimal_places=6), "lat": drf_serializers.DecimalField(max_digits=9, decimal_places=6), "input_block_count": drf_serializers.IntegerField(), "farm_boundary": drf_serializers.JSONField(), "block_layout": drf_serializers.JSONField(), "block_subdivisions": BlockSubdivisionSerializer(many=True), "satellite_snapshots": drf_serializers.JSONField(), }, ) SoilDataResponseSerializer = build_envelope_serializer( "SoilDataResponseSerializer", SoilLocationPayloadSerializer, ) SoilErrorResponseSerializer = build_envelope_serializer( "SoilErrorResponseSerializer", data_required=False, allow_null=True, ) NdviHealthEnvelopeSerializer = build_envelope_serializer( "NdviHealthEnvelopeSerializer", NdviHealthResponseSerializer, ) RemoteSensingEnvelopeSerializer = build_envelope_serializer( "RemoteSensingEnvelopeSerializer", RemoteSensingResponseSerializer, ) RemoteSensingQueuedEnvelopeSerializer = build_envelope_serializer( "RemoteSensingQueuedEnvelopeSerializer", inline_serializer( name="RemoteSensingQueuedPayloadSerializer", fields={ "status": drf_serializers.CharField(), "source": drf_serializers.CharField(), "location": drf_serializers.JSONField(), "block_code": drf_serializers.CharField(), "chunk_size_sqm": drf_serializers.IntegerField(allow_null=True), "temporal_extent": drf_serializers.JSONField(), "summary": RemoteSensingSummarySerializer(), "cells": drf_serializers.JSONField(), "run": drf_serializers.JSONField(allow_null=True), "task_id": drf_serializers.UUIDField(), }, ), ) RemoteSensingRunStatusEnvelopeSerializer = build_envelope_serializer( "RemoteSensingRunStatusEnvelopeSerializer", RemoteSensingRunStatusResponseSerializer, ) RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer( "RemoteSensingClusterBlockLiveEnvelopeSerializer", RemoteSensingClusterBlockLiveResponseSerializer, ) ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer( "ClusterCropRecommendationEnvelopeSerializer", ClusterCropRecommendationResponseSerializer, ) RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer( "RemoteSensingSubdivisionOptionListEnvelopeSerializer", RemoteSensingSubdivisionOptionListResponseSerializer, ) RemoteSensingSubdivisionOptionActivateEnvelopeSerializer = build_envelope_serializer( "RemoteSensingSubdivisionOptionActivateEnvelopeSerializer", RemoteSensingSubdivisionOptionActivateResponseSerializer, ) class SoilDataView(APIView): """ ثبت مختصات گوشه‌های مزرعه و بلوک‌های تعریف‌شده توسط کشاورز. """ @extend_schema( tags=["Location Data"], summary="خواندن ساختار مزرعه و بلوک‌ها (GET)", description="با ارسال lat و lon، ساختار ذخیره‌شده مزرعه، بلوک‌ها و آخرین خلاصه سنجش‌ازدور هر بلوک بازگردانده می‌شود.", parameters=[ { "name": "lat", "in": "query", "required": True, "schema": {"type": "number"}, "description": "عرض جغرافیایی", }, { "name": "lon", "in": "query", "required": True, "schema": {"type": "number"}, "description": "طول جغرافیایی", }, { "name": "block_code", "in": "query", "required": False, "schema": {"type": "string", "default": "block-1"}, "description": "در GET فقط برای فیلتر کلاینتی است و الگوریتمی اجرا نمی‌کند.", }, ], responses={ 200: build_response( SoilDataResponseSerializer, "ساختار بلوک‌های زمین از دیتابیس بازگردانده شد.", ), 404: build_response( SoilErrorResponseSerializer, "location موردنظر پیدا نشد.", ), 400: build_response( SoilErrorResponseSerializer, "پارامترهای ورودی نامعتبر هستند.", ), }, ) def get(self, request): serializer = SoilDataRequestSerializer(data=request.query_params) if not serializer.is_valid(): return Response( {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) lat = serializer.validated_data["lat"] lon = serializer.validated_data["lon"] location = _get_location_by_lat_lon(lat, lon, prefetch=True) if location is None: return Response( {"code": 404, "msg": "location پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) data_serializer = SoilLocationResponseSerializer(location) return Response( {"code": 200, "msg": "success", "data": {"source": "database", **data_serializer.data}}, status=status.HTTP_200_OK, ) @extend_schema( tags=["Location Data"], summary="ثبت مزرعه و بلوک‌های کشاورز (POST)", description="مختصات گوشه‌های مزرعه و boundary هر بلوک کشاورز ذخیره می‌شود. هیچ subdivision سنکرونی اجرا نمی‌شود.", request=SoilDataRequestSerializer, responses={ 200: build_response( SoilDataResponseSerializer, "اطلاعات location ذخیره یا به‌روزرسانی شد.", ), 400: build_response( SoilErrorResponseSerializer, "پارامترهای ورودی نامعتبر هستند.", ), }, examples=[ OpenApiExample( "نمونه درخواست", value={ "lat": 35.6892, "lon": 51.3890, "farm_boundary": { "type": "Polygon", "coordinates": [ [ [51.3890, 35.6890], [51.3902, 35.6890], [51.3902, 35.6900], [51.3890, 35.6900], [51.3890, 35.6890], ] ], }, "blocks": [ { "block_code": "block-1", "boundary": { "type": "Polygon", "coordinates": [ [ [51.3890, 35.6890], [51.3896, 35.6890], [51.3896, 35.6900], [51.3890, 35.6900], [51.3890, 35.6890], ] ], }, } ], }, request_only=True, ), ], ) def post(self, request): serializer = SoilDataRequestSerializer( data=request.data, context={"require_farm_boundary": True}, ) if not serializer.is_valid(): return Response( {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) lat = serializer.validated_data["lat"] lon = serializer.validated_data["lon"] block_count = serializer.validated_data.get("block_count", 1) farm_boundary = serializer.validated_data.get("farm_boundary") blocks = serializer.validated_data.get("blocks") or [] farm_boundary_changed = False lat_rounded = round(lat, 6) lon_rounded = round(lon, 6) location, created = SoilLocation.objects.get_or_create( latitude=lat_rounded, longitude=lon_rounded, defaults={ "input_block_count": block_count, "farm_boundary": farm_boundary or {}, }, ) if created: location.set_input_block_count(block_count, blocks=blocks or None) if farm_boundary is not None: location.farm_boundary = farm_boundary location.save(update_fields=["input_block_count", "farm_boundary", "block_layout", "updated_at"]) else: changed_fields = [] if block_count != location.input_block_count or blocks: location.set_input_block_count(block_count, blocks=blocks or None) changed_fields.extend(["input_block_count", "block_layout"]) if farm_boundary is not None and location.farm_boundary != farm_boundary: location.farm_boundary = farm_boundary changed_fields.append("farm_boundary") farm_boundary_changed = True if changed_fields: changed_fields.append("updated_at") location.save(update_fields=changed_fields) if farm_boundary_changed: _clear_block_analysis_state(location, "") if not (farm_boundary or location.farm_boundary): return Response( { "code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": ["برای ثبت location باید گوشه‌های کل زمین ارسال یا قبلاً ذخیره شده باشد."]}, }, status=status.HTTP_400_BAD_REQUEST, ) _sync_defined_blocks(location, blocks) location = _get_location_by_lat_lon(lat, lon, prefetch=True) data_serializer = SoilLocationResponseSerializer(location) return Response( { "code": 200, "msg": "success", "data": { "source": "created" if created else "database", **data_serializer.data, }, }, status=status.HTTP_200_OK, ) class NdviHealthView(APIView): @extend_schema( tags=["Location 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, ) class RemoteSensingAnalysisView(APIView): @extend_schema( tags=["Location Data"], summary="اجرای async تحلیل سنجش‌ازدور و subdivision داده‌محور", description=( "اگر خروجی cache شده برای مزرعه موجود باشد، همان داده مستقیم برگردانده می‌شود. " "در غیر این صورت pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود." ), request=RemoteSensingFarmRequestSerializer, responses={ 200: build_response( RemoteSensingEnvelopeSerializer, "خروجی cache شده remote sensing بدون enqueue کردن Celery بازگردانده شد.", ), 202: build_response( RemoteSensingQueuedEnvelopeSerializer, "درخواست تحلیل سنجش‌ازدور در صف قرار گرفت.", ), 400: build_response( SoilErrorResponseSerializer, "داده ورودی نامعتبر است.", ), 404: build_response( SoilErrorResponseSerializer, "location موردنظر پیدا نشد.", ), }, examples=[ OpenApiExample( "نمونه درخواست remote sensing", value={ "farm_uuid": "11111111-1111-1111-1111-111111111111", "force_refresh": False, }, request_only=True, ), ], ) def post(self, request): serializer = RemoteSensingFarmRequestSerializer(data=request.data) if not serializer.is_valid(): return Response( {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) payload = serializer.validated_data farm = SensorData.objects.select_related("center_location").filter(farm_uuid=payload["farm_uuid"]).first() location = getattr(farm, "center_location", None) if location is None: return Response( {"code": 404, "msg": "location پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) temporal_end = timezone.localdate() - timedelta(days=1) temporal_start = temporal_end - timedelta(days=30) if not payload.get("force_refresh", False): cached_response = _build_cached_remote_sensing_response( location=location, farm_uuid=str(payload["farm_uuid"]), block_code="", start_date=temporal_start, end_date=temporal_end, page=payload.get("page", 1), page_size=payload.get("page_size", 100), ) if cached_response is not None: processing = cached_response.get("status") == "processing" status_code = status.HTTP_202_ACCEPTED if processing else status.HTTP_200_OK response_payload = cached_response return Response( { "code": 202 if status_code == status.HTTP_202_ACCEPTED else 200, "msg": "success" if processing else "داده cache شده بازگردانده شد.", "data": response_payload, }, status=status_code, ) run = RemoteSensingRun.objects.create( soil_location=location, block_code="", chunk_size_sqm=_resolve_chunk_size_for_location(location, ""), temporal_start=temporal_start, temporal_end=temporal_end, status=RemoteSensingRun.STATUS_PENDING, metadata={ "requested_via": "api", "stage": "queued", "status_label": "pending", "requested_cluster_count": None, "selected_features": list(DEFAULT_CLUSTER_FEATURES), "farm_uuid": str(payload["farm_uuid"]), "scope": "all_blocks", }, ) task_result = run_remote_sensing_analysis_task.delay( soil_location_id=location.id, block_code="", temporal_start=temporal_start.isoformat(), temporal_end=temporal_end.isoformat(), force_refresh=payload.get("force_refresh", False), run_id=run.id, cluster_count=None, selected_features=list(DEFAULT_CLUSTER_FEATURES), ) run.metadata = {**(run.metadata or {}), "task_id": task_result.id} run.save(update_fields=["metadata", "updated_at"]) location_data = SoilLocationResponseSerializer(location).data response_payload = { "status": "processing", "source": "processing", "location": location_data, "block_code": "", "chunk_size_sqm": run.chunk_size_sqm, "temporal_extent": { "start_date": temporal_start.isoformat(), "end_date": temporal_end.isoformat(), }, "summary": _empty_remote_sensing_summary(), "cells": [], "run": RemoteSensingRunSerializer(run).data, "task_id": task_result.id, } return Response( {"code": 202, "msg": "تحلیل سنجش‌ازدور در صف قرار گرفت.", "data": response_payload}, status=status.HTTP_202_ACCEPTED, ) @extend_schema( tags=["Location Data"], summary="خواندن نتایج cache شده سنجش‌ازدور و subdivision", description="فقط نتایج ذخیره‌شده remote sensing و clustering را برمی‌گرداند و هیچ پردازش sync اجرا نمی‌کند.", parameters=[ OpenApiParameter( name="farm_uuid", type=str, location=OpenApiParameter.QUERY, required=True, description="شناسه یکتای مزرعه", default="11111111-1111-1111-1111-111111111111", ), OpenApiParameter( name="page", type=int, location=OpenApiParameter.QUERY, required=False, default=1, ), OpenApiParameter( name="page_size", type=int, location=OpenApiParameter.QUERY, required=False, default=100, ), ], responses={ 200: build_response( RemoteSensingEnvelopeSerializer, "نتایج cache شده remote sensing بازگردانده شد.", ), 404: build_response( SoilErrorResponseSerializer, "location موردنظر پیدا نشد.", ), 400: build_response( SoilErrorResponseSerializer, "داده ورودی نامعتبر است.", ), }, examples=[ OpenApiExample( "نمونه درخواست GET remote sensing", value={ "farm_uuid": "11111111-1111-1111-1111-111111111111", "page": 1, "page_size": 100, }, parameter_only=("farm_uuid", "query"), ), ], ) def get(self, request): serializer = RemoteSensingFarmRequestSerializer(data=request.query_params) if not serializer.is_valid(): return Response( {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) payload = serializer.validated_data farm = SensorData.objects.select_related("center_location").filter(farm_uuid=payload["farm_uuid"]).first() location = getattr(farm, "center_location", None) if location is None: return Response( {"code": 404, "msg": "location پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) temporal_end = timezone.localdate() - timedelta(days=1) temporal_start = temporal_end - timedelta(days=30) response_payload = _build_cached_remote_sensing_response( location=location, farm_uuid=str(payload["farm_uuid"]), block_code="", start_date=temporal_start, end_date=temporal_end, page=payload["page"], page_size=payload["page_size"], ) return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) class RemoteSensingRunStatusView(APIView): @extend_schema( tags=["Location Data"], summary="وضعیت run تحلیل سنجش‌ازدور", description="وضعیت async pipeline را با task_id از نوع UUID برمی‌گرداند. این task_id همان شناسه تسک Celery ذخیره‌شده در metadata.run است.", parameters=[ OpenApiParameter( name="run_id", type={"type": "string", "format": "uuid"}, location=OpenApiParameter.PATH, required=True, description="شناسه UUID تسک async (task_id).", ), ], responses={ 200: build_response( RemoteSensingRunStatusEnvelopeSerializer, "وضعیت run بازگردانده شد و بعد از اتمام، نتیجه نهایی نیز از همین route برگردانده می‌شود.", ), 404: build_response( SoilErrorResponseSerializer, "run موردنظر پیدا نشد.", ), }, ) def get(self, request, run_id): page = _safe_positive_int(request.query_params.get("page"), default=1) page_size = min(_safe_positive_int(request.query_params.get("page_size"), default=100), MAX_REMOTE_SENSING_PAGE_SIZE) run = RemoteSensingRun.objects.filter(metadata__task_id=str(run_id)).select_related("soil_location").first() if run is None: return Response( {"code": 404, "msg": "run با این task_id پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) response_payload = _build_remote_sensing_run_status_payload(run, page=page, page_size=page_size) return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) 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) cached_cluster_payload = _build_cached_cluster_block_live_payload( cluster_block=cluster_block, temporal_start=temporal_start, temporal_end=temporal_end, ) if cached_cluster_payload is not None: return Response( {"code": 200, "msg": "success", "data": cached_cluster_payload}, status=status.HTTP_200_OK, ) 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 RemoteSensingClusterRecommendationView(APIView): @extend_schema( tags=["Location Data"], summary="پیشنهاد گیاه برای هر کلاستر KMeans", description=( "با دریافت farm_uuid، داده هر کلاستر KMeans location_data به‌همراه " "ndvi، ndwi، soil_vv، soil_vv_db و مقایسه گیاه‌های ثبت‌شده در farm_data " "با crop_simulation برگردانده می‌شود و برای هر زیر‌بلاک یک گیاه پیشنهادی ارائه می‌شود." ), parameters=[ OpenApiParameter( name="farm_uuid", type=str, location=OpenApiParameter.QUERY, required=True, description="شناسه یکتای مزرعه", ), ], responses={ 200: build_response( ClusterCropRecommendationEnvelopeSerializer, "داده کلاسترها و پیشنهاد گیاه برای هر زیر‌بلاک بازگردانده شد.", ), 400: build_response( SoilErrorResponseSerializer, "پیش‌نیازهای مقایسه نامعتبر یا ناقص است.", ), 404: build_response( SoilErrorResponseSerializer, "مزرعه یا خروجی KMeans یافت نشد.", ), }, examples=[ OpenApiExample( "نمونه درخواست پیشنهاد گیاه برای کلاسترها", value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, parameter_only=("farm_uuid", "query"), ) ], ) def get(self, request): serializer = ClusterCropRecommendationRequestSerializer(data=request.query_params) if not serializer.is_valid(): return Response( {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) try: payload = build_cluster_crop_recommendations( str(serializer.validated_data["farm_uuid"]) ) except ClusterRecommendationNotFound as exc: return Response( {"code": 404, "msg": str(exc), "data": None}, status=status.HTTP_404_NOT_FOUND, ) except ClusterRecommendationValidationError as exc: return Response( {"code": 400, "msg": str(exc), "data": None}, status=status.HTTP_400_BAD_REQUEST, ) return Response( {"code": 200, "msg": "success", "data": 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") task_data = _build_remote_sensing_task_payload(run) effective_status = _apply_live_retry_state_override(run_data, task_data) status_payload = { "status": effective_status or run_data["status_label"], "source": "database", "run": run_data, "task_id": task_id, "task": task_data, } if run.status in {RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING}: return status_payload if run.status == RemoteSensingRun.STATUS_FAILURE: return status_payload source_run = _resolve_status_source_run(run) location = _get_location_by_lat_lon(run.soil_location.latitude, run.soil_location.longitude, prefetch=True) observations = _get_remote_sensing_observations( location=source_run.soil_location, block_code=source_run.block_code, start_date=source_run.temporal_start, end_date=source_run.temporal_end, run=source_run, ) subdivision_result = _resolve_status_subdivision_result(run, source_run=source_run) response_payload = { **status_payload, "location": SoilLocationResponseSerializer(location).data, "block_code": source_run.block_code, "chunk_size_sqm": source_run.chunk_size_sqm, "temporal_extent": { "start_date": source_run.temporal_start.isoformat() if source_run.temporal_start else None, "end_date": source_run.temporal_end.isoformat() if source_run.temporal_end else None, }, "summary": _empty_remote_sensing_summary(), "cells": [], "subdivision_result": None, } if not observations.exists(): return response_payload paginated_observations = _paginate_observations( observations, page=page, page_size=page_size, ) paginated_assignments = [] pagination = {"cells": paginated_observations["pagination"]} if subdivision_result is not None: paginated = _paginate_assignments( subdivision_result, page=page, page_size=page_size, ) paginated_assignments = paginated["items"] pagination["assignments"] = paginated["pagination"] response_payload["summary"] = _build_remote_sensing_summary(observations) response_payload["cells"] = RemoteSensingCellObservationSerializer( paginated_observations["items"], many=True, ).data response_payload["pagination"] = pagination if subdivision_result is not None: response_payload["subdivision_result"] = RemoteSensingSubdivisionResultSerializer( subdivision_result, context={"paginated_assignments": paginated_assignments}, ).data return response_payload def _get_remote_sensing_async_result(task_id: str): try: from celery.result import AsyncResult except ImportError: # pragma: no cover - fallback when Celery is absent return None try: return AsyncResult(task_id) except Exception: # pragma: no cover - depends on Celery backend configuration return None def _serialize_task_value(value): if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): return {str(key): _serialize_task_value(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_serialize_task_value(item) for item in value] return str(value) def _build_remote_sensing_task_payload(run: RemoteSensingRun) -> dict: metadata = dict(run.metadata or {}) timestamps = dict(metadata.get("timestamps") or {}) stage_details = dict(metadata.get("stage_details") or {}) current_stage = metadata.get("stage") failed_stage = metadata.get("failed_stage") task_payload = { "current_stage": current_stage, "current_stage_details": stage_details.get(current_stage, {}), "timestamps": timestamps, "stages": _build_remote_sensing_stage_entries( current_stage=current_stage, stage_details=stage_details, timestamps=timestamps, run_status=run.status, ), } if failed_stage: task_payload["failed_stage"] = failed_stage metric_progress = (stage_details.get("fetching_remote_metrics") or {}).get("metric_progress") if metric_progress: task_payload["metric_progress"] = metric_progress retry_context = stage_details.get("retrying") if retry_context: task_payload["retry"] = retry_context task_payload["last_error"] = retry_context.get("last_error") failure_reason = None if metadata.get("stage") == "failed" or run.status == RemoteSensingRun.STATUS_FAILURE: failure_reason = metadata.get("failure_reason") or run.error_message if failure_reason: task_payload["failure_reason"] = failure_reason task_id = metadata.get("task_id") celery_payload = _build_remote_sensing_celery_payload(str(task_id)) if task_id else None if celery_payload is not None: task_payload["celery"] = celery_payload return task_payload def _apply_live_retry_state_override(run_data: dict[str, Any], task_data: dict[str, Any]) -> str | None: celery_payload = task_data.get("celery") or {} if celery_payload.get("state") != "RETRY": return None retry_context = dict(task_data.get("retry") or {}) if not retry_context: retry_context = { "retry_count": None, "retry_delay_seconds": None, "last_error": task_data.get("failure_reason") or celery_payload.get("info"), "failed_stage": task_data.get("failed_stage"), "failed_stage_details": ( task_data.get("current_stage_details", {}) if task_data.get("current_stage") == "failed" else {} ), } task_data["retry"] = retry_context task_data["last_error"] = retry_context.get("last_error") or celery_payload.get("info") task_data["current_stage"] = "retrying" task_data["current_stage_details"] = retry_context task_data.pop("failure_reason", None) _upsert_retrying_stage_entry(task_data, retry_context) run_data["status_label"] = "retrying" run_data["pipeline_status"] = "retrying" run_data["stage"] = "retrying" return "retrying" def _upsert_retrying_stage_entry(task_data: dict[str, Any], retry_context: dict[str, Any]) -> None: stages = list(task_data.get("stages") or []) retrying_entry = { "name": "retrying", "status": "running", "entered_at": (task_data.get("timestamps") or {}).get("retrying_at"), "details": retry_context, } for index, entry in enumerate(stages): if entry.get("name") == "retrying": stages[index] = retrying_entry task_data["stages"] = stages return stages.append(retrying_entry) task_data["stages"] = stages def _build_remote_sensing_stage_entries( *, current_stage: str | None, stage_details: dict, timestamps: dict, run_status: str, ) -> list[dict]: stage_names = [] for stage_name in REMOTE_SENSING_RUN_STAGE_ORDER: if stage_name == current_stage or stage_name in stage_details or f"{stage_name}_at" in timestamps: stage_names.append(stage_name) if current_stage and current_stage not in stage_names: stage_names.append(current_stage) entries = [] for stage_name in stage_names: if run_status == RemoteSensingRun.STATUS_FAILURE and stage_name == current_stage: stage_status = "failed" elif stage_name == current_stage and run_status == RemoteSensingRun.STATUS_PENDING: stage_status = "pending" elif stage_name == current_stage and run_status == RemoteSensingRun.STATUS_RUNNING: stage_status = "running" else: stage_status = "completed" entries.append( { "name": stage_name, "status": stage_status, "entered_at": timestamps.get(f"{stage_name}_at"), "details": stage_details.get(stage_name, {}), } ) return entries def _build_remote_sensing_celery_payload(task_id: str) -> dict | None: async_result = _get_remote_sensing_async_result(task_id) if async_result is None: return None try: payload = { "state": str(async_result.state), "ready": bool(async_result.ready()), "successful": bool(async_result.successful()) if async_result.ready() else False, "failed": bool(async_result.failed()) if async_result.ready() else False, } except Exception: # pragma: no cover - depends on Celery backend configuration return None info = getattr(async_result, "info", None) if info not in (None, {}): payload["info"] = _serialize_task_value(info) if async_result.failed(): payload["error"] = _serialize_task_value(async_result.result) return payload def _create_cached_status_run( *, location: SoilLocation, farm_uuid: str, block_code: str, temporal_start, temporal_end, cached_response: dict[str, Any], ) -> RemoteSensingRun: source_run_id = ((cached_response.get("run") or {}).get("id")) source_result_id = ((cached_response.get("subdivision_result") or {}).get("id")) task_id = str(uuid4()) return RemoteSensingRun.objects.create( soil_location=location, block_subdivision=None, block_code=block_code or "", chunk_size_sqm=int(cached_response.get("chunk_size_sqm") or _resolve_chunk_size_for_location(location, block_code)), temporal_start=temporal_start, temporal_end=temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, started_at=timezone.now(), finished_at=timezone.now(), metadata={ "requested_via": "api", "farm_uuid": farm_uuid, "task_id": task_id, "stage": "completed", "status_label": "completed", "selected_features": list( ((cached_response.get("subdivision_result") or {}).get("selected_features")) or ((cached_response.get("run") or {}).get("selected_features")) or DEFAULT_CLUSTER_FEATURES ), "scope": "all_blocks", "cache_hit": True, "source_run_id": source_run_id, "source_result_id": source_result_id, "timestamps": { "queued_at": timezone.now().isoformat(), "completed_at": timezone.now().isoformat(), }, }, ) def _resolve_status_source_run(run: RemoteSensingRun) -> RemoteSensingRun: source_run_id = dict(run.metadata or {}).get("source_run_id") if not source_run_id: return run return RemoteSensingRun.objects.filter(pk=source_run_id).select_related("soil_location").first() or run def _resolve_status_subdivision_result( run: RemoteSensingRun, *, source_run: RemoteSensingRun, ) -> RemoteSensingSubdivisionResult | None: source_result_id = dict(run.metadata or {}).get("source_result_id") if source_result_id: return ( RemoteSensingSubdivisionResult.objects.filter(pk=source_result_id) .prefetch_related("assignments__cell", "cluster_blocks") .first() ) return getattr(source_run, "subdivision_result", None) def _get_location_by_lat_lon(lat, lon, *, prefetch: bool = False): lat_rounded = round(lat, 6) lon_rounded = round(lon, 6) queryset = SoilLocation.objects.filter(latitude=lat_rounded, longitude=lon_rounded) if prefetch: queryset = queryset.prefetch_related("block_subdivisions") return queryset.first() def _sync_defined_blocks(location: SoilLocation, blocks: list[dict]) -> None: if not blocks: return with transaction.atomic(): normalized_blocks = [] for index, block in enumerate(blocks): normalized_blocks.append( { "block_code": str(block.get("block_code") or f"block-{index + 1}").strip(), "boundary": block.get("boundary") or {}, "order": int(block.get("order") or index + 1), } ) normalized_codes = {block["block_code"] for block in normalized_blocks} existing_subdivisions = { subdivision.block_code: subdivision for subdivision in location.block_subdivisions.all() } for block_code, subdivision in existing_subdivisions.items(): if block_code not in normalized_codes: _clear_block_analysis_state(location, block_code, subdivision=subdivision) subdivision.delete() for block in normalized_blocks: block_code = block["block_code"] boundary = block["boundary"] subdivision = existing_subdivisions.get(block_code) definition_metadata = { "definition_source": "farmer_input", "order": block["order"], } if subdivision is None: BlockSubdivision.objects.create( soil_location=location, block_code=block_code, source_boundary=boundary, chunk_size_sqm=900, grid_points=[], centroid_points=[], grid_point_count=0, centroid_count=0, status="defined", metadata=definition_metadata, ) continue boundary_changed = subdivision.source_boundary != boundary metadata = dict(subdivision.metadata or {}) metadata.update(definition_metadata) subdivision.source_boundary = boundary subdivision.chunk_size_sqm = 900 subdivision.metadata = metadata if boundary_changed: _clear_block_analysis_state(location, block_code, subdivision=subdivision) subdivision.grid_points = [] subdivision.centroid_points = [] subdivision.grid_point_count = 0 subdivision.centroid_count = 0 subdivision.status = "defined" subdivision.metadata = definition_metadata subdivision.elbow_plot = None subdivision.save( update_fields=[ "source_boundary", "chunk_size_sqm", "grid_points", "centroid_points", "grid_point_count", "centroid_count", "status", "metadata", "elbow_plot", "updated_at", ] ) continue subdivision.save( update_fields=[ "source_boundary", "chunk_size_sqm", "metadata", "updated_at", ] ) def _clear_block_analysis_state( location: SoilLocation, block_code: str, *, subdivision: BlockSubdivision | None = None, ) -> None: AnalysisGridCell.objects.filter( soil_location=location, block_code=block_code or "", ).delete() RemoteSensingSubdivisionResult.objects.filter( soil_location=location, block_code=block_code or "", ).delete() RemoteSensingRun.objects.filter( soil_location=location, block_code=block_code or "", ).delete() if subdivision is None: return subdivision.grid_points = [] subdivision.centroid_points = [] subdivision.grid_point_count = 0 subdivision.centroid_count = 0 subdivision.status = "defined" subdivision.elbow_plot = None def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) -> int: if block_code: subdivision = location.block_subdivisions.filter(block_code=block_code).first() if subdivision is not None: return int(subdivision.chunk_size_sqm or 900) block_layout = location.block_layout or {} if not block_code: return int(block_layout.get("analysis_grid_summary", {}).get("chunk_size_sqm") or 900) for block in block_layout.get("blocks", []): if block.get("block_code") == block_code: return int(block.get("analysis_grid_summary", {}).get("chunk_size_sqm") or 900) return 900 def _build_cached_remote_sensing_response( *, location: SoilLocation, farm_uuid: str, block_code: str, start_date, end_date, page: int, page_size: int, ) -> dict[str, Any] | None: run = _get_latest_remote_sensing_run( location=location, farm_uuid=farm_uuid, block_code=block_code, start_date=start_date, end_date=end_date, ) subdivision_result = _get_remote_sensing_subdivision_result( location=location, farm_uuid=farm_uuid, block_code=block_code, start_date=start_date, end_date=end_date, ) observations = _get_remote_sensing_observations( location=location, block_code=block_code, start_date=start_date, end_date=end_date, run=run if run is not None else getattr(subdivision_result, "run", None), ) if run is None and subdivision_result is None: observations = observations.none() if not observations.exists(): fallback_cached_response = _build_fallback_cached_remote_sensing_response( location=location, farm_uuid=farm_uuid, block_code=block_code, page=page, page_size=page_size, ) if fallback_cached_response is not None: return fallback_cached_response if run is None: return None processing = run.status in { RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING, } source = "processing" if processing else "database" status_label = "processing" if processing else "not_found" payload = { "status": status_label, "source": source, "location": SoilLocationResponseSerializer(location).data, "block_code": block_code or "", "chunk_size_sqm": getattr(run, "chunk_size_sqm", None), "temporal_extent": { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), }, "summary": _empty_remote_sensing_summary(), "cells": [], "run": RemoteSensingRunSerializer(run).data, "subdivision_result": None, "metadata": { "farm_uuid": farm_uuid, "cache_hit": True, }, } return payload paginated_observations = _paginate_observations( observations, page=page, page_size=page_size, ) paginated_assignments = [] pagination = {"cells": paginated_observations["pagination"]} if subdivision_result is not None: paginated = _paginate_assignments( subdivision_result, page=page, page_size=page_size, ) paginated_assignments = paginated["items"] pagination["assignments"] = paginated["pagination"] subdivision_data = None if subdivision_result is not None: subdivision_data = RemoteSensingSubdivisionResultSerializer( subdivision_result, context={"paginated_assignments": paginated_assignments}, ).data payload = { "status": "success", "source": "database", "location": SoilLocationResponseSerializer(location).data, "block_code": block_code or "", "chunk_size_sqm": observations.first().cell.chunk_size_sqm, "temporal_extent": { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), }, "summary": _build_remote_sensing_summary(observations), "cells": RemoteSensingCellObservationSerializer( paginated_observations["items"], many=True, ).data, "run": RemoteSensingRunSerializer(run).data if run else None, "subdivision_result": subdivision_data, "pagination": pagination, "metadata": { "farm_uuid": farm_uuid, "cache_hit": True, }, } return payload def _build_fallback_cached_remote_sensing_response( *, location: SoilLocation, farm_uuid: str, block_code: str, page: int, page_size: int, ) -> dict[str, Any] | None: fallback_run = _get_latest_completed_remote_sensing_run( location=location, farm_uuid=farm_uuid, block_code=block_code, ) if fallback_run is None: return None fallback_observations = _get_remote_sensing_observations( location=location, block_code=block_code, start_date=fallback_run.temporal_start, end_date=fallback_run.temporal_end, run=fallback_run, ) if not fallback_observations.exists(): return None fallback_result = _get_remote_sensing_subdivision_result( location=location, farm_uuid=farm_uuid, block_code=block_code, start_date=fallback_run.temporal_start, end_date=fallback_run.temporal_end, ) paginated_observations = _paginate_observations( fallback_observations, page=page, page_size=page_size, ) paginated_assignments = [] pagination = {"cells": paginated_observations["pagination"]} if fallback_result is not None: paginated = _paginate_assignments( fallback_result, page=page, page_size=page_size, ) paginated_assignments = paginated["items"] pagination["assignments"] = paginated["pagination"] subdivision_data = None if fallback_result is not None: subdivision_data = RemoteSensingSubdivisionResultSerializer( fallback_result, context={"paginated_assignments": paginated_assignments}, ).data return { "status": "success", "source": "database", "location": SoilLocationResponseSerializer(location).data, "block_code": block_code or "", "chunk_size_sqm": fallback_run.chunk_size_sqm, "temporal_extent": { "start_date": fallback_run.temporal_start.isoformat() if fallback_run.temporal_start else None, "end_date": fallback_run.temporal_end.isoformat() if fallback_run.temporal_end else None, }, "summary": _build_remote_sensing_summary(fallback_observations), "cells": RemoteSensingCellObservationSerializer( paginated_observations["items"], many=True, ).data, "run": RemoteSensingRunSerializer(fallback_run).data, "subdivision_result": subdivision_data, "pagination": pagination, "metadata": { "farm_uuid": farm_uuid, "cache_hit": True, "cache_match": "latest_completed_for_farm", }, } 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 _build_cached_cluster_block_live_payload( *, cluster_block: RemoteSensingClusterBlock, temporal_start, temporal_end, ) -> dict[str, Any] | None: result = cluster_block.result if result.temporal_start != temporal_start or result.temporal_end != temporal_end: return None observations = ( AnalysisGridObservation.objects.select_related("cell") .filter( cell__soil_location=cluster_block.soil_location, cell__cell_code__in=list(cluster_block.cell_codes or []), temporal_start=temporal_start, temporal_end=temporal_end, ) .order_by("cell__cell_code") ) if not observations.exists(): return None metrics = observations.aggregate( ndvi=Avg("ndvi"), ndwi=Avg("ndwi"), soil_vv=Avg("soil_vv"), soil_vv_db=Avg("soil_vv_db"), ) return { "status": "success", "source": "database", "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 observations.count()), "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": { "requested_cluster_uuid": str(cluster_block.uuid), "cache_hit": True, "source_run_id": result.run_id, "source_result_id": result.id, }, } def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date, run=None): queryset = ( AnalysisGridObservation.objects.select_related("cell", "run") .filter( cell__soil_location=location, temporal_start=start_date, temporal_end=end_date, ) .order_by("cell__cell_code") ) queryset = queryset.filter(cell__block_code=block_code or "") if run is not None: queryset = queryset.filter(run=run) return queryset def _select_farm_scoped_run(runs, farm_uuid: str): legacy_candidate = None for run in runs: metadata = dict(run.metadata or {}) scoped_farm_uuid = metadata.get("farm_uuid") if scoped_farm_uuid == farm_uuid: return run if scoped_farm_uuid in (None, "") and legacy_candidate is None: legacy_candidate = run return legacy_candidate def _get_latest_remote_sensing_run(*, location, farm_uuid: str, block_code: str, start_date, end_date): runs = list( RemoteSensingRun.objects.filter( soil_location=location, block_code=block_code or "", temporal_start=start_date, temporal_end=end_date, ).order_by("-created_at", "-id") ) return _select_farm_scoped_run(runs, farm_uuid) def _get_latest_completed_remote_sensing_run(*, location, farm_uuid: str, block_code: str): runs = list( RemoteSensingRun.objects.filter( soil_location=location, block_code=block_code or "", status=RemoteSensingRun.STATUS_SUCCESS, ).order_by("-created_at", "-id") ) return _select_farm_scoped_run(runs, farm_uuid) def _get_remote_sensing_subdivision_result( *, location, farm_uuid: str, block_code: str, start_date, end_date, ): results = list( RemoteSensingSubdivisionResult.objects.filter( soil_location=location, block_code=block_code or "", temporal_start=start_date, temporal_end=end_date, ) .select_related("run") .prefetch_related("assignments__cell", "cluster_blocks") .order_by("-created_at", "-id") ) legacy_candidate = None for result in results: run = getattr(result, "run", None) scoped_farm_uuid = dict(getattr(run, "metadata", {}) or {}).get("farm_uuid") if scoped_farm_uuid == farm_uuid: return result if scoped_farm_uuid in (None, "") and legacy_candidate is None: legacy_candidate = result return legacy_candidate def _build_remote_sensing_summary(observations): aggregates = observations.aggregate( cell_count=Avg("cell_id"), ndvi_mean=Avg("ndvi"), ndwi_mean=Avg("ndwi"), soil_vv_db_mean=Avg("soil_vv_db"), ) summary = { "cell_count": observations.count(), "ndvi_mean": _round_or_none(aggregates.get("ndvi_mean")), "ndwi_mean": _round_or_none(aggregates.get("ndwi_mean")), "soil_vv_db_mean": _round_or_none(aggregates.get("soil_vv_db_mean")), } return summary def _empty_remote_sensing_summary(): return { "cell_count": 0, "ndvi_mean": None, "ndwi_mean": None, "soil_vv_db_mean": None, } def _round_or_none(value): if value is None: return None return round(float(value), 6) def _paginate_assignments(result: RemoteSensingSubdivisionResult, *, page: int, page_size: int) -> dict: page_size = min(max(page_size, 1), MAX_REMOTE_SENSING_PAGE_SIZE) assignments = result.assignments.select_related("cell").order_by("cluster_label", "cell__cell_code") paginator = Paginator(assignments, page_size) if paginator.count == 0: return { "items": [], "pagination": { "page": 1, "page_size": page_size, "total_items": 0, "total_pages": 0, "has_next": False, "has_previous": False, }, } try: page_obj = paginator.page(page) except EmptyPage: page_obj = paginator.page(paginator.num_pages) return { "items": list(page_obj.object_list), "pagination": { "page": page_obj.number, "page_size": page_size, "total_items": paginator.count, "total_pages": paginator.num_pages, "has_next": page_obj.has_next(), "has_previous": page_obj.has_previous(), }, } def _safe_positive_int(value, *, default: int) -> int: try: parsed = int(value) except (TypeError, ValueError): return default return parsed if parsed > 0 else default def _paginate_observations(observations, *, page: int, page_size: int) -> dict: page_size = min(max(page_size, 1), MAX_REMOTE_SENSING_PAGE_SIZE) paginator = Paginator(observations, page_size) if paginator.count == 0: return { "items": [], "pagination": { "page": 1, "page_size": page_size, "total_items": 0, "total_pages": 0, "has_next": False, "has_previous": False, }, } try: page_obj = paginator.page(page) except EmptyPage: page_obj = paginator.page(paginator.num_pages) return { "items": list(page_obj.object_list), "pagination": { "page": page_obj.number, "page_size": page_size, "total_items": paginator.count, "total_pages": paginator.num_pages, "has_next": page_obj.has_next(), "has_previous": page_obj.has_previous(), }, }