UPDATE
This commit is contained in:
+414
-41
@@ -1,10 +1,47 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict:
|
||||
normalized_blocks = []
|
||||
if blocks:
|
||||
for index, block in enumerate(blocks):
|
||||
normalized_blocks.append(
|
||||
{
|
||||
"block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
|
||||
"order": int(block.get("order") or index + 1),
|
||||
"source": "input",
|
||||
"boundary": block.get("boundary") or {},
|
||||
"needs_subdivision": None,
|
||||
"sub_blocks": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
normalized_count = max(int(block_count or 1), 1)
|
||||
for index in range(normalized_count):
|
||||
normalized_blocks.append(
|
||||
{
|
||||
"block_code": f"block-{index + 1}",
|
||||
"order": index + 1,
|
||||
"source": "input" if normalized_count > 1 else "default",
|
||||
"boundary": {},
|
||||
"needs_subdivision": None,
|
||||
"sub_blocks": [],
|
||||
}
|
||||
)
|
||||
|
||||
normalized_count = len(normalized_blocks) if normalized_blocks else max(int(block_count or 1), 1)
|
||||
|
||||
return {
|
||||
"input_block_count": normalized_count,
|
||||
"default_full_farm": normalized_count == 1,
|
||||
"algorithm_status": "pending",
|
||||
"blocks": normalized_blocks,
|
||||
}
|
||||
|
||||
|
||||
class SoilLocation(models.Model):
|
||||
"""
|
||||
مرکز زمین برای دادههای خاک و مزرعه.
|
||||
هر مختصات سه سطر در SoilDepthData دارد (۰–۵، ۵–۱۵، ۱۵–۳۰ سانتیمتر).
|
||||
مرکز زمین و مرز مزرعه/بلوکهای تعریفشده توسط کشاورز.
|
||||
"""
|
||||
|
||||
latitude = models.DecimalField(
|
||||
@@ -33,6 +70,18 @@ class SoilLocation(models.Model):
|
||||
'میتواند GeoJSON polygon یا bbox مثل {"type": "Polygon", "coordinates": [...]} باشد.'
|
||||
),
|
||||
)
|
||||
input_block_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="تعداد بلوکهای اولیهای که کشاورز برای زمین ثبت میکند.",
|
||||
)
|
||||
block_layout = models.JSONField(
|
||||
default=build_block_layout,
|
||||
blank=True,
|
||||
help_text=(
|
||||
"ساختار بلوکهای زمین. بهصورت پیشفرض کل زمین یک بلوک است و "
|
||||
"بعداً الگوریتم میتواند برای هر بلوک زیربلوک تعریف کند."
|
||||
),
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -60,63 +109,387 @@ class SoilLocation(models.Model):
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
"""آیا هر سه عمق ذخیره شدهاند؟"""
|
||||
return self.depths.count() == 3
|
||||
"""آیا حداقل یک run کامل remote sensing برای این location وجود دارد؟"""
|
||||
return self.remote_sensing_runs.filter(status="success").exists()
|
||||
|
||||
def set_input_block_count(self, block_count: int = 1, blocks: list[dict] | None = None):
|
||||
normalized_count = len(blocks) if blocks else max(int(block_count or 1), 1)
|
||||
self.input_block_count = normalized_count
|
||||
self.block_layout = build_block_layout(normalized_count, blocks=blocks)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.input_block_count:
|
||||
self.input_block_count = 1
|
||||
if not self.block_layout:
|
||||
self.block_layout = build_block_layout(self.input_block_count)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SoilDepthData(models.Model):
|
||||
class BlockSubdivision(models.Model):
|
||||
"""
|
||||
دادههای خاک برای یک عمق مشخص، مرتبط با یک SoilLocation.
|
||||
مقادیر خام از API SoilGrids (قبل از اعمال d_factor).
|
||||
نتیجه خردسازی یک بلوک برای یک SoilLocation.
|
||||
grid_points نقاط اولیه شبکه هستند و centroid_points مراکز نهایی بخشها.
|
||||
"""
|
||||
|
||||
DEPTH_0_5 = "0-5cm"
|
||||
DEPTH_5_15 = "5-15cm"
|
||||
DEPTH_15_30 = "15-30cm"
|
||||
DEPTH_CHOICES = [
|
||||
(DEPTH_0_5, "۰–۵ سانتیمتر"),
|
||||
(DEPTH_5_15, "۵–۱۵ سانتیمتر"),
|
||||
(DEPTH_15_30, "۱۵–۳۰ سانتیمتر"),
|
||||
soil_location = models.ForeignKey(
|
||||
SoilLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="block_subdivisions",
|
||||
)
|
||||
block_code = models.CharField(
|
||||
max_length=64,
|
||||
help_text="شناسه بلوکی که این خردسازی برای آن انجام شده است.",
|
||||
)
|
||||
source_boundary = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="مرز همان بلوکی که به سرویس subdivision داده شده است.",
|
||||
)
|
||||
chunk_size_sqm = models.PositiveIntegerField(
|
||||
default=900,
|
||||
help_text="اندازه هر chunk به متر مربع.",
|
||||
)
|
||||
grid_points = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="نقاط اولیه شبکه داخل مرز بلوک.",
|
||||
)
|
||||
centroid_points = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="مراکز نهایی بخشهای خردشده.",
|
||||
)
|
||||
grid_point_count = models.PositiveIntegerField(default=0)
|
||||
centroid_count = models.PositiveIntegerField(default=0)
|
||||
elbow_plot = models.ImageField(
|
||||
upload_to="location_data/elbow_plots/",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="تصویر نمودار elbow برای انتخاب تعداد بهینه خوشهها.",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
default="created",
|
||||
help_text="وضعیت تولید subdivision برای این بلوک.",
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["soil_location", "block_code"],
|
||||
name="location_block_subdivision_unique_location_block_code",
|
||||
)
|
||||
]
|
||||
ordering = ["soil_location", "block_code", "-updated_at"]
|
||||
verbose_name = "خردسازی بلوک"
|
||||
verbose_name_plural = "خردسازی بلوکها"
|
||||
|
||||
def __str__(self):
|
||||
return f"BlockSubdivision({self.soil_location_id}, {self.block_code})"
|
||||
|
||||
|
||||
class RemoteSensingRun(models.Model):
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_SUCCESS = "success"
|
||||
STATUS_FAILURE = "failure"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_RUNNING, "Running"),
|
||||
(STATUS_SUCCESS, "Success"),
|
||||
(STATUS_FAILURE, "Failure"),
|
||||
]
|
||||
|
||||
soil_location = models.ForeignKey(
|
||||
SoilLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="depths",
|
||||
related_name="remote_sensing_runs",
|
||||
)
|
||||
depth_label = models.CharField(
|
||||
max_length=10,
|
||||
choices=DEPTH_CHOICES,
|
||||
block_subdivision = models.ForeignKey(
|
||||
BlockSubdivision,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="remote_sensing_runs",
|
||||
)
|
||||
block_code = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
db_index=True,
|
||||
help_text="شناسه بلوکی که این run برای آن اجرا شده است.",
|
||||
)
|
||||
provider = models.CharField(
|
||||
max_length=64,
|
||||
default="openeo",
|
||||
help_text="ارائهدهنده داده سنجشازدور.",
|
||||
)
|
||||
chunk_size_sqm = models.PositiveIntegerField(
|
||||
default=900,
|
||||
help_text="اندازه هر سلول تحلیل به متر مربع.",
|
||||
)
|
||||
temporal_start = models.DateField(null=True, blank=True)
|
||||
temporal_end = models.DateField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_PENDING,
|
||||
db_index=True,
|
||||
)
|
||||
# خواص خاک — مقادیر mean از API (raw)
|
||||
bdod = models.FloatField(null=True, blank=True)
|
||||
cec = models.FloatField(null=True, blank=True)
|
||||
cfvo = models.FloatField(null=True, blank=True)
|
||||
clay = models.FloatField(null=True, blank=True)
|
||||
nitrogen = models.FloatField(null=True, blank=True)
|
||||
ocd = models.FloatField(null=True, blank=True)
|
||||
ocs = models.FloatField(null=True, blank=True)
|
||||
phh2o = models.FloatField(null=True, blank=True)
|
||||
sand = models.FloatField(null=True, blank=True)
|
||||
silt = models.FloatField(null=True, blank=True)
|
||||
soc = models.FloatField(null=True, blank=True)
|
||||
wv0010 = models.FloatField(null=True, blank=True)
|
||||
wv0033 = models.FloatField(null=True, blank=True)
|
||||
wv1500 = models.FloatField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, default="")
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["soil_location", "depth_label"],
|
||||
name="soil_depth_unique_location_depth",
|
||||
)
|
||||
ordering = ["-created_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["soil_location", "status", "created_at"],
|
||||
name="rs_run_loc_status_created_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["block_code", "created_at"],
|
||||
name="rs_run_block_created_idx",
|
||||
),
|
||||
]
|
||||
ordering = ["soil_location", "depth_label"]
|
||||
verbose_name = "remote sensing run"
|
||||
verbose_name_plural = "remote sensing runs"
|
||||
|
||||
def __str__(self):
|
||||
return f"SoilDepthData({self.soil_location_id}, {self.depth_label})"
|
||||
block_text = self.block_code or "farm"
|
||||
return f"RemoteSensingRun({self.soil_location_id}, {block_text}, {self.status})"
|
||||
|
||||
@property
|
||||
def normalized_status(self) -> str:
|
||||
"""
|
||||
Return the client-facing lifecycle status while keeping legacy DB values stable.
|
||||
"""
|
||||
if self.status == self.STATUS_SUCCESS:
|
||||
return "completed"
|
||||
if self.status == self.STATUS_FAILURE:
|
||||
return "failed"
|
||||
return self.status
|
||||
|
||||
|
||||
class AnalysisGridCell(models.Model):
|
||||
soil_location = models.ForeignKey(
|
||||
SoilLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="analysis_grid_cells",
|
||||
)
|
||||
block_subdivision = models.ForeignKey(
|
||||
BlockSubdivision,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="analysis_grid_cells",
|
||||
)
|
||||
block_code = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
db_index=True,
|
||||
help_text="شناسه بلوکی که این سلول به آن تعلق دارد.",
|
||||
)
|
||||
cell_code = models.CharField(
|
||||
max_length=128,
|
||||
unique=True,
|
||||
help_text="شناسه یکتای سلول تحلیل.",
|
||||
)
|
||||
chunk_size_sqm = models.PositiveIntegerField(
|
||||
default=900,
|
||||
db_index=True,
|
||||
help_text="اندازه سلول تحلیل به متر مربع.",
|
||||
)
|
||||
geometry = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="هندسه سلول به صورت GeoJSON polygon یا ساختار مشابه.",
|
||||
)
|
||||
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="طول جغرافیایی مرکز سلول.",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["soil_location", "block_code", "cell_code"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["soil_location", "block_code"],
|
||||
name="grid_cell_loc_block_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["soil_location", "chunk_size_sqm"],
|
||||
name="grid_cell_loc_chunk_idx",
|
||||
),
|
||||
]
|
||||
verbose_name = "analysis grid cell"
|
||||
verbose_name_plural = "analysis grid cells"
|
||||
|
||||
def __str__(self):
|
||||
return f"AnalysisGridCell({self.cell_code})"
|
||||
|
||||
|
||||
class AnalysisGridObservation(models.Model):
|
||||
cell = models.ForeignKey(
|
||||
AnalysisGridCell,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="observations",
|
||||
)
|
||||
run = models.ForeignKey(
|
||||
RemoteSensingRun,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="observations",
|
||||
)
|
||||
temporal_start = models.DateField(db_index=True)
|
||||
temporal_end = models.DateField(db_index=True)
|
||||
ndvi = models.FloatField(null=True, blank=True)
|
||||
ndwi = models.FloatField(null=True, blank=True)
|
||||
lst_c = models.FloatField(null=True, blank=True)
|
||||
soil_vv = models.FloatField(null=True, blank=True)
|
||||
soil_vv_db = models.FloatField(null=True, blank=True)
|
||||
dem_m = models.FloatField(null=True, blank=True)
|
||||
slope_deg = models.FloatField(null=True, 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 = ["-temporal_start", "-temporal_end", "-id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["cell", "temporal_start", "temporal_end"],
|
||||
name="grid_obs_unique_cell_temporal_range",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["cell", "temporal_start", "temporal_end"],
|
||||
name="grid_obs_cell_temporal_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["temporal_start", "temporal_end"],
|
||||
name="grid_obs_temporal_idx",
|
||||
),
|
||||
]
|
||||
verbose_name = "analysis grid observation"
|
||||
verbose_name_plural = "analysis grid observations"
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"AnalysisGridObservation({self.cell_id}, "
|
||||
f"{self.temporal_start}, {self.temporal_end})"
|
||||
)
|
||||
|
||||
|
||||
class RemoteSensingSubdivisionResult(models.Model):
|
||||
soil_location = models.ForeignKey(
|
||||
SoilLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="remote_sensing_subdivision_results",
|
||||
)
|
||||
run = models.OneToOneField(
|
||||
RemoteSensingRun,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="subdivision_result",
|
||||
)
|
||||
block_subdivision = models.ForeignKey(
|
||||
BlockSubdivision,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="remote_sensing_subdivision_results",
|
||||
)
|
||||
block_code = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
db_index=True,
|
||||
)
|
||||
chunk_size_sqm = models.PositiveIntegerField(default=900)
|
||||
temporal_start = models.DateField(db_index=True)
|
||||
temporal_end = models.DateField(db_index=True)
|
||||
cluster_count = models.PositiveIntegerField(default=0)
|
||||
selected_features = models.JSONField(default=list, blank=True)
|
||||
skipped_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 = ["-created_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["soil_location", "block_code", "temporal_start", "temporal_end"],
|
||||
name="rs_subdiv_result_lookup_idx",
|
||||
)
|
||||
]
|
||||
verbose_name = "remote sensing subdivision result"
|
||||
verbose_name_plural = "remote sensing subdivision results"
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"RemoteSensingSubdivisionResult({self.soil_location_id}, "
|
||||
f"{self.block_code or 'farm'}, clusters={self.cluster_count})"
|
||||
)
|
||||
|
||||
|
||||
class RemoteSensingClusterAssignment(models.Model):
|
||||
result = models.ForeignKey(
|
||||
RemoteSensingSubdivisionResult,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="assignments",
|
||||
)
|
||||
cell = models.ForeignKey(
|
||||
AnalysisGridCell,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cluster_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 = ["cluster_label", "cell__cell_code"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["result", "cell"],
|
||||
name="rs_cluster_assign_unique_result_cell",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["result", "cluster_label"],
|
||||
name="rs_cluster_assign_result_label_idx",
|
||||
)
|
||||
]
|
||||
verbose_name = "remote sensing cluster assignment"
|
||||
verbose_name_plural = "remote sensing cluster assignments"
|
||||
|
||||
def __str__(self):
|
||||
return f"RemoteSensingClusterAssignment({self.result_id}, {self.cell_id}, {self.cluster_label})"
|
||||
|
||||
|
||||
|
||||
|
||||
class NdviObservation(models.Model):
|
||||
|
||||
Reference in New Issue
Block a user