2026-04-06 23:50:24 +03:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from decimal import Decimal, ROUND_HALF_UP
|
2026-04-25 17:22:41 +03:30
|
|
|
from numbers import Number
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
|
|
|
|
|
from location_data.models import SoilLocation
|
|
|
|
|
from location_data.serializers import SoilDepthDataSerializer
|
2026-04-07 01:08:41 +03:30
|
|
|
from location_data.tasks import fetch_soil_data_for_coordinates
|
2026-04-24 02:50:27 +03:30
|
|
|
from irrigation.serializers import IrrigationMethodSerializer
|
2026-04-06 23:50:24 +03:30
|
|
|
from plant.serializers import PlantSerializer
|
2026-04-07 01:08:41 +03:30
|
|
|
from weather.services import update_weather_for_location
|
2026-04-06 23:50:24 +03:30
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 01:08:41 +03:30
|
|
|
class ExternalDataSyncError(Exception):
|
|
|
|
|
"""خطا در همگامسازی داده از سرویسهای بیرونی."""
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 23:50:24 +03:30
|
|
|
def get_farm_details(farm_uuid: str):
|
|
|
|
|
farm = (
|
2026-04-24 02:50:27 +03:30
|
|
|
SensorData.objects.select_related(
|
|
|
|
|
"center_location",
|
|
|
|
|
"weather_forecast",
|
|
|
|
|
"irrigation_method",
|
|
|
|
|
)
|
2026-04-06 23:50:24 +03:30
|
|
|
.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)
|
2026-04-25 17:22:41 +03:30
|
|
|
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
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
|
2026-04-25 17:22:41 +03:30
|
|
|
metric_sources[key] = sensor_metric_sources[key]
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
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,
|
2026-04-24 02:50:27 +03:30
|
|
|
"irrigation_method_id": farm.irrigation_method_id,
|
|
|
|
|
"irrigation_method": (
|
|
|
|
|
IrrigationMethodSerializer(farm.irrigation_method).data
|
|
|
|
|
if farm.irrigation_method
|
|
|
|
|
else None
|
|
|
|
|
),
|
2026-04-06 23:50:24 +03:30
|
|
|
"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 گوشه معتبر داشته باشد.")
|
|
|
|
|
|
2026-04-25 17:22:41 +03:30
|
|
|
center_lat, center_lon = _compute_polygon_centroid(normalized_points)
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 01:08:41 +03:30
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-25 17:22:41 +03:30
|
|
|
def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
|
2026-04-06 23:50:24 +03:30
|
|
|
if not isinstance(sensor_payload, dict):
|
2026-04-25 17:22:41 +03:30
|
|
|
return {}, {}
|
2026-04-06 23:50:24 +03:30
|
|
|
|
2026-04-25 17:22:41 +03:30
|
|
|
readings_by_metric: dict[str, list[tuple[str, object]]] = {}
|
|
|
|
|
for sensor_key, sensor_values in sorted(sensor_payload.items()):
|
2026-04-06 23:50:24 +03:30
|
|
|
if not isinstance(sensor_values, dict):
|
|
|
|
|
continue
|
2026-04-25 17:22:41 +03:30
|
|
|
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))
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
}
|
2026-04-25 17:22:41 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|