233 lines
7.9 KiB
Python
233 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
|
|
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 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")
|
|
.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 = _flatten_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"
|
|
|
|
return {
|
|
"farm_uuid": farm.farm_uuid,
|
|
"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,
|
|
"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 گوشه معتبر داشته باشد.")
|
|
|
|
lat_sum = sum(lat for lat, _ in normalized_points)
|
|
lon_sum = sum(lon for _, lon in normalized_points)
|
|
count = Decimal(len(normalized_points))
|
|
center_lat = (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
|
|
center_lon = (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
|
|
|
|
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 _flatten_sensor_metrics(sensor_payload: dict | None) -> dict:
|
|
if not isinstance(sensor_payload, dict):
|
|
return {}
|
|
|
|
flattened = {}
|
|
for sensor_values in sensor_payload.values():
|
|
if not isinstance(sensor_values, dict):
|
|
continue
|
|
flattened.update(sensor_values)
|
|
return flattened
|
|
|
|
|
|
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],
|
|
}
|