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}"