This commit is contained in:
2026-03-29 13:40:23 +03:30
parent cef1b5335a
commit 24cb87d94e
29 changed files with 1071 additions and 4887 deletions
+54
View File
@@ -0,0 +1,54 @@
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='CropArea',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('geometry', models.JSONField(default=dict)),
('points', models.JSONField(default=list)),
('center', models.JSONField(default=dict)),
('area_sqm', models.FloatField()),
('area_hectares', models.FloatField()),
('chunk_area_sqm', models.FloatField()),
('zone_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'crop_areas',
'ordering': ['-created_at', '-id'],
},
),
migrations.CreateModel(
name='CropZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('zone_id', models.CharField(max_length=64)),
('geometry', models.JSONField(default=dict)),
('points', models.JSONField(default=list)),
('center', models.JSONField(default=dict)),
('area_sqm', models.FloatField()),
('area_hectares', models.FloatField()),
('sequence', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('crop_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='zones', to='crop_zoning.croparea')),
],
options={
'db_table': 'crop_zones',
'ordering': ['sequence', 'id'],
'constraints': [models.UniqueConstraint(fields=('crop_area', 'zone_id'), name='unique_crop_area_zone_id')],
},
),
]
View File
+51
View File
@@ -0,0 +1,51 @@
import uuid
from django.db import models
class CropArea(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
geometry = models.JSONField(default=dict)
points = models.JSONField(default=list)
center = models.JSONField(default=dict)
area_sqm = models.FloatField()
area_hectares = models.FloatField()
chunk_area_sqm = models.FloatField()
zone_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_areas"
ordering = ["-created_at", "-id"]
def __str__(self):
return f"Area {self.uuid}"
class CropZone(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
crop_area = models.ForeignKey(
CropArea,
on_delete=models.CASCADE,
related_name="zones",
)
zone_id = models.CharField(max_length=64)
geometry = models.JSONField(default=dict)
points = models.JSONField(default=list)
center = models.JSONField(default=dict)
area_sqm = models.FloatField()
area_hectares = models.FloatField()
sequence = models.PositiveIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zones"
ordering = ["sequence", "id"]
constraints = [
models.UniqueConstraint(fields=["crop_area", "zone_id"], name="unique_crop_area_zone_id"),
]
def __str__(self):
return self.zone_id
+202
View File
@@ -0,0 +1,202 @@
import math
from copy import deepcopy
from django.conf import settings
from django.db import transaction
from .models import CropArea, CropZone
EARTH_RADIUS_METERS = 6378137.0
def get_chunk_area_sqm():
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
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),
}
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
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)],
]
def split_area_into_zones(area_feature):
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))
zones = []
remaining_area = total_area_sqm
base_longitude = area_center["longitude"]
base_latitude = area_center["latitude"]
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)
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)
zone_geometry = {
"type": "Feature",
"properties": {
"zone_id": f"zone-{sequence}",
"sequence": sequence,
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"center": zone_center,
},
"geometry": {
"type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]],
},
}
zones.append(
{
"zone_id": f"zone-{sequence}",
"geometry": zone_geometry,
"points": zone_points,
"center": zone_center,
"area_sqm": zone_area_sqm,
"area_hectares": zone_area_sqm / 10000,
"sequence": sequence,
}
)
remaining_area = max(0.0, remaining_area - zone_area_sqm)
area_geometry = deepcopy(area_feature)
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),
}
)
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,
"zone_count": zone_count,
},
"zones": zones,
}
def persist_zones(area_feature):
zoning_result = split_area_into_zones(area_feature)
area_data = zoning_result["area"]
with transaction.atomic():
crop_area = CropArea.objects.create(
geometry=area_data["geometry"],
points=area_data["points"],
center=area_data["center"],
area_sqm=round(area_data["area_sqm"], 2),
area_hectares=round(area_data["area_hectares"], 4),
chunk_area_sqm=round(area_data["chunk_area_sqm"], 2),
zone_count=area_data["zone_count"],
)
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"]
]
)
zoning_result["area"]["id"] = crop_area.id
zoning_result["area"]["uuid"] = str(crop_area.uuid)
return zoning_result
+51 -13
View File
@@ -5,6 +5,7 @@ Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
"""
from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -21,6 +22,7 @@ from .mock_data import (
ZONES_SOIL_QUALITY_RESPONSE_DATA,
ZONES_WATER_NEED_RESPONSE_DATA,
)
from .services import persist_zones
class AreaView(APIView):
@@ -88,23 +90,17 @@ class ZonesInitialView(APIView):
POST endpoint for initial zone data (map + hover/tooltip).
Purpose:
Accepts zones (FeatureCollection of grid squares) and returns static
initial data per zone for map rendering and hover/tooltip display.
Does not include reason or criteria (those are in zone details).
Accepts the main area polygon and creates zones based on configured
area chunk size. Stores generated zones in database.
Input parameters (body, JSON):
- zones: GeoJSON FeatureCollection. Location: body. Grid square polygons.
- products: array of strings, optional. Location: body. Product IDs.
Not read or used in response.
- area: GeoJSON Feature. Location: body. Main land polygon.
If omitted, the static area from mock data is used.
Response structure:
- status: string, always "success".
- status: string.
- data: object with total_area_hectares, total_area_sqm, zone_count,
zones (array of { zoneId, geometry, crop, matchPercent, waterNeed,
estimatedProfit }).
No processing or validation is performed on inputs. Input values are
not used in the response.
chunk_area_sqm and zones.
"""
@extend_schema(
@@ -113,12 +109,54 @@ class ZonesInitialView(APIView):
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = request.data.get("area") or AREA_RESPONSE_DATA.get("area")
try:
zoning_result = persist_zones(area_feature)
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,
)
area_data = zoning_result["area"]
response_data = {
"area": {
"id": area_data["id"],
"uuid": area_data["uuid"],
"geometry": area_data["geometry"],
"points": area_data["points"],
"center": area_data["center"],
"area_sqm": round(area_data["area_sqm"], 2),
"area_hectares": round(area_data["area_hectares"], 4),
"chunk_area_sqm": round(area_data["chunk_area_sqm"], 2),
"zone_count": area_data["zone_count"],
},
"zones": [
{
"zoneId": 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),
}
for zone in zoning_result["zones"]
],
}
return Response(
{"status": "success", "data": ZONES_INITIAL_RESPONSE_DATA},
{"status": "success", "data": response_data},
status=status.HTTP_200_OK,
)
class ZonesWaterNeedView(APIView):
"""
POST endpoint for water need per zone (water need layer).