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
+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),
},
}