from django.apps import apps from django.core.paginator import EmptyPage, Paginator from django.db.models import Avg from django.db import transaction from rest_framework import status from drf_spectacular.utils import ( OpenApiExample, 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 ( AnalysisGridObservation, BlockSubdivision, RemoteSensingRun, RemoteSensingSubdivisionResult, SoilLocation, ) from .serializers import ( BlockSubdivisionSerializer, NdviHealthRequestSerializer, NdviHealthResponseSerializer, RemoteSensingCellObservationSerializer, RemoteSensingResponseSerializer, RemoteSensingResultQuerySerializer, RemoteSensingRunResultResponseSerializer, RemoteSensingRunSerializer, RemoteSensingRunStatusResponseSerializer, RemoteSensingSummarySerializer, RemoteSensingSubdivisionResultSerializer, RemoteSensingTriggerSerializer, SoilDataRequestSerializer, SoilLocationResponseSerializer, ) from .tasks import run_remote_sensing_analysis_task MAX_REMOTE_SENSING_PAGE_SIZE = 200 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.CharField(), }, ), ) RemoteSensingRunStatusEnvelopeSerializer = build_envelope_serializer( "RemoteSensingRunStatusEnvelopeSerializer", RemoteSensingRunStatusResponseSerializer, ) RemoteSensingRunResultEnvelopeSerializer = build_envelope_serializer( "RemoteSensingRunResultEnvelopeSerializer", RemoteSensingRunResultResponseSerializer, ) class SoilDataView(APIView): """ ثبت مختصات گوشه‌های مزرعه و بلوک‌های تعریف‌شده توسط کشاورز. """ @extend_schema( tags=["Soil 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=["Soil 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 [] 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") if changed_fields: changed_fields.append("updated_at") location.save(update_fields=changed_fields) 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=["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, ) class RemoteSensingAnalysisView(APIView): @extend_schema( tags=["Soil Data"], summary="اجرای async تحلیل سنجش‌ازدور و subdivision داده‌محور", description="برای location موجود، pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود و sync اجرا نمی‌شود.", request=RemoteSensingTriggerSerializer, responses={ 202: build_response( RemoteSensingQueuedEnvelopeSerializer, "درخواست تحلیل سنجش‌ازدور در صف قرار گرفت.", ), 400: build_response( SoilErrorResponseSerializer, "داده ورودی نامعتبر است.", ), 404: build_response( SoilErrorResponseSerializer, "location موردنظر پیدا نشد.", ), }, examples=[ OpenApiExample( "نمونه درخواست remote sensing", value={ "lat": 35.6892, "lon": 51.3890, "block_code": "block-1", "start_date": "2025-01-01", "end_date": "2025-01-31", "force_refresh": False, "cluster_count": 3, "selected_features": ["ndvi", "ndwi", "soil_vv_db"], }, request_only=True, ), ], ) def post(self, request): serializer = RemoteSensingTriggerSerializer(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 location = _get_location_by_lat_lon(payload["lat"], payload["lon"], prefetch=True) if location is None: return Response( {"code": 404, "msg": "location پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) block_code = str(payload.get("block_code", "") or "").strip() run = RemoteSensingRun.objects.create( soil_location=location, block_code=block_code, chunk_size_sqm=_resolve_chunk_size_for_location(location, block_code), temporal_start=payload["start_date"], temporal_end=payload["end_date"], status=RemoteSensingRun.STATUS_PENDING, metadata={ "requested_via": "api", "status_label": "pending", "cluster_count": payload.get("cluster_count"), "selected_features": payload.get("selected_features") or [], }, ) task_result = run_remote_sensing_analysis_task.delay( soil_location_id=location.id, block_code=block_code, temporal_start=payload["start_date"].isoformat(), temporal_end=payload["end_date"].isoformat(), force_refresh=payload.get("force_refresh", False), run_id=run.id, cluster_count=payload.get("cluster_count"), selected_features=payload.get("selected_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": block_code, "chunk_size_sqm": run.chunk_size_sqm, "temporal_extent": { "start_date": payload["start_date"].isoformat(), "end_date": payload["end_date"].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=["Soil Data"], summary="خواندن نتایج cache شده سنجش‌ازدور و subdivision", description="فقط نتایج ذخیره‌شده remote sensing و clustering را برمی‌گرداند و هیچ پردازش sync اجرا نمی‌کند.", parameters=[ {"name": "lat", "in": "query", "required": True, "schema": {"type": "number"}}, {"name": "lon", "in": "query", "required": True, "schema": {"type": "number"}}, {"name": "block_code", "in": "query", "required": False, "schema": {"type": "string"}}, {"name": "start_date", "in": "query", "required": True, "schema": {"type": "string", "format": "date"}}, {"name": "end_date", "in": "query", "required": True, "schema": {"type": "string", "format": "date"}}, {"name": "page", "in": "query", "required": False, "schema": {"type": "integer", "default": 1}}, {"name": "page_size", "in": "query", "required": False, "schema": {"type": "integer", "default": 100}}, ], responses={ 200: build_response( RemoteSensingEnvelopeSerializer, "نتایج cache شده remote sensing بازگردانده شد.", ), 404: build_response( SoilErrorResponseSerializer, "location موردنظر پیدا نشد.", ), 400: build_response( SoilErrorResponseSerializer, "داده ورودی نامعتبر است.", ), }, ) def get(self, request): serializer = RemoteSensingResultQuerySerializer(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 location = _get_location_by_lat_lon(payload["lat"], payload["lon"], prefetch=True) if location is None: return Response( {"code": 404, "msg": "location پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) block_code = str(payload.get("block_code", "") or "").strip() observations = _get_remote_sensing_observations( location=location, block_code=block_code, start_date=payload["start_date"], end_date=payload["end_date"], ) run = _get_latest_remote_sensing_run( location=location, block_code=block_code, start_date=payload["start_date"], end_date=payload["end_date"], ) subdivision_result = _get_remote_sensing_subdivision_result( location=location, block_code=block_code, start_date=payload["start_date"], end_date=payload["end_date"], ) if not observations.exists(): processing = run is not None and run.status in { RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING, } response_payload = { "status": "processing" if processing else "not_found", "source": "processing" if processing else "database", "location": SoilLocationResponseSerializer(location).data, "block_code": block_code, "chunk_size_sqm": getattr(run, "chunk_size_sqm", None), "temporal_extent": { "start_date": payload["start_date"].isoformat(), "end_date": payload["end_date"].isoformat(), }, "summary": _empty_remote_sensing_summary(), "cells": [], "run": RemoteSensingRunSerializer(run).data if run else None, "subdivision_result": None, } return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) paginated_observations = _paginate_observations( observations, page=payload["page"], page_size=payload["page_size"], ) paginated_assignments = [] pagination = {"cells": paginated_observations["pagination"]} if subdivision_result is not None: paginated = _paginate_assignments( subdivision_result, page=payload["page"], page_size=payload["page_size"], ) paginated_assignments = paginated["items"] pagination["assignments"] = paginated["pagination"] cells_data = RemoteSensingCellObservationSerializer(paginated_observations["items"], many=True).data subdivision_data = None if subdivision_result is not None: subdivision_data = RemoteSensingSubdivisionResultSerializer( subdivision_result, context={"paginated_assignments": paginated_assignments}, ).data response_payload = { "status": "success", "source": "database", "location": SoilLocationResponseSerializer(location).data, "block_code": block_code, "chunk_size_sqm": observations.first().cell.chunk_size_sqm, "temporal_extent": { "start_date": payload["start_date"].isoformat(), "end_date": payload["end_date"].isoformat(), }, "summary": _build_remote_sensing_summary(observations), "cells": cells_data, "run": RemoteSensingRunSerializer(run).data if run else None, "subdivision_result": subdivision_data, } if pagination is not None: response_payload["pagination"] = pagination return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) class RemoteSensingRunStatusView(APIView): @extend_schema( tags=["Soil Data"], summary="وضعیت run تحلیل سنجش‌ازدور", description="وضعیت async pipeline را با شناسه run برمی‌گرداند.", responses={ 200: build_response( RemoteSensingRunStatusEnvelopeSerializer, "وضعیت run بازگردانده شد.", ), 404: build_response( SoilErrorResponseSerializer, "run موردنظر پیدا نشد.", ), }, ) def get(self, request, run_id): run = RemoteSensingRun.objects.filter(pk=run_id).select_related("soil_location").first() if run is None: return Response( {"code": 404, "msg": "run پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) task_id = (run.metadata or {}).get("task_id") response_payload = { "status": RemoteSensingRunSerializer(run).data["status_label"], "source": "database", "run": RemoteSensingRunSerializer(run).data, "task_id": task_id, } return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) class RemoteSensingRunResultView(APIView): @extend_schema( tags=["Soil Data"], summary="نتیجه نهایی run تحلیل سنجش‌ازدور", description="نتایج observation و subdivision داده‌محور را با شناسه run برمی‌گرداند.", parameters=[ {"name": "page", "in": "query", "required": False, "schema": {"type": "integer", "default": 1}}, {"name": "page_size", "in": "query", "required": False, "schema": {"type": "integer", "default": 100}}, ], responses={ 200: build_response( RemoteSensingRunResultEnvelopeSerializer, "نتیجه run بازگردانده شد.", ), 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(pk=run_id) .select_related("soil_location") .first() ) if run is None: return Response( {"code": 404, "msg": "run پیدا نشد.", "data": None}, status=status.HTTP_404_NOT_FOUND, ) location = _get_location_by_lat_lon(run.soil_location.latitude, run.soil_location.longitude, prefetch=True) observations = _get_remote_sensing_observations( location=run.soil_location, block_code=run.block_code, start_date=run.temporal_start, end_date=run.temporal_end, ) subdivision_result = getattr(run, "subdivision_result", None) if not observations.exists(): response_payload = { "status": RemoteSensingRunSerializer(run).data["status_label"], "source": "processing" if run.status in {RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING} else "database", "location": SoilLocationResponseSerializer(location).data, "block_code": run.block_code, "chunk_size_sqm": run.chunk_size_sqm, "temporal_extent": { "start_date": run.temporal_start.isoformat() if run.temporal_start else None, "end_date": run.temporal_end.isoformat() if run.temporal_end else None, }, "summary": _empty_remote_sensing_summary(), "cells": [], "run": RemoteSensingRunSerializer(run).data, "subdivision_result": None, } return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) 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 response_payload = { "status": RemoteSensingRunSerializer(run).data["status_label"], "source": "database", "location": SoilLocationResponseSerializer(location).data, "block_code": run.block_code, "chunk_size_sqm": run.chunk_size_sqm, "temporal_extent": { "start_date": run.temporal_start.isoformat() if run.temporal_start else None, "end_date": run.temporal_end.isoformat() if run.temporal_end else None, }, "summary": _build_remote_sensing_summary(observations), "cells": RemoteSensingCellObservationSerializer(paginated_observations["items"], many=True).data, "run": RemoteSensingRunSerializer(run).data, "subdivision_result": subdivision_data, } if pagination is not None: response_payload["pagination"] = pagination return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) 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(): for index, block in enumerate(blocks): block_code = str(block.get("block_code") or f"block-{index + 1}").strip() boundary = block.get("boundary") or {} BlockSubdivision.objects.update_or_create( soil_location=location, block_code=block_code, defaults={ "source_boundary": boundary, "chunk_size_sqm": 900, "status": "defined", "metadata": { "definition_source": "farmer_input", "order": int(block.get("order") or index + 1), }, }, ) def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) -> int | None: if block_code: subdivision = location.block_subdivisions.filter(block_code=block_code).first() if subdivision is not None: return subdivision.chunk_size_sqm block_layout = location.block_layout or {} if not block_code: return block_layout.get("analysis_grid_summary", {}).get("chunk_size_sqm") for block in block_layout.get("blocks", []): if block.get("block_code") == block_code: return block.get("analysis_grid_summary", {}).get("chunk_size_sqm") return None def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date): 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") ) return queryset.filter(cell__block_code=block_code or "") def _get_latest_remote_sensing_run(*, location, block_code: str, start_date, end_date): return ( RemoteSensingRun.objects.filter( soil_location=location, block_code=block_code or "", temporal_start=start_date, temporal_end=end_date, ) .order_by("-created_at", "-id") .first() ) def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_date, end_date): return ( 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") .order_by("-created_at", "-id") .first() ) def _build_remote_sensing_summary(observations): aggregates = observations.aggregate( cell_count=Avg("cell_id"), ndvi_mean=Avg("ndvi"), ndwi_mean=Avg("ndwi"), lst_c_mean=Avg("lst_c"), soil_vv_db_mean=Avg("soil_vv_db"), dem_m_mean=Avg("dem_m"), slope_deg_mean=Avg("slope_deg"), ) summary = { "cell_count": observations.count(), "ndvi_mean": _round_or_none(aggregates.get("ndvi_mean")), "ndwi_mean": _round_or_none(aggregates.get("ndwi_mean")), "lst_c_mean": _round_or_none(aggregates.get("lst_c_mean")), "soil_vv_db_mean": _round_or_none(aggregates.get("soil_vv_db_mean")), "dem_m_mean": _round_or_none(aggregates.get("dem_m_mean")), "slope_deg_mean": _round_or_none(aggregates.get("slope_deg_mean")), } return summary def _empty_remote_sensing_summary(): return { "cell_count": 0, "ndvi_mean": None, "ndwi_mean": None, "lst_c_mean": None, "soil_vv_db_mean": None, "dem_m_mean": None, "slope_deg_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(), }, }