Files
Ai/location_data/views.py
T
2026-05-11 00:52:12 +03:30

1216 lines
45 KiB
Python

from datetime import timedelta
from typing import Any
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,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
SoilLocation,
)
from farm_data.models import SensorData
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from .serializers import (
BlockSubdivisionSerializer,
NdviHealthRequestSerializer,
NdviHealthResponseSerializer,
RemoteSensingCellObservationSerializer,
RemoteSensingResponseSerializer,
RemoteSensingFarmRequestSerializer,
RemoteSensingRunSerializer,
RemoteSensingRunStatusResponseSerializer,
RemoteSensingSummarySerializer,
RemoteSensingSubdivisionResultSerializer,
SoilDataRequestSerializer,
SoilLocationResponseSerializer,
)
from .tasks import run_remote_sensing_analysis_task
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,
)
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="برای location موجود، pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود و sync اجرا نمی‌شود.",
request=RemoteSensingFarmRequestSerializer,
responses={
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)
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",
"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)
block_code = ""
observations = _get_remote_sensing_observations(
location=location,
block_code=block_code,
start_date=temporal_start,
end_date=temporal_end,
)
run = _get_latest_remote_sensing_run(
location=location,
block_code=block_code,
start_date=temporal_start,
end_date=temporal_end,
)
subdivision_result = _get_remote_sensing_subdivision_result(
location=location,
block_code=block_code,
start_date=temporal_start,
end_date=temporal_end,
)
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": "",
"chunk_size_sqm": getattr(run, "chunk_size_sqm", None),
"temporal_extent": {
"start_date": temporal_start.isoformat(),
"end_date": temporal_end.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": "",
"chunk_size_sqm": observations.first().cell.chunk_size_sqm,
"temporal_extent": {
"start_date": temporal_start.isoformat(),
"end_date": temporal_end.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=["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,
)
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
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)
response_payload = {
**status_payload,
"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": [],
"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 _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 _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"),
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(),
},
}