UPDATE
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
from datetime import date
|
||||
|
||||
|
||||
def load_farm_context(sensor_id: str) -> dict | None:
|
||||
from irrigation.models import IrrigationMethod
|
||||
from location_data.models import SoilDepthData
|
||||
from farm_data.models import SensorData
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("center_location").prefetch_related("plants").get(
|
||||
farm_uuid=sensor_id
|
||||
)
|
||||
except SensorData.DoesNotExist:
|
||||
return None
|
||||
|
||||
location = sensor.center_location
|
||||
depths = list(SoilDepthData.objects.filter(soil_location=location).order_by("depth_label"))
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7]
|
||||
)
|
||||
plants = list(sensor.plants.all())
|
||||
irrigation_methods = list(IrrigationMethod.objects.all()[:5])
|
||||
|
||||
return {
|
||||
"sensor": sensor,
|
||||
"location": location,
|
||||
"depths": depths,
|
||||
"forecasts": forecasts,
|
||||
"history": [],
|
||||
"plants": plants,
|
||||
"irrigation_methods": irrigation_methods,
|
||||
}
|
||||
+114
-13
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from numbers import Number
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
@@ -49,13 +50,13 @@ def get_farm_details(farm_uuid: str):
|
||||
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)
|
||||
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] = sensor_metric_sources[key]
|
||||
|
||||
return {
|
||||
"center_location": {
|
||||
@@ -97,11 +98,7 @@ def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLoc
|
||||
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)
|
||||
center_lat, center_lon = _compute_polygon_centroid(normalized_points)
|
||||
|
||||
with transaction.atomic():
|
||||
location, _ = SoilLocation.objects.update_or_create(
|
||||
@@ -152,16 +149,83 @@ def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocati
|
||||
return location, weather_forecast
|
||||
|
||||
|
||||
def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict:
|
||||
def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
|
||||
if not isinstance(sensor_payload, dict):
|
||||
return {}
|
||||
return {}, {}
|
||||
|
||||
flattened = {}
|
||||
for sensor_values in sensor_payload.values():
|
||||
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
|
||||
flattened.update(sensor_values)
|
||||
return flattened
|
||||
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:
|
||||
@@ -240,3 +304,40 @@ def _serialize_boundary(boundary: dict | list) -> dict:
|
||||
"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),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ from irrigation.models import IrrigationMethod
|
||||
from plant.models import Plant
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from farm_data.services import resolve_center_location_from_boundary
|
||||
|
||||
|
||||
def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict:
|
||||
return {
|
||||
@@ -93,7 +95,8 @@ class FarmDetailApiTests(TestCase):
|
||||
metric_sources = payload["soil"]["metric_sources"]
|
||||
|
||||
self.assertEqual(resolved_metrics["nitrogen"], 99.0)
|
||||
self.assertEqual(metric_sources["nitrogen"], "sensor")
|
||||
self.assertEqual(metric_sources["nitrogen"]["type"], "sensor")
|
||||
self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value")
|
||||
self.assertEqual(resolved_metrics["clay"], 22.0)
|
||||
self.assertEqual(metric_sources["clay"], "soil")
|
||||
self.assertEqual(len(payload["soil"]["depths"]), 2)
|
||||
@@ -112,6 +115,38 @@ class FarmDetailApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["msg"], "farm یافت نشد.")
|
||||
|
||||
def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self):
|
||||
self.farm.sensor_payload = {
|
||||
"sensor-a": {
|
||||
"soil_moisture": 20.0,
|
||||
"nitrogen": 90.0,
|
||||
"status": "ok",
|
||||
},
|
||||
"sensor-b": {
|
||||
"soil_moisture": 40.0,
|
||||
"nitrogen": 110.0,
|
||||
"status": "needs-check",
|
||||
},
|
||||
}
|
||||
self.farm.save(update_fields=["sensor_payload"])
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
resolved_metrics = payload["soil"]["resolved_metrics"]
|
||||
metric_sources = payload["soil"]["metric_sources"]
|
||||
|
||||
self.assertEqual(resolved_metrics["soil_moisture"], 30.0)
|
||||
self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average")
|
||||
self.assertCountEqual(
|
||||
metric_sources["soil_moisture"]["sensor_keys"],
|
||||
["sensor-a", "sensor-b"],
|
||||
)
|
||||
self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0])
|
||||
self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"])
|
||||
self.assertEqual(metric_sources["status"]["strategy"], "distinct_values")
|
||||
|
||||
|
||||
class FarmDataUpsertApiTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -120,6 +155,21 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
latitude="35.710000",
|
||||
longitude="51.410000",
|
||||
)
|
||||
SoilDepthData.objects.create(
|
||||
soil_location=self.location,
|
||||
depth_label="0-5cm",
|
||||
clay=20.0,
|
||||
)
|
||||
SoilDepthData.objects.create(
|
||||
soil_location=self.location,
|
||||
depth_label="5-15cm",
|
||||
clay=18.0,
|
||||
)
|
||||
SoilDepthData.objects.create(
|
||||
soil_location=self.location,
|
||||
depth_label="15-30cm",
|
||||
clay=16.0,
|
||||
)
|
||||
self.boundary = square_boundary_for_center(35.71, 51.41)
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
@@ -176,7 +226,16 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("farm_uuid", response.json()["data"])
|
||||
|
||||
def test_post_creates_center_location_from_boundary_when_missing(self):
|
||||
@patch("farm_data.services.update_weather_for_location", return_value={"status": "no_data"})
|
||||
@patch(
|
||||
"farm_data.services.fetch_soil_data_for_coordinates",
|
||||
return_value={"status": "completed", "depths": []},
|
||||
)
|
||||
def test_post_creates_center_location_from_boundary_when_missing(
|
||||
self,
|
||||
_mock_fetch_soil_data_for_coordinates,
|
||||
_mock_update_weather_for_location,
|
||||
):
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
@@ -203,6 +262,26 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
self.assertEqual(str(farm.center_location.longitude), "50.010000")
|
||||
self.assertIsNone(farm.weather_forecast_id)
|
||||
|
||||
def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self):
|
||||
location = resolve_center_location_from_boundary(
|
||||
{
|
||||
"corners": [
|
||||
{"lat": 0.0, "lon": 0.0},
|
||||
{"lat": 0.0, "lon": 4.0},
|
||||
{"lat": 4.0, "lon": 4.0},
|
||||
{"lat": 4.0, "lon": 0.0},
|
||||
{"lat": 1.0, "lon": 0.0},
|
||||
{"lat": 1.0, "lon": 3.0},
|
||||
{"lat": 3.0, "lon": 3.0},
|
||||
{"lat": 3.0, "lon": 1.0},
|
||||
{"lat": 0.0, "lon": 1.0},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(str(location.latitude), "2.078947")
|
||||
self.assertEqual(str(location.longitude), "2.078947")
|
||||
|
||||
@patch("farm_data.services.update_weather_for_location")
|
||||
@patch("farm_data.services.fetch_soil_data_for_coordinates")
|
||||
def test_post_fetches_missing_location_and_weather_data(
|
||||
|
||||
+2
-1
@@ -248,7 +248,8 @@ class FarmDetailView(APIView):
|
||||
summary="دریافت همه اطلاعات farm",
|
||||
description=(
|
||||
"اطلاعات تجمیعی farm را برمیگرداند. "
|
||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند."
|
||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
||||
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
||||
),
|
||||
responses={
|
||||
200: build_response(
|
||||
|
||||
Reference in New Issue
Block a user