UPDATE
This commit is contained in:
@@ -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),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user