Files
Ai/farm_data/services.py
T
2026-04-25 17:22:41 +03:30

344 lines
12 KiB
Python

from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from numbers import Number
from django.db import transaction
from location_data.models import SoilLocation
from location_data.serializers import SoilDepthDataSerializer
from location_data.tasks import fetch_soil_data_for_coordinates
from irrigation.serializers import IrrigationMethodSerializer
from plant.serializers import PlantSerializer
from weather.services import update_weather_for_location
from weather.models import WeatherForecast
from .models import SensorData
from .serializers import WeatherForecastDetailSerializer
DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
DECIMAL_PRECISION = Decimal("0.000001")
class ExternalDataSyncError(Exception):
"""خطا در همگام‌سازی داده از سرویس‌های بیرونی."""
def get_farm_details(farm_uuid: str):
farm = (
SensorData.objects.select_related(
"center_location",
"weather_forecast",
"irrigation_method",
)
.prefetch_related("plants", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None:
return None
center_location = farm.center_location
weather = farm.weather_forecast
if weather is None:
weather = (
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
)
depths = list(center_location.depths.all())
depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99)
soil_metrics = _surface_soil_metrics(depths)
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
resolved_metrics = dict(soil_metrics)
metric_sources = {key: "soil" for key in soil_metrics}
for key, value in sensor_metrics.items():
resolved_metrics[key] = value
metric_sources[key] = sensor_metric_sources[key]
return {
"center_location": {
"id": center_location.id,
"lat": center_location.latitude,
"lon": center_location.longitude,
"farm_boundary": center_location.farm_boundary,
},
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {},
"soil": {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"depths": SoilDepthDataSerializer(depths, many=True).data,
},
"plant_ids": list(farm.plants.values_list("id", flat=True)),
"plants": PlantSerializer(farm.plants.all(), many=True).data,
"irrigation_method_id": farm.irrigation_method_id,
"irrigation_method": (
IrrigationMethodSerializer(farm.irrigation_method).data
if farm.irrigation_method
else None
),
"created_at": farm.created_at,
"updated_at": farm.updated_at,
}
def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLocation:
"""
مرز مزرعه را می‌گیرد، مرکز را محاسبه می‌کند و رکورد SoilLocation را
ایجاد/به‌روزرسانی می‌کند.
"""
points = _extract_boundary_points(farm_boundary)
if not points:
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
normalized_points = _normalize_points(points)
if len(normalized_points) < 3:
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
center_lat, center_lon = _compute_polygon_centroid(normalized_points)
with transaction.atomic():
location, _ = SoilLocation.objects.update_or_create(
latitude=center_lat,
longitude=center_lon,
defaults={"farm_boundary": _serialize_boundary(farm_boundary)},
)
return location
def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None:
return (
WeatherForecast.objects.filter(location=location)
.order_by("-forecast_date", "-id")
.first()
)
def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]:
"""
اگر داده خاک یا آب‌وهوا برای location موجود نباشد، از سرویس مربوطه
واکشی و در دیتابیس ذخیره می‌شود.
"""
if not location.is_complete:
try:
soil_result = fetch_soil_data_for_coordinates(
latitude=float(location.latitude),
longitude=float(location.longitude),
)
except Exception as exc:
raise ExternalDataSyncError(f"خطا در واکشی داده خاک: {exc}") from exc
if soil_result.get("status") != "completed":
raise ExternalDataSyncError(
soil_result.get("error") or "واکشی داده خاک کامل نشد."
)
location.refresh_from_db()
weather_forecast = resolve_weather_for_location(location)
if weather_forecast is None:
weather_result = update_weather_for_location(location)
if weather_result.get("status") not in {"success", "no_data"}:
raise ExternalDataSyncError(
weather_result.get("error") or "واکشی داده آب‌وهوا کامل نشد."
)
weather_forecast = resolve_weather_for_location(location)
return location, weather_forecast
def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
if not isinstance(sensor_payload, dict):
return {}, {}
readings_by_metric: dict[str, list[tuple[str, object]]] = {}
for sensor_key, sensor_values in sorted(sensor_payload.items()):
if not isinstance(sensor_values, dict):
continue
for metric_key, metric_value in sensor_values.items():
readings_by_metric.setdefault(metric_key, []).append((sensor_key, metric_value))
resolved_metrics = {}
metric_sources = {}
for metric_key, readings in readings_by_metric.items():
resolved_value, source = _resolve_metric_readings(readings)
resolved_metrics[metric_key] = resolved_value
metric_sources[metric_key] = source
return resolved_metrics, metric_sources
def _resolve_metric_readings(readings: list[tuple[str, object]]) -> tuple[object, dict[str, object]]:
if not readings:
return None, {"type": "sensor", "strategy": "empty", "sensor_keys": []}
sensor_keys = [sensor_key for sensor_key, _value in readings]
distinct_values: list[object] = []
for _sensor_key, value in readings:
if value not in distinct_values:
distinct_values.append(value)
if len(distinct_values) == 1:
return distinct_values[0], {
"type": "sensor",
"strategy": "single_value",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
}
numeric_values = [_coerce_numeric(value) for value in distinct_values]
if all(value is not None for value in numeric_values):
average = sum(numeric_values) / len(numeric_values)
resolved_value = _normalize_numeric_result(average, distinct_values)
return resolved_value, {
"type": "sensor",
"strategy": "average",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
"conflict": True,
"distinct_values": distinct_values,
}
return distinct_values, {
"type": "sensor",
"strategy": "distinct_values",
"sensor_keys": sensor_keys,
"sensor_count": len(sensor_keys),
"conflict": True,
"distinct_values": distinct_values,
}
def _coerce_numeric(value: object) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, Number):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalize_numeric_result(value: float, source_values: list[object]) -> int | float:
if all(isinstance(item, int) and not isinstance(item, bool) for item in source_values):
if value.is_integer():
return int(value)
return float(Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP))
def _surface_soil_metrics(depths) -> dict:
if not depths:
return {}
primary_depth = depths[0]
fields = [
"bdod",
"cec",
"cfvo",
"clay",
"nitrogen",
"ocd",
"ocs",
"phh2o",
"sand",
"silt",
"soc",
"wv0010",
"wv0033",
"wv1500",
]
return {
field: getattr(primary_depth, field)
for field in fields
if getattr(primary_depth, field) is not None
}
def _extract_boundary_points(boundary: dict | list) -> list:
if isinstance(boundary, dict):
if boundary.get("type") == "Polygon":
coordinates = boundary.get("coordinates") or []
if coordinates and isinstance(coordinates[0], list):
return coordinates[0]
return []
if "corners" in boundary:
return boundary.get("corners") or []
if isinstance(boundary, list):
return boundary
return []
def _normalize_points(points: list) -> list[tuple[Decimal, Decimal]]:
normalized: list[tuple[Decimal, Decimal]] = []
for point in points:
lat = lon = None
if isinstance(point, dict):
lat = point.get("lat", point.get("latitude"))
lon = point.get("lon", point.get("longitude"))
elif isinstance(point, (list, tuple)) and len(point) >= 2:
lon, lat = point[0], point[1]
if lat is None or lon is None:
continue
lat_decimal = Decimal(str(lat))
lon_decimal = Decimal(str(lon))
normalized.append((lat_decimal, lon_decimal))
if len(normalized) > 1 and normalized[0] == normalized[-1]:
normalized = normalized[:-1]
return normalized
def _serialize_boundary(boundary: dict | list) -> dict:
if isinstance(boundary, dict) and boundary.get("type") == "Polygon":
return boundary
raw_points = boundary.get("corners") if isinstance(boundary, dict) else boundary
normalized = _normalize_points(raw_points or [])
coordinates = [[float(lon), float(lat)] for lat, lon in normalized]
if coordinates and coordinates[0] != coordinates[-1]:
coordinates.append(coordinates[0])
return {
"type": "Polygon",
"coordinates": [coordinates],
}
def _compute_polygon_centroid(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
polygon = list(points)
if polygon[0] != polygon[-1]:
polygon.append(polygon[0])
twice_area = Decimal("0")
centroid_lon = Decimal("0")
centroid_lat = Decimal("0")
for index in range(len(polygon) - 1):
lat1, lon1 = polygon[index]
lat2, lon2 = polygon[index + 1]
cross = (lon1 * lat2) - (lon2 * lat1)
twice_area += cross
centroid_lon += (lon1 + lon2) * cross
centroid_lat += (lat1 + lat2) * cross
if twice_area == 0:
return _compute_average_center(points)
factor = Decimal("3") * twice_area
return (
(centroid_lat / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
(centroid_lon / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
)
def _compute_average_center(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
lat_sum = sum(lat for lat, _ in points)
lon_sum = sum(lon for _, lon in points)
count = Decimal(len(points))
return (
(lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
(lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
)