This commit is contained in:
2026-05-11 04:38:44 +03:30
parent 17628f503f
commit c2b6052e5c
69 changed files with 3073 additions and 57 deletions
+66
View File
@@ -3,7 +3,11 @@ from .models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingClusterAssignment,
RemoteSensingSubdivisionOption,
RemoteSensingSubdivisionOptionAssignment,
RemoteSensingSubdivisionOptionBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
SoilLocation,
@@ -121,6 +125,68 @@ class RemoteSensingSubdivisionResultAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at")
@admin.register(RemoteSensingClusterBlock)
class RemoteSensingClusterBlockAdmin(admin.ModelAdmin):
list_display = (
"uuid",
"soil_location",
"block_code",
"sub_block_code",
"cluster_label",
"cell_count",
"updated_at",
)
list_filter = ("cluster_label", "chunk_size_sqm", "created_at")
search_fields = ("uuid", "block_code", "sub_block_code", "soil_location__latitude", "soil_location__longitude")
readonly_fields = ("created_at", "updated_at")
@admin.register(RemoteSensingSubdivisionOption)
class RemoteSensingSubdivisionOptionAdmin(admin.ModelAdmin):
list_display = (
"id",
"result",
"requested_k",
"effective_cluster_count",
"is_active",
"is_recommended",
"selection_source",
"updated_at",
)
list_filter = ("is_active", "is_recommended", "selection_source", "created_at")
search_fields = ("result__block_code", "result__soil_location__latitude", "result__soil_location__longitude")
readonly_fields = ("created_at", "updated_at")
@admin.register(RemoteSensingSubdivisionOptionBlock)
class RemoteSensingSubdivisionOptionBlockAdmin(admin.ModelAdmin):
list_display = (
"id",
"option",
"cluster_label",
"sub_block_code",
"cell_count",
"updated_at",
)
list_filter = ("cluster_label", "created_at")
search_fields = ("sub_block_code", "option__result__block_code")
readonly_fields = ("created_at", "updated_at")
@admin.register(RemoteSensingSubdivisionOptionAssignment)
class RemoteSensingSubdivisionOptionAssignmentAdmin(admin.ModelAdmin):
list_display = (
"id",
"option",
"cell",
"cluster_label",
"updated_at",
)
list_filter = ("cluster_label", "created_at")
search_fields = ("cell__cell_code", "option__result__block_code")
readonly_fields = ("created_at", "updated_at")
@admin.register(RemoteSensingClusterAssignment)
class RemoteSensingClusterAssignmentAdmin(admin.ModelAdmin):
list_display = (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location_data", "0016_remove_analysisgridobservation_lst_c"),
]
operations = [
migrations.CreateModel(
name="RemoteSensingClusterBlock",
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)),
("block_code", models.CharField(blank=True, db_index=True, default="", help_text="شناسه بلوک والد که این زیر‌بلاک KMeans داخل آن ساخته شده است.", max_length=64)),
("sub_block_code", models.CharField(db_index=True, help_text="شناسه زیر‌بلاک ساخته‌شده توسط KMeans مثل cluster-0.", max_length=64)),
("cluster_label", models.PositiveIntegerField(db_index=True)),
("chunk_size_sqm", models.PositiveIntegerField(default=900)),
("centroid_lat", models.DecimalField(db_index=True, decimal_places=6, help_text="عرض جغرافیایی مرکز زیر‌بلاک.", max_digits=9)),
("centroid_lon", models.DecimalField(db_index=True, decimal_places=6, help_text="طول جغرافیایی مرکز زیر‌بلاک.", max_digits=9)),
("geometry", models.JSONField(blank=True, default=dict, help_text="هندسه GeoJSON زیر‌بلاک KMeans. فعلا از چندضلعی/چندچندضلعی سلول‌های عضو ساخته می‌شود.")),
("cell_count", models.PositiveIntegerField(default=0)),
("cell_codes", models.JSONField(blank=True, default=list)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("block_subdivision", models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name="remote_sensing_cluster_blocks", to="location_data.blocksubdivision")),
("result", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="cluster_blocks", to="location_data.remotesensingsubdivisionresult")),
("soil_location", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="remote_sensing_cluster_blocks", to="location_data.soillocation")),
],
options={
"verbose_name": "remote sensing cluster block",
"verbose_name_plural": "remote sensing cluster blocks",
"ordering": ["result", "cluster_label", "id"],
},
),
migrations.AddConstraint(
model_name="remotesensingclusterblock",
constraint=models.UniqueConstraint(fields=("result", "cluster_label"), name="rs_cluster_block_unique_result_label"),
),
migrations.AddIndex(
model_name="remotesensingclusterblock",
index=models.Index(fields=["soil_location", "block_code", "cluster_label"], name="rs_cluster_block_lookup_idx"),
),
]
@@ -0,0 +1,92 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location_data", "0017_remotesensingclusterblock"),
]
operations = [
migrations.CreateModel(
name="RemoteSensingSubdivisionOption",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("requested_k", models.PositiveIntegerField(db_index=True)),
("effective_cluster_count", models.PositiveIntegerField(default=0)),
("is_active", models.BooleanField(db_index=True, default=False)),
("is_recommended", models.BooleanField(db_index=True, default=False)),
("selection_source", models.CharField(default="system", help_text="منشا انتخاب این گزینه؛ مثل system یا user.", max_length=32)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("result", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="options", to="location_data.remotesensingsubdivisionresult")),
],
options={
"verbose_name": "remote sensing subdivision option",
"verbose_name_plural": "remote sensing subdivision options",
"ordering": ["result", "requested_k", "id"],
},
),
migrations.CreateModel(
name="RemoteSensingSubdivisionOptionBlock",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("cluster_label", models.PositiveIntegerField(db_index=True)),
("sub_block_code", models.CharField(db_index=True, max_length=64)),
("chunk_size_sqm", models.PositiveIntegerField(default=900)),
("centroid_lat", models.DecimalField(db_index=True, decimal_places=6, max_digits=9)),
("centroid_lon", models.DecimalField(db_index=True, decimal_places=6, max_digits=9)),
("geometry", models.JSONField(blank=True, default=dict)),
("cell_count", models.PositiveIntegerField(default=0)),
("cell_codes", models.JSONField(blank=True, default=list)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("option", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="cluster_blocks", to="location_data.remotesensingsubdivisionoption")),
],
options={
"verbose_name": "remote sensing subdivision option block",
"verbose_name_plural": "remote sensing subdivision option blocks",
"ordering": ["option", "cluster_label", "id"],
},
),
migrations.CreateModel(
name="RemoteSensingSubdivisionOptionAssignment",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("cluster_label", models.PositiveIntegerField(db_index=True)),
("raw_feature_values", models.JSONField(blank=True, default=dict)),
("scaled_feature_values", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("cell", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="subdivision_option_assignments", to="location_data.analysisgridcell")),
("option", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="assignments", to="location_data.remotesensingsubdivisionoption")),
],
options={
"verbose_name": "remote sensing subdivision option assignment",
"verbose_name_plural": "remote sensing subdivision option assignments",
"ordering": ["option", "cluster_label", "cell__cell_code"],
},
),
migrations.AddConstraint(
model_name="remotesensingsubdivisionoption",
constraint=models.UniqueConstraint(fields=("result", "requested_k"), name="rs_subdiv_option_unique_result_requested_k"),
),
migrations.AddIndex(
model_name="remotesensingsubdivisionoption",
index=models.Index(fields=["result", "is_active"], name="rs_subdiv_option_active_idx"),
),
migrations.AddConstraint(
model_name="remotesensingsubdivisionoptionblock",
constraint=models.UniqueConstraint(fields=("option", "cluster_label"), name="rs_subdiv_option_block_unique_option_label"),
),
migrations.AddConstraint(
model_name="remotesensingsubdivisionoptionassignment",
constraint=models.UniqueConstraint(fields=("option", "cell"), name="rs_subdiv_option_assign_unique_option_cell"),
),
migrations.AddIndex(
model_name="remotesensingsubdivisionoptionassignment",
index=models.Index(fields=["option", "cluster_label"], name="rs_subopt_assign_lbl_idx"),
),
]
@@ -0,0 +1,81 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location_data", "0018_remotesensingsubdivisionoption_and_more"),
]
operations = [
migrations.AddField(
model_name="remotesensingclusterblock",
name="center_cell_code",
field=models.CharField(
blank=True,
db_index=True,
default="",
help_text="شناسه سلول مرکزی انتخاب‌شده با بهینه‌سازی 1-center در همین کلاستر.",
max_length=64,
),
),
migrations.AddField(
model_name="remotesensingclusterblock",
name="center_cell_lat",
field=models.DecimalField(
blank=True,
db_index=True,
decimal_places=6,
help_text="عرض جغرافیایی سلول مرکزی کلاستر.",
max_digits=9,
null=True,
),
),
migrations.AddField(
model_name="remotesensingclusterblock",
name="center_cell_lon",
field=models.DecimalField(
blank=True,
db_index=True,
decimal_places=6,
help_text="طول جغرافیایی سلول مرکزی کلاستر.",
max_digits=9,
null=True,
),
),
migrations.AddField(
model_name="remotesensingsubdivisionoptionblock",
name="center_cell_code",
field=models.CharField(
blank=True,
db_index=True,
default="",
help_text="شناسه سلول مرکزی انتخاب‌شده با بهینه‌سازی 1-center روی اعضای همین کلاستر.",
max_length=64,
),
),
migrations.AddField(
model_name="remotesensingsubdivisionoptionblock",
name="center_cell_lat",
field=models.DecimalField(
blank=True,
db_index=True,
decimal_places=6,
help_text="عرض جغرافیایی سلول مرکزی کلاستر.",
max_digits=9,
null=True,
),
),
migrations.AddField(
model_name="remotesensingsubdivisionoptionblock",
name="center_cell_lon",
field=models.DecimalField(
blank=True,
db_index=True,
decimal_places=6,
help_text="طول جغرافیایی سلول مرکزی کلاستر.",
max_digits=9,
null=True,
),
),
]
+248
View File
@@ -1,3 +1,5 @@
import uuid
from django.db import models
@@ -451,6 +453,252 @@ class RemoteSensingSubdivisionResult(models.Model):
)
class RemoteSensingSubdivisionOption(models.Model):
result = models.ForeignKey(
RemoteSensingSubdivisionResult,
on_delete=models.CASCADE,
related_name="options",
)
requested_k = models.PositiveIntegerField(db_index=True)
effective_cluster_count = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=False, db_index=True)
is_recommended = models.BooleanField(default=False, db_index=True)
selection_source = models.CharField(
max_length=32,
default="system",
help_text="منشا انتخاب این گزینه؛ مثل system یا user.",
)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["result", "requested_k", "id"]
constraints = [
models.UniqueConstraint(
fields=["result", "requested_k"],
name="rs_subdiv_option_unique_result_requested_k",
)
]
indexes = [
models.Index(
fields=["result", "is_active"],
name="rs_subdiv_option_active_idx",
)
]
verbose_name = "remote sensing subdivision option"
verbose_name_plural = "remote sensing subdivision options"
def __str__(self):
return (
f"RemoteSensingSubdivisionOption(result={self.result_id}, "
f"requested_k={self.requested_k}, active={self.is_active})"
)
class RemoteSensingSubdivisionOptionBlock(models.Model):
option = models.ForeignKey(
RemoteSensingSubdivisionOption,
on_delete=models.CASCADE,
related_name="cluster_blocks",
)
cluster_label = models.PositiveIntegerField(db_index=True)
sub_block_code = models.CharField(max_length=64, db_index=True)
chunk_size_sqm = models.PositiveIntegerField(default=900)
centroid_lat = models.DecimalField(max_digits=9, decimal_places=6, db_index=True)
centroid_lon = models.DecimalField(max_digits=9, decimal_places=6, db_index=True)
center_cell_code = models.CharField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="شناسه سلول مرکزی انتخاب‌شده با بهینه‌سازی 1-center روی اعضای همین کلاستر.",
)
center_cell_lat = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
db_index=True,
help_text="عرض جغرافیایی سلول مرکزی کلاستر.",
)
center_cell_lon = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
db_index=True,
help_text="طول جغرافیایی سلول مرکزی کلاستر.",
)
geometry = models.JSONField(default=dict, blank=True)
cell_count = models.PositiveIntegerField(default=0)
cell_codes = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["option", "cluster_label", "id"]
constraints = [
models.UniqueConstraint(
fields=["option", "cluster_label"],
name="rs_subdiv_option_block_unique_option_label",
)
]
verbose_name = "remote sensing subdivision option block"
verbose_name_plural = "remote sensing subdivision option blocks"
def __str__(self):
return (
f"RemoteSensingSubdivisionOptionBlock(option={self.option_id}, "
f"cluster={self.cluster_label})"
)
class RemoteSensingSubdivisionOptionAssignment(models.Model):
option = models.ForeignKey(
RemoteSensingSubdivisionOption,
on_delete=models.CASCADE,
related_name="assignments",
)
cell = models.ForeignKey(
AnalysisGridCell,
on_delete=models.CASCADE,
related_name="subdivision_option_assignments",
)
cluster_label = models.PositiveIntegerField(db_index=True)
raw_feature_values = models.JSONField(default=dict, blank=True)
scaled_feature_values = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["option", "cluster_label", "cell__cell_code"]
constraints = [
models.UniqueConstraint(
fields=["option", "cell"],
name="rs_subdiv_option_assign_unique_option_cell",
)
]
indexes = [
models.Index(
fields=["option", "cluster_label"],
name="rs_subopt_assign_lbl_idx",
)
]
verbose_name = "remote sensing subdivision option assignment"
verbose_name_plural = "remote sensing subdivision option assignments"
def __str__(self):
return (
f"RemoteSensingSubdivisionOptionAssignment(option={self.option_id}, "
f"cell={self.cell_id}, cluster={self.cluster_label})"
)
class RemoteSensingClusterBlock(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)
result = models.ForeignKey(
RemoteSensingSubdivisionResult,
on_delete=models.CASCADE,
related_name="cluster_blocks",
)
soil_location = models.ForeignKey(
SoilLocation,
on_delete=models.CASCADE,
related_name="remote_sensing_cluster_blocks",
)
block_subdivision = models.ForeignKey(
BlockSubdivision,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="remote_sensing_cluster_blocks",
)
block_code = models.CharField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="شناسه بلوک والد که این زیر‌بلاک KMeans داخل آن ساخته شده است.",
)
sub_block_code = models.CharField(
max_length=64,
db_index=True,
help_text="شناسه زیر‌بلاک ساخته‌شده توسط KMeans مثل cluster-0.",
)
cluster_label = models.PositiveIntegerField(db_index=True)
chunk_size_sqm = models.PositiveIntegerField(default=900)
centroid_lat = models.DecimalField(
max_digits=9,
decimal_places=6,
db_index=True,
help_text="عرض جغرافیایی مرکز زیر‌بلاک.",
)
centroid_lon = models.DecimalField(
max_digits=9,
decimal_places=6,
db_index=True,
help_text="طول جغرافیایی مرکز زیر‌بلاک.",
)
center_cell_code = models.CharField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="شناسه سلول مرکزی انتخاب‌شده با بهینه‌سازی 1-center در همین کلاستر.",
)
center_cell_lat = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
db_index=True,
help_text="عرض جغرافیایی سلول مرکزی کلاستر.",
)
center_cell_lon = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
db_index=True,
help_text="طول جغرافیایی سلول مرکزی کلاستر.",
)
geometry = models.JSONField(
default=dict,
blank=True,
help_text="هندسه GeoJSON زیر‌بلاک KMeans. فعلا از چندضلعی/چندچندضلعی سلول‌های عضو ساخته می‌شود.",
)
cell_count = models.PositiveIntegerField(default=0)
cell_codes = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["result", "cluster_label", "id"]
constraints = [
models.UniqueConstraint(
fields=["result", "cluster_label"],
name="rs_cluster_block_unique_result_label",
)
]
indexes = [
models.Index(
fields=["soil_location", "block_code", "cluster_label"],
name="rs_cluster_block_lookup_idx",
)
]
verbose_name = "remote sensing cluster block"
verbose_name_plural = "remote sensing cluster blocks"
def __str__(self):
return (
f"RemoteSensingClusterBlock({self.uuid}, "
f"{self.block_code or 'farm'}:{self.sub_block_code})"
)
class RemoteSensingClusterAssignment(models.Model):
result = models.ForeignKey(
RemoteSensingSubdivisionResult,
+22 -2
View File
@@ -411,8 +411,7 @@ def build_spatial_extent(cells: list[AnalysisGridCell]) -> dict[str, float]:
south = None
north = None
for cell in cells:
coordinates = ((cell.geometry or {}).get("coordinates") or [[]])[0]
for lon, lat in coordinates:
for lon, lat in _iter_geometry_lon_lat_pairs(cell.geometry):
west = lon if west is None else min(west, lon)
east = lon if east is None else max(east, lon)
south = lat if south is None else min(south, lat)
@@ -426,6 +425,27 @@ def build_spatial_extent(cells: list[AnalysisGridCell]) -> dict[str, float]:
}
def _iter_geometry_lon_lat_pairs(geometry: dict[str, Any] | None):
geometry = dict(geometry or {})
geometry_type = geometry.get("type")
coordinates = geometry.get("coordinates") or []
if geometry_type == "Polygon":
for ring in coordinates:
for point in ring or []:
if len(point) >= 2:
yield point[0], point[1]
return
if geometry_type == "MultiPolygon":
for polygon in coordinates:
for ring in polygon or []:
for point in ring or []:
if len(point) >= 2:
yield point[0], point[1]
return
def build_empty_metric_payload() -> dict[str, Any]:
return {metric_name: None for metric_name in METRIC_NAMES}
+128
View File
@@ -3,9 +3,12 @@ from rest_framework import serializers
from .models import (
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingClusterAssignment,
RemoteSensingSubdivisionResult,
RemoteSensingSubdivisionOption,
RemoteSensingSubdivisionOptionBlock,
SoilLocation,
)
from .satellite_snapshot import build_location_block_satellite_snapshots
@@ -141,6 +144,25 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer):
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100)
class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
temporal_start = serializers.DateField(required=False)
temporal_end = serializers.DateField(required=False)
days = serializers.IntegerField(required=False, min_value=1, max_value=90, default=30)
def validate(self, attrs):
temporal_start = attrs.get("temporal_start")
temporal_end = attrs.get("temporal_end")
if bool(temporal_start) != bool(temporal_end):
raise serializers.ValidationError(
"برای بازه سفارشی باید هر دو فیلد temporal_start و temporal_end ارسال شوند."
)
if temporal_start and temporal_end and temporal_start > temporal_end:
raise serializers.ValidationError(
{"temporal_start": ["temporal_start نمی‌تواند بعد از temporal_end باشد."]}
)
return attrs
class RemoteSensingCellObservationSerializer(serializers.ModelSerializer):
cell_code = serializers.CharField(source="cell.cell_code", read_only=True)
block_code = serializers.CharField(source="cell.block_code", read_only=True)
@@ -175,6 +197,13 @@ class RemoteSensingSummarySerializer(serializers.Serializer):
soil_vv_db_mean = serializers.FloatField(allow_null=True)
class RemoteSensingClusterBlockLiveMetricsSerializer(serializers.Serializer):
ndvi = serializers.FloatField(allow_null=True)
ndwi = serializers.FloatField(allow_null=True)
soil_vv = serializers.FloatField(allow_null=True)
soil_vv_db = serializers.FloatField(allow_null=True)
class RemoteSensingRunSerializer(serializers.ModelSerializer):
status_label = serializers.SerializerMethodField()
pipeline_status = serializers.SerializerMethodField()
@@ -239,8 +268,74 @@ class RemoteSensingClusterAssignmentSerializer(serializers.ModelSerializer):
]
class RemoteSensingClusterBlockSerializer(serializers.ModelSerializer):
class Meta:
model = RemoteSensingClusterBlock
fields = [
"uuid",
"sub_block_code",
"cluster_label",
"chunk_size_sqm",
"centroid_lat",
"centroid_lon",
"center_cell_code",
"center_cell_lat",
"center_cell_lon",
"cell_count",
"cell_codes",
"geometry",
"metadata",
"created_at",
"updated_at",
]
class RemoteSensingSubdivisionOptionBlockSerializer(serializers.ModelSerializer):
class Meta:
model = RemoteSensingSubdivisionOptionBlock
fields = [
"cluster_label",
"sub_block_code",
"chunk_size_sqm",
"centroid_lat",
"centroid_lon",
"center_cell_code",
"center_cell_lat",
"center_cell_lon",
"cell_count",
"cell_codes",
"geometry",
"metadata",
]
class RemoteSensingSubdivisionOptionSerializer(serializers.ModelSerializer):
cluster_blocks = RemoteSensingSubdivisionOptionBlockSerializer(many=True, read_only=True)
class Meta:
model = RemoteSensingSubdivisionOption
fields = [
"id",
"requested_k",
"effective_cluster_count",
"is_active",
"is_recommended",
"selection_source",
"metadata",
"cluster_blocks",
"created_at",
"updated_at",
]
class RemoteSensingSubdivisionOptionActivateSerializer(serializers.Serializer):
requested_k = serializers.IntegerField(min_value=1)
class RemoteSensingSubdivisionResultSerializer(serializers.ModelSerializer):
assignments = serializers.SerializerMethodField()
cluster_blocks = RemoteSensingClusterBlockSerializer(many=True, read_only=True)
available_k_options = serializers.SerializerMethodField()
def get_assignments(self, obj):
assignments = self.context.get("paginated_assignments")
@@ -248,6 +343,10 @@ class RemoteSensingSubdivisionResultSerializer(serializers.ModelSerializer):
assignments = obj.assignments.all().order_by("cluster_label", "cell__cell_code")
return RemoteSensingClusterAssignmentSerializer(assignments, many=True).data
def get_available_k_options(self, obj):
options = obj.options.all().order_by("requested_k")
return RemoteSensingSubdivisionOptionSerializer(options, many=True).data
class Meta:
model = RemoteSensingSubdivisionResult
fields = [
@@ -260,6 +359,8 @@ class RemoteSensingSubdivisionResultSerializer(serializers.ModelSerializer):
"selected_features",
"skipped_cell_codes",
"metadata",
"available_k_options",
"cluster_blocks",
"assignments",
"created_at",
"updated_at",
@@ -309,3 +410,30 @@ class RemoteSensingRunResultResponseSerializer(serializers.Serializer):
run = RemoteSensingRunSerializer()
subdivision_result = RemoteSensingSubdivisionResultSerializer(allow_null=True)
pagination = serializers.JSONField(required=False)
class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer):
status = serializers.CharField()
source = serializers.CharField()
cluster_block = RemoteSensingClusterBlockSerializer()
temporal_extent = serializers.JSONField()
selected_features = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
)
summary = RemoteSensingSummarySerializer()
metrics = RemoteSensingClusterBlockLiveMetricsSerializer()
metadata = serializers.JSONField()
class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
result_id = serializers.IntegerField()
active_requested_k = serializers.IntegerField(allow_null=True)
recommended_requested_k = serializers.IntegerField(allow_null=True)
options = RemoteSensingSubdivisionOptionSerializer(many=True)
class RemoteSensingSubdivisionOptionActivateResponseSerializer(serializers.Serializer):
result_id = serializers.IntegerField()
activated_requested_k = serializers.IntegerField()
subdivision_result = RemoteSensingSubdivisionResultSerializer()
@@ -0,0 +1,195 @@
from datetime import date, timedelta
import uuid
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from location_data.models import (
AnalysisGridCell,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
SoilLocation,
)
@override_settings(ROOT_URLCONF="location_data.urls")
class RemoteSensingClusterBlockLiveApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.boundary = {
"type": "Polygon",
"coordinates": [
[
[51.3890, 35.6890],
[51.3900, 35.6890],
[51.3900, 35.6900],
[51.3890, 35.6900],
[51.3890, 35.6890],
]
],
}
self.location = SoilLocation.objects.create(
latitude="35.689200",
longitude="51.389000",
farm_boundary=self.boundary,
)
self.subdivision = BlockSubdivision.objects.create(
soil_location=self.location,
block_code="block-1",
source_boundary=self.boundary,
chunk_size_sqm=900,
status="subdivided",
)
self.run = RemoteSensingRun.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
chunk_size_sqm=900,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
status=RemoteSensingRun.STATUS_SUCCESS,
metadata={"stage": "completed"},
)
self.result = RemoteSensingSubdivisionResult.objects.create(
soil_location=self.location,
run=self.run,
block_subdivision=self.subdivision,
block_code="block-1",
chunk_size_sqm=900,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
cluster_count=1,
selected_features=["ndvi", "ndwi", "soil_vv_db"],
metadata={"used_cell_count": 2, "skipped_cell_count": 0},
)
self.cluster_block = RemoteSensingClusterBlock.objects.create(
result=self.result,
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
sub_block_code="cluster-0",
cluster_label=0,
chunk_size_sqm=900,
centroid_lat="35.689500",
centroid_lon="51.389500",
cell_count=2,
cell_codes=["cell-1", "cell-2"],
geometry=self.boundary,
metadata={"source": "analysis_grid_cells"},
)
def test_get_cluster_block_live_returns_404_when_uuid_missing(self):
response = self.client.get(
f"/remote-sensing/cluster-blocks/{uuid.uuid4()}/live/"
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "زیر‌بلاک KMeans پیدا نشد.")
@patch("location_data.views.compute_remote_sensing_metrics")
def test_get_cluster_block_live_reads_fresh_metrics_from_openeo(self, compute_mock):
compute_mock.return_value = {
"results": {
f"cluster-{self.cluster_block.uuid}": {
"ndvi": 0.63,
"ndwi": 0.21,
"soil_vv": 0.13,
"soil_vv_db": -8.860566,
}
},
"metadata": {
"backend": "openeo",
"backend_url": "https://openeofed.dataspace.copernicus.eu",
"collections_used": ["SENTINEL2_L2A", "SENTINEL1_GRD"],
"job_refs": {"ndvi": "job-1", "ndwi": "job-2", "soil_vv": "job-3"},
"failed_metrics": [],
"payload_diagnostics": {},
},
}
response = self.client.get(
f"/remote-sensing/cluster-blocks/{self.cluster_block.uuid}/live/",
data={"temporal_start": "2025-02-01", "temporal_end": "2025-02-15"},
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["status"], "success")
self.assertEqual(payload["source"], "openeo")
self.assertEqual(payload["cluster_block"]["uuid"], str(self.cluster_block.uuid))
self.assertEqual(payload["summary"]["cell_count"], 2)
self.assertEqual(payload["summary"]["ndvi_mean"], 0.63)
self.assertEqual(payload["metrics"]["soil_vv_db"], -8.860566)
self.assertEqual(payload["temporal_extent"]["start_date"], "2025-02-01")
self.assertEqual(payload["temporal_extent"]["end_date"], "2025-02-15")
self.assertEqual(
payload["metadata"]["requested_cluster_uuid"],
str(self.cluster_block.uuid),
)
compute_mock.assert_called_once()
args, kwargs = compute_mock.call_args
self.assertEqual(len(args[0]), 1)
self.assertEqual(args[0][0].geometry, self.boundary)
self.assertEqual(kwargs["temporal_start"].isoformat(), "2025-02-01")
self.assertEqual(kwargs["temporal_end"].isoformat(), "2025-02-15")
@patch("location_data.views.compute_remote_sensing_metrics")
def test_get_cluster_block_live_rebuilds_geometry_from_member_cells_when_missing(self, compute_mock):
cell_geometry = {
"type": "Polygon",
"coordinates": [
[
[51.3890, 35.6890],
[51.3895, 35.6890],
[51.3895, 35.6895],
[51.3890, 35.6895],
[51.3890, 35.6890],
]
],
}
AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-1",
chunk_size_sqm=900,
geometry=cell_geometry,
centroid_lat="35.689250",
centroid_lon="51.389250",
)
self.cluster_block.geometry = {}
self.cluster_block.save(update_fields=["geometry", "updated_at"])
compute_mock.return_value = {
"results": {
f"cluster-{self.cluster_block.uuid}": {
"ndvi": 0.55,
"ndwi": 0.18,
"soil_vv": 0.10,
"soil_vv_db": -10.0,
}
},
"metadata": {"job_refs": {}, "failed_metrics": [], "payload_diagnostics": {}},
}
response = self.client.get(
f"/remote-sensing/cluster-blocks/{self.cluster_block.uuid}/live/",
data={"days": 7},
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["metrics"]["ndvi"], 0.55)
compute_mock.assert_called_once()
args, kwargs = compute_mock.call_args
self.assertEqual(args[0][0].geometry["type"], "Polygon")
self.assertEqual(args[0][0].geometry, cell_geometry)
expected_end = timezone.localdate() - timedelta(days=1)
expected_start = expected_end - timedelta(days=6)
self.assertEqual(kwargs["temporal_start"], expected_start)
self.assertEqual(kwargs["temporal_end"], expected_end)
+322 -2
View File
@@ -7,17 +7,25 @@ from django.core.files.base import ContentFile
from django.test import TestCase
from location_data.data_driven_subdivision import (
ClusteringDataset,
EmptyObservationDatasetError,
_persist_remote_sensing_diagnostic_artifacts,
_build_observation_label,
_build_cluster_geometry,
build_cluster_summaries,
build_clustering_dataset,
create_remote_sensing_subdivision_result,
enforce_spatial_contiguity,
sync_block_subdivision_with_result,
)
from location_data.models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
RemoteSensingSubdivisionOption,
SoilLocation,
)
@@ -58,7 +66,8 @@ class DataDrivenSubdivisionSyncTests(TestCase):
status=RemoteSensingRun.STATUS_SUCCESS,
)
def test_sync_block_subdivision_with_result_updates_saved_sub_blocks(self):
@patch("location_data.data_driven_subdivision.render_elbow_plot", return_value=None)
def test_sync_block_subdivision_with_result_updates_saved_sub_blocks(self, _mock_plot):
cell_1 = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
@@ -190,6 +199,9 @@ class DataDrivenSubdivisionSyncTests(TestCase):
), patch(
"location_data.data_driven_subdivision._render_feature_pair_plot",
return_value=ContentFile(b"pairs"),
), patch(
"location_data.data_driven_subdivision._render_feature_projection_plot",
return_value=ContentFile(b"projection"),
):
artifacts = _persist_remote_sensing_diagnostic_artifacts(
result=result,
@@ -207,13 +219,23 @@ class DataDrivenSubdivisionSyncTests(TestCase):
selected_features=["ndvi", "ndwi", "soil_vv_db"],
scaled_matrix=[[0.0, 0.0, 0.0]],
inertia_curve=[{"k": 1, "sse": 0.0}],
requested_k=1,
effective_cluster_count=1,
)
self.assertEqual(
sorted(artifacts["files"].keys()),
["cluster_map", "cluster_sizes", "elbow_plot", "feature_pairs"],
[
"cluster_map",
"cluster_sizes",
"elbow_plot",
"feature_pairs",
"feature_projection",
],
)
self.assertIn("k-1-effective-1", artifacts["directory"])
for path in artifacts["files"].values():
self.assertTrue(os.path.exists(path))
self.assertIn("__k-1__effective-1__", path)
def test_build_clustering_dataset_raises_clear_error_when_all_selected_features_are_null(self):
cell = AnalysisGridCell.objects.create(
@@ -250,3 +272,301 @@ class DataDrivenSubdivisionSyncTests(TestCase):
self.assertIn("No usable observations available for clustering", joined)
self.assertIn('"run_id": {}'.format(self.run.id), joined)
self.assertIn('"region_id": {}'.format(self.location.id), joined)
def test_build_cluster_summaries_selects_middle_grid_as_k_center(self):
observations = []
for index in range(3):
cell = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code=f"cell-{index}",
chunk_size_sqm=900,
geometry={
"type": "Polygon",
"coordinates": [[
[51.3890 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6890],
]],
},
centroid_lat="35.689200",
centroid_lon=f"{51.3892 + (index * 0.0001):.6f}",
)
observations.append(
AnalysisGridObservation.objects.create(
cell=cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.2 + index,
)
)
cluster_summaries = build_cluster_summaries(
observations=observations,
labels=[0, 0, 0],
)
self.assertEqual(cluster_summaries[0]["center_cell_code"], "cell-1")
self.assertEqual(cluster_summaries[0]["center_cell_lat"], 35.6892)
self.assertEqual(cluster_summaries[0]["center_cell_lon"], 51.3893)
def test_build_observation_label_uses_numeric_index_for_30m_cells(self):
cell = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-arbitrary-name",
chunk_size_sqm=900,
geometry=self.boundary,
centroid_lat="35.689200",
centroid_lon="51.389200",
)
observation = AnalysisGridObservation.objects.create(
cell=cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.5,
)
self.assertEqual(_build_observation_label(observation=observation, index=0), "1")
self.assertEqual(_build_observation_label(observation=observation, index=7), "8")
@patch("location_data.data_driven_subdivision.run_kmeans_labels", return_value=[0, 1, 1])
@patch("location_data.data_driven_subdivision.choose_cluster_count", return_value=(2, []))
@patch("location_data.data_driven_subdivision.build_clustering_dataset")
@patch("location_data.data_driven_subdivision._persist_remote_sensing_diagnostic_artifacts", return_value={})
@patch("location_data.data_driven_subdivision.render_elbow_plot", return_value=None)
def test_create_remote_sensing_subdivision_result_persists_cluster_blocks_with_geometry(
self,
_mock_plot,
_mock_artifacts,
mock_build_dataset,
_mock_choose_k,
_mock_run_kmeans,
):
cells = [
AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code=f"cell-{index}",
chunk_size_sqm=900,
geometry={
"type": "Polygon",
"coordinates": [[
[51.3890 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6890],
]],
},
centroid_lat=f"{35.6892 + (index * 0.0001):.6f}",
centroid_lon=f"{51.3892 + (index * 0.0001):.6f}",
)
for index in range(3)
]
observations = [
AnalysisGridObservation.objects.create(
cell=cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.2 + (index * 0.3),
ndwi=0.1 + (index * 0.2),
soil_vv_db=-8.0 + index,
)
for index, cell in enumerate(cells)
]
mock_build_dataset.return_value = ClusteringDataset(
observations=observations,
selected_features=["ndvi", "ndwi", "soil_vv_db"],
raw_feature_rows=[[0.2, 0.1, -8.0], [0.5, 0.3, -7.0], [0.8, 0.5, -6.0]],
raw_feature_maps=[
{"ndvi": 0.2, "ndwi": 0.1, "soil_vv_db": -8.0},
{"ndvi": 0.5, "ndwi": 0.3, "soil_vv_db": -7.0},
{"ndvi": 0.8, "ndwi": 0.5, "soil_vv_db": -6.0},
],
skipped_cell_codes=[],
used_cell_codes=[cell.cell_code for cell in cells],
imputed_matrix=[[0.2, 0.1, -8.0], [0.5, 0.3, -7.0], [0.8, 0.5, -6.0]],
scaled_matrix=[[-1.0, -1.0, -1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]],
imputer_statistics={"ndvi": 0.5, "ndwi": 0.3, "soil_vv_db": -7.0},
scaler_means={"ndvi": 0.5, "ndwi": 0.3, "soil_vv_db": -7.0},
scaler_scales={"ndvi": 0.1, "ndwi": 0.1, "soil_vv_db": 1.0},
missing_value_counts={"ndvi": 0, "ndwi": 0, "soil_vv_db": 0},
skipped_reasons={"all_features_missing": []},
)
result = create_remote_sensing_subdivision_result(
location=self.location,
run=self.run,
observations=observations,
block_subdivision=self.subdivision,
block_code="block-1",
selected_features=["ndvi", "ndwi", "soil_vv_db"],
explicit_k=2,
)
self.assertEqual(_mock_artifacts.call_count, 4)
requested_ks = sorted(
{
call.kwargs.get("requested_k")
for call in _mock_artifacts.call_args_list
if call.kwargs.get("requested_k") is not None
}
)
self.assertEqual(requested_ks, [1, 2, 3])
cluster_blocks = list(result.cluster_blocks.order_by("cluster_label"))
self.assertEqual(len(cluster_blocks), 2)
self.assertTrue(all(cluster_block.uuid for cluster_block in cluster_blocks))
self.assertTrue(all(cluster_block.geometry for cluster_block in cluster_blocks))
self.assertEqual(
sum(cluster_block.cell_count for cluster_block in cluster_blocks),
3,
)
self.assertEqual(RemoteSensingClusterBlock.objects.filter(result=result).count(), 2)
result.refresh_from_db()
cluster_summaries = result.metadata["cluster_summaries"]
self.assertTrue(all(summary.get("cluster_uuid") for summary in cluster_summaries))
self.assertTrue(all(summary.get("geometry") for summary in cluster_summaries))
self.assertEqual(cluster_summaries[0]["center_cell_code"], "cell-0")
self.assertEqual(cluster_summaries[1]["center_cell_code"], "cell-1")
self.assertEqual(result.cluster_count, 2)
self.assertEqual(
result.metadata["spatial_constraint"]["final_cluster_count"],
2,
)
self.assertEqual(
list(
RemoteSensingSubdivisionOption.objects.filter(result=result)
.order_by("requested_k")
.values_list("requested_k", flat=True)
),
[1, 2, 3],
)
self.assertEqual(result.options.filter(is_active=True).get().requested_k, 2)
self.assertEqual(result.options.filter(is_recommended=True).get().requested_k, 2)
self.subdivision.refresh_from_db()
self.assertTrue(all(point.get("cluster_uuid") for point in self.subdivision.centroid_points))
self.assertEqual(self.subdivision.centroid_points[1]["center_cell_code"], "cell-1")
self.location.refresh_from_db()
block_layout = self.location.block_layout["blocks"][0]
self.assertTrue(all(block.get("cluster_uuid") for block in block_layout["sub_blocks"]))
self.assertEqual(block_layout["sub_blocks"][1]["center_cell_code"], "cell-1")
self.assertEqual(cluster_blocks[1].geometry["type"], "Polygon")
self.assertEqual(cluster_blocks[1].center_cell_code, "cell-1")
def test_enforce_spatial_contiguity_merges_diagonal_island_into_adjacent_cluster(self):
cell_payloads = [
("cell-00", [[51.3890, 35.6890], [51.3891, 35.6890], [51.3891, 35.6891], [51.3890, 35.6891], [51.3890, 35.6890]]),
("cell-01", [[51.3891, 35.6890], [51.3892, 35.6890], [51.3892, 35.6891], [51.3891, 35.6891], [51.3891, 35.6890]]),
("cell-10", [[51.3890, 35.6891], [51.3891, 35.6891], [51.3891, 35.6892], [51.3890, 35.6892], [51.3890, 35.6891]]),
("cell-11", [[51.3891, 35.6891], [51.3892, 35.6891], [51.3892, 35.6892], [51.3891, 35.6892], [51.3891, 35.6891]]),
]
observations = []
for index, (cell_code, ring) in enumerate(cell_payloads):
cell = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code=cell_code,
chunk_size_sqm=900,
geometry={"type": "Polygon", "coordinates": [ring]},
centroid_lat=f"{35.68905 + (index // 2) * 0.0001:.6f}",
centroid_lon=f"{51.38905 + (index % 2) * 0.0001:.6f}",
)
observations.append(
AnalysisGridObservation.objects.create(
cell=cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.1 + index,
)
)
labels, metadata = enforce_spatial_contiguity(
observations=observations,
labels=[0, 1, 1, 0],
scaled_matrix=[
[0.0, 0.0, 0.0],
[1.0, 1.0, 1.0],
[1.1, 1.1, 1.1],
[0.1, 0.1, 0.1],
],
)
self.assertEqual(labels, [0, 1, 1, 1])
self.assertTrue(metadata["applied"])
self.assertEqual(metadata["disconnected_components_merged"], 1)
def test_build_cluster_geometry_returns_single_polygon_for_adjacent_cells(self):
left_cell = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-left",
chunk_size_sqm=900,
geometry={
"type": "Polygon",
"coordinates": [[
[51.3890, 35.6890],
[51.3891, 35.6890],
[51.3891, 35.6891],
[51.3890, 35.6891],
[51.3890, 35.6890],
]],
},
centroid_lat="35.689050",
centroid_lon="51.389050",
)
right_cell = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-right",
chunk_size_sqm=900,
geometry={
"type": "Polygon",
"coordinates": [[
[51.3891, 35.6890],
[51.3892, 35.6890],
[51.3892, 35.6891],
[51.3891, 35.6891],
[51.3891, 35.6890],
]],
},
centroid_lat="35.689050",
centroid_lon="51.389150",
)
observations = [
AnalysisGridObservation.objects.create(
cell=left_cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.4,
),
AnalysisGridObservation.objects.create(
cell=right_cell,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.5,
),
]
geometry = _build_cluster_geometry(observations)
self.assertEqual(geometry["type"], "Polygon")
self.assertEqual(len(geometry["coordinates"][0]), 7)
+207
View File
@@ -0,0 +1,207 @@
from datetime import date
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from location_data.models import (
AnalysisGridCell,
BlockSubdivision,
RemoteSensingSubdivisionOption,
RemoteSensingSubdivisionOptionAssignment,
RemoteSensingSubdivisionOptionBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
SoilLocation,
)
@override_settings(ROOT_URLCONF="location_data.urls")
class RemoteSensingSubdivisionOptionApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.boundary = {
"type": "Polygon",
"coordinates": [
[
[51.3890, 35.6890],
[51.3900, 35.6890],
[51.3900, 35.6900],
[51.3890, 35.6900],
[51.3890, 35.6890],
]
],
}
self.location = SoilLocation.objects.create(
latitude="35.689200",
longitude="51.389000",
farm_boundary=self.boundary,
)
self.subdivision = BlockSubdivision.objects.create(
soil_location=self.location,
block_code="block-1",
source_boundary=self.boundary,
chunk_size_sqm=900,
status="subdivided",
)
self.run = RemoteSensingRun.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
chunk_size_sqm=900,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
status=RemoteSensingRun.STATUS_SUCCESS,
metadata={"stage": "completed"},
)
self.result = RemoteSensingSubdivisionResult.objects.create(
soil_location=self.location,
run=self.run,
block_subdivision=self.subdivision,
block_code="block-1",
chunk_size_sqm=900,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
cluster_count=1,
selected_features=["ndvi", "ndwi", "soil_vv_db"],
metadata={"recommended_requested_k": 2, "active_requested_k": 1},
)
self.cells = [
AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code=f"cell-{index}",
chunk_size_sqm=900,
geometry={
"type": "Polygon",
"coordinates": [[
[51.3890 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6890],
[51.3891 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6891],
[51.3890 + (index * 0.0001), 35.6890],
]],
},
centroid_lat=f"{35.68905 + (index * 0.0001):.6f}",
centroid_lon=f"{51.38905 + (index * 0.0001):.6f}",
)
for index in range(2)
]
self.option_k1 = RemoteSensingSubdivisionOption.objects.create(
result=self.result,
requested_k=1,
effective_cluster_count=1,
is_active=True,
is_recommended=False,
selection_source="system",
metadata={"cluster_summaries": []},
)
self.option_k2 = RemoteSensingSubdivisionOption.objects.create(
result=self.result,
requested_k=2,
effective_cluster_count=2,
is_active=False,
is_recommended=True,
selection_source="system",
metadata={"cluster_summaries": []},
)
for cell in self.cells:
RemoteSensingSubdivisionOptionAssignment.objects.create(
option=self.option_k1,
cell=cell,
cluster_label=0,
raw_feature_values={"ndvi": 0.4},
scaled_feature_values={"ndvi": 0.0},
)
RemoteSensingSubdivisionOptionBlock.objects.create(
option=self.option_k1,
cluster_label=0,
sub_block_code="cluster-0",
chunk_size_sqm=900,
centroid_lat="35.689100",
centroid_lon="51.389100",
center_cell_code="cell-0",
center_cell_lat="35.689050",
center_cell_lon="51.389050",
cell_count=2,
cell_codes=[cell.cell_code for cell in self.cells],
geometry=self.boundary,
metadata={
"source": "analysis_grid_cells",
"center_selection": {"strategy": "coordinate_1_center", "center_cell_code": "cell-0"},
},
)
for index, cell in enumerate(self.cells):
RemoteSensingSubdivisionOptionAssignment.objects.create(
option=self.option_k2,
cell=cell,
cluster_label=index,
raw_feature_values={"ndvi": 0.4 + index},
scaled_feature_values={"ndvi": float(index)},
)
RemoteSensingSubdivisionOptionBlock.objects.create(
option=self.option_k2,
cluster_label=index,
sub_block_code=f"cluster-{index}",
chunk_size_sqm=900,
centroid_lat=f"{35.68905 + (index * 0.0001):.6f}",
centroid_lon=f"{51.38905 + (index * 0.0001):.6f}",
center_cell_code=cell.cell_code,
center_cell_lat=f"{35.68905 + (index * 0.0001):.6f}",
center_cell_lon=f"{51.38905 + (index * 0.0001):.6f}",
cell_count=1,
cell_codes=[cell.cell_code],
geometry=cell.geometry,
metadata={
"source": "analysis_grid_cells",
"center_selection": {"strategy": "coordinate_1_center", "center_cell_code": cell.cell_code},
},
)
def test_get_k_options_returns_all_persisted_options(self):
response = self.client.get(
f"/remote-sensing/results/{self.result.id}/k-options/"
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["result_id"], self.result.id)
self.assertEqual(payload["active_requested_k"], 1)
self.assertEqual(payload["recommended_requested_k"], 2)
self.assertEqual([item["requested_k"] for item in payload["options"]], [1, 2])
self.assertEqual(payload["options"][0]["cluster_blocks"][0]["center_cell_code"], "cell-0")
@patch("location_data.data_driven_subdivision.render_elbow_plot", return_value=None)
def test_post_activate_k_marks_selected_option_active_and_syncs_result(self, _mock_plot):
response = self.client.post(
f"/remote-sensing/results/{self.result.id}/k-options/activate/",
data={"requested_k": 2},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["activated_requested_k"], 2)
self.assertEqual(payload["subdivision_result"]["cluster_count"], 2)
self.assertEqual(
payload["subdivision_result"]["metadata"]["active_requested_k"],
2,
)
self.assertEqual(len(payload["subdivision_result"]["cluster_blocks"]), 2)
self.assertEqual(
payload["subdivision_result"]["cluster_blocks"][0]["center_cell_code"],
"cell-0",
)
self.option_k1.refresh_from_db()
self.option_k2.refresh_from_db()
self.assertFalse(self.option_k1.is_active)
self.assertTrue(self.option_k2.is_active)
self.assertEqual(self.option_k2.selection_source, "user")
self.result.refresh_from_db()
self.assertEqual(self.result.cluster_count, 2)
self.assertEqual(self.result.assignments.count(), 2)
self.assertEqual(self.result.cluster_blocks.count(), 2)
self.assertEqual(self.result.cluster_blocks.order_by("cluster_label").first().center_cell_code, "cell-0")
+17
View File
@@ -11,6 +11,7 @@ from location_data.data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from location_data.models import (
AnalysisGridCell,
AnalysisGridObservation,
RemoteSensingClusterBlock,
BlockSubdivision,
RemoteSensingClusterAssignment,
RemoteSensingRun,
@@ -457,6 +458,21 @@ class RemoteSensingApiTests(TestCase):
raw_feature_values={"ndvi": 0.61},
scaled_feature_values={"ndvi": 0.0},
)
cluster_block = RemoteSensingClusterBlock.objects.create(
result=result,
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="",
sub_block_code="cluster-0",
cluster_label=0,
chunk_size_sqm=900,
centroid_lat="35.689500",
centroid_lon="51.389500",
cell_count=1,
cell_codes=["cell-1"],
geometry=self.boundary,
metadata={"source": "analysis_grid_cells"},
)
task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401"
run.metadata = {**run.metadata, "task_id": task_id}
@@ -468,5 +484,6 @@ class RemoteSensingApiTests(TestCase):
payload = response.json()["data"]
self.assertEqual(payload["status"], "completed")
self.assertEqual(payload["subdivision_result"]["cluster_count"], 1)
self.assertEqual(payload["subdivision_result"]["cluster_blocks"][0]["uuid"], str(cluster_block.uuid))
self.assertEqual(len(payload["subdivision_result"]["assignments"]), 1)
self.assertEqual(payload["pagination"]["assignments"]["total_items"], 1)
+18
View File
@@ -3,6 +3,9 @@ from django.urls import path
from .views import (
NdviHealthView,
RemoteSensingAnalysisView,
RemoteSensingClusterBlockLiveView,
RemoteSensingSubdivisionOptionActivateView,
RemoteSensingSubdivisionOptionListView,
RemoteSensingRunStatusView,
SoilDataView,
)
@@ -10,6 +13,21 @@ from .views import (
urlpatterns = [
path("", SoilDataView.as_view(), name="soil-data"),
path("remote-sensing/", RemoteSensingAnalysisView.as_view(), name="remote-sensing"),
path(
"remote-sensing/cluster-blocks/<uuid:cluster_uuid>/live/",
RemoteSensingClusterBlockLiveView.as_view(),
name="remote-sensing-cluster-block-live",
),
path(
"remote-sensing/results/<int:result_id>/k-options/",
RemoteSensingSubdivisionOptionListView.as_view(),
name="remote-sensing-k-options",
),
path(
"remote-sensing/results/<int:result_id>/k-options/activate/",
RemoteSensingSubdivisionOptionActivateView.as_view(),
name="remote-sensing-k-options-activate",
),
path("remote-sensing/runs/<uuid:run_id>/status/", RemoteSensingRunStatusView.as_view(), name="remote-sensing-run-status"),
path("ndvi-health/", NdviHealthView.as_view(), name="ndvi-health"),
]
+336 -1
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from django.apps import apps
@@ -26,13 +27,16 @@ from .models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
RemoteSensingSubdivisionOption,
SoilLocation,
)
from farm_data.models import SensorData
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from .data_driven_subdivision import activate_subdivision_option
from .serializers import (
BlockSubdivisionSerializer,
NdviHealthRequestSerializer,
@@ -42,12 +46,25 @@ from .serializers import (
RemoteSensingFarmRequestSerializer,
RemoteSensingRunSerializer,
RemoteSensingRunStatusResponseSerializer,
RemoteSensingClusterBlockLiveRequestSerializer,
RemoteSensingClusterBlockLiveResponseSerializer,
RemoteSensingClusterBlockSerializer,
RemoteSensingSubdivisionOptionActivateResponseSerializer,
RemoteSensingSubdivisionOptionActivateSerializer,
RemoteSensingSubdivisionOptionListResponseSerializer,
RemoteSensingSubdivisionOptionSerializer,
RemoteSensingSummarySerializer,
RemoteSensingSubdivisionResultSerializer,
SoilDataRequestSerializer,
SoilLocationResponseSerializer,
)
from .tasks import run_remote_sensing_analysis_task
from .openeo_service import (
OpenEOAuthenticationError,
OpenEOExecutionError,
OpenEOServiceError,
compute_remote_sensing_metrics,
)
MAX_REMOTE_SENSING_PAGE_SIZE = 200
REMOTE_SENSING_RUN_STAGE_ORDER = (
@@ -119,6 +136,18 @@ RemoteSensingRunStatusEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingRunStatusEnvelopeSerializer",
RemoteSensingRunStatusResponseSerializer,
)
RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
RemoteSensingClusterBlockLiveResponseSerializer,
)
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
RemoteSensingSubdivisionOptionListResponseSerializer,
)
RemoteSensingSubdivisionOptionActivateEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionActivateEnvelopeSerializer",
RemoteSensingSubdivisionOptionActivateResponseSerializer,
)
class SoilDataView(APIView):
"""
ثبت مختصات گوشههای مزرعه و بلوکهای تعریفشده توسط کشاورز.
@@ -679,6 +708,256 @@ class RemoteSensingRunStatusView(APIView):
)
class RemoteSensingClusterBlockLiveView(APIView):
@extend_schema(
tags=["Location Data"],
summary="دریافت زنده remote sensing برای زیر‌بلاک KMeans",
description="با دریافت UUID زیر‌بلاک ساخته‌شده توسط KMeans، هندسه همان زیر‌بلاک از دیتابیس خوانده می‌شود و داده تازه ماهواره‌ای از openEO برگردانده می‌شود.",
parameters=[
OpenApiParameter(
name="cluster_uuid",
type={"type": "string", "format": "uuid"},
location=OpenApiParameter.PATH,
required=True,
description="شناسه UUID زیر‌بلاک KMeans.",
),
OpenApiParameter(
name="temporal_start",
type={"type": "string", "format": "date"},
location=OpenApiParameter.QUERY,
required=False,
description="شروع بازه سفارشی. اگر ست شود، temporal_end هم باید ارسال شود.",
),
OpenApiParameter(
name="temporal_end",
type={"type": "string", "format": "date"},
location=OpenApiParameter.QUERY,
required=False,
description="پایان بازه سفارشی. اگر ست شود، temporal_start هم باید ارسال شود.",
),
OpenApiParameter(
name="days",
type=int,
location=OpenApiParameter.QUERY,
required=False,
default=30,
description="اگر بازه سفارشی ارسال نشود، از yesterday backfill با این تعداد روز استفاده می‌شود.",
),
],
responses={
200: build_response(
RemoteSensingClusterBlockLiveEnvelopeSerializer,
"داده زنده openEO برای زیر‌بلاک KMeans بازگردانده شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"پارامترهای ورودی یا هندسه زیر‌بلاک نامعتبر است.",
),
404: build_response(
SoilErrorResponseSerializer,
"زیر‌بلاک KMeans پیدا نشد.",
),
502: build_response(
SoilErrorResponseSerializer,
"خواندن داده از openEO ناموفق بود.",
),
},
)
def get(self, request, cluster_uuid):
serializer = RemoteSensingClusterBlockLiveRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
cluster_block = (
RemoteSensingClusterBlock.objects.select_related("soil_location", "block_subdivision", "result")
.filter(uuid=cluster_uuid)
.first()
)
if cluster_block is None:
return Response(
{"code": 404, "msg": "زیر‌بلاک KMeans پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
geometry = _resolve_cluster_block_geometry(cluster_block)
if not geometry:
return Response(
{
"code": 400,
"msg": "هندسه زیر‌بلاک KMeans نامعتبر است.",
"data": {"cluster_uuid": [str(cluster_block.uuid)]},
},
status=status.HTTP_400_BAD_REQUEST,
)
temporal_start, temporal_end = _resolve_live_remote_sensing_window(serializer.validated_data)
virtual_cell = _build_virtual_cluster_block_cell(cluster_block=cluster_block, geometry=geometry)
try:
remote_payload = compute_remote_sensing_metrics(
[virtual_cell],
temporal_start=temporal_start,
temporal_end=temporal_end,
selected_features=list(DEFAULT_CLUSTER_FEATURES),
)
except (OpenEOAuthenticationError, OpenEOExecutionError, OpenEOServiceError) as exc:
return Response(
{"code": 502, "msg": "خواندن داده از openEO ناموفق بود.", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
metrics = dict(remote_payload.get("results", {}).get(virtual_cell.cell_code, {}) or {})
response_payload = {
"status": "success",
"source": "openeo",
"cluster_block": RemoteSensingClusterBlockSerializer(cluster_block).data,
"temporal_extent": {
"start_date": temporal_start.isoformat(),
"end_date": temporal_end.isoformat(),
},
"selected_features": list(DEFAULT_CLUSTER_FEATURES),
"summary": {
"cell_count": int(cluster_block.cell_count or 0),
"ndvi_mean": _round_or_none(metrics.get("ndvi")),
"ndwi_mean": _round_or_none(metrics.get("ndwi")),
"soil_vv_db_mean": _round_or_none(metrics.get("soil_vv_db")),
},
"metrics": {
"ndvi": _round_or_none(metrics.get("ndvi")),
"ndwi": _round_or_none(metrics.get("ndwi")),
"soil_vv": _round_or_none(metrics.get("soil_vv")),
"soil_vv_db": _round_or_none(metrics.get("soil_vv_db")),
},
"metadata": {
**dict(remote_payload.get("metadata") or {}),
"requested_cluster_uuid": str(cluster_block.uuid),
"block_code": cluster_block.block_code,
"sub_block_code": cluster_block.sub_block_code,
},
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
class RemoteSensingSubdivisionOptionListView(APIView):
@extend_schema(
tags=["Location Data"],
summary="فهرست همه گزینه‌های K ذخیره‌شده برای یک subdivision result",
description="همه ترکیب‌های K که برای این run/result محاسبه و ذخیره شده‌اند را برمی‌گرداند و مشخص می‌کند کدام‌یک active و کدام‌یک recommended است.",
responses={
200: build_response(
RemoteSensingSubdivisionOptionListEnvelopeSerializer,
"فهرست گزینه‌های K بازگردانده شد.",
),
404: build_response(
SoilErrorResponseSerializer,
"subdivision result موردنظر پیدا نشد.",
),
},
)
def get(self, request, result_id):
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result_id)
.prefetch_related("options__cluster_blocks")
.first()
)
if result is None:
return Response(
{"code": 404, "msg": "subdivision result پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
options = list(result.options.all().order_by("requested_k"))
response_payload = {
"result_id": result.id,
"active_requested_k": next((option.requested_k for option in options if option.is_active), None),
"recommended_requested_k": next((option.requested_k for option in options if option.is_recommended), None),
"options": RemoteSensingSubdivisionOptionSerializer(options, many=True).data,
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
class RemoteSensingSubdivisionOptionActivateView(APIView):
@extend_schema(
tags=["Location Data"],
summary="فعال‌سازی یک K ذخیره‌شده برای subdivision result",
description="کاربر می‌تواند یکی از Kهای از قبل محاسبه‌شده و ذخیره‌شده را انتخاب کند تا active شود و خروجی اصلی subdivision بر همان مبنا sync شود.",
request=RemoteSensingSubdivisionOptionActivateSerializer,
responses={
200: build_response(
RemoteSensingSubdivisionOptionActivateEnvelopeSerializer,
"K انتخابی فعال شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"درخواست نامعتبر است یا K انتخابی موجود نیست.",
),
404: build_response(
SoilErrorResponseSerializer,
"subdivision result موردنظر پیدا نشد.",
),
},
)
def post(self, request, result_id):
serializer = RemoteSensingSubdivisionOptionActivateSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result_id)
.select_related("soil_location", "block_subdivision")
.prefetch_related("options__cluster_blocks", "options__assignments__cell")
.first()
)
if result is None:
return Response(
{"code": 404, "msg": "subdivision result پیدا نشد.", "data": None},
status=status.HTTP_404_NOT_FOUND,
)
requested_k = serializer.validated_data["requested_k"]
option = next(
(candidate for candidate in result.options.all() if candidate.requested_k == requested_k),
None,
)
if option is None:
return Response(
{
"code": 400,
"msg": "K انتخابی برای این subdivision result موجود نیست.",
"data": {"requested_k": [requested_k]},
},
status=status.HTTP_400_BAD_REQUEST,
)
activate_subdivision_option(option=option, selection_source="user")
result.refresh_from_db()
result = (
RemoteSensingSubdivisionResult.objects.filter(pk=result.pk)
.prefetch_related("assignments__cell", "cluster_blocks", "options__cluster_blocks")
.first()
)
response_payload = {
"result_id": result.id,
"activated_requested_k": requested_k,
"subdivision_result": RemoteSensingSubdivisionResultSerializer(result).data,
}
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int, page_size: int) -> dict:
run_data = RemoteSensingRunSerializer(run).data
task_id = (run.metadata or {}).get("task_id")
@@ -1069,6 +1348,62 @@ def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) ->
return 900
def _resolve_live_remote_sensing_window(payload: dict[str, Any]):
temporal_start = payload.get("temporal_start")
temporal_end = payload.get("temporal_end")
if temporal_start and temporal_end:
return temporal_start, temporal_end
days = int(payload.get("days") or 30)
end_date = timezone.localdate() - timedelta(days=1)
start_date = end_date - timedelta(days=days - 1)
return start_date, end_date
def _resolve_cluster_block_geometry(cluster_block: RemoteSensingClusterBlock) -> dict[str, Any]:
geometry = dict(cluster_block.geometry or {})
if geometry.get("type") and geometry.get("coordinates"):
return geometry
cell_codes = list(cluster_block.cell_codes or [])
if not cell_codes:
return {}
cell_geometries = list(
AnalysisGridCell.objects.filter(
soil_location=cluster_block.soil_location,
cell_code__in=cell_codes,
)
.order_by("cell_code")
.values_list("geometry", flat=True)
)
polygon_coordinates: list[list[list[list[float]]]] = []
for cell_geometry in cell_geometries:
cell_geometry = dict(cell_geometry or {})
coordinates = cell_geometry.get("coordinates") or []
if cell_geometry.get("type") == "Polygon" and coordinates:
polygon_coordinates.append(coordinates)
elif cell_geometry.get("type") == "MultiPolygon" and coordinates:
polygon_coordinates.extend(coordinates)
if not polygon_coordinates:
return {}
if len(polygon_coordinates) == 1:
return {"type": "Polygon", "coordinates": polygon_coordinates[0]}
return {"type": "MultiPolygon", "coordinates": polygon_coordinates}
def _build_virtual_cluster_block_cell(
*,
cluster_block: RemoteSensingClusterBlock,
geometry: dict[str, Any],
):
return SimpleNamespace(
cell_code=f"cluster-{cluster_block.uuid}",
block_code=cluster_block.block_code,
soil_location_id=cluster_block.soil_location_id,
chunk_size_sqm=cluster_block.chunk_size_sqm,
geometry=geometry,
)
def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date):
queryset = (
AnalysisGridObservation.objects.select_related("cell", "run")
@@ -1104,7 +1439,7 @@ def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_d
temporal_end=end_date,
)
.select_related("run")
.prefetch_related("assignments__cell")
.prefetch_related("assignments__cell", "cluster_blocks")
.order_by("-created_at", "-id")
.first()
)