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