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
+378 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from numbers import Number
import logging
import uuid
import warnings
from django.conf import settings
@@ -17,13 +18,14 @@ from location_data.block_subdivision import create_or_get_block_subdivision
from location_data.models import BlockSubdivision, SoilLocation
from location_data.satellite_snapshot import (
build_block_layout_metric_summary,
build_farmer_block_aggregated_snapshot,
build_location_block_satellite_snapshots,
build_location_satellite_snapshot,
)
from irrigation.serializers import IrrigationMethodSerializer
from weather.models import WeatherForecast
from .models import (
Device,
FarmPlantAssignment,
ParameterUpdateLog,
PlantCatalogSnapshot,
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
return synced_parameters
def _parse_cluster_uuid(value: object) -> uuid.UUID | None:
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]:
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
location = farm.center_location
synced_devices: list[Device] = []
with transaction.atomic():
active_sensor_names: list[str] = []
for sensor_name, payload in sensor_payload.items():
if not isinstance(payload, dict):
continue
active_sensor_names.append(sensor_name)
device, _created = Device.objects.update_or_create(
farm=farm,
sensor_name=sensor_name,
defaults={
"location": location,
"payload": payload,
"cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")),
},
)
synced_devices.append(device)
stale_devices = Device.objects.filter(farm=farm)
if active_sensor_names:
stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names)
stale_devices.delete()
return synced_devices
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
if sensor_payload and isinstance(sensor_payload, dict):
@@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str):
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
)
latest_satellite = build_location_satellite_snapshot(center_location)
block_metric_snapshots = build_location_block_satellite_snapshots(
soil_snapshot = _build_farm_soil_snapshot(
center_location,
sensor_payload=farm.sensor_payload,
)
if all(
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
for snapshot in block_metric_snapshots
):
block_metric_snapshots = []
soil_metrics = dict(latest_satellite.get("resolved_metrics") or {})
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
resolved_metrics = dict(soil_metrics)
metric_sources = {key: "remote_sensing" for key in soil_metrics}
for key, value in sensor_metrics.items():
resolved_metrics[key] = value
metric_sources[key] = sensor_metric_sources[key]
plant_assignments = get_farm_plant_assignments(farm)
plant_snapshots = [assignment.plant for assignment in plant_assignments]
@@ -501,11 +528,7 @@ def get_farm_details(farm_uuid: str):
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {},
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
"soil": {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"satellite_snapshots": block_metric_snapshots,
},
"soil": soil_snapshot,
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
"plant_assignments": [
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
),
"created_at": farm.created_at,
"updated_at": farm.updated_at,
"source_metadata": {
"soil": soil_snapshot.get("source_metadata", {}),
"weather": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"note": "Weather remains tied to the farm center location.",
},
},
}
def _build_farm_soil_snapshot(
center_location: SoilLocation,
*,
sensor_payload: dict | None,
) -> dict[str, object]:
# Canonical farm soil metrics now come from farmer-level block aggregation.
aggregated_snapshot = build_farmer_block_aggregated_snapshot(
center_location,
sensor_payload=sensor_payload,
)
block_snapshots = build_location_block_satellite_snapshots(
center_location,
sensor_payload=sensor_payload,
)
if all(
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
for snapshot in block_snapshots
):
block_snapshots = []
has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks"))
resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {})
metric_sources = dict(aggregated_snapshot.get("metric_sources") or {})
compatibility_sensor_overlay_applied = False
if not has_explicit_blocks:
compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing(
resolved_metrics,
metric_sources,
sensor_payload,
)
cluster_breakdown = _build_cluster_breakdown(block_snapshots)
return {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"block_snapshots": block_snapshots,
"satellite_snapshots": block_snapshots,
"cluster_breakdown": cluster_breakdown,
"source_metadata": {
"canonical_source": "farmer_block_aggregated_snapshot",
"aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing",
"status": aggregated_snapshot.get("status") or "missing",
"block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)),
"has_explicit_blocks": has_explicit_blocks,
"compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied,
"policy": {
"sensor": "cluster_mean -> block_mean -> farm_mean",
"satellite": "cluster_mean -> block_mean -> farm_mean",
"weather": "location_center_based",
},
},
}
def _merge_legacy_sensor_metrics_if_missing(
resolved_metrics: dict,
metric_sources: dict,
sensor_payload: dict | None,
) -> bool:
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload)
applied = False
for metric_name, metric_value in sensor_metrics.items():
if metric_name in resolved_metrics:
continue
resolved_metrics[metric_name] = metric_value
metric_sources[metric_name] = sensor_metric_sources[metric_name]
applied = True
return applied
def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
cluster_breakdown: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip()
for sub_block in snapshot.get("satellite_sub_blocks") or []:
cluster_breakdown.append(
{
"block_code": block_code,
"source": "satellite",
**dict(sub_block),
}
)
for sub_block in snapshot.get("sensor_sub_blocks") or []:
cluster_breakdown.append(
{
"block_code": block_code,
"source": "sensor",
**dict(sub_block),
}
)
return cluster_breakdown
AI_FARM_AGGREGATION_POLICY = {
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
"weather": "center_location_latest_forecast",
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing",
}
def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None:
farm = get_canonical_farm_record(farm_uuid)
if farm is None:
return None
sync_sensor_parameters_from_payload(farm.sensor_payload)
center_location = farm.center_location
weather = farm.weather_forecast
if weather is None:
weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
soil_snapshot = _build_farm_soil_snapshot(
center_location,
sensor_payload=farm.sensor_payload,
)
block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or [])
sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or [])
plant_assignments = get_farm_plant_assignments(farm)
return {
"farm_uuid": str(farm.farm_uuid),
"aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY),
"farm_metrics": {
"resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}),
"metric_sources": dict(soil_snapshot.get("metric_sources") or {}),
"status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"),
"aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get(
"aggregation_strategy", "missing"
),
},
"block_metrics": block_metrics,
"sub_block_metrics": sub_block_metrics,
"weather": {
"forecast": WeatherForecastDetailSerializer(weather).data if weather else None,
"source_metadata": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"status": "completed" if weather else "missing",
},
},
"plants": [
{
"plant_id": assignment.plant.backend_plant_id,
"position": assignment.position,
"stage": assignment.stage,
"metadata": assignment.metadata,
"assigned_at": assignment.assigned_at,
"updated_at": assignment.updated_at,
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
}
for assignment in plant_assignments
],
"irrigation_method": {
"id": farm.irrigation_method_id,
"details": (
IrrigationMethodSerializer(farm.irrigation_method).data
if farm.irrigation_method
else None
),
"source_metadata": {
"source": "farm_record",
"status": "completed" if farm.irrigation_method_id else "missing",
},
},
"source_metadata": {
"farm": {
"farm_uuid": str(farm.farm_uuid),
"center_location_id": center_location.id,
"has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")),
},
"farm_metrics": dict(soil_snapshot.get("source_metadata") or {}),
"block_metrics": {
"source": "build_location_block_satellite_snapshots",
"block_count": len(block_metrics),
"status": "completed" if block_metrics else "missing",
},
"sub_block_metrics": {
"source": "block_snapshot_sub_blocks",
"sub_block_count": len(sub_block_metrics),
"status": "completed" if sub_block_metrics else "missing",
},
"weather": {
"source": "center_location_forecast",
"scope": "location_center_based",
"location_id": center_location.id,
"note": "Weather remains tied to the farm center location.",
},
"plants": {
"source": "farm_plant_assignments",
"count": len(plant_assignments),
},
"irrigation_method": {
"source": "farm_record",
"status": "completed" if farm.irrigation_method_id else "missing",
},
},
}
def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None:
"""Return the canonical AI snapshot, or fall back to farm details for older consumers."""
snapshot = build_ai_farm_snapshot(farm_uuid)
if snapshot is None:
return None
return snapshot
def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None:
if not isinstance(snapshot, dict):
return None
farm_metrics = snapshot.get("farm_metrics") or {}
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
if isinstance(resolved_metrics, dict):
return resolved_metrics.get(metric_name)
return None
def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]:
if not isinstance(snapshot, dict):
return {}
weather_section = snapshot.get("weather") or {}
forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None
return forecast if isinstance(forecast, dict) else {}
def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
block_metrics: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
block_metrics.append(
{
"block_code": block_code,
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
"metric_sources": dict(snapshot.get("metric_sources") or {}),
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
"status": snapshot.get("status") or "missing",
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
"sub_block_count": int(snapshot.get("sub_block_count") or 0),
"temporal_extent": snapshot.get("temporal_extent"),
"source_metadata": {
"source": "build_location_block_satellite_snapshots",
"block_code": block_code,
"run_id": snapshot.get("run_id"),
"cell_count": snapshot.get("cell_count"),
},
}
)
return block_metrics
def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
sub_block_metrics: list[dict[str, object]] = []
for snapshot in block_snapshots:
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or []
sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or []
if not satellite_sub_blocks and not sensor_sub_blocks:
sub_block_metrics.append(
{
"block_code": block_code,
"sub_block_code": "default-sub-block",
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "default_sub_block_compatibility",
"scope": "future_default_policy",
},
}
)
continue
sub_blocks_by_code: dict[str, dict[str, object]] = {}
for sub_block in satellite_sub_blocks:
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
entry = sub_blocks_by_code.setdefault(
sub_block_code,
{
"block_code": block_code,
"sub_block_code": sub_block_code,
"resolved_metrics": {},
"satellite_metrics": {},
"sensor_metrics": {},
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "block_snapshot_sub_blocks",
"satellite_present": False,
"sensor_present": False,
},
},
)
entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["satellite_metrics"])
entry["source_metadata"]["satellite_present"] = True
for sub_block in sensor_sub_blocks:
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
entry = sub_blocks_by_code.setdefault(
sub_block_code,
{
"block_code": block_code,
"sub_block_code": sub_block_code,
"resolved_metrics": {},
"satellite_metrics": {},
"sensor_metrics": {},
"status": snapshot.get("status") or "missing",
"source_metadata": {
"source": "block_snapshot_sub_blocks",
"satellite_present": False,
"sensor_present": False,
},
},
)
entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {})
entry["resolved_metrics"].update(entry["sensor_metrics"])
entry["source_metadata"]["sensor_present"] = True
sub_block_metrics.extend(sub_blocks_by_code.values())
return sub_block_metrics
def resolve_center_location_from_boundary(
farm_boundary: dict | list,
block_count: int = 1,