This commit is contained in:
2026-03-30 23:29:03 +03:30
parent 9323000bac
commit c97b90bfaf
13 changed files with 945 additions and 131 deletions
+2 -3
View File
@@ -30,13 +30,12 @@ COPY requirements.txt .
# Python mirrors
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
pip config --user set global.trusted-host package-mirror.liara.ir && \
pip config --user set global.trusted-host mirror.cdn.ir && \
pip config --user set global.trusted-host mirror2.chabokan.net && \
pip config --user set global.trusted-host mirror-pypi.runflare.com
RUN pip install -r requirements.txt
RUN pip install -r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . .
+3
View File
@@ -5,3 +5,6 @@ class CropZoningConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "crop_zoning"
verbose_name = "Crop Zoning"
def ready(self):
from . import tasks # noqa: F401
@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("sensor_hub", "0001_initial"),
("crop_zoning", "0003_zone_processing_and_analysis"),
]
operations = [
migrations.AddField(
model_name="croparea",
name="sensor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="crop_areas",
to="sensor_hub.sensor",
),
),
]
+9 -1
View File
@@ -1,10 +1,19 @@
import uuid
from django.db import models
from sensor_hub.models import Sensor
class CropArea(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
sensor = models.ForeignKey(
Sensor,
on_delete=models.CASCADE,
related_name="crop_areas",
null=True,
blank=True,
db_index=True,
)
geometry = models.JSONField(default=dict)
points = models.JSONField(default=list)
center = models.JSONField(default=dict)
@@ -216,4 +225,3 @@ class CropZoneAnalysis(models.Model):
db_table = "crop_zone_analyses"
ordering = ["crop_zone_id"]
File diff suppressed because one or more lines are too long
+500 -98
View File
@@ -3,8 +3,10 @@ from copy import deepcopy
from decimal import Decimal
from django.conf import settings
from kombu.exceptions import OperationalError
from django.db import transaction
from django.db.models import Prefetch
from sensor_hub.models import Sensor
from external_api_adapter.adapter import request as external_request
@@ -23,17 +25,68 @@ from .models import (
EARTH_RADIUS_METERS = 6378137.0
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
DEFAULT_CELL_SIDE_KM = 0.15
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())
def get_chunk_area_sqm():
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
raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0)
try:
chunk_area = float(raw_value)
except (TypeError, ValueError):
chunk_area = 0
if chunk_area <= 0:
raise ValueError("CROP_ZONE_CHUNK_AREA_SQM must be a positive number.")
return chunk_area
if chunk_area > 0:
return math.sqrt(chunk_area) / 1000.0
return DEFAULT_CELL_SIDE_KM
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
def get_default_area_feature():
@@ -138,69 +191,206 @@ def calculate_center(points):
}
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),
}
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:
longitude_factor = 1.0
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)
if orientation_1 != orientation_2 and orientation_3 != orientation_4:
return True
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):
return [
[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)],
]
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"])
latitude_factor = 111320.0
longitude_factor = 111320.0 * math.cos(math.radians(center["latitude"]))
if longitude_factor == 0:
longitude_factor = 1.0
delta_lat = half_width / latitude_factor
delta_lng = half_width / longitude_factor
return [
[round(center["longitude"] - delta_lng, 8), round(center["latitude"] - delta_lat, 8)],
[round(center["longitude"] + delta_lng, 8), round(center["latitude"] - delta_lat, 8)],
[round(center["longitude"] + delta_lng, 8), round(center["latitude"] + delta_lat, 8)],
[round(center["longitude"] - delta_lng, 8), round(center["latitude"] + delta_lat, 8)],
]
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):
def split_area_into_zones(area_feature, cell_side_km=None):
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)
chunk_area_sqm = get_chunk_area_sqm()
zone_count = max(1, math.ceil(total_area_sqm / chunk_area_sqm))
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)
zones = []
remaining_area = total_area_sqm
base_longitude = area_center["longitude"]
base_latitude = area_center["latitude"]
sequence = 0
current_lat = bbox["min_lat"]
for sequence in range(zone_count):
zone_area_sqm = min(chunk_area_sqm, remaining_area) if sequence < zone_count - 1 else remaining_area
if zone_area_sqm <= 0:
zone_area_sqm = min(chunk_area_sqm, total_area_sqm)
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"]
shift = (sequence - ((zone_count - 1) / 2)) * 0.0003
zone_center = {
"longitude": round(base_longitude + shift, 8),
"latitude": round(base_latitude, 8),
}
zone_points = build_zone_square(area_points, zone_center, zone_area_sqm)
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))
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}",
"zone_id": "zone-0",
"geometry": zone_geometry,
"points": zone_points,
"center": zone_center,
"area_sqm": zone_area_sqm,
"area_hectares": zone_area_sqm / 10000,
"sequence": sequence,
"center": area_center,
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"sequence": 0,
}
)
remaining_area = max(0.0, remaining_area - zone_area_sqm)
zone_count = len(zones)
area_geometry = {
"type": "Feature",
@@ -213,6 +403,7 @@ def split_area_into_zones(area_feature):
"center": area_center,
"area_sqm": round(total_area_sqm, 2),
"area_hectares": round(total_area_sqm / 10000, 4),
"cell_side_km": round(resolved_cell_side_km, 4),
}
)
@@ -224,12 +415,50 @@ def split_area_into_zones(area_feature):
"area_sqm": total_area_sqm,
"area_hectares": total_area_sqm / 10000,
"chunk_area_sqm": chunk_area_sqm,
"cell_side_km": resolved_cell_side_km,
"zone_count": zone_count,
},
"zones": zones,
}
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,
}
def build_initial_zone_payload(zone):
recommendation = getattr(zone, "recommendation", None)
return {
@@ -242,6 +471,67 @@ def build_initial_zone_payload(zone):
}
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
def _get_level_color_map(layer_name, level):
mappings = {
"water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"},
@@ -371,51 +661,7 @@ def analyze_and_store_zone_soil_data(zone_id):
"depths": depths,
},
)
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"]),
},
)
persist_zone_analysis_metrics(zone, metrics)
zone.processing_status = CropZone.STATUS_COMPLETED
zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
@@ -428,28 +674,130 @@ def analyze_and_store_zone_soil_data(zone_id):
return zone
def dispatch_zone_processing_tasks(crop_area_id):
def dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None):
from .tasks import process_zone_soil_data
zones = list(CropZone.objects.filter(crop_area_id=crop_area_id).only("id"))
queryset = CropZone.objects.select_related("crop_area").all()
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)
zones = list(queryset.only("id", "task_id", "processing_status", "crop_area__sensor_id"))
sensor_task_ids = {}
for zone in zones:
task_identifier = ""
sensor_id = zone.crop_area.sensor_id
existing_task_id = sensor_task_ids.get(sensor_id) or zone.task_id
if existing_task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}:
sensor_task_ids[sensor_id] = existing_task_id
if zone.task_id != existing_task_id:
CropZone.objects.filter(id=zone.id).update(task_id=existing_task_id)
continue
try:
async_result = process_zone_soil_data.delay(zone.id)
task_identifier = getattr(async_result, "id", "") or ""
except Exception:
analyze_and_store_zone_soil_data(zone_id=zone.id)
CropZone.objects.filter(id=zone.id).update(task_id=task_identifier)
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}"
update_fields = {"task_id": task_identifier}
if zone.processing_status == CropZone.STATUS_FAILED:
update_fields["processing_status"] = CropZone.STATUS_PENDING
if processing_error:
update_fields["processing_error"] = processing_error
elif zone.processing_status == CropZone.STATUS_FAILED:
update_fields["processing_error"] = ""
CropZone.objects.filter(id=zone.id).update(**update_fields)
if sensor_id and task_identifier:
sensor_task_ids[sensor_id] = task_identifier
def create_zones_and_dispatch(area_feature):
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"))
def get_sensor_for_uuid(sensor_uuid):
if not sensor_uuid:
raise ValueError("sensor_uuid is required.")
try:
return Sensor.objects.get(uuid_sensor=sensor_uuid)
except Sensor.DoesNotExist as exc:
raise ValueError("Sensor not found.") from exc
def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None):
sensor = get_sensor_for_uuid(sensor_uuid)
latest_area = CropArea.objects.filter(sensor=sensor).order_by("-created_at", "-id").first()
if latest_area is None:
latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), sensor=sensor)
return latest_area
zones = create_missing_zones_for_area(latest_area)
for zone in zones:
ensure_rule_based_zone_data(zone)
active_task_id = next((zone.task_id for zone in zones if zone.task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}), "")
zones_to_dispatch = []
for zone in zones:
if zone.processing_status == CropZone.STATUS_COMPLETED:
continue
if active_task_id:
if not zone.task_id:
CropZone.objects.filter(id=zone.id).update(task_id=active_task_id)
continue
if zone.processing_status == CropZone.STATUS_PROCESSING and zone.task_id:
active_task_id = zone.task_id
continue
if zone.processing_status == CropZone.STATUS_PENDING and zone.task_id:
active_task_id = zone.task_id
continue
zones_to_dispatch.append(zone.id)
if zones_to_dispatch:
dispatch_zone_processing_tasks(zone_ids=zones_to_dispatch)
return CropArea.objects.get(id=latest_area.id)
def create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None):
ensure_products_exist()
area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature)
zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km)
area_data = zoning_result["area"]
with transaction.atomic():
crop_area = CropArea.objects.create(
sensor=sensor,
geometry=area_data["geometry"],
points=area_data["points"],
center=area_data["center"],
@@ -475,6 +823,9 @@ def create_zones_and_dispatch(area_feature):
)
crop_area.refresh_from_db()
zones = list(crop_area.zones.order_by("sequence", "id"))
for zone in zones:
ensure_rule_based_zone_data(zone)
dispatch_zone_processing_tasks(crop_area.id)
return crop_area, zones
@@ -493,11 +844,62 @@ def _zones_queryset(zone_ids=None):
return queryset
def get_latest_area_payload():
area = CropArea.objects.order_by("-created_at", "-id").first()
def get_latest_area_payload(area=None):
area = area or CropArea.objects.order_by("-created_at", "-id").first()
if area:
return {"area": area.geometry}
return {"area": get_default_area_feature()}
zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
total_zones = len(zones)
completed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_COMPLETED)
processing_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PROCESSING)
failed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_FAILED)
pending_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PENDING)
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"
return {
"task": {
"status": task_status,
"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,
"task_ids": [zone.task_id for zone in zones if zone.task_id],
"failed_zone_errors": [
{
"zoneId": zone.zone_id,
"error": zone.processing_error,
}
for zone in 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,
}
return {
"task": {
"status": "IDLE",
"area_uuid": "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"task_ids": [],
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
},
"area": get_default_area_feature(),
}
def get_initial_zones_payload(crop_area):
+239 -1
View File
@@ -1,7 +1,14 @@
from unittest.mock import patch
from kombu.exceptions import OperationalError
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from crop_zoning.views import ZonesInitialView
from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import AreaView, ZonesInitialView
from sensor_hub.models import Sensor
AREA_GEOJSON = {
@@ -45,3 +52,234 @@ class ZonesInitialViewTests(TestCase):
response.data["data"]["zone_count"],
len(response.data["data"]["zones"]),
)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class AreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="farmer",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.sensor = Sensor.objects.create(owner=self.user, name="sensor-1")
def _create_area(self, **kwargs):
defaults = {
"sensor": self.sensor,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 2,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _request(self):
return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}")
def test_get_requires_sensor_uuid(self):
request = self.factory.get("/api/crop-zoning/area/")
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "sensor_uuid is required.")
def test_get_returns_pending_task_status_until_all_zones_complete(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="celery-task-1",
)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PROCESSING,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING")
self.assertEqual(response.data["data"]["task"]["total_zones"], 2)
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
def test_get_returns_area_when_all_tasks_complete(self):
crop_area = self._create_area()
for sequence in range(2):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
mock_dispatch.assert_called_once()
@patch("crop_zoning.services.create_zones_and_dispatch")
def test_get_creates_area_when_sensor_has_no_data(self, mock_create):
created_area = self._create_area(zone_count=0)
mock_create.return_value = (created_area, [])
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_create.assert_called_once()
self.assertEqual(mock_create.call_args.kwargs["sensor"], self.sensor)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_only_one_active_task_is_created_per_sensor(self, mock_delay):
crop_area = self._create_area()
zone0 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
zone1 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=150000,
area_hectares=15,
sequence=1,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
class Result:
id = "shared-task-id"
mock_delay.return_value = Result()
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 1)
zone0.refresh_from_db()
zone1.refresh_from_db()
self.assertEqual(zone0.task_id, "shared-task-id")
self.assertEqual(zone1.task_id, "shared-task-id")
@patch("crop_zoning.tasks.process_zone_soil_data.delay", side_effect=OperationalError("redis down"))
def test_get_generates_local_task_id_when_broker_is_unavailable(self, mock_delay):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
zone.refresh_from_db()
self.assertTrue(zone.task_id)
self.assertEqual(response.data["data"]["task"]["task_ids"], [zone.task_id])
self.assertEqual(response.data["data"]["task"]["status"], "PENDING")
self.assertIn("Celery broker unavailable", zone.processing_error)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_get_stores_task_id_and_reuses_it_on_next_request(self, mock_delay):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
class Result:
id = "persisted-task-id"
mock_delay.return_value = Result()
first_response = AreaView.as_view()(self._request())
self.assertEqual(first_response.status_code, 200)
zone.refresh_from_db()
self.assertEqual(zone.task_id, "persisted-task-id")
self.assertEqual(first_response.data["data"]["task"]["task_ids"], ["persisted-task-id"])
self.assertEqual(mock_delay.call_count, 1)
second_response = AreaView.as_view()(self._request())
self.assertEqual(second_response.status_code, 200)
self.assertEqual(second_response.data["data"]["task"]["task_ids"], ["persisted-task-id"])
self.assertEqual(second_response.data["data"]["task"]["status"], "PENDING")
self.assertEqual(mock_delay.call_count, 1)
+33 -5
View File
@@ -4,11 +4,12 @@ from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from .services import (
create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
@@ -23,10 +24,31 @@ from .services import (
class AreaView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())},
parameters=[
OpenApiParameter(
name="sensor_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.",
)
],
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return Response({"status": "success", "data": get_latest_area_payload()}, status=status.HTTP_200_OK)
sensor_uuid = request.query_params.get("sensor_uuid")
try:
crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"status": "success", "data": get_latest_area_payload(crop_area)}, status=status.HTTP_200_OK)
class ProductsView(APIView):
@@ -45,10 +67,16 @@ class ZonesInitialView(APIView):
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = request.data.get("area") or request.data.get("area_geojson") or get_default_area_feature()
area_feature = (
request.data.get("area")
or request.data.get("area_geojson")
or request.data.get("boundary")
or get_default_area_feature()
)
cell_side_km = request.data.get("cell_side_km")
try:
crop_area, _zones = create_zones_and_dispatch(area_feature)
crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
+79 -19
View File
@@ -1,46 +1,106 @@
# Development: volumes mount source so code updates apply without rebuild
services:
db:
image: mysql:8.0
image: docker.iranserver.com/mysql:8
container_name: ai-db
environment:
MYSQL_DATABASE: ${DB_NAME:-croplogic}
MYSQL_USER: ${DB_USER:-croplogic}
MYSQL_DATABASE: ${DB_NAME:-ai}
MYSQL_USER: ${DB_USER:-ai}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- mysql_data:/var/lib/mysql
- ai_mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"]
interval: 5s
timeout: 5s
retries: 5
# phpmyadmin:
# image: docker.iranserver.com/phpmyadmin
# environment:
# PMA_HOST: db
# PMA_PORT: 3306
# UPLOAD_LIMIT: 64M
# ports:
# - "8081:80"
# depends_on:
# db:
# condition: service_healthy
phpmyadmin:
image: docker-mirror.liara.ir/phpmyadmin:latest
container_name: ai-phpmyadmin
environment:
PMA_HOST: db
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
ports:
- "8082:80"
depends_on:
db:
condition: service_healthy
redis:
image: redis:7-alpine
container_name: ai-redis
ports:
- "6380:6379"
qdrant:
image: qdrant/qdrant:latest
container_name: ai-qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-web
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
volumes:
- .:/app
- ./logs:/app/logs
ports:
- "8000:8000"
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
QDRANT_HOST: qdrant
QDRANT_PORT: 6333
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
qdrant:
condition: service_started
celery:
build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-celery
command: celery -A config worker -l info
volumes:
- .:/app
- ./logs:/app/logs
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
SKIP_MIGRATE: "1"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
mysql_data:
ai_mysql_data:
qdrant_data:
+19
View File
@@ -5,6 +5,7 @@ from django.db import transaction
from account.seeds import seed_admin_user
from .models import Sensor
from .services import dispatch_sensor_zoning
ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
@@ -74,6 +75,22 @@ ADMIN_SENSOR_DATA = {
},
}
ADMIN_SENSOR_AREA_GEOJSON = {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.418934, 35.706815],
[51.423054, 35.691062],
[51.384258, 35.689389],
[51.418934, 35.706815],
]
],
},
}
@transaction.atomic
def seed_admin_sensor():
@@ -89,4 +106,6 @@ def seed_admin_sensor():
"customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"],
},
)
if created:
dispatch_sensor_zoning(ADMIN_SENSOR_AREA_GEOJSON)
return sensor, created
+8 -1
View File
@@ -1,6 +1,5 @@
from rest_framework import serializers
from .models import Sensor
@@ -51,6 +50,14 @@ class SensorCreateSerializer(serializers.ModelSerializer):
return value
def create(self, validated_data):
validated_data.pop("area_geojson", None)
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data.pop("area_geojson", None)
return super().update(instance, validated_data)
class SensorToggleSerializer(serializers.Serializer):
uuid_sensor = serializers.UUIDField()
+6 -2
View File
@@ -3,6 +3,11 @@ from django.db import transaction
from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature
def dispatch_sensor_zoning(area_feature, sensor):
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), sensor=sensor)
return get_initial_zones_payload(crop_area)
def create_sensor_with_zoning(serializer, owner):
area_feature = serializer.validated_data.pop("area_geojson", None)
@@ -11,7 +16,6 @@ def create_sensor_with_zoning(serializer, owner):
zoning_payload = None
if area_feature is not None:
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature))
zoning_payload = get_initial_zones_payload(crop_area)
zoning_payload = dispatch_sensor_zoning(area_feature, sensor)
return sensor, zoning_payload
+23
View File
@@ -3,6 +3,7 @@ from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea
from sensor_hub.seeds import seed_admin_sensor
from sensor_hub.views import SensorListCreateView
@@ -63,3 +64,25 @@ class SensorListCreateViewTests(TestCase):
CropArea.objects.get().zone_count,
)
self.assertEqual(CropArea.objects.count(), 1)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class SensorSeedTests(TestCase):
def test_seed_admin_sensor_dispatches_crop_logic_flow_on_create(self):
sensor, created = seed_admin_sensor()
self.assertTrue(created)
self.assertEqual(sensor.uuid_sensor.hex, "11111111111111111111111111111111")
self.assertEqual(CropArea.objects.count(), 1)
def test_seed_admin_sensor_does_not_dispatch_twice_for_existing_seed(self):
first_sensor, first_created = seed_admin_sensor()
second_sensor, second_created = seed_admin_sensor()
self.assertTrue(first_created)
self.assertFalse(second_created)
self.assertEqual(first_sensor.id, second_sensor.id)
self.assertEqual(CropArea.objects.count(), 1)