2026-05-13 16:45:54 +03:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from copy import deepcopy
|
|
|
|
|
from dataclasses import dataclass
|
2026-05-13 22:28:56 +03:30
|
|
|
from datetime import date, datetime
|
|
|
|
|
from decimal import Decimal
|
2026-05-13 16:45:54 +03:30
|
|
|
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
|
2026-05-13 22:28:56 +03:30
|
|
|
from .models import AnalysisGridObservation, RemoteSensingClusterBlock, RemoteSensingSubdivisionResult
|
2026-05-13 16:45:54 +03:30
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 22:28:56 +03:30
|
|
|
def _json_safe(value: Any) -> Any:
|
|
|
|
|
if isinstance(value, Decimal):
|
|
|
|
|
return float(value)
|
|
|
|
|
if isinstance(value, datetime):
|
|
|
|
|
formatted = value.isoformat()
|
|
|
|
|
if formatted.endswith("+00:00"):
|
|
|
|
|
return formatted[:-6] + "Z"
|
|
|
|
|
return formatted
|
|
|
|
|
if isinstance(value, date):
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
|
|
|
if isinstance(value, (list, tuple)):
|
|
|
|
|
return [_json_safe(item) for item in value]
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 16:45:54 +03:30
|
|
|
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("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.")
|
|
|
|
|
|
2026-05-13 22:28:56 +03:30
|
|
|
recommendation_result_ids = sorted(
|
|
|
|
|
{
|
|
|
|
|
int(cluster_block.result_id)
|
|
|
|
|
for cluster_block in cluster_blocks_by_uuid.values()
|
|
|
|
|
if cluster_block.result_id
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
cached_payload = _load_cached_cluster_recommendations(
|
|
|
|
|
farm_uuid=str(farm.farm_uuid),
|
|
|
|
|
result_ids=recommendation_result_ids,
|
|
|
|
|
plant_assignments=plant_assignments,
|
|
|
|
|
)
|
|
|
|
|
if cached_payload is not None:
|
|
|
|
|
return cached_payload
|
|
|
|
|
|
2026-05-13 16:45:54 +03:30
|
|
|
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 {}),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-13 22:28:56 +03:30
|
|
|
payload = {
|
2026-05-13 16:45:54 +03:30
|
|
|
"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),
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-05-13 22:28:56 +03:30
|
|
|
_store_cached_cluster_recommendations(
|
|
|
|
|
farm_uuid=str(farm.farm_uuid),
|
|
|
|
|
result_ids=recommendation_result_ids,
|
|
|
|
|
plant_assignments=plant_assignments,
|
|
|
|
|
payload=payload,
|
|
|
|
|
)
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_assignment_cache_signature(plant_assignments: list[Any]) -> list[dict[str, Any]]:
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"plant_id": getattr(assignment.plant, "backend_plant_id", None),
|
|
|
|
|
"position": int(assignment.position or 0),
|
|
|
|
|
"stage": str(assignment.stage or ""),
|
|
|
|
|
}
|
|
|
|
|
for assignment in plant_assignments
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_cached_cluster_recommendations(
|
|
|
|
|
*,
|
|
|
|
|
farm_uuid: str,
|
|
|
|
|
result_ids: list[int],
|
|
|
|
|
plant_assignments: list[Any],
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
if not result_ids:
|
|
|
|
|
return None
|
|
|
|
|
cache_key = f"farm::{farm_uuid}"
|
|
|
|
|
assignment_signature = _build_assignment_cache_signature(plant_assignments)
|
|
|
|
|
for result in RemoteSensingSubdivisionResult.objects.filter(id__in=result_ids):
|
|
|
|
|
metadata = dict(result.metadata or {})
|
|
|
|
|
recommendation_cache = dict(metadata.get("cluster_recommendations") or {})
|
|
|
|
|
cached_entry = recommendation_cache.get(cache_key)
|
|
|
|
|
if not isinstance(cached_entry, dict):
|
|
|
|
|
continue
|
|
|
|
|
if cached_entry.get("assignment_signature") != assignment_signature:
|
|
|
|
|
continue
|
|
|
|
|
payload = cached_entry.get("payload")
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
return payload
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _store_cached_cluster_recommendations(
|
|
|
|
|
*,
|
|
|
|
|
farm_uuid: str,
|
|
|
|
|
result_ids: list[int],
|
|
|
|
|
plant_assignments: list[Any],
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
) -> None:
|
|
|
|
|
if not result_ids:
|
|
|
|
|
return
|
|
|
|
|
cache_key = f"farm::{farm_uuid}"
|
|
|
|
|
assignment_signature = _build_assignment_cache_signature(plant_assignments)
|
|
|
|
|
for result in RemoteSensingSubdivisionResult.objects.filter(id__in=result_ids):
|
|
|
|
|
metadata = dict(result.metadata or {})
|
|
|
|
|
recommendation_cache = dict(metadata.get("cluster_recommendations") or {})
|
|
|
|
|
recommendation_cache[cache_key] = {
|
|
|
|
|
"assignment_signature": assignment_signature,
|
|
|
|
|
"payload": _json_safe(payload),
|
|
|
|
|
}
|
|
|
|
|
metadata["cluster_recommendations"] = recommendation_cache
|
|
|
|
|
result.metadata = metadata
|
|
|
|
|
result.save(update_fields=["metadata", "updated_at"])
|