UPDATE
This commit is contained in:
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user