Files

1755 lines
64 KiB
Python
Raw Permalink Normal View History

2026-03-29 13:40:23 +03:30
import math
2026-05-13 22:29:18 +03:30
import hashlib
2026-03-29 13:40:23 +03:30
from copy import deepcopy
2026-03-29 15:07:14 +03:30
from decimal import Decimal
2026-04-01 17:28:24 +03:30
from datetime import timedelta
2026-03-29 13:40:23 +03:30
from django.conf import settings
2026-05-13 22:29:18 +03:30
from django.core.exceptions import ImproperlyConfigured
2026-04-01 17:28:24 +03:30
from celery.result import AsyncResult
2026-03-30 23:29:03 +03:30
from kombu.exceptions import OperationalError
2026-03-29 13:40:23 +03:30
from django.db import transaction
2026-03-29 15:07:14 +03:30
from django.db.models import Prefetch
2026-04-01 17:28:24 +03:30
from django.utils import timezone
2026-04-02 23:25:39 +03:30
from farm_hub.models import FarmHub
2026-03-29 15:07:14 +03:30
from external_api_adapter.adapter import request as external_request
2026-05-05 21:01:58 +03:30
from .defaults import DEFAULT_AREA_FEATURE, DEFAULT_PRODUCTS_PAYLOAD
2026-03-29 15:07:14 +03:30
from .models import (
CropArea,
CropProduct,
CropZone,
CropZoneAnalysis,
CropZoneCriteria,
CropZoneCultivationRiskLayer,
CropZoneRecommendation,
CropZoneSoilQualityLayer,
CropZoneWaterNeedLayer,
)
2026-03-29 13:40:23 +03:30
EARTH_RADIUS_METERS = 6378137.0
2026-05-05 21:01:58 +03:30
PRODUCT_DEFAULTS = DEFAULT_PRODUCTS_PAYLOAD["products"]
2026-03-30 23:29:03 +03:30
DEFAULT_CELL_SIDE_KM = 0.15
2026-04-01 18:38:05 +03:30
DEFAULT_ZONE_PAGE_SIZE = 10
2026-03-30 23:29:03 +03:30
RULE_BASED_ALGORITHM = "rule_based_v1"
RULE_BASED_PRODUCTS = {
"wheat": {
"water_need": "۴۵۰۰-۵۵۰۰ m³/ha",
"water_need_level": "medium",
"estimated_profit": "۱۵-۲۵ میلیون/هکتار",
"reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی",
},
"canola": {
"water_need": "۵۰۰۰-۶۰۰۰ m³/ha",
"water_need_level": "high",
"estimated_profit": "۲۰-۳۵ میلیون/هکتار",
"reason": "پایداری بهتر در برابر نوسان دما و پتانسیل سود اقتصادی مناسب",
},
"saffron": {
"water_need": "۳۰۰۰-۴۰۰۰ m³/ha",
"water_need_level": "low",
"estimated_profit": "۵۰-۱۵۰ میلیون/هکتار",
"reason": "اقلیم خشک‌تر و نیاز آبی کمتر این زون برای زعفران مناسب‌تر است",
},
}
RULE_BASED_CROP_IDS = tuple(RULE_BASED_PRODUCTS.keys())
2026-04-01 17:28:24 +03:30
TASK_STATE_PENDING = "PENDING"
TASK_STATE_STARTED = "STARTED"
TASK_STATE_RETRY = "RETRY"
TASK_STATE_SUCCESS = "SUCCESS"
TASK_STATE_FAILURE = "FAILURE"
TASK_STATE_REVOKED = "REVOKED"
2026-05-13 22:29:18 +03:30
AI_LOCATION_DATA_PATH = "/api/location-data/"
AI_REMOTE_SENSING_PATH = "/api/location-data/remote-sensing/"
AI_CLUSTER_RECOMMENDATIONS_PATH = "/api/location-data/remote-sensing/cluster-recommendations/"
2026-03-30 23:29:03 +03:30
def get_default_cell_side_km():
raw_value = getattr(settings, "CROP_ZONE_CELL_SIDE_KM", None)
try:
cell_side_km = float(raw_value)
except (TypeError, ValueError):
cell_side_km = 0
if cell_side_km > 0:
return cell_side_km
2026-03-29 13:40:23 +03:30
raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0)
try:
chunk_area = float(raw_value)
except (TypeError, ValueError):
chunk_area = 0
2026-03-30 23:29:03 +03:30
if chunk_area > 0:
return math.sqrt(chunk_area) / 1000.0
return DEFAULT_CELL_SIDE_KM
2026-04-01 17:28:24 +03:30
def get_task_stale_seconds():
raw_value = getattr(settings, "CROP_ZONE_TASK_STALE_SECONDS", 300)
try:
stale_seconds = int(raw_value)
except (TypeError, ValueError):
stale_seconds = 300
return max(stale_seconds, 0)
2026-03-30 23:29:03 +03:30
def get_cell_side_km(cell_side_km=None):
if cell_side_km is None or cell_side_km == "":
resolved_value = get_default_cell_side_km()
else:
try:
resolved_value = float(cell_side_km)
except (TypeError, ValueError) as exc:
raise ValueError("cell_side_km must be a positive number.") from exc
if resolved_value <= 0:
raise ValueError("cell_side_km must be a positive number.")
return resolved_value
def get_chunk_area_sqm(cell_side_km=None):
resolved_cell_side_km = get_cell_side_km(cell_side_km)
return (resolved_cell_side_km * 1000.0) ** 2
2026-03-29 13:40:23 +03:30
2026-04-01 18:38:05 +03:30
def parse_positive_int(value, field_name, default=None):
if value in {None, ""}:
if default is None:
raise ValueError(f"{field_name} must be a positive integer.")
return default
try:
parsed_value = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be a positive integer.") from exc
if parsed_value <= 0:
raise ValueError(f"{field_name} must be a positive integer.")
return parsed_value
def get_zone_page_request_params(query_params):
return (
parse_positive_int(query_params.get("page"), "page", default=1),
parse_positive_int(query_params.get("page_size"), "page_size", default=DEFAULT_ZONE_PAGE_SIZE),
)
2026-03-29 15:07:14 +03:30
def get_default_area_feature():
2026-05-05 21:01:58 +03:30
return deepcopy(DEFAULT_AREA_FEATURE["area"])
2026-03-29 15:07:14 +03:30
def normalize_area_feature(area_feature):
if area_feature is None:
raise ValueError("Area polygon coordinates are required.")
if not isinstance(area_feature, dict):
raise ValueError("Area GeoJSON must be an object.")
if area_feature.get("type") == "Feature":
geometry = deepcopy(area_feature.get("geometry") or {})
normalized_feature = {
"type": "Feature",
"properties": deepcopy(area_feature.get("properties") or {}),
"geometry": geometry,
}
else:
normalized_feature = {
"type": "Feature",
"properties": {},
"geometry": deepcopy(area_feature),
}
geometry = normalized_feature.get("geometry") or {}
if geometry.get("type") != "Polygon":
raise ValueError("Area GeoJSON geometry type must be Polygon.")
ring = get_polygon_ring(normalized_feature)
if len(ring) < 4:
raise ValueError("Area polygon must contain at least four coordinates.")
return normalized_feature
def ensure_products_exist():
for product in PRODUCT_DEFAULTS:
CropProduct.objects.update_or_create(
product_id=product["id"],
defaults={"label": product["label"], "color": product["color"]},
)
def get_products_payload():
ensure_products_exist()
products = CropProduct.objects.order_by("id")
return {
"products": [
{"id": product.product_id, "label": product.label, "color": product.color}
for product in products
]
}
2026-03-29 13:40:23 +03:30
def get_polygon_ring(area_feature):
geometry = (area_feature or {}).get("geometry", {})
coordinates = geometry.get("coordinates", [])
if not coordinates or not coordinates[0]:
raise ValueError("Area polygon coordinates are required.")
return coordinates[0]
def polygon_area_sqm(ring):
if len(ring) < 4:
return 0.0
latitudes = [point[1] for point in ring]
mean_latitude = math.radians(sum(latitudes) / len(latitudes))
projected_points = []
for longitude, latitude in ring:
x = math.radians(longitude) * EARTH_RADIUS_METERS * math.cos(mean_latitude)
y = math.radians(latitude) * EARTH_RADIUS_METERS
projected_points.append((x, y))
area = 0.0
for index in range(len(projected_points) - 1):
x1, y1 = projected_points[index]
x2, y2 = projected_points[index + 1]
area += (x1 * y2) - (x2 * y1)
return abs(area) / 2.0
def normalize_points(ring):
if len(ring) > 1 and ring[0] == ring[-1]:
ring = ring[:-1]
return [[point[0], point[1]] for point in ring]
def calculate_center(points):
if not points:
return {"longitude": 0.0, "latitude": 0.0}
longitude = sum(point[0] for point in points) / len(points)
latitude = sum(point[1] for point in points) / len(points)
return {
"longitude": round(longitude, 8),
"latitude": round(latitude, 8),
}
2026-03-30 23:29:03 +03:30
def get_bbox(points):
longitudes = [point[0] for point in points]
latitudes = [point[1] for point in points]
return {
"min_lng": min(longitudes),
"max_lng": max(longitudes),
"min_lat": min(latitudes),
"max_lat": max(latitudes),
}
2026-03-29 13:40:23 +03:30
2026-03-30 23:29:03 +03:30
def meters_to_latitude_delta(meters):
return meters / 111320.0
def meters_to_longitude_delta(meters, latitude):
longitude_factor = 111320.0 * math.cos(math.radians(latitude))
if abs(longitude_factor) < 1e-9:
2026-03-29 13:40:23 +03:30
longitude_factor = 1.0
2026-03-30 23:29:03 +03:30
return meters / longitude_factor
def point_in_polygon(point, polygon_points):
x, y = point
inside = False
point_count = len(polygon_points)
if point_count < 3:
return False
for index in range(point_count):
x1, y1 = polygon_points[index]
x2, y2 = polygon_points[(index + 1) % point_count]
intersects = ((y1 > y) != (y2 > y)) and (
x < ((x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12)) + x1
)
if intersects:
inside = not inside
return inside
def _orientation(point_a, point_b, point_c):
value = ((point_b[1] - point_a[1]) * (point_c[0] - point_b[0])) - (
(point_b[0] - point_a[0]) * (point_c[1] - point_b[1])
)
if abs(value) < 1e-12:
return 0
return 1 if value > 0 else 2
def _on_segment(point_a, point_b, point_c):
return (
min(point_a[0], point_c[0]) - 1e-12 <= point_b[0] <= max(point_a[0], point_c[0]) + 1e-12
and min(point_a[1], point_c[1]) - 1e-12 <= point_b[1] <= max(point_a[1], point_c[1]) + 1e-12
)
def segments_intersect(point_a, point_b, point_c, point_d):
orientation_1 = _orientation(point_a, point_b, point_c)
orientation_2 = _orientation(point_a, point_b, point_d)
orientation_3 = _orientation(point_c, point_d, point_a)
orientation_4 = _orientation(point_c, point_d, point_b)
2026-03-29 13:40:23 +03:30
2026-03-30 23:29:03 +03:30
if orientation_1 != orientation_2 and orientation_3 != orientation_4:
return True
2026-03-29 13:40:23 +03:30
2026-03-30 23:29:03 +03:30
if orientation_1 == 0 and _on_segment(point_a, point_c, point_b):
return True
if orientation_2 == 0 and _on_segment(point_a, point_d, point_b):
return True
if orientation_3 == 0 and _on_segment(point_c, point_a, point_d):
return True
if orientation_4 == 0 and _on_segment(point_c, point_b, point_d):
return True
return False
def rectangle_contains_point(point, cell_points):
min_lng = min(vertex[0] for vertex in cell_points)
max_lng = max(vertex[0] for vertex in cell_points)
min_lat = min(vertex[1] for vertex in cell_points)
max_lat = max(vertex[1] for vertex in cell_points)
return min_lng <= point[0] <= max_lng and min_lat <= point[1] <= max_lat
def polygon_intersects_cell(polygon_points, cell_points):
cell_center = calculate_center(cell_points)
if point_in_polygon([cell_center["longitude"], cell_center["latitude"]], polygon_points):
return True
if any(point_in_polygon(point, polygon_points) for point in cell_points):
return True
if any(rectangle_contains_point(point, cell_points) for point in polygon_points):
return True
polygon_edges = list(zip(polygon_points, polygon_points[1:] + polygon_points[:1]))
cell_edges = list(zip(cell_points, cell_points[1:] + cell_points[:1]))
return any(
segments_intersect(start_a, end_a, start_b, end_b)
for start_a, end_a in polygon_edges
for start_b, end_b in cell_edges
)
def build_square_points(left_lng, bottom_lat, right_lng, top_lat):
2026-03-29 13:40:23 +03:30
return [
2026-03-30 23:29:03 +03:30
[round(left_lng, 8), round(bottom_lat, 8)],
[round(right_lng, 8), round(bottom_lat, 8)],
[round(right_lng, 8), round(top_lat, 8)],
[round(left_lng, 8), round(top_lat, 8)],
2026-03-29 13:40:23 +03:30
]
2026-03-30 23:29:03 +03:30
def build_zone_square(area_points, center, zone_area_sqm):
if len(area_points) < 4:
return area_points
width = math.sqrt(max(zone_area_sqm, 1))
half_width = width / 2.0
delta_lat = meters_to_latitude_delta(half_width)
delta_lng = meters_to_longitude_delta(half_width, center["latitude"])
return build_square_points(
center["longitude"] - delta_lng,
center["latitude"] - delta_lat,
center["longitude"] + delta_lng,
center["latitude"] + delta_lat,
)
def split_area_into_zones(area_feature, cell_side_km=None):
2026-03-29 13:40:23 +03:30
area_ring = get_polygon_ring(area_feature)
area_points = normalize_points(area_ring)
area_center = calculate_center(area_points)
total_area_sqm = polygon_area_sqm(area_ring)
2026-03-30 23:29:03 +03:30
resolved_cell_side_km = get_cell_side_km(cell_side_km)
chunk_area_sqm = get_chunk_area_sqm(resolved_cell_side_km)
cell_side_meters = resolved_cell_side_km * 1000.0
bbox = get_bbox(area_points)
latitude_step = meters_to_latitude_delta(cell_side_meters)
2026-03-29 13:40:23 +03:30
zones = []
2026-03-30 23:29:03 +03:30
sequence = 0
current_lat = bbox["min_lat"]
while current_lat < bbox["max_lat"] - 1e-12:
next_lat = current_lat + latitude_step
row_center_lat = current_lat + (latitude_step / 2.0)
longitude_step = meters_to_longitude_delta(cell_side_meters, row_center_lat)
current_lng = bbox["min_lng"]
while current_lng < bbox["max_lng"] - 1e-12:
next_lng = current_lng + longitude_step
zone_points = build_square_points(current_lng, current_lat, next_lng, next_lat)
if polygon_intersects_cell(area_points, zone_points):
zone_geometry = {
"type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]],
}
zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0])
zones.append(
{
"zone_id": f"zone-{sequence}",
"geometry": zone_geometry,
"points": zone_points,
"center": calculate_center(zone_points),
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"sequence": sequence,
}
)
sequence += 1
current_lng = next_lng
current_lat = next_lat
if not zones:
zone_points = build_zone_square(area_points, area_center, max(total_area_sqm, chunk_area_sqm))
2026-03-29 13:40:23 +03:30
zone_geometry = {
2026-03-29 15:07:14 +03:30
"type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]],
2026-03-29 13:40:23 +03:30
}
2026-03-30 23:29:03 +03:30
zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0])
2026-03-29 13:40:23 +03:30
zones.append(
{
2026-03-30 23:29:03 +03:30
"zone_id": "zone-0",
2026-03-29 13:40:23 +03:30
"geometry": zone_geometry,
"points": zone_points,
2026-03-30 23:29:03 +03:30
"center": area_center,
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"sequence": 0,
2026-03-29 13:40:23 +03:30
}
)
2026-03-30 23:29:03 +03:30
zone_count = len(zones)
2026-03-29 13:40:23 +03:30
2026-03-29 15:07:14 +03:30
area_geometry = {
"type": "Feature",
"properties": {},
"geometry": deepcopy(area_feature.get("geometry", {})),
}
2026-03-29 13:40:23 +03:30
area_geometry.setdefault("properties", {})
area_geometry["properties"].update(
{
"center": area_center,
"area_sqm": round(total_area_sqm, 2),
"area_hectares": round(total_area_sqm / 10000, 4),
2026-03-30 23:29:03 +03:30
"cell_side_km": round(resolved_cell_side_km, 4),
2026-03-29 13:40:23 +03:30
}
)
return {
"area": {
"geometry": area_geometry,
"points": area_points,
"center": area_center,
"area_sqm": total_area_sqm,
"area_hectares": total_area_sqm / 10000,
"chunk_area_sqm": chunk_area_sqm,
2026-03-30 23:29:03 +03:30
"cell_side_km": resolved_cell_side_km,
2026-03-29 13:40:23 +03:30
"zone_count": zone_count,
},
"zones": zones,
}
2026-03-30 23:29:03 +03:30
def build_rule_based_zone_metrics(index, coords):
if coords:
first_longitude, first_latitude = coords[0]
else:
first_longitude, first_latitude = (0.0, 0.0)
seed = int((index * 7) + math.floor(first_latitude * 100) + math.floor(first_longitude * 100))
crop_id = RULE_BASED_CROP_IDS[abs(seed) % len(RULE_BASED_CROP_IDS)]
crop_metadata = RULE_BASED_PRODUCTS[crop_id]
match_percent = 60 + (abs(seed) % 35)
criteria = [
{"name": "دما", "value": 55 + (abs(seed + 11) % 40)},
{"name": "بارش", "value": 55 + (abs(seed + 17) % 40)},
{"name": "خاک", "value": 55 + (abs(seed + 23) % 40)},
{"name": "آب", "value": 55 + (abs(seed + 29) % 40)},
]
soil_quality_score = criteria[2]["value"]
soil_level = _pick_level(soil_quality_score, 65, 85)
cultivation_risk_score = max(1, min(100, round(100 - match_percent + ((abs(seed) % 9) - 4))))
cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 60 else "high"
return {
"soil_quality_score": soil_quality_score,
"soil_level": soil_level,
"water_need_level": crop_metadata["water_need_level"],
"water_need_value": crop_metadata["water_need"],
"cultivation_risk_level": cultivation_risk_level,
"recommended_crop": crop_id,
"match_percent": match_percent,
"estimated_profit": crop_metadata["estimated_profit"],
"reason": crop_metadata["reason"],
"criteria": criteria,
"algorithm": RULE_BASED_ALGORITHM,
}
2026-03-29 15:07:14 +03:30
def build_initial_zone_payload(zone):
recommendation = getattr(zone, "recommendation", None)
return {
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
"estimatedProfit": recommendation.estimated_profit if recommendation else "",
}
2026-04-01 17:28:24 +03:30
def build_area_zone_payload(zone):
2026-04-08 23:00:54 +03:30
base_payload = _build_area_layer_zone_base_payload(zone)
2026-04-01 17:28:24 +03:30
recommendation = getattr(zone, "recommendation", None)
water_need_layer = getattr(zone, "water_need_layer", None)
soil_quality_layer = getattr(zone, "soil_quality_layer", None)
cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None)
2026-04-08 23:00:54 +03:30
base_payload.update(
{
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
"estimatedProfit": recommendation.estimated_profit if recommendation else "",
"waterNeedLayer": {
"level": getattr(water_need_layer, "level", ""),
"value": getattr(water_need_layer, "value", ""),
"color": getattr(water_need_layer, "color", ""),
},
"soilQualityLayer": {
"level": getattr(soil_quality_layer, "level", ""),
"score": getattr(soil_quality_layer, "score", 0),
"color": getattr(soil_quality_layer, "color", ""),
},
"cultivationRiskLayer": {
"level": getattr(cultivation_risk_layer, "level", ""),
"color": getattr(cultivation_risk_layer, "color", ""),
},
}
)
return base_payload
2026-05-13 22:29:18 +03:30
def _serialize_cluster_candidate(candidate_payload):
if not isinstance(candidate_payload, dict):
return None
return {
"plantId": candidate_payload.get("plant_id"),
"plantName": str(candidate_payload.get("plant_name") or ""),
"position": candidate_payload.get("position"),
"stage": str(candidate_payload.get("stage") or ""),
"score": candidate_payload.get("score"),
"predictedYield": candidate_payload.get("predicted_yield"),
"predictedYieldTons": candidate_payload.get("predicted_yield_tons"),
"biomass": candidate_payload.get("biomass"),
"maxLai": candidate_payload.get("max_lai"),
"simulationEngine": candidate_payload.get("simulation_engine"),
"simulationModelName": candidate_payload.get("simulation_model_name"),
"simulationWarning": str(candidate_payload.get("simulation_warning") or ""),
"supportingMetrics": deepcopy(candidate_payload.get("supporting_metrics") or {}),
}
def _get_zone_ai_cluster_payload(zone):
analysis = getattr(zone, "analysis", None)
raw_response = getattr(analysis, "raw_response", None)
if not isinstance(raw_response, dict):
return {}
cluster_payload = raw_response.get("cluster_recommendation") or {}
if isinstance(cluster_payload, dict):
return cluster_payload
return {}
def _build_zone_cluster_info(zone, cluster_payload):
cluster_block = cluster_payload.get("cluster_block") or {}
2026-04-01 17:28:24 +03:30
return {
2026-05-13 22:29:18 +03:30
"blockCode": str(cluster_payload.get("block_code") or ""),
"clusterUuid": str(cluster_payload.get("cluster_uuid") or cluster_block.get("uuid") or zone.zone_id),
"subBlockCode": str(cluster_payload.get("sub_block_code") or cluster_block.get("sub_block_code") or zone.zone_id),
"clusterLabel": cluster_payload.get("cluster_label"),
"cellCount": cluster_block.get("cell_count"),
"cellCodes": deepcopy(cluster_block.get("cell_codes") or []),
"centerCellCode": cluster_block.get("center_cell_code"),
"centerCellLat": cluster_block.get("center_cell_lat"),
"centerCellLon": cluster_block.get("center_cell_lon"),
"sourceMetadata": deepcopy(cluster_payload.get("source_metadata") or {}),
}
def _build_zone_cluster_metrics(cluster_payload):
if not cluster_payload:
return {
"satelliteMetrics": {},
"sensorMetrics": {},
"resolvedMetrics": {},
"criteria": [],
}
suggested_plant = cluster_payload.get("suggested_plant")
return {
"satelliteMetrics": deepcopy(cluster_payload.get("satellite_metrics") or {}),
"sensorMetrics": deepcopy(cluster_payload.get("sensor_metrics") or {}),
"resolvedMetrics": deepcopy(cluster_payload.get("resolved_metrics") or {}),
"criteria": _build_metric_criteria(cluster_payload, suggested_plant),
}
def _build_zone_crop_prediction(cluster_payload):
if not cluster_payload:
return {"suggestedPlant": None, "candidatePlants": []}
return {
"suggestedPlant": _serialize_cluster_candidate(cluster_payload.get("suggested_plant")),
"candidatePlants": [
item
for item in (
_serialize_cluster_candidate(candidate_payload)
for candidate_payload in (cluster_payload.get("candidate_plants") or [])
)
if item is not None
],
}
def _attach_ai_zone_payload(base_payload, zone):
cluster_payload = _get_zone_ai_cluster_payload(zone)
base_payload["clusterInfo"] = _build_zone_cluster_info(zone, cluster_payload)
base_payload["clusterMetrics"] = _build_zone_cluster_metrics(cluster_payload)
base_payload["cropPrediction"] = _build_zone_crop_prediction(cluster_payload)
return base_payload
def _build_area_layer_zone_base_payload(zone):
return _attach_ai_zone_payload(
{
2026-04-01 17:28:24 +03:30
"zoneId": zone.zone_id,
"zoneUuid": str(zone.uuid),
"geometry": zone.geometry,
"center": zone.center,
"area_sqm": zone.area_sqm,
"area_hectares": zone.area_hectares,
"sequence": zone.sequence,
"processing_status": zone.processing_status,
"processing_error": zone.processing_error,
2026-05-13 22:29:18 +03:30
},
zone,
)
2026-04-01 17:28:24 +03:30
2026-04-08 23:00:54 +03:30
def build_water_need_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
water_need_layer = getattr(zone, "water_need_layer", None)
base_payload["waterNeedLayer"] = {
"level": getattr(water_need_layer, "level", ""),
"value": getattr(water_need_layer, "value", ""),
"color": getattr(water_need_layer, "color", ""),
}
return base_payload
def build_soil_quality_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
soil_quality_layer = getattr(zone, "soil_quality_layer", None)
base_payload["soilQualityLayer"] = {
"level": getattr(soil_quality_layer, "level", ""),
"score": getattr(soil_quality_layer, "score", 0),
"color": getattr(soil_quality_layer, "color", ""),
}
return base_payload
def build_cultivation_risk_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None)
base_payload["cultivationRiskLayer"] = {
"level": getattr(cultivation_risk_layer, "level", ""),
"color": getattr(cultivation_risk_layer, "color", ""),
}
return base_payload
2026-03-30 23:29:03 +03:30
def persist_zone_analysis_metrics(zone, metrics):
ensure_products_exist()
product = CropProduct.objects.get(product_id=metrics["recommended_crop"])
recommendation, _ = CropZoneRecommendation.objects.update_or_create(
crop_zone=zone,
defaults={
"product": product,
"match_percent": metrics["match_percent"],
"water_need": metrics["water_need_value"],
"estimated_profit": metrics["estimated_profit"],
"reason": metrics["reason"],
},
)
CropZoneCriteria.objects.filter(recommendation=recommendation).delete()
CropZoneCriteria.objects.bulk_create(
[
CropZoneCriteria(
recommendation=recommendation,
name=item["name"],
value=item["value"],
sequence=index,
)
for index, item in enumerate(metrics["criteria"])
]
)
CropZoneWaterNeedLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["water_need_level"],
"value": metrics["water_need_value"],
"color": _get_level_color_map("water", metrics["water_need_level"]),
},
)
CropZoneSoilQualityLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["soil_level"],
"score": metrics["soil_quality_score"],
"color": _get_level_color_map("soil", metrics["soil_level"]),
},
)
CropZoneCultivationRiskLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["cultivation_risk_level"],
"color": _get_level_color_map("risk", metrics["cultivation_risk_level"]),
},
)
return recommendation
def ensure_rule_based_zone_data(zone, force=False):
has_recommendation = CropZoneRecommendation.objects.filter(crop_zone=zone).exists()
if has_recommendation and not force:
return zone
metrics = build_rule_based_zone_metrics(zone.sequence, zone.points)
persist_zone_analysis_metrics(zone, metrics)
return zone
2026-03-29 15:07:14 +03:30
def _get_level_color_map(layer_name, level):
mappings = {
"water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"},
"soil": {"low": "#ef4444", "medium": "#eab308", "high": "#22c55e"},
"risk": {"low": "#22c55e", "medium": "#f59e0b", "high": "#ef4444"},
}
return mappings[layer_name][level]
def _pick_level(score, low_threshold, high_threshold):
if score >= high_threshold:
return "high"
if score >= low_threshold:
return "medium"
return "low"
def _format_range(start, end, suffix):
return f"{start}-{end} {suffix}"
def _derive_analysis_metrics(depths):
if not depths:
return {
"soil_quality_score": 0,
"soil_level": "low",
"water_need_level": "high",
"water_need_value": "0-0 m³/ha",
"cultivation_risk_level": "high",
"recommended_crop": PRODUCT_DEFAULTS[0]["id"],
"match_percent": 0,
"estimated_profit": "0-0 میلیون/هکتار",
"reason": "داده تحلیل خاک موجود نیست",
"criteria": [],
}
avg_ph = sum(item.get("phh2o", 0) for item in depths) / len(depths)
avg_soc = sum(item.get("soc", 0) for item in depths) / len(depths)
avg_clay = sum(item.get("clay", 0) for item in depths) / len(depths)
avg_nitrogen = sum(item.get("nitrogen", 0) for item in depths) / len(depths)
avg_wv0033 = sum(item.get("wv0033", 0) for item in depths) / len(depths)
soil_quality_score = max(0, min(100, round((avg_soc * 20) + (avg_nitrogen * 120) + (avg_wv0033 * 120) + (20 - abs(avg_ph - 7) * 10))))
soil_level = _pick_level(soil_quality_score, 50, 80)
water_base = round(3000 + (avg_clay * 70))
water_need_value = _format_range(water_base, water_base + 1000, "m³/ha")
water_need_level = "low" if water_base < 4000 else "medium" if water_base < 5000 else "high"
cultivation_risk_score = max(0, min(100, round(100 - soil_quality_score + abs(avg_ph - 7) * 8)))
cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 55 else "high"
if water_need_level == "low" and soil_quality_score >= 85:
recommended_crop = "saffron"
estimated_profit = "۵۰-۱۵۰ میلیون/هکتار"
elif soil_quality_score >= 70:
recommended_crop = "wheat"
estimated_profit = "۱۵-۲۵ میلیون/هکتار"
else:
recommended_crop = "canola"
estimated_profit = "۲۰-۳۵ میلیون/هکتار"
match_percent = max(1, min(100, round((soil_quality_score * 0.55) + ((100 - cultivation_risk_score) * 0.45))))
reason = "خاک و شرایط رطوبتی این زون برای محصول پیشنهادی مناسب ارزیابی شده است"
criteria = [
{"name": "دما", "value": max(1, min(100, round(70 + (avg_ph - 6.5) * 10)))},
{"name": "بارش", "value": max(1, min(100, round(60 + avg_wv0033 * 100)))},
{"name": "خاک", "value": soil_quality_score},
{"name": "آب", "value": max(1, min(100, round(100 - ((water_base - 3000) / 30))))},
]
return {
"soil_quality_score": soil_quality_score,
"soil_level": soil_level,
"water_need_level": water_need_level,
"water_need_value": water_need_value,
"cultivation_risk_level": cultivation_risk_level,
"recommended_crop": recommended_crop,
"match_percent": match_percent,
"estimated_profit": estimated_profit,
"reason": reason,
"criteria": criteria,
}
def fetch_soil_data_for_zone(zone):
center = zone.center or calculate_center(zone.points)
payload = {
"lon": center["longitude"],
"lat": center["latitude"],
"zone": {
"id": zone.zone_id,
"geometry": zone.geometry,
"center": center,
"area_sqm": zone.area_sqm,
"area_hectares": zone.area_hectares,
},
}
return external_request("ai", "/soil-data", method="POST", payload=payload).data
def analyze_and_store_zone_soil_data(zone_id):
ensure_products_exist()
zone = CropZone.objects.select_related("crop_area").get(id=zone_id)
if zone.processing_status == CropZone.STATUS_COMPLETED:
return zone
zone.processing_status = CropZone.STATUS_PROCESSING
zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
try:
adapter_data = fetch_soil_data_for_zone(zone)
soil_data = adapter_data.get("data", {}) if isinstance(adapter_data, dict) else {}
depths = soil_data.get("depths", [])
metrics = _derive_analysis_metrics(depths)
product = CropProduct.objects.get(product_id=metrics["recommended_crop"])
CropZoneAnalysis.objects.update_or_create(
crop_zone=zone,
defaults={
"source": soil_data.get("source", ""),
"external_record_id": str(soil_data.get("id", "")),
"longitude": Decimal(str(soil_data.get("lon", zone.center.get("longitude", 0)))),
"latitude": Decimal(str(soil_data.get("lat", zone.center.get("latitude", 0)))),
"raw_response": adapter_data if isinstance(adapter_data, dict) else {},
"depths": depths,
},
)
2026-03-30 23:29:03 +03:30
persist_zone_analysis_metrics(zone, metrics)
2026-03-29 15:07:14 +03:30
zone.processing_status = CropZone.STATUS_COMPLETED
zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
except Exception as exc:
zone.processing_status = CropZone.STATUS_FAILED
zone.processing_error = str(exc)
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
raise
return zone
2026-04-01 17:28:24 +03:30
def _get_stale_zone_ids(zones):
completed_task_ids = {
zone.task_id
for zone in zones
if zone.processing_status == CropZone.STATUS_COMPLETED and zone.task_id
}
stale_before = timezone.now() - timedelta(seconds=get_task_stale_seconds())
stale_zone_ids = []
for zone in zones:
if zone.processing_status == CropZone.STATUS_COMPLETED or not zone.task_id:
continue
if zone.task_id in completed_task_ids:
stale_zone_ids.append(zone.id)
continue
if zone.updated_at > stale_before:
continue
try:
task_state = AsyncResult(zone.task_id).state
except Exception:
task_state = TASK_STATE_PENDING
if task_state in {
TASK_STATE_PENDING,
TASK_STATE_SUCCESS,
TASK_STATE_FAILURE,
TASK_STATE_REVOKED,
}:
stale_zone_ids.append(zone.id)
return stale_zone_ids
def dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None, force=False):
2026-03-29 15:07:14 +03:30
from .tasks import process_zone_soil_data
2026-04-01 17:28:24 +03:30
queryset = CropZone.objects.all()
2026-03-30 23:29:03 +03:30
if crop_area_id is not None:
queryset = queryset.filter(crop_area_id=crop_area_id)
if zone_ids is not None:
queryset = queryset.filter(id__in=zone_ids)
2026-04-01 17:28:24 +03:30
zones = list(queryset.only("id", "task_id", "processing_status").order_by("sequence", "id"))
2026-03-29 15:07:14 +03:30
for zone in zones:
2026-04-01 17:28:24 +03:30
if zone.processing_status == CropZone.STATUS_COMPLETED:
continue
if not force and zone.processing_status == CropZone.STATUS_PROCESSING and zone.task_id:
continue
if not force and zone.processing_status == CropZone.STATUS_PENDING and zone.task_id:
2026-03-30 23:29:03 +03:30
continue
2026-03-29 15:07:14 +03:30
try:
async_result = process_zone_soil_data.delay(zone.id)
2026-03-30 23:29:03 +03:30
task_identifier = getattr(async_result, "id", "") or str(uuid.uuid4())
processing_error = ""
except OperationalError as exc:
task_identifier = str(uuid.uuid4())
processing_error = f"Celery broker unavailable: {exc}"
except Exception as exc:
task_identifier = str(uuid.uuid4())
processing_error = f"Celery dispatch failed: {exc}"
2026-04-01 17:28:24 +03:30
update_fields = {
"task_id": task_identifier,
"processing_status": CropZone.STATUS_PENDING,
}
update_fields["processing_error"] = processing_error
2026-03-30 23:29:03 +03:30
CropZone.objects.filter(id=zone.id).update(**update_fields)
def create_missing_zones_for_area(crop_area):
if crop_area.zones.exists():
return list(crop_area.zones.order_by("sequence", "id"))
area_feature = normalize_area_feature(crop_area.geometry)
zoning_result = split_area_into_zones(
area_feature,
cell_side_km=math.sqrt(max(crop_area.chunk_area_sqm, 1)) / 1000.0,
)
zones = CropZone.objects.bulk_create(
[
CropZone(
crop_area=crop_area,
zone_id=zone["zone_id"],
geometry=zone["geometry"],
points=zone["points"],
center=zone["center"],
area_sqm=round(zone["area_sqm"], 2),
area_hectares=round(zone["area_hectares"], 4),
sequence=zone["sequence"],
)
for zone in zoning_result["zones"]
]
)
crop_area.zone_count = len(zones)
crop_area.save(update_fields=["zone_count", "updated_at"])
return list(crop_area.zones.order_by("sequence", "id"))
2026-04-02 23:25:39 +03:30
def get_farm_for_uuid(farm_uuid, owner=None):
if not farm_uuid:
raise ValueError("farm_uuid is required.")
filters = {"farm_uuid": farm_uuid}
if owner is not None:
filters["owner"] = owner
2026-03-30 23:29:03 +03:30
try:
2026-04-02 23:25:39 +03:30
return FarmHub.objects.get(**filters)
except FarmHub.DoesNotExist as exc:
raise ValueError("Farm not found.") from exc
2026-03-29 15:07:14 +03:30
2026-05-13 22:29:18 +03:30
def _raise_ai_response_error(response, default_message):
payload = response.data if isinstance(response.data, dict) else {}
message = payload.get("msg") or payload.get("message") or default_message
if response.status_code >= 500:
raise ImproperlyConfigured(message)
raise ValueError(message)
2026-03-30 23:29:03 +03:30
2026-05-13 22:29:18 +03:30
def _unwrap_ai_response(response, *, expected_statuses):
if response.status_code not in expected_statuses:
_raise_ai_response_error(response, f"AI location_data API returned status {response.status_code}.")
2026-03-30 23:29:03 +03:30
2026-05-13 22:29:18 +03:30
payload = response.data if isinstance(response.data, dict) else {}
if "data" in payload:
return payload["data"]
return payload
2026-03-30 23:29:03 +03:30
2026-05-13 22:29:18 +03:30
def _request_ai_location_data(path, *, method="GET", payload=None, query=None):
return external_request(
"ai",
path,
method=method,
payload=payload,
query=query,
)
2026-03-30 23:29:03 +03:30
2026-03-29 13:40:23 +03:30
2026-05-13 22:29:18 +03:30
def _feature_from_geometry(geometry):
if not isinstance(geometry, dict):
return get_default_area_feature()
if geometry.get("type") == "Feature":
return normalize_area_feature(geometry)
return normalize_area_feature(
{
"type": "Feature",
"properties": {},
"geometry": geometry,
}
)
def _upsert_crop_area_snapshot(farm, area_feature):
normalized_feature = normalize_area_feature(area_feature)
ring = get_polygon_ring(normalized_feature)
points = normalize_points(ring)
area_sqm = round(polygon_area_sqm(ring), 2)
area_hectares = round(area_sqm / 10000.0, 4)
defaults = {
"geometry": normalized_feature,
"points": points,
"center": calculate_center(points),
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"chunk_area_sqm": round(get_chunk_area_sqm(), 2),
}
crop_area = farm.current_crop_area
if crop_area is None:
2026-03-29 13:40:23 +03:30
crop_area = CropArea.objects.create(
2026-04-02 23:25:39 +03:30
farm=farm,
2026-05-13 22:29:18 +03:30
zone_count=0,
**defaults,
2026-03-29 13:40:23 +03:30
)
2026-05-13 22:29:18 +03:30
farm.current_crop_area = crop_area
farm.save(update_fields=["current_crop_area", "updated_at"])
return crop_area
for field_name, value in defaults.items():
setattr(crop_area, field_name, value)
crop_area.save(update_fields=[*defaults.keys(), "updated_at"])
return crop_area
def _get_farm_area_feature(farm, fallback=None):
if fallback is not None:
return normalize_area_feature(fallback)
crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first()
if crop_area is not None and crop_area.geometry:
return normalize_area_feature(crop_area.geometry)
return get_default_area_feature()
def _build_processing_layer_payload(farm, remote_payload, *, page, page_size):
area_feature = _get_farm_area_feature(
farm,
fallback=((remote_payload.get("location") or {}).get("farm_boundary")),
)
location = remote_payload.get("location") or {}
run = remote_payload.get("run") or {}
status_value = str(remote_payload.get("status") or "").lower()
task_status = "PROCESSING" if status_value == "processing" else "PENDING"
return {
"task": {
"status": task_status,
"stage": status_value or "queued",
"stage_label": "در حال دریافت تقسیم بندی و متریک ها از AI",
"area_uuid": str(getattr(farm.current_crop_area, "uuid", "")) if farm.current_crop_area_id else "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"remaining_zones": 0,
"progress_percent": 0,
"summary": {
"done": 0,
"in_progress": 0,
"remaining": 0,
"failed": 0,
},
"message": "تقسیم بندی و متریک های کشت در AI در حال آماده سازی است.",
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
"task_id": remote_payload.get("task_id") or (run.get("metadata") or {}).get("task_id"),
},
"area": area_feature,
"zones": [],
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": [],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
}
def _hash_color(value):
digest = hashlib.md5(str(value).encode("utf-8")).hexdigest()
return f"#{digest[:6]}"
def _clamp_percent(value, *, default=0):
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0, min(100, round(numeric)))
def _extract_zone_points(geometry):
coordinates = (geometry or {}).get("coordinates") or []
if not coordinates or not coordinates[0]:
return []
ring = coordinates[0]
return ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
def _build_metric_criteria(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = []
ndvi_score = _clamp_percent((resolved_metrics.get("ndvi") or 0) * 100)
criteria.append({"name": "NDVI", "value": ndvi_score})
ndwi_raw = resolved_metrics.get("ndwi")
ndwi_score = _clamp_percent(((float(ndwi_raw) + 1.0) / 2.0) * 100) if ndwi_raw is not None else 0
criteria.append({"name": "NDWI", "value": ndwi_score})
soil_moisture = resolved_metrics.get("soil_moisture")
if soil_moisture is not None:
criteria.append({"name": "رطوبت خاک", "value": _clamp_percent(soil_moisture)})
nitrogen = resolved_metrics.get("nitrogen")
if nitrogen is not None:
criteria.append({"name": "نیتروژن", "value": _clamp_percent(float(nitrogen) * 4)})
if suggested_plant is not None:
criteria.append({"name": "امتیاز AI", "value": _clamp_percent(suggested_plant.get("score"))})
return criteria[:4]
def _derive_layer_bundle(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = _build_metric_criteria(cluster_payload, suggested_plant)
soil_score = next((item["value"] for item in criteria if item["name"] in {"NDVI", "نیتروژن"}), 0)
if soil_score >= 75:
soil_level = "high"
elif soil_score >= 45:
soil_level = "medium"
else:
soil_level = "low"
moisture_value = resolved_metrics.get("soil_moisture")
ndwi_raw = resolved_metrics.get("ndwi")
if moisture_value is not None:
water_score = _clamp_percent(100 - float(moisture_value))
water_value_text = f"{round(float(moisture_value), 2)}% soil moisture"
elif ndwi_raw is not None:
water_score = _clamp_percent(100 - (((float(ndwi_raw) + 1.0) / 2.0) * 100))
water_value_text = f"NDWI {round(float(ndwi_raw), 3)}"
else:
water_score = 0
water_value_text = ""
if water_score >= 65:
water_level = "high"
elif water_score >= 35:
water_level = "medium"
else:
water_level = "low"
ai_score = _clamp_percent((suggested_plant or {}).get("score"))
risk_score = max(0, min(100, round((100 - soil_score) * 0.6 + (100 - ai_score) * 0.4)))
if risk_score >= 65:
risk_level = "high"
elif risk_score >= 35:
risk_level = "medium"
else:
risk_level = "low"
return {
"criteria": criteria,
"soil": {
"score": soil_score,
"level": soil_level,
"color": _get_level_color_map("soil", soil_level),
},
"water": {
"level": water_level,
"value": water_value_text,
"color": _get_level_color_map("water", water_level),
},
"risk": {
"level": risk_level,
"color": _get_level_color_map("risk", risk_level),
},
}
def _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload=None):
location_payload = remote_payload.get("location") or {}
area_feature = _get_farm_area_feature(
farm,
fallback=location_payload.get("farm_boundary"),
)
crop_area = _upsert_crop_area_snapshot(farm, area_feature)
subdivision_result = remote_payload.get("subdivision_result") or {}
cluster_blocks = subdivision_result.get("cluster_blocks") or []
recommendation_map = {}
for cluster in (recommendation_payload or {}).get("clusters", []):
cluster_uuid = str(cluster.get("cluster_uuid") or ((cluster.get("cluster_block") or {}).get("uuid") or ""))
if cluster_uuid:
recommendation_map[cluster_uuid] = cluster
existing_zones = {zone.zone_id: zone for zone in crop_area.zones.all()}
retained_zone_ids = []
with transaction.atomic():
for sequence, cluster_block in enumerate(
sorted(cluster_blocks, key=lambda item: (item.get("cluster_label") is None, item.get("cluster_label"), item.get("sub_block_code") or ""))
):
zone_id = str(cluster_block.get("uuid") or cluster_block.get("sub_block_code") or f"cluster-{sequence}")
geometry = cluster_block.get("geometry") or {}
points = _extract_zone_points(geometry)
area_sqm = round(polygon_area_sqm((geometry.get("coordinates") or [[points]])[0]), 2) if geometry.get("coordinates") else 0.0
area_hectares = round(area_sqm / 10000.0, 4)
zone_defaults = {
"geometry": geometry,
"points": points,
"center": {
"longitude": float(cluster_block.get("centroid_lon") or 0),
"latitude": float(cluster_block.get("centroid_lat") or 0),
},
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"sequence": sequence,
"processing_status": CropZone.STATUS_COMPLETED,
"processing_error": "",
"task_id": str(((remote_payload.get("run") or {}).get("metadata") or {}).get("task_id") or ""),
}
zone = existing_zones.get(zone_id)
if zone is None:
zone = CropZone.objects.create(crop_area=crop_area, zone_id=zone_id, **zone_defaults)
else:
for field_name, value in zone_defaults.items():
setattr(zone, field_name, value)
zone.save(update_fields=[*zone_defaults.keys(), "updated_at"])
retained_zone_ids.append(zone.zone_id)
cluster_payload = recommendation_map.get(zone_id, {})
suggested_plant = cluster_payload.get("suggested_plant")
layer_bundle = _derive_layer_bundle(cluster_payload, suggested_plant)
product_id = str((suggested_plant or {}).get("plant_name") or (suggested_plant or {}).get("plant_id") or "")
if product_id:
product, _ = CropProduct.objects.update_or_create(
product_id=product_id,
defaults={
"label": str((suggested_plant or {}).get("plant_name") or product_id),
"color": _hash_color(product_id),
},
2026-03-29 13:40:23 +03:30
)
2026-05-13 22:29:18 +03:30
recommendation, _ = CropZoneRecommendation.objects.update_or_create(
crop_zone=zone,
defaults={
"product": product,
"match_percent": _clamp_percent((suggested_plant or {}).get("score")),
"water_need": layer_bundle["water"]["value"],
"estimated_profit": (
f"{round(float((suggested_plant or {}).get('predicted_yield_tons')), 2)} ton/ha"
if (suggested_plant or {}).get("predicted_yield_tons") is not None
else ""
),
"reason": "پیشنهاد محصول بر اساس متریک های سنجش از دور و تحلیل کلاستر AI تولید شده است.",
},
)
CropZoneCriteria.objects.filter(recommendation=recommendation).delete()
CropZoneCriteria.objects.bulk_create(
[
CropZoneCriteria(
recommendation=recommendation,
name=item["name"],
value=item["value"],
sequence=index,
)
for index, item in enumerate(layer_bundle["criteria"])
]
)
else:
CropZoneRecommendation.objects.filter(crop_zone=zone).delete()
2026-03-29 13:40:23 +03:30
2026-05-13 22:29:18 +03:30
CropZoneWaterNeedLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["water"],
)
CropZoneSoilQualityLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["soil"],
)
CropZoneCultivationRiskLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["risk"],
)
CropZoneAnalysis.objects.update_or_create(
crop_zone=zone,
defaults={
"source": "ai_location_data",
"external_record_id": zone_id,
"latitude": zone.center.get("latitude"),
"longitude": zone.center.get("longitude"),
"raw_response": {
"remote_sensing": remote_payload,
"cluster_recommendation": cluster_payload,
},
"depths": [],
},
)
CropZone.objects.filter(crop_area=crop_area).exclude(zone_id__in=retained_zone_ids).delete()
crop_area.zone_count = len(retained_zone_ids)
crop_area.chunk_area_sqm = subdivision_result.get("chunk_size_sqm") or crop_area.chunk_area_sqm
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"])
return crop_area
def _get_ai_remote_sensing_payload(*, farm_uuid, page, page_size):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="GET",
query={
"farm_uuid": str(farm_uuid),
"page": page,
"page_size": page_size,
},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _start_ai_remote_sensing(*, farm_uuid):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="POST",
payload={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={202})
def _get_ai_cluster_recommendations(*, farm_uuid):
response = _request_ai_location_data(
AI_CLUSTER_RECOMMENDATIONS_PATH,
method="GET",
query={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _build_ai_layer_context(remote_payload, recommendation_payload=None):
location = deepcopy(remote_payload.get("location") or {})
subdivision_result = deepcopy(remote_payload.get("subdivision_result") or {})
run = deepcopy(remote_payload.get("run") or {})
return {
"source": {
"type": "ai_location_data",
"service": "ai",
"status": str(remote_payload.get("status") or ""),
},
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": deepcopy(subdivision_result.get("cluster_blocks") or []),
"subdivisionSummary": {
"clusterCount": subdivision_result.get("cluster_count")
or len(subdivision_result.get("cluster_blocks") or []),
"chunkSizeSqm": subdivision_result.get("chunk_size_sqm") or remote_payload.get("chunk_size_sqm"),
"selectedFeatures": deepcopy(
subdivision_result.get("selected_features")
or run.get("selected_features")
or []
),
"temporalExtent": deepcopy(remote_payload.get("temporal_extent") or {}),
"summary": deepcopy(remote_payload.get("summary") or {}),
},
"registeredPlants": deepcopy((recommendation_payload or {}).get("registered_plants") or []),
"evaluatedPlantCount": (recommendation_payload or {}).get("evaluated_plant_count"),
}
def _get_latest_layer_payload_from_ai(zone_builder, *, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
farm = get_farm_for_uuid(farm_uuid, owner=owner)
remote_payload = _get_ai_remote_sensing_payload(
farm_uuid=farm_uuid,
page=page,
page_size=page_size,
)
remote_status = str(remote_payload.get("status") or "").lower()
if remote_status == "not_found":
remote_payload = _start_ai_remote_sensing(farm_uuid=farm_uuid)
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
if remote_status != "success":
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
recommendation_payload = None
try:
recommendation_payload = _get_ai_cluster_recommendations(farm_uuid=farm_uuid)
except ValueError:
recommendation_payload = None
crop_area = _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload)
return _build_latest_area_layer_payload(
zone_builder,
area=crop_area,
page=page,
page_size=page_size,
extra_payload=_build_ai_layer_context(remote_payload, recommendation_payload),
)
def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None):
farm = get_farm_for_uuid(farm_uuid, owner=owner)
return _upsert_crop_area_snapshot(farm, _get_farm_area_feature(farm, fallback=area_feature))
def create_zones_and_dispatch(area_feature, cell_side_km=None, farm=None):
if farm is None:
raise ValueError("farm is required.")
crop_area = _upsert_crop_area_snapshot(farm, area_feature)
CropZone.objects.filter(crop_area=crop_area).delete()
crop_area.zone_count = 0
crop_area.chunk_area_sqm = round(get_chunk_area_sqm(cell_side_km), 2)
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"])
return crop_area, []
2026-03-29 15:07:14 +03:30
def _zones_queryset(zone_ids=None):
queryset = CropZone.objects.select_related(
"recommendation__product",
"water_need_layer",
"soil_quality_layer",
"cultivation_risk_layer",
2026-05-13 22:29:18 +03:30
"analysis",
2026-03-29 15:07:14 +03:30
).prefetch_related(
Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id"))
).order_by("sequence", "id")
if zone_ids:
queryset = queryset.filter(zone_id__in=zone_ids)
return queryset
2026-04-08 23:00:54 +03:30
def _get_idle_area_payload(page, page_size):
2026-03-30 23:29:03 +03:30
return {
"task": {
"status": "IDLE",
"area_uuid": "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
},
"area": get_default_area_feature(),
2026-04-01 17:28:24 +03:30
"zones": [],
2026-04-01 18:38:05 +03:30
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
2026-03-30 23:29:03 +03:30
}
2026-03-29 15:07:14 +03:30
2026-05-13 22:29:18 +03:30
def _build_latest_area_layer_payload(
zone_builder,
area=None,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
extra_payload=None,
):
2026-04-08 23:00:54 +03:30
area = area or CropArea.objects.order_by("-created_at", "-id").first()
if not area:
return _get_idle_area_payload(page, page_size)
status_zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
total_zones = len(status_zones)
completed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_COMPLETED)
processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING)
failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED)
pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING)
total_pages = math.ceil(total_zones / page_size) if total_zones else 0
start_index = (page - 1) * page_size
end_index = start_index + page_size
zones = list(_zones_queryset().filter(crop_area=area)[start_index:end_index])
if failed_zones:
task_status = "FAILURE"
elif total_zones and completed_zones == total_zones:
task_status = "SUCCESS"
elif processing_zones or completed_zones:
task_status = "PROCESSING"
else:
task_status = "PENDING"
current_stage = "waiting_to_start"
if failed_zones:
current_stage = "failed"
elif total_zones and completed_zones == total_zones:
current_stage = "completed"
elif processing_zones:
current_stage = "processing_zones"
elif pending_zones and completed_zones:
current_stage = "continuing_processing"
elif pending_zones:
current_stage = "queued"
progress_percent = 0
if total_zones:
progress_percent = round((completed_zones / total_zones) * 100, 2)
2026-05-13 22:29:18 +03:30
payload = {
2026-04-08 23:00:54 +03:30
"task": {
"status": task_status,
"stage": current_stage,
"stage_label": {
"waiting_to_start": "در انتظار شروع پردازش",
"queued": "تسک ساخته شده و در صف پردازش است",
"processing_zones": "در حال پردازش زون‌ها",
"continuing_processing": "بخشی از زون‌ها پردازش شده و بقیه در صف هستند",
"completed": "پردازش همه زون‌ها کامل شده است",
"failed": "پردازش بعضی زون‌ها با خطا مواجه شده است",
}[current_stage],
"area_uuid": str(area.uuid),
"total_zones": total_zones,
"completed_zones": completed_zones,
"processing_zones": processing_zones,
"pending_zones": pending_zones,
"failed_zones": failed_zones,
"remaining_zones": max(total_zones - completed_zones, 0),
"progress_percent": progress_percent,
"summary": {
"done": completed_zones,
"in_progress": processing_zones,
"remaining": pending_zones,
"failed": failed_zones,
},
"message": f"از مجموع {total_zones} زون، {completed_zones} زون پردازش شده، {processing_zones} زون در حال پردازش و {pending_zones} زون باقی مانده است.",
"failed_zone_errors": [
{
"zoneId": zone.zone_id,
"error": zone.processing_error,
}
for zone in status_zones
if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error
],
"cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4),
},
"area": area.geometry,
"zones": [zone_builder(zone) for zone in zones],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"total_zones": total_zones,
"returned_zones": len(zones),
"has_next": page < total_pages,
"has_previous": page > 1 and total_pages > 0,
},
}
2026-05-13 22:29:18 +03:30
if extra_payload:
payload.update(extra_payload)
return payload
2026-04-08 23:00:54 +03:30
2026-05-13 22:29:18 +03:30
def get_latest_area_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
2026-04-08 23:00:54 +03:30
2026-05-13 22:29:18 +03:30
def get_latest_water_need_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
2026-04-08 23:00:54 +03:30
build_water_need_area_zone_payload,
2026-05-13 22:29:18 +03:30
farm_uuid=farm_uuid,
owner=owner,
2026-04-08 23:00:54 +03:30
page=page,
page_size=page_size,
)
2026-05-13 22:29:18 +03:30
def get_latest_soil_quality_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
2026-04-08 23:00:54 +03:30
build_soil_quality_area_zone_payload,
2026-05-13 22:29:18 +03:30
farm_uuid=farm_uuid,
owner=owner,
2026-04-08 23:00:54 +03:30
page=page,
page_size=page_size,
)
2026-05-13 22:29:18 +03:30
def get_latest_cultivation_risk_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
2026-04-08 23:00:54 +03:30
build_cultivation_risk_area_zone_payload,
2026-05-13 22:29:18 +03:30
farm_uuid=farm_uuid,
owner=owner,
2026-04-08 23:00:54 +03:30
page=page,
page_size=page_size,
)
2026-03-29 15:07:14 +03:30
def get_initial_zones_payload(crop_area):
zones = _zones_queryset().filter(crop_area=crop_area)
return {
"total_area_hectares": crop_area.area_hectares,
"total_area_sqm": crop_area.area_sqm,
"zone_count": crop_area.zone_count,
"zones": [build_initial_zone_payload(zone) for zone in zones],
}
def get_water_need_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.water_need_layer, "level", ""),
"value": getattr(zone.water_need_layer, "value", ""),
"color": getattr(zone.water_need_layer, "color", ""),
}
for zone in zones
]
}
def get_soil_quality_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.soil_quality_layer, "level", ""),
"score": getattr(zone.soil_quality_layer, "score", 0),
"color": getattr(zone.soil_quality_layer, "color", ""),
}
for zone in zones
]
}
def get_cultivation_risk_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.cultivation_risk_layer, "level", ""),
"color": getattr(zone.cultivation_risk_layer, "color", ""),
}
for zone in zones
]
}
2026-05-13 22:29:18 +03:30
def get_zone_details_payload(zone_id, *, farm_uuid=None, owner=None):
zone_filters = {"zone_id": zone_id}
if farm_uuid:
_get_latest_layer_payload_from_ai(
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
)
zone_filters["crop_area__farm__farm_uuid"] = farm_uuid
if owner is not None:
zone_filters["crop_area__farm__owner"] = owner
zone = _zones_queryset().get(**zone_filters)
2026-03-29 15:07:14 +03:30
recommendation = getattr(zone, "recommendation", None)
criteria = recommendation.criteria.all() if recommendation else []
2026-05-13 22:29:18 +03:30
cluster_payload = _get_zone_ai_cluster_payload(zone)
2026-03-29 15:07:14 +03:30
return {
"zoneId": zone.zone_id,
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
"estimatedProfit": recommendation.estimated_profit if recommendation else "",
"reason": recommendation.reason if recommendation else "",
"criteria": [{"name": item.name, "value": item.value} for item in criteria],
"area_hectares": zone.area_hectares,
2026-05-13 22:29:18 +03:30
"clusterInfo": _build_zone_cluster_info(zone, cluster_payload),
"clusterMetrics": _build_zone_cluster_metrics(cluster_payload),
"cropPrediction": _build_zone_crop_prediction(cluster_payload),
2026-03-29 15:07:14 +03:30
}