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