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), )