This commit is contained in:
2026-04-25 17:22:41 +03:30
parent 569d520a5c
commit aa24fc22b0
124 changed files with 8491 additions and 2582 deletions
+33
View File
@@ -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
View File
@@ -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),
)
+81 -2
View File
@@ -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
View File
@@ -248,7 +248,8 @@ class FarmDetailView(APIView):
summary="دریافت همه اطلاعات farm",
description=(
"اطلاعات تجمیعی farm را برمی‌گرداند. "
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند."
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند "
"و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند."
),
responses={
200: build_response(