This commit is contained in:
2026-05-13 16:45:54 +03:30
parent 948c062b93
commit 46fe62fa04
96 changed files with 3834 additions and 155 deletions
+8 -3
View File
@@ -32,7 +32,7 @@ def create_or_get_block_subdivision(
اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمی‌گرداند؛
در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره می‌کند.
"""
from .models import BlockSubdivision
from .models import BlockSubdivision, build_default_sub_block, ensure_block_layout_defaults
existing = BlockSubdivision.objects.filter(
soil_location=location,
@@ -244,7 +244,7 @@ def render_elbow_plot(
def sync_block_layout_with_subdivision(location, subdivision) -> None:
layout = location.block_layout or {}
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
blocks = list(layout.get("blocks") or [])
target_block = None
for block in blocks:
@@ -263,7 +263,12 @@ def sync_block_layout_with_subdivision(location, subdivision) -> None:
blocks.append(target_block)
target_block["needs_subdivision"] = subdivision.centroid_count > 1
target_block["sub_blocks"] = list(subdivision.centroid_points or [])
target_block["sub_blocks"] = list(subdivision.centroid_points or []) or [
build_default_sub_block(
str(target_block.get("block_code") or "block-1"),
boundary=target_block.get("boundary") or {},
)
]
target_block["subdivision_summary"] = {
"chunk_size_sqm": subdivision.chunk_size_sqm,
"grid_point_count": subdivision.grid_point_count,
+415
View File
@@ -0,0 +1,415 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from django.db.models import Avg
from crop_simulation.growth_simulation import GrowthSimulationContext, _run_projection_engine
from crop_simulation.services import PcseSimulationManager, build_simulation_payload_from_farm
from farm_data.services import get_canonical_farm_record, get_farm_plant_assignments
from .models import AnalysisGridObservation, RemoteSensingClusterBlock
from .satellite_snapshot import build_location_block_satellite_snapshots
class ClusterRecommendationNotFound(Exception):
pass
class ClusterRecommendationValidationError(Exception):
pass
@dataclass
class ClusterPlantCandidate:
plant_id: int | None
plant_name: str
position: int | None
stage: str
score: float
predicted_yield: float | None
predicted_yield_tons: float | None
biomass: float | None
max_lai: float | None
simulation_engine: str | None
simulation_model_name: str | None
simulation_warning: str | None
supporting_metrics: dict[str, Any]
def as_dict(self) -> dict[str, Any]:
return {
"plant_id": self.plant_id,
"plant_name": self.plant_name,
"position": self.position,
"stage": self.stage,
"score": self.score,
"predicted_yield": self.predicted_yield,
"predicted_yield_tons": self.predicted_yield_tons,
"biomass": self.biomass,
"max_lai": self.max_lai,
"simulation_engine": self.simulation_engine,
"simulation_model_name": self.simulation_model_name,
"simulation_warning": self.simulation_warning,
"supporting_metrics": self.supporting_metrics,
}
def _safe_float(value: Any) -> float | None:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
def _clamp(value: float, minimum: float, maximum: float) -> float:
if minimum > maximum:
minimum, maximum = maximum, minimum
return max(minimum, min(value, maximum))
def _build_cluster_entries(
snapshots: list[dict[str, Any]],
*,
cluster_blocks_by_uuid: dict[str, RemoteSensingClusterBlock],
) -> list[dict[str, Any]]:
entries_by_key: dict[str, dict[str, Any]] = {}
for snapshot in snapshots:
block_code = str(snapshot.get("block_code") or "").strip()
temporal_extent = snapshot.get("temporal_extent")
for satellite_sub_block in snapshot.get("satellite_sub_blocks") or []:
cluster_uuid = str(satellite_sub_block.get("cluster_uuid") or "").strip()
sub_block_code = str(satellite_sub_block.get("sub_block_code") or "").strip()
cluster_label = satellite_sub_block.get("cluster_label")
if cluster_uuid:
entry_key = cluster_uuid
elif sub_block_code:
entry_key = f"{block_code}::{sub_block_code}"
else:
entry_key = f"{block_code}::cluster-{cluster_label}"
entry = entries_by_key.setdefault(
entry_key,
{
"block_code": block_code,
"cluster_uuid": cluster_uuid or None,
"sub_block_code": sub_block_code,
"cluster_label": cluster_label,
"temporal_extent": temporal_extent,
"cluster_block": None,
"satellite_metrics": {},
"sensor_metrics": {},
"resolved_metrics": {},
"source_metadata": {
"block_status": snapshot.get("status") or "missing",
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
"has_satellite_metrics": False,
"has_sensor_metrics": False,
},
},
)
entry["satellite_metrics"] = dict(satellite_sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["satellite_metrics"])
entry["source_metadata"]["has_satellite_metrics"] = True
if cluster_uuid and cluster_uuid in cluster_blocks_by_uuid:
entry["cluster_block"] = cluster_blocks_by_uuid[cluster_uuid]
for sensor_sub_block in snapshot.get("sensor_sub_blocks") or []:
cluster_uuid = str(sensor_sub_block.get("cluster_uuid") or "").strip()
sub_block_code = str(sensor_sub_block.get("sub_block_code") or "").strip()
cluster_label = sensor_sub_block.get("cluster_label")
candidate_keys = [
cluster_uuid,
f"{block_code}::{sub_block_code}" if sub_block_code else "",
f"{block_code}::cluster-{cluster_label}" if cluster_label is not None else "",
]
entry = None
for candidate_key in candidate_keys:
if candidate_key and candidate_key in entries_by_key:
entry = entries_by_key[candidate_key]
break
if entry is None:
continue
entry["sensor_metrics"] = dict(sensor_sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["sensor_metrics"])
entry["source_metadata"]["has_sensor_metrics"] = True
return list(entries_by_key.values())
def _attach_missing_satellite_metrics(cluster_entries: list[dict[str, Any]]) -> None:
for cluster_entry in cluster_entries:
cluster_block = cluster_entry.get("cluster_block")
if cluster_block is None:
continue
needs_soil_vv = "soil_vv" not in (cluster_entry.get("resolved_metrics") or {})
if not needs_soil_vv:
continue
observation_summary = AnalysisGridObservation.objects.filter(
cell__cell_code__in=list(cluster_block.cell_codes or []),
temporal_start=cluster_block.result.temporal_start,
temporal_end=cluster_block.result.temporal_end,
).aggregate(soil_vv_mean=Avg("soil_vv"))
soil_vv_mean = _safe_float(observation_summary.get("soil_vv_mean"))
if soil_vv_mean is None:
continue
rounded_soil_vv = round(soil_vv_mean, 6)
cluster_entry.setdefault("satellite_metrics", {})["soil_vv"] = rounded_soil_vv
cluster_entry.setdefault("resolved_metrics", {})["soil_vv"] = rounded_soil_vv
def _build_cluster_overrides(
base_payload: dict[str, Any],
*,
cluster_metrics: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
soil_parameters = deepcopy(base_payload.get("soil") or {})
site_parameters = deepcopy(base_payload.get("site_parameters") or {})
ndwi = _safe_float(cluster_metrics.get("ndwi"))
if ndwi is not None:
smfcf = _clamp(ndwi, 0.2, 0.55)
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
sm0 = _clamp(
min(max(smfcf + 0.08, smw + 0.12), 0.6),
max(smfcf + 0.02, smw + 0.05),
0.8,
)
soil_parameters["SMFCF"] = round(smfcf, 3)
soil_parameters["SMW"] = round(smw, 3)
soil_parameters["SM0"] = round(sm0, 3)
site_parameters["SMLIM"] = round(_clamp(smfcf, smw, sm0), 3)
soil_moisture = _safe_float(cluster_metrics.get("soil_moisture"))
if soil_moisture is not None:
soil_parameters["soil_moisture"] = soil_moisture
site_parameters["WAV"] = round(max(soil_moisture, 0.0), 3)
nutrient_mappings = (
("nitrogen", "NAVAILI", "nitrogen"),
("phosphorus", "P_STATUS", "phosphorus"),
("potassium", "K_STATUS", "potassium"),
("soil_ph", "SOIL_PH", "soil_ph"),
("electrical_conductivity", "EC", "electrical_conductivity"),
)
for metric_name, site_key, soil_key in nutrient_mappings:
value = _safe_float(cluster_metrics.get(metric_name))
if value is None:
continue
soil_parameters[soil_key] = value
site_parameters[site_key] = value
return soil_parameters, site_parameters
def _serialize_cluster_block(cluster_block: RemoteSensingClusterBlock | None) -> dict[str, Any] | None:
if cluster_block is None:
return None
return {
"uuid": str(cluster_block.uuid),
"sub_block_code": cluster_block.sub_block_code,
"cluster_label": cluster_block.cluster_label,
"chunk_size_sqm": cluster_block.chunk_size_sqm,
"centroid_lat": cluster_block.centroid_lat,
"centroid_lon": cluster_block.centroid_lon,
"center_cell_code": cluster_block.center_cell_code,
"center_cell_lat": cluster_block.center_cell_lat,
"center_cell_lon": cluster_block.center_cell_lon,
"cell_count": cluster_block.cell_count,
"cell_codes": list(cluster_block.cell_codes or []),
"geometry": cluster_block.geometry,
"metadata": dict(cluster_block.metadata or {}),
"created_at": cluster_block.created_at,
"updated_at": cluster_block.updated_at,
}
def _simulate_candidate(
*,
base_payload: dict[str, Any],
soil_parameters: dict[str, Any],
site_parameters: dict[str, Any],
) -> tuple[dict[str, Any], str | None]:
manager = PcseSimulationManager()
try:
return (
manager.run_simulation(
weather=base_payload.get("weather") or [],
soil=soil_parameters,
crop_parameters=base_payload.get("crop_parameters") or {},
agromanagement=base_payload.get("agromanagement") or [],
site_parameters=site_parameters,
),
None,
)
except Exception as exc:
context = GrowthSimulationContext(
farm_uuid=None,
plant_name=str((base_payload.get("crop_parameters") or {}).get("crop_name") or ""),
plant=base_payload.get("plant"),
dynamic_parameters=[],
weather=base_payload.get("weather") or [],
crop_parameters=base_payload.get("crop_parameters") or {},
soil_parameters=soil_parameters,
site_parameters=site_parameters,
agromanagement=base_payload.get("agromanagement") or [],
page_size=10,
)
fallback_result = _run_projection_engine(context)
return fallback_result, f"simulation_fallback:{exc}"
def _rank_cluster_plants(
cluster_entry: dict[str, Any],
*,
plant_assignments: list[Any],
base_payloads: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
candidates: list[ClusterPlantCandidate] = []
for assignment in plant_assignments:
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
if not plant_name:
continue
base_payload = base_payloads[plant_name]
soil_parameters, site_parameters = _build_cluster_overrides(
base_payload,
cluster_metrics=dict(cluster_entry.get("resolved_metrics") or {}),
)
simulation_result, simulation_warning = _simulate_candidate(
base_payload=base_payload,
soil_parameters=soil_parameters,
site_parameters=site_parameters,
)
metrics = dict(simulation_result.get("metrics") or {})
predicted_yield = _safe_float(metrics.get("yield_estimate"))
biomass = _safe_float(metrics.get("biomass"))
max_lai = _safe_float(metrics.get("max_lai"))
predicted_yield_tons = None if predicted_yield is None else round(max(predicted_yield, 0.0) / 1000.0, 4)
score = round(predicted_yield if predicted_yield is not None else -1.0, 4)
candidates.append(
ClusterPlantCandidate(
plant_id=getattr(assignment.plant, "backend_plant_id", None),
plant_name=plant_name,
position=getattr(assignment, "position", None),
stage=str(getattr(assignment, "stage", "") or ""),
score=score,
predicted_yield=round(predicted_yield, 4) if predicted_yield is not None else None,
predicted_yield_tons=predicted_yield_tons,
biomass=round(biomass, 4) if biomass is not None else None,
max_lai=round(max_lai, 4) if max_lai is not None else None,
simulation_engine=simulation_result.get("engine"),
simulation_model_name=simulation_result.get("model_name"),
simulation_warning=simulation_warning,
supporting_metrics=metrics,
)
)
ranked_candidates = sorted(
candidates,
key=lambda item: (
item.score,
item.biomass if item.biomass is not None else float("-inf"),
-1 * (item.position if item.position is not None else 10_000),
),
reverse=True,
)
return [candidate.as_dict() for candidate in ranked_candidates]
def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]:
farm = get_canonical_farm_record(farm_uuid)
if farm is None:
raise ClusterRecommendationNotFound("مزرعه پیدا نشد.")
plant_assignments = get_farm_plant_assignments(farm)
if not plant_assignments:
raise ClusterRecommendationValidationError("برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.")
location = farm.center_location
snapshots = build_location_block_satellite_snapshots(
location,
sensor_payload=farm.sensor_payload,
)
cluster_uuids = {
str(sub_block.get("cluster_uuid") or "").strip()
for snapshot in snapshots
for sub_block in (snapshot.get("satellite_sub_blocks") or [])
if str(sub_block.get("cluster_uuid") or "").strip()
}
if not cluster_uuids:
raise ClusterRecommendationNotFound("برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.")
cluster_blocks_by_uuid = {
str(cluster_block.uuid): cluster_block
for cluster_block in RemoteSensingClusterBlock.objects.filter(uuid__in=list(cluster_uuids)).select_related("result")
}
cluster_entries = _build_cluster_entries(
snapshots,
cluster_blocks_by_uuid=cluster_blocks_by_uuid,
)
_attach_missing_satellite_metrics(cluster_entries)
if not cluster_entries:
raise ClusterRecommendationNotFound("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.")
base_payloads: dict[str, dict[str, Any]] = {}
for assignment in plant_assignments:
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
if not plant_name or plant_name in base_payloads:
continue
try:
base_payloads[plant_name] = build_simulation_payload_from_farm(
farm_uuid=str(farm.farm_uuid),
plant_name=plant_name,
)
except Exception as exc:
raise ClusterRecommendationValidationError(
f"مقایسه گیاه‌ها با crop_simulation انجام نشد: {exc}"
) from exc
response_clusters: list[dict[str, Any]] = []
for cluster_entry in cluster_entries:
candidate_plants = _rank_cluster_plants(
cluster_entry,
plant_assignments=plant_assignments,
base_payloads=base_payloads,
)
response_clusters.append(
{
"block_code": cluster_entry.get("block_code") or "",
"cluster_uuid": cluster_entry.get("cluster_uuid"),
"sub_block_code": cluster_entry.get("sub_block_code") or "",
"cluster_label": cluster_entry.get("cluster_label"),
"temporal_extent": cluster_entry.get("temporal_extent"),
"cluster_block": _serialize_cluster_block(cluster_entry.get("cluster_block")),
"satellite_metrics": dict(cluster_entry.get("satellite_metrics") or {}),
"sensor_metrics": dict(cluster_entry.get("sensor_metrics") or {}),
"resolved_metrics": dict(cluster_entry.get("resolved_metrics") or {}),
"candidate_plants": candidate_plants,
"suggested_plant": candidate_plants[0] if candidate_plants else None,
"source_metadata": dict(cluster_entry.get("source_metadata") or {}),
}
)
return {
"farm_uuid": str(farm.farm_uuid),
"location_id": location.id,
"evaluated_plant_count": len(base_payloads),
"cluster_count": len(response_clusters),
"registered_plants": [
{
"plant_id": assignment.plant.backend_plant_id,
"plant_name": assignment.plant.name,
"position": assignment.position,
"stage": assignment.stage,
}
for assignment in plant_assignments
],
"clusters": response_clusters,
"source_metadata": {
"source": "location_data+kmeans+farm_data+crop_simulation",
"location_id": location.id,
"snapshot_block_count": len(snapshots),
},
}
+11 -1
View File
@@ -16,6 +16,8 @@ from django.db import transaction
from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot
from .models import (
build_default_sub_block,
ensure_block_layout_defaults,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterBlock,
@@ -1272,7 +1274,7 @@ def sync_location_block_layout_with_result(
result: RemoteSensingSubdivisionResult,
cluster_summaries: list[dict[str, Any]],
) -> None:
layout = dict(location.block_layout or {})
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
blocks = list(layout.get("blocks") or [])
target_block = None
for block in blocks:
@@ -1307,6 +1309,14 @@ def sync_location_block_layout_with_result(
}
for cluster in cluster_summaries
]
if not target_block["sub_blocks"]:
target_block["sub_blocks"] = [
build_default_sub_block(
str(target_block.get("block_code") or "block-1"),
boundary=target_block.get("boundary") or {},
)
]
target_block["subdivision_summary"] = {
"type": "data_driven_remote_sensing",
"cluster_count": result.cluster_count,
@@ -0,0 +1,533 @@
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.dateparse import parse_datetime
from location_data.models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
NdviObservation,
RemoteSensingClusterAssignment,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionOption,
RemoteSensingSubdivisionOptionAssignment,
RemoteSensingSubdivisionOptionBlock,
RemoteSensingSubdivisionResult,
SoilLocation,
)
SEED_DATA = {
"soillocations": [
{
"id": 1,
"latitude": "50.000000",
"longitude": "50.000000",
"task_id": "",
"farm_boundary": {
"type": "Polygon",
"coordinates": [[[49.9995, 49.9995], [50.0005, 49.9995], [50.0005, 50.0005], [49.9995, 50.0005], [49.9995, 49.9995]]],
},
"input_block_count": 1,
"block_layout": {
"blocks": [
{
"order": 1,
"source": "default",
"boundary": {},
"block_code": "block-1",
"sub_blocks": [],
"needs_subdivision": None,
},
{
"order": 2,
"source": "remote_sensing",
"block_code": "",
"sub_blocks": [
{
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0004993, "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "center_mean_distance": 0.00029732}, "cell_geometry_type": "Polygon"},
"cell_count": 4,
"centroid_lat": 49.99977,
"centroid_lon": 49.99992,
"cluster_uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
"cluster_label": 0,
"sub_block_code": "cluster-0",
"center_cell_lat": 49.999635,
"center_cell_lon": 49.99971,
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
},
{
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0006827, "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "center_mean_distance": 0.00041092}, "cell_geometry_type": "Polygon"},
"cell_count": 8,
"centroid_lat": 50.000174,
"centroid_lon": 50.000235,
"cluster_uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
"cluster_label": 1,
"sub_block_code": "cluster-1",
"center_cell_lat": 50.000174,
"center_cell_lon": 50.00013,
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
},
],
"needs_subdivision": True,
"subdivision_summary": {
"type": "data_driven_remote_sensing",
"run_id": 1,
"cluster_count": 2,
"used_cell_count": 12,
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
"skipped_cell_count": 0,
},
},
],
"algorithm_status": "completed",
"default_full_farm": True,
"input_block_count": 1,
"analysis_grid_summary": {"cell_count": 12, "chunk_size_sqm": 900},
},
"created_at": "2026-05-11T14:41:43.319380+00:00",
"updated_at": "2026-05-12T12:37:38.239904+00:00",
}
],
"blocksubdivisions": [],
"remotesensingruns": [
{
"id": 1,
"soil_location_id": 1,
"block_subdivision_id": None,
"block_code": "",
"provider": "openeo",
"chunk_size_sqm": 900,
"temporal_start": "2026-04-11",
"temporal_end": "2026-05-11",
"status": "success",
"metadata": {"farm_uuid": "11111111-1111-1111-1111-111111111111", "requested_via": "api", "scope": "all_blocks"},
"error_message": "",
"started_at": "2026-05-12T12:19:03.911826+00:00",
"finished_at": "2026-05-12T12:37:39.018428+00:00",
"created_at": "2026-05-12T12:19:03.912346+00:00",
"updated_at": "2026-05-12T12:37:39.019007+00:00",
}
],
"remotesensingsubdivisionresults": [
{
"id": 1,
"soil_location_id": 1,
"run_id": 1,
"block_subdivision_id": None,
"block_code": "",
"chunk_size_sqm": 900,
"temporal_start": "2026-04-11",
"temporal_end": "2026-05-11",
"cluster_count": 2,
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
"skipped_cell_codes": [],
"metadata": {"selection_strategy": "elbow", "used_cell_count": 12},
"created_at": "2026-05-12T12:37:27.897155+00:00",
"updated_at": "2026-05-12T12:37:27.897180+00:00",
}
],
"remotesensingclusterblocks": [
{
"id": 1,
"uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
"result_id": 1,
"soil_location_id": 1,
"block_subdivision_id": None,
"block_code": "",
"sub_block_code": "cluster-0",
"cluster_label": 0,
"chunk_size_sqm": 900,
"centroid_lat": "49.999770",
"centroid_lon": "49.999920",
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
"center_cell_lat": "49.999635",
"center_cell_lon": "49.999710",
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
"cell_count": 4,
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0000", "loc-1__block-farm__chunk-900__r0000c0001", "loc-1__block-farm__chunk-900__r0001c0000", "loc-1__block-farm__chunk-900__r0001c0001"],
"metadata": {"source": "analysis_grid_cells"},
"created_at": "2026-05-12T12:37:27.899874+00:00",
"updated_at": "2026-05-12T12:37:27.899899+00:00",
},
{
"id": 2,
"uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
"result_id": 1,
"soil_location_id": 1,
"block_subdivision_id": None,
"block_code": "",
"sub_block_code": "cluster-1",
"cluster_label": 1,
"chunk_size_sqm": 900,
"centroid_lat": "50.000174",
"centroid_lon": "50.000235",
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
"center_cell_lat": "50.000174",
"center_cell_lon": "50.000130",
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
"cell_count": 8,
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0002", "loc-1__block-farm__chunk-900__r0001c0002", "loc-1__block-farm__chunk-900__r0002c0000", "loc-1__block-farm__chunk-900__r0002c0001", "loc-1__block-farm__chunk-900__r0002c0002", "loc-1__block-farm__chunk-900__r0003c0000", "loc-1__block-farm__chunk-900__r0003c0001", "loc-1__block-farm__chunk-900__r0003c0002"],
"metadata": {"source": "analysis_grid_cells"},
"created_at": "2026-05-12T12:37:27.901926+00:00",
"updated_at": "2026-05-12T12:37:27.901945+00:00",
},
],
"analysisgridcells": [
{"id": 1, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [49.99992, 49.99977], [49.9995, 49.99977], [49.9995, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.930590+00:00", "updated_at": "2026-05-12T12:19:03.930609+00:00"},
{"id": 2, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [49.99992, 49.99977], [49.99992, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931797+00:00", "updated_at": "2026-05-12T12:19:03.931817+00:00"},
{"id": 3, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000339, 49.99977], [50.000339, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931857+00:00", "updated_at": "2026-05-12T12:19:03.931864+00:00"},
{"id": 4, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.99977], [49.99992, 49.99977], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.931899+00:00", "updated_at": "2026-05-12T12:19:03.931906+00:00"},
{"id": 5, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.99977], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.99992, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931939+00:00", "updated_at": "2026-05-12T12:19:03.931945+00:00"},
{"id": 6, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.99977], [50.000759, 49.99977], [50.000759, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931978+00:00", "updated_at": "2026-05-12T12:19:03.931985+00:00"},
{"id": 7, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.00004], [49.99992, 50.00004], [49.99992, 50.000309], [49.9995, 50.000309], [49.9995, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932017+00:00", "updated_at": "2026-05-12T12:19:03.932024+00:00"},
{"id": 8, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 50.000309], [49.99992, 50.000309], [49.99992, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932056+00:00", "updated_at": "2026-05-12T12:19:03.932063+00:00"},
{"id": 9, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.00004], [50.000759, 50.00004], [50.000759, 50.000309], [50.000339, 50.000309], [50.000339, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932100+00:00", "updated_at": "2026-05-12T12:19:03.932107+00:00"},
{"id": 10, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.000309], [49.99992, 50.000309], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932145+00:00", "updated_at": "2026-05-12T12:19:03.932152+00:00"},
{"id": 11, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.000309], [50.000339, 50.000309], [50.000339, 50.000579], [49.99992, 50.000579], [49.99992, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932191+00:00", "updated_at": "2026-05-12T12:19:03.932198+00:00"},
{"id": 12, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.000309], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [50.000339, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932234+00:00", "updated_at": "2026-05-12T12:19:03.932241+00:00"},
],
"analysisgridobservations": [
{"id": 1, "cell_id": 1, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv": -15.369688, "soil_vv_db": -15.369688, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.614257+00:00", "updated_at": "2026-05-12T12:37:26.614281+00:00"},
{"id": 2, "cell_id": 2, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv": -14.043169, "soil_vv_db": -14.043169, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615124+00:00", "updated_at": "2026-05-12T12:37:26.615143+00:00"},
{"id": 3, "cell_id": 3, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv": -13.727797, "soil_vv_db": -13.727797, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615867+00:00", "updated_at": "2026-05-12T12:37:26.615885+00:00"},
{"id": 4, "cell_id": 4, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv": -13.127913, "soil_vv_db": -13.127913, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.616668+00:00", "updated_at": "2026-05-12T12:37:26.616687+00:00"},
{"id": 5, "cell_id": 5, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv": -12.400669, "soil_vv_db": -12.400669, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.617453+00:00", "updated_at": "2026-05-12T12:37:26.617472+00:00"},
{"id": 6, "cell_id": 6, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv": -12.273758, "soil_vv_db": -12.273758, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618181+00:00", "updated_at": "2026-05-12T12:37:26.618199+00:00"},
{"id": 7, "cell_id": 7, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv": -12.147284, "soil_vv_db": -12.147284, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618964+00:00", "updated_at": "2026-05-12T12:37:26.618982+00:00"},
{"id": 8, "cell_id": 8, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv": -13.170681, "soil_vv_db": -13.170681, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.619733+00:00", "updated_at": "2026-05-12T12:37:26.619750+00:00"},
{"id": 9, "cell_id": 9, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv": -13.873331, "soil_vv_db": -13.873331, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.620504+00:00", "updated_at": "2026-05-12T12:37:26.620522+00:00"},
{"id": 10, "cell_id": 10, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv": -14.09151, "soil_vv_db": -14.09151, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621257+00:00", "updated_at": "2026-05-12T12:37:26.621275+00:00"},
{"id": 11, "cell_id": 11, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv": -13.272252, "soil_vv_db": -13.272252, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621989+00:00", "updated_at": "2026-05-12T12:37:26.622008+00:00"},
{"id": 12, "cell_id": 12, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv": -12.991811, "soil_vv_db": -12.991811, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.622751+00:00", "updated_at": "2026-05-12T12:37:26.622769+00:00"},
],
"remotesensingclusterassignments": [
{"id": 1, "result_id": 1, "cell_id": 1, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv_db": -15.369688}, "scaled_feature_values": {"ndvi": -1.630738, "ndwi": 1.792215, "soil_vv_db": -2.274005}, "created_at": "2026-05-12T12:37:27.901986+00:00", "updated_at": "2026-05-12T12:37:27.902003+00:00"},
{"id": 2, "result_id": 1, "cell_id": 2, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv_db": -14.043169}, "scaled_feature_values": {"ndvi": -1.094298, "ndwi": 1.109414, "soil_vv_db": -0.762373}, "created_at": "2026-05-12T12:37:27.902768+00:00", "updated_at": "2026-05-12T12:37:27.902786+00:00"},
{"id": 3, "result_id": 1, "cell_id": 3, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv_db": -13.727797}, "scaled_feature_values": {"ndvi": 0.736534, "ndwi": -0.8832, "soil_vv_db": -0.402992}, "created_at": "2026-05-12T12:37:27.903479+00:00", "updated_at": "2026-05-12T12:37:27.903497+00:00"},
{"id": 4, "result_id": 1, "cell_id": 4, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv_db": -13.127913}, "scaled_feature_values": {"ndvi": -0.614307, "ndwi": 0.732429, "soil_vv_db": 0.280605}, "created_at": "2026-05-12T12:37:27.904197+00:00", "updated_at": "2026-05-12T12:37:27.904214+00:00"},
{"id": 5, "result_id": 1, "cell_id": 5, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv_db": -12.400669}, "scaled_feature_values": {"ndvi": -0.818023, "ndwi": 1.232666, "soil_vv_db": 1.109334}, "created_at": "2026-05-12T12:37:27.904909+00:00", "updated_at": "2026-05-12T12:37:27.904926+00:00"},
{"id": 6, "result_id": 1, "cell_id": 6, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv_db": -12.273758}, "scaled_feature_values": {"ndvi": 1.296436, "ndwi": -0.703617, "soil_vv_db": 1.253955}, "created_at": "2026-05-12T12:37:27.905606+00:00", "updated_at": "2026-05-12T12:37:27.905623+00:00"},
{"id": 7, "result_id": 1, "cell_id": 7, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv_db": -12.147284}, "scaled_feature_values": {"ndvi": 0.11252, "ndwi": -0.229749, "soil_vv_db": 1.398079}, "created_at": "2026-05-12T12:37:27.906325+00:00", "updated_at": "2026-05-12T12:37:27.906343+00:00"},
{"id": 8, "result_id": 1, "cell_id": 8, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv_db": -13.170681}, "scaled_feature_values": {"ndvi": 0.788741, "ndwi": -0.715232, "soil_vv_db": 0.231869}, "created_at": "2026-05-12T12:37:27.907052+00:00", "updated_at": "2026-05-12T12:37:27.907069+00:00"},
{"id": 9, "result_id": 1, "cell_id": 9, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv_db": -13.873331}, "scaled_feature_values": {"ndvi": 1.485916, "ndwi": -1.526247, "soil_vv_db": -0.568835}, "created_at": "2026-05-12T12:37:27.907735+00:00", "updated_at": "2026-05-12T12:37:27.907752+00:00"},
{"id": 10, "result_id": 1, "cell_id": 10, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv_db": -14.09151}, "scaled_feature_values": {"ndvi": -0.194631, "ndwi": -0.300985, "soil_vv_db": -0.81746}, "created_at": "2026-05-12T12:37:27.908441+00:00", "updated_at": "2026-05-12T12:37:27.908458+00:00"},
{"id": 11, "result_id": 1, "cell_id": 11, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv_db": -13.272252}, "scaled_feature_values": {"ndvi": -1.031701, "ndwi": 0.492105, "soil_vv_db": 0.116124}, "created_at": "2026-05-12T12:37:27.909117+00:00", "updated_at": "2026-05-12T12:37:27.909134+00:00"},
{"id": 12, "result_id": 1, "cell_id": 12, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv_db": -12.991811}, "scaled_feature_values": {"ndvi": 0.963553, "ndwi": -0.999798, "soil_vv_db": 0.4357}, "created_at": "2026-05-12T12:37:27.909828+00:00", "updated_at": "2026-05-12T12:37:27.909845+00:00"},
],
"remotesensingsubdivisionoptions": [],
"remotesensingsubdivisionoptionblocks": [],
"remotesensingsubdivisionoptionassignments": [],
"ndviobservations": [],
}
def _dt(value: str | None):
if not value:
return None
parsed = parse_datetime(value)
if parsed is not None:
return parsed
return datetime.fromisoformat(value)
def _date(value: str | None):
if not value:
return None
return date.fromisoformat(value)
def _decimal(value):
if value is None:
return None
return Decimal(str(value))
def _uuid(value):
if not value:
return None
return UUID(str(value))
class Command(BaseCommand):
help = "Seed the current location_data database snapshot into local tables."
def add_arguments(self, parser):
parser.add_argument(
"--flush-existing",
action="store_true",
help="Delete existing location_data seeded tables before inserting the snapshot.",
)
@transaction.atomic
def handle(self, *args, **options):
if options["flush_existing"]:
self._flush_existing()
self._seed_soil_locations()
self._seed_block_subdivisions()
self._seed_remote_sensing_runs()
self._seed_subdivision_results()
self._seed_cluster_blocks()
self._seed_analysis_grid_cells()
self._seed_analysis_grid_observations()
self._seed_cluster_assignments()
self._seed_subdivision_options()
self._seed_subdivision_option_blocks()
self._seed_subdivision_option_assignments()
self._seed_ndvi_observations()
self.stdout.write(self.style.SUCCESS("location_data seed snapshot applied successfully."))
def _flush_existing(self):
RemoteSensingSubdivisionOptionAssignment.objects.all().delete()
RemoteSensingSubdivisionOptionBlock.objects.all().delete()
RemoteSensingSubdivisionOption.objects.all().delete()
RemoteSensingClusterAssignment.objects.all().delete()
AnalysisGridObservation.objects.all().delete()
RemoteSensingClusterBlock.objects.all().delete()
RemoteSensingSubdivisionResult.objects.all().delete()
RemoteSensingRun.objects.all().delete()
AnalysisGridCell.objects.all().delete()
BlockSubdivision.objects.all().delete()
NdviObservation.objects.all().delete()
SoilLocation.objects.all().delete()
def _seed_soil_locations(self):
for row in SEED_DATA["soillocations"]:
obj, _ = SoilLocation.objects.update_or_create(
id=row["id"],
defaults={
"latitude": _decimal(row["latitude"]),
"longitude": _decimal(row["longitude"]),
"task_id": row["task_id"],
"farm_boundary": row["farm_boundary"],
"input_block_count": row["input_block_count"],
"block_layout": row["block_layout"],
},
)
self._touch(obj, row)
def _seed_block_subdivisions(self):
for row in SEED_DATA["blocksubdivisions"]:
obj, _ = BlockSubdivision.objects.update_or_create(
id=row["id"],
defaults={
"soil_location_id": row["soil_location_id"],
"block_code": row["block_code"],
"source_boundary": row["source_boundary"],
"chunk_size_sqm": row["chunk_size_sqm"],
"grid_points": row["grid_points"],
"centroid_points": row["centroid_points"],
"grid_point_count": row["grid_point_count"],
"centroid_count": row["centroid_count"],
"elbow_plot": row.get("elbow_plot", ""),
"subdivision_summary": row.get("subdivision_summary", {}),
},
)
self._touch(obj, row)
def _seed_remote_sensing_runs(self):
for row in SEED_DATA["remotesensingruns"]:
obj, _ = RemoteSensingRun.objects.update_or_create(
id=row["id"],
defaults={
"soil_location_id": row["soil_location_id"],
"block_subdivision_id": row["block_subdivision_id"],
"block_code": row["block_code"],
"provider": row["provider"],
"chunk_size_sqm": row["chunk_size_sqm"],
"temporal_start": _date(row["temporal_start"]),
"temporal_end": _date(row["temporal_end"]),
"status": row["status"],
"metadata": row["metadata"],
"error_message": row["error_message"],
"started_at": _dt(row["started_at"]),
"finished_at": _dt(row["finished_at"]),
},
)
self._touch(obj, row)
def _seed_subdivision_results(self):
for row in SEED_DATA["remotesensingsubdivisionresults"]:
obj, _ = RemoteSensingSubdivisionResult.objects.update_or_create(
id=row["id"],
defaults={
"soil_location_id": row["soil_location_id"],
"run_id": row["run_id"],
"block_subdivision_id": row["block_subdivision_id"],
"block_code": row["block_code"],
"chunk_size_sqm": row["chunk_size_sqm"],
"temporal_start": _date(row["temporal_start"]),
"temporal_end": _date(row["temporal_end"]),
"cluster_count": row["cluster_count"],
"selected_features": row["selected_features"],
"skipped_cell_codes": row["skipped_cell_codes"],
"metadata": row["metadata"],
},
)
self._touch(obj, row)
def _seed_cluster_blocks(self):
for row in SEED_DATA["remotesensingclusterblocks"]:
obj, _ = RemoteSensingClusterBlock.objects.update_or_create(
id=row["id"],
defaults={
"uuid": _uuid(row["uuid"]),
"result_id": row["result_id"],
"soil_location_id": row["soil_location_id"],
"block_subdivision_id": row["block_subdivision_id"],
"block_code": row["block_code"],
"sub_block_code": row["sub_block_code"],
"cluster_label": row["cluster_label"],
"chunk_size_sqm": row["chunk_size_sqm"],
"centroid_lat": _decimal(row["centroid_lat"]),
"centroid_lon": _decimal(row["centroid_lon"]),
"center_cell_code": row["center_cell_code"],
"center_cell_lat": _decimal(row["center_cell_lat"]),
"center_cell_lon": _decimal(row["center_cell_lon"]),
"geometry": row["geometry"],
"cell_count": row["cell_count"],
"cell_codes": row["cell_codes"],
"metadata": row["metadata"],
},
)
self._touch(obj, row)
def _seed_analysis_grid_cells(self):
for row in SEED_DATA["analysisgridcells"]:
obj, _ = AnalysisGridCell.objects.update_or_create(
id=row["id"],
defaults={
"soil_location_id": row["soil_location_id"],
"block_subdivision_id": row["block_subdivision_id"],
"block_code": row["block_code"],
"cell_code": row["cell_code"],
"chunk_size_sqm": row["chunk_size_sqm"],
"geometry": row["geometry"],
"centroid_lat": _decimal(row["centroid_lat"]),
"centroid_lon": _decimal(row["centroid_lon"]),
},
)
self._touch(obj, row)
def _seed_analysis_grid_observations(self):
for row in SEED_DATA["analysisgridobservations"]:
obj, _ = AnalysisGridObservation.objects.update_or_create(
id=row["id"],
defaults={
"cell_id": row["cell_id"],
"run_id": row["run_id"],
"temporal_start": _date(row["temporal_start"]),
"temporal_end": _date(row["temporal_end"]),
"ndvi": row["ndvi"],
"ndwi": row["ndwi"],
"soil_vv": row["soil_vv"],
"soil_vv_db": row["soil_vv_db"],
"dem_m": row["dem_m"],
"slope_deg": row["slope_deg"],
"metadata": row["metadata"],
},
)
self._touch(obj, row)
def _seed_cluster_assignments(self):
for row in SEED_DATA["remotesensingclusterassignments"]:
obj, _ = RemoteSensingClusterAssignment.objects.update_or_create(
id=row["id"],
defaults={
"result_id": row["result_id"],
"cell_id": row["cell_id"],
"cluster_label": row["cluster_label"],
"raw_feature_values": row["raw_feature_values"],
"scaled_feature_values": row["scaled_feature_values"],
},
)
self._touch(obj, row)
def _seed_subdivision_options(self):
for row in SEED_DATA["remotesensingsubdivisionoptions"]:
obj, _ = RemoteSensingSubdivisionOption.objects.update_or_create(
id=row["id"],
defaults={
"result_id": row["result_id"],
"requested_k": row["requested_k"],
"effective_cluster_count": row["effective_cluster_count"],
"is_active": row["is_active"],
"is_recommended": row["is_recommended"],
"selection_source": row["selection_source"],
"metadata": row["metadata"],
},
)
self._touch(obj, row)
def _seed_subdivision_option_blocks(self):
for row in SEED_DATA["remotesensingsubdivisionoptionblocks"]:
obj, _ = RemoteSensingSubdivisionOptionBlock.objects.update_or_create(
id=row["id"],
defaults={
"option_id": row["option_id"],
"cluster_label": row["cluster_label"],
"sub_block_code": row["sub_block_code"],
"chunk_size_sqm": row["chunk_size_sqm"],
"centroid_lat": _decimal(row["centroid_lat"]),
"centroid_lon": _decimal(row["centroid_lon"]),
"center_cell_code": row["center_cell_code"],
"center_cell_lat": _decimal(row["center_cell_lat"]),
"center_cell_lon": _decimal(row["center_cell_lon"]),
"geometry": row["geometry"],
"cell_count": row["cell_count"],
"cell_codes": row["cell_codes"],
"metadata": row["metadata"],
},
)
self._touch(obj, row)
def _seed_subdivision_option_assignments(self):
for row in SEED_DATA["remotesensingsubdivisionoptionassignments"]:
obj, _ = RemoteSensingSubdivisionOptionAssignment.objects.update_or_create(
id=row["id"],
defaults={
"option_id": row["option_id"],
"cell_id": row["cell_id"],
"cluster_label": row["cluster_label"],
"raw_feature_values": row["raw_feature_values"],
"scaled_feature_values": row["scaled_feature_values"],
},
)
self._touch(obj, row)
def _seed_ndvi_observations(self):
for row in SEED_DATA["ndviobservations"]:
obj, _ = NdviObservation.objects.update_or_create(
id=row["id"],
defaults={
"location_id": row["location_id"],
"observation_date": _date(row["observation_date"]),
"mean_ndvi": row["mean_ndvi"],
"ndvi_map": row["ndvi_map"],
"vegetation_health_class": row["vegetation_health_class"],
"satellite_source": row["satellite_source"],
"cloud_cover": row["cloud_cover"],
"metadata": row["metadata"],
},
)
self._touch(obj, row, created_field=False)
def _touch(self, obj, row, *, created_field=True):
updates = []
if created_field and hasattr(obj, "created_at") and row.get("created_at"):
obj.created_at = _dt(row["created_at"])
updates.append("created_at")
if hasattr(obj, "updated_at") and row.get("updated_at"):
obj.updated_at = _dt(row["updated_at"])
updates.append("updated_at")
if updates:
obj.save(update_fields=updates)
+84 -28
View File
@@ -3,24 +3,83 @@ import uuid
from django.db import models
def build_default_sub_block(block_code: str, *, boundary: dict | None = None) -> dict:
normalized_block_code = str(block_code or "block-1").strip() or "block-1"
return {
"sub_block_code": f"{normalized_block_code}-sub-1",
"cluster_label": 0,
"source": "default",
"boundary": boundary or {},
"cluster_uuid": None,
}
def ensure_block_layout_defaults(layout: dict | None, *, block_count: int | None = None) -> dict:
raw_layout = dict(layout or {})
raw_blocks = list(raw_layout.get("blocks") or [])
normalized_count = len(raw_blocks) if raw_blocks else max(int(block_count or raw_layout.get("input_block_count") or 1), 1)
normalized_blocks: list[dict] = []
for index in range(normalized_count):
raw_block = raw_blocks[index] if index < len(raw_blocks) else {}
block_code = str(raw_block.get("block_code") or f"block-{index + 1}").strip() or f"block-{index + 1}"
boundary = raw_block.get("boundary") or {}
sub_blocks = [dict(sub_block) for sub_block in (raw_block.get("sub_blocks") or []) if isinstance(sub_block, dict)]
if not sub_blocks:
sub_blocks = [build_default_sub_block(block_code, boundary=boundary)]
normalized_block = {
"block_code": block_code,
"order": int(raw_block.get("order") or index + 1),
"source": raw_block.get("source") or ("input" if raw_blocks or normalized_count > 1 else "default"),
"boundary": boundary,
"needs_subdivision": raw_block.get("needs_subdivision"),
"sub_blocks": sub_blocks,
}
for extra_key in ("subdivision_summary", "analysis_grid_summary", "aggregated_metrics"):
if extra_key in raw_block:
normalized_block[extra_key] = raw_block[extra_key]
normalized_blocks.append(normalized_block)
return {
"input_block_count": normalized_count,
"default_full_farm": raw_layout.get("default_full_farm", normalized_count == 1),
"algorithm_status": raw_layout.get("algorithm_status") or "pending",
"blocks": normalized_blocks,
}
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(
return ensure_block_layout_defaults(
{
"input_block_count": len(blocks),
"default_full_farm": len(blocks) == 1,
"algorithm_status": "pending",
"blocks": [
{
"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": [dict(sub_block) for sub_block in (block.get("sub_blocks") or [])],
}
for index, block in enumerate(blocks)
],
},
block_count=len(blocks),
)
normalized_count = max(int(block_count or 1), 1)
return ensure_block_layout_defaults(
{
"input_block_count": normalized_count,
"default_full_farm": normalized_count == 1,
"algorithm_status": "pending",
"blocks": [
{
"block_code": f"block-{index + 1}",
"order": index + 1,
@@ -29,16 +88,11 @@ def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -
"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,
}
for index in range(normalized_count)
],
},
block_count=normalized_count,
)
class SoilLocation(models.Model):
@@ -122,8 +176,10 @@ class SoilLocation(models.Model):
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)
self.block_layout = ensure_block_layout_defaults(
self.block_layout,
block_count=self.input_block_count,
)
super().save(*args, **kwargs)
+4 -3
View File
@@ -5,6 +5,7 @@ from typing import Any
from django.db.models import Avg, QuerySet
from .models import (
ensure_block_layout_defaults,
AnalysisGridObservation,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
@@ -90,7 +91,7 @@ def build_location_block_satellite_snapshots(
*,
sensor_payload: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
block_layout = location.block_layout or {}
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
blocks = block_layout.get("blocks") or []
if not blocks:
return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)]
@@ -112,7 +113,7 @@ def build_block_layout_metric_summary(
*,
sensor_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
layout = dict(location.block_layout or {})
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
blocks = [dict(block) for block in (layout.get("blocks") or [])]
snapshots_by_block_code = {
str(snapshot.get("block_code") or ""): snapshot
@@ -461,7 +462,7 @@ def build_block_sensor_summary(
def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]:
block_layout = dict(location.block_layout or {})
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
by_cluster_uuid: dict[str, dict[str, Any]] = {}
by_sub_block_code: dict[str, list[dict[str, Any]]] = {}
by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {}
+52
View File
@@ -144,6 +144,10 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer):
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100)
class ClusterCropRecommendationRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
temporal_start = serializers.DateField(required=False)
temporal_end = serializers.DateField(required=False)
@@ -426,6 +430,54 @@ class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer):
metadata = serializers.JSONField()
class ClusterCropRegisteredPlantSerializer(serializers.Serializer):
plant_id = serializers.IntegerField()
plant_name = serializers.CharField()
position = serializers.IntegerField(allow_null=True)
stage = serializers.CharField(allow_blank=True)
class ClusterCropCandidateSerializer(serializers.Serializer):
plant_id = serializers.IntegerField(allow_null=True)
plant_name = serializers.CharField()
position = serializers.IntegerField(allow_null=True)
stage = serializers.CharField(allow_blank=True)
score = serializers.FloatField()
predicted_yield = serializers.FloatField(allow_null=True)
predicted_yield_tons = serializers.FloatField(allow_null=True)
biomass = serializers.FloatField(allow_null=True)
max_lai = serializers.FloatField(allow_null=True)
simulation_engine = serializers.CharField(allow_null=True)
simulation_model_name = serializers.CharField(allow_null=True)
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
supporting_metrics = serializers.JSONField()
class ClusterCropRecommendationClusterSerializer(serializers.Serializer):
block_code = serializers.CharField(allow_blank=True)
cluster_uuid = serializers.CharField(allow_null=True, allow_blank=True)
sub_block_code = serializers.CharField()
cluster_label = serializers.IntegerField(allow_null=True)
temporal_extent = serializers.JSONField(allow_null=True)
cluster_block = RemoteSensingClusterBlockSerializer(allow_null=True)
satellite_metrics = serializers.JSONField()
sensor_metrics = serializers.JSONField()
resolved_metrics = serializers.JSONField()
candidate_plants = ClusterCropCandidateSerializer(many=True)
suggested_plant = ClusterCropCandidateSerializer(allow_null=True)
source_metadata = serializers.JSONField()
class ClusterCropRecommendationResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
location_id = serializers.IntegerField()
evaluated_plant_count = serializers.IntegerField()
cluster_count = serializers.IntegerField()
registered_plants = ClusterCropRegisteredPlantSerializer(many=True)
clusters = ClusterCropRecommendationClusterSerializer(many=True)
source_metadata = serializers.JSONField()
class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
result_id = serializers.IntegerField()
active_requested_k = serializers.IntegerField(allow_null=True)
@@ -0,0 +1,281 @@
from datetime import date
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from farm_data.models import FarmPlantAssignment, PlantCatalogSnapshot, SensorData
from location_data.models import (
AnalysisGridCell,
AnalysisGridObservation,
BlockSubdivision,
RemoteSensingClusterAssignment,
RemoteSensingClusterBlock,
RemoteSensingRun,
RemoteSensingSubdivisionResult,
SoilLocation,
)
from weather.models import WeatherForecast
@override_settings(ROOT_URLCONF="location_data.urls")
class RemoteSensingClusterRecommendationApiTests(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.location.set_input_block_count(1)
self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"])
self.farm = SensorData.objects.create(
farm_uuid="11111111-1111-1111-1111-111111111111",
center_location=self.location,
sensor_payload={},
)
for day_index in range(1, 5):
WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2025, 2, day_index),
temperature_min=12.0,
temperature_max=24.0,
temperature_mean=18.0,
precipitation=1.0,
precipitation_probability=25.0,
humidity_mean=55.0,
wind_speed_max=10.0,
et0=3.0,
weather_code=1,
)
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=2,
selected_features=["ndvi", "ndwi", "soil_vv_db"],
metadata={"used_cell_count": 2, "skipped_cell_count": 0},
)
self.cell_1 = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-1",
chunk_size_sqm=900,
geometry=self.boundary,
centroid_lat="35.689250",
centroid_lon="51.389250",
)
self.cell_2 = AnalysisGridCell.objects.create(
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
cell_code="cell-2",
chunk_size_sqm=900,
geometry=self.boundary,
centroid_lat="35.689750",
centroid_lon="51.389750",
)
AnalysisGridObservation.objects.create(
cell=self.cell_1,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.51,
ndwi=0.24,
soil_vv=0.13,
soil_vv_db=-10.0,
metadata={"backend_name": "openeo"},
)
AnalysisGridObservation.objects.create(
cell=self.cell_2,
run=self.run,
temporal_start=date(2025, 1, 1),
temporal_end=date(2025, 1, 31),
ndvi=0.71,
ndwi=0.48,
soil_vv=0.19,
soil_vv_db=-7.5,
metadata={"backend_name": "openeo"},
)
RemoteSensingClusterAssignment.objects.create(
result=self.result,
cell=self.cell_1,
cluster_label=0,
raw_feature_values={"ndvi": 0.51, "ndwi": 0.24, "soil_vv_db": -10.0},
scaled_feature_values={"ndvi": -1.0, "ndwi": -1.0, "soil_vv_db": -1.0},
)
RemoteSensingClusterAssignment.objects.create(
result=self.result,
cell=self.cell_2,
cluster_label=1,
raw_feature_values={"ndvi": 0.71, "ndwi": 0.48, "soil_vv_db": -7.5},
scaled_feature_values={"ndvi": 1.0, "ndwi": 1.0, "soil_vv_db": 1.0},
)
self.cluster_0 = 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.689250",
centroid_lon="51.389250",
center_cell_code="cell-1",
cell_count=1,
cell_codes=["cell-1"],
geometry=self.boundary,
)
self.cluster_1 = RemoteSensingClusterBlock.objects.create(
result=self.result,
soil_location=self.location,
block_subdivision=self.subdivision,
block_code="block-1",
sub_block_code="cluster-1",
cluster_label=1,
chunk_size_sqm=900,
centroid_lat="35.689750",
centroid_lon="51.389750",
center_cell_code="cell-2",
cell_count=1,
cell_codes=["cell-2"],
geometry=self.boundary,
)
self.location.block_layout = {
"input_block_count": 1,
"default_full_farm": True,
"algorithm_status": "completed",
"blocks": [
{
"block_code": "block-1",
"order": 1,
"source": "input",
"boundary": self.boundary,
"needs_subdivision": True,
"sub_blocks": [
{
"sub_block_code": "cluster-0",
"cluster_label": 0,
"cluster_uuid": str(self.cluster_0.uuid),
},
{
"sub_block_code": "cluster-1",
"cluster_label": 1,
"cluster_uuid": str(self.cluster_1.uuid),
},
],
}
],
}
self.location.save(update_fields=["block_layout", "updated_at"])
self.tomato = PlantCatalogSnapshot.objects.create(
backend_plant_id=101,
name="Tomato",
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Tomato", "MAX_BIOMASS": 14000.0}}},
)
self.wheat = PlantCatalogSnapshot.objects.create(
backend_plant_id=102,
name="Wheat",
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Wheat", "MAX_BIOMASS": 11000.0}}},
)
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.tomato, position=0, stage="vegetative")
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.wheat, position=1, stage="vegetative")
@patch("location_data.cluster_recommendation._simulate_candidate")
def test_cluster_recommendations_return_ranked_plants_for_each_cluster(self, simulate_mock):
def fake_simulation(*, base_payload, soil_parameters, site_parameters):
plant_name = base_payload["crop_parameters"]["crop_name"]
smfcf = float(soil_parameters["SMFCF"])
if plant_name == "Tomato":
yield_estimate = 150.0 if smfcf >= 0.4 else 80.0
else:
yield_estimate = 110.0 if smfcf >= 0.4 else 120.0
return (
{
"engine": "pcse",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": yield_estimate,
"biomass": yield_estimate * 2,
"max_lai": 4.2,
},
},
None,
)
simulate_mock.side_effect = fake_simulation
response = self.client.get(
"/remote-sensing/cluster-recommendations/",
data={"farm_uuid": str(self.farm.farm_uuid)},
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["cluster_count"], 2)
self.assertEqual(payload["evaluated_plant_count"], 2)
self.assertEqual(len(payload["registered_plants"]), 2)
clusters = {item["cluster_label"]: item for item in payload["clusters"]}
self.assertEqual(clusters[0]["resolved_metrics"]["ndvi"], 0.51)
self.assertEqual(clusters[0]["resolved_metrics"]["ndwi"], 0.24)
self.assertEqual(clusters[0]["resolved_metrics"]["soil_vv"], 0.13)
self.assertEqual(clusters[1]["resolved_metrics"]["ndwi"], 0.48)
self.assertEqual(clusters[0]["suggested_plant"]["plant_name"], "Wheat")
self.assertEqual(clusters[1]["suggested_plant"]["plant_name"], "Tomato")
self.assertEqual(clusters[0]["candidate_plants"][0]["score"], 120.0)
self.assertEqual(clusters[1]["candidate_plants"][0]["score"], 150.0)
self.assertEqual(clusters[0]["cluster_block"]["uuid"], str(self.cluster_0.uuid))
self.assertEqual(clusters[1]["cluster_block"]["uuid"], str(self.cluster_1.uuid))
def test_cluster_recommendations_return_400_when_no_plants_registered(self):
FarmPlantAssignment.objects.all().delete()
response = self.client.get(
"/remote-sensing/cluster-recommendations/",
data={"farm_uuid": str(self.farm.farm_uuid)},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.json()["msg"],
"برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.",
)
+19
View File
@@ -57,10 +57,29 @@ class SoilDataApiTests(TestCase):
self.assertEqual(len(payload["block_layout"]["blocks"]), 1)
self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary)
self.assertEqual(payload["block_layout"]["algorithm_status"], "pending")
self.assertEqual(len(payload["block_layout"]["blocks"][0]["sub_blocks"]), 1)
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["sub_block_code"], "block-1-sub-1")
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["cluster_label"], 0)
self.assertEqual(len(payload["block_subdivisions"]), 1)
self.assertEqual(payload["block_subdivisions"][0]["status"], "defined")
self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing")
def test_model_default_layout_includes_default_sub_block_when_missing(self):
location = SoilLocation.objects.create(
latitude="35.689201",
longitude="51.389001",
)
self.assertEqual(location.input_block_count, 1)
self.assertEqual(location.block_layout["blocks"][0]["block_code"], "block-1")
self.assertEqual(len(location.block_layout["blocks"][0]["sub_blocks"]), 1)
self.assertEqual(
location.block_layout["blocks"][0]["sub_blocks"][0]["sub_block_code"],
"block-1-sub-1",
)
self.assertEqual(location.block_layout["blocks"][0]["sub_blocks"][0]["source"], "default")
def test_post_updates_block_layout_from_input(self):
SoilLocation.objects.create(
latitude="35.689200",
+6
View File
@@ -4,6 +4,7 @@ from .views import (
NdviHealthView,
RemoteSensingAnalysisView,
RemoteSensingClusterBlockLiveView,
RemoteSensingClusterRecommendationView,
RemoteSensingSubdivisionOptionActivateView,
RemoteSensingSubdivisionOptionListView,
RemoteSensingRunStatusView,
@@ -18,6 +19,11 @@ urlpatterns = [
RemoteSensingClusterBlockLiveView.as_view(),
name="remote-sensing-cluster-block-live",
),
path(
"remote-sensing/cluster-recommendations/",
RemoteSensingClusterRecommendationView.as_view(),
name="remote-sensing-cluster-recommendations",
),
path(
"remote-sensing/results/<int:result_id>/k-options/",
RemoteSensingSubdivisionOptionListView.as_view(),
+80
View File
@@ -37,8 +37,15 @@ from farm_data.models import SensorData
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
from .data_driven_subdivision import activate_subdivision_option
from .cluster_recommendation import (
ClusterRecommendationNotFound,
ClusterRecommendationValidationError,
build_cluster_crop_recommendations,
)
from .serializers import (
BlockSubdivisionSerializer,
ClusterCropRecommendationRequestSerializer,
ClusterCropRecommendationResponseSerializer,
NdviHealthRequestSerializer,
NdviHealthResponseSerializer,
RemoteSensingCellObservationSerializer,
@@ -140,6 +147,10 @@ RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
RemoteSensingClusterBlockLiveResponseSerializer,
)
ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer(
"ClusterCropRecommendationEnvelopeSerializer",
ClusterCropRecommendationResponseSerializer,
)
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
RemoteSensingSubdivisionOptionListResponseSerializer,
@@ -843,6 +854,75 @@ class RemoteSensingClusterBlockLiveView(APIView):
)
class RemoteSensingClusterRecommendationView(APIView):
@extend_schema(
tags=["Location Data"],
summary="پیشنهاد گیاه برای هر کلاستر KMeans",
description=(
"با دریافت farm_uuid، داده هر کلاستر KMeans location_data به‌همراه "
"ndvi، ndwi، soil_vv، soil_vv_db و مقایسه گیاه‌های ثبت‌شده در farm_data "
"با crop_simulation برگردانده می‌شود و برای هر زیر‌بلاک یک گیاه پیشنهادی ارائه می‌شود."
),
parameters=[
OpenApiParameter(
name="farm_uuid",
type=str,
location=OpenApiParameter.QUERY,
required=True,
description="شناسه یکتای مزرعه",
),
],
responses={
200: build_response(
ClusterCropRecommendationEnvelopeSerializer,
"داده کلاسترها و پیشنهاد گیاه برای هر زیر‌بلاک بازگردانده شد.",
),
400: build_response(
SoilErrorResponseSerializer,
"پیش‌نیازهای مقایسه نامعتبر یا ناقص است.",
),
404: build_response(
SoilErrorResponseSerializer,
"مزرعه یا خروجی KMeans یافت نشد.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست پیشنهاد گیاه برای کلاسترها",
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
parameter_only=("farm_uuid", "query"),
)
],
)
def get(self, request):
serializer = ClusterCropRecommendationRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
try:
payload = build_cluster_crop_recommendations(
str(serializer.validated_data["farm_uuid"])
)
except ClusterRecommendationNotFound as exc:
return Response(
{"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND,
)
except ClusterRecommendationValidationError as exc:
return Response(
{"code": 400, "msg": str(exc), "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"code": 200, "msg": "success", "data": payload},
status=status.HTTP_200_OK,
)
class RemoteSensingSubdivisionOptionListView(APIView):
@extend_schema(
tags=["Location Data"],