524 lines
18 KiB
Python
524 lines
18 KiB
Python
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):
|
|
"""
|
|
مرکز زمین و مرز مزرعه/بلوکهای تعریفشده توسط کشاورز.
|
|
"""
|
|
|
|
latitude = models.DecimalField(
|
|
max_digits=9,
|
|
decimal_places=6,
|
|
db_index=True,
|
|
help_text="عرض جغرافیایی مرکز زمین (lat)",
|
|
)
|
|
longitude = models.DecimalField(
|
|
max_digits=9,
|
|
decimal_places=6,
|
|
db_index=True,
|
|
help_text="طول جغرافیایی مرکز زمین (lon)",
|
|
)
|
|
task_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
help_text="شناسه تسک Celery در حال پردازش",
|
|
)
|
|
|
|
farm_boundary = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text=(
|
|
"مرز مزرعه برای درخواستهای سنجشازدور. "
|
|
'میتواند 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)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["latitude", "longitude"],
|
|
name="soil_location_unique_lat_lon",
|
|
)
|
|
]
|
|
ordering = ["-updated_at"]
|
|
verbose_name = "مرکز زمین"
|
|
verbose_name_plural = "مراکز زمین"
|
|
|
|
def __str__(self):
|
|
return f"SoilLocation({self.latitude}, {self.longitude})"
|
|
|
|
@property
|
|
def center_latitude(self):
|
|
return self.latitude
|
|
|
|
@property
|
|
def center_longitude(self):
|
|
return self.longitude
|
|
|
|
@property
|
|
def is_complete(self):
|
|
"""آیا حداقل یک 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 BlockSubdivision(models.Model):
|
|
"""
|
|
نتیجه خردسازی یک بلوک برای یک SoilLocation.
|
|
grid_points نقاط اولیه شبکه هستند و centroid_points مراکز نهایی بخشها.
|
|
"""
|
|
|
|
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="remote_sensing_runs",
|
|
)
|
|
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,
|
|
)
|
|
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:
|
|
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",
|
|
),
|
|
]
|
|
verbose_name = "remote sensing run"
|
|
verbose_name_plural = "remote sensing runs"
|
|
|
|
def __str__(self):
|
|
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):
|
|
location = models.ForeignKey(
|
|
SoilLocation,
|
|
on_delete=models.CASCADE,
|
|
related_name="ndvi_observations",
|
|
)
|
|
observation_date = models.DateField(db_index=True)
|
|
mean_ndvi = models.FloatField()
|
|
ndvi_map = models.JSONField(default=dict, blank=True)
|
|
vegetation_health_class = models.CharField(max_length=64)
|
|
satellite_source = models.CharField(max_length=64, default="sentinel-2")
|
|
cloud_cover = models.FloatField(null=True, blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
|
|
class Meta:
|
|
db_table = "dashboard_data_ndviobservation"
|
|
ordering = ["-observation_date", "-created_at"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["location", "observation_date", "satellite_source"],
|
|
name="ndvi_unique_location_date_source",
|
|
)
|
|
]
|
|
verbose_name = "NDVI Observation"
|
|
verbose_name_plural = "NDVI Observations"
|
|
|
|
def __str__(self):
|
|
return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}"
|