diff --git a/.env.example b/.env.example index ea49c91..f7c574b 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,11 @@ GAPGPT_BASE_URL=https://api.gapgpt.app/v1 # Weather API (Open-Meteo) WEATHER_API_BASE_URL=https://api.open-meteo.com/v1/forecast WEATHER_API_KEY= +WEATHER_DATA_PROVIDER=mock +WEATHER_MOCK_DELAY_SECONDS=0.8 +WEATHER_TIMEOUT_SECONDS=60 + +# Soil data provider: mock | soilgrids +SOIL_DATA_PROVIDER=mock +SOIL_MOCK_DELAY_SECONDS=0.8 +SOILGRIDS_TIMEOUT_SECONDS=60 diff --git a/SENSOR_DASHBOARD_API_FA.md b/SENSOR_DASHBOARD_API_FA.md new file mode 100644 index 0000000..7aab587 --- /dev/null +++ b/SENSOR_DASHBOARD_API_FA.md @@ -0,0 +1,594 @@ +# مستند API سنسورها برای فرانت + +این فایل قرارداد پیشنهادی/هدف برای endpointهای سنسوری زیر است و بر اساس نیاز اعلام‌شده تهیه شده است: + +- `GET /api/sensor-7-in-1/summary/` +- `GET /api/sensors/comparison-chart/` +- `GET /api/sensors/radar-chart/` +- `GET /api/sensors/values-list/` +- `GET /api/sensor-external-api/logs/` + +نکته مهم: + +- این سند بر اساس نیاز محصول و قرارداد موردنظر فرانت نوشته شده است. +- در این قرارداد دیگر `physical_device_uuid` از فرانت گرفته نمی‌شود. +- مبنای جست‌وجو فقط `farm_uuid` است. +- backend باید با استفاده از `farm_uuid`، رکورد مزرعه را پیدا کند و اولین سنسور خاک را به‌عنوان سنسور مبنا انتخاب کند. +- اگر `range` ارسال نشود، backend باید بدون خطا مقدار پیش‌فرض `7` روز را در نظر بگیرد. + +--- + +## 1) قواعد عمومی + +### آدرس پایه + +- پیشوند تمام مسیرها: `/api/` + +### فرمت پاسخ + +همه endpointها بهتر است envelope استاندارد زیر را برگردانند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +### پارامترهای مشترک + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض: `7` + +### رفتار `range` + +- اگر `range` ارسال نشده باشد: backend باید `7` را استفاده کند. +- اگر `range` کمتر از `1` باشد: بهتر است `400` برگردد. +- اگر `range` خیلی بزرگ باشد: پیشنهاد می‌شود backend آن را محدود کند، مثلا حداکثر `90`. + +نمونه: + +- `/api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111` +- `/api/sensors/comparison-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=30` + +### منطق انتخاب سنسور + +با دریافت `farm_uuid`: + +1. رکورد `farm_data.SensorData` پیدا می‌شود. +2. از `sensor_payload` اولین سنسور خاک انتخاب می‌شود. +3. اگر چند سنسور موجود باشد، اولویت پیشنهادی: + - اولین کلیدی که با `sensor-7` یا `sensor-7-in-1` شروع می‌شود + - اگر نبود، اولین block معتبر از نوع object +4. همان سنسور برای ساخت summary، chart و values استفاده می‌شود. + +### خطاهای مشترک + +#### 400 — ورودی نامعتبر + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "This field is required." + ] + } +} +``` + +یا: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "range": [ + "range باید عددی بزرگ‌تر از صفر باشد." + ] + } +} +``` + +#### 404 — مزرعه یا سنسور پیدا نشد + +```json +{ + "code": 404, + "msg": "farm یا سنسور خاک یافت نشد.", + "data": null +} +``` + +#### 200 — بدون داده کافی + +اگر مزرعه وجود داشته باشد ولی history کافی برای بازه در دسترس نباشد، پیشنهاد می‌شود endpoint به‌جای خطا، پاسخ موفق با `data` خالی یا حداقلی برگرداند. + +--- + +## 2) GET /api/sensor-7-in-1/summary/ + +### هدف + +نمایش خلاصه سریع آخرین وضعیت سنسور خاک انتخاب‌شده برای یک مزرعه. + +### Query Params + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض `7` + +### منطق پاسخ + +- آخرین reading سنسور انتخاب‌شده نمایش داده می‌شود. +- اگر داده historical موجود باشد، trend نسبت به بازه `range` محاسبه می‌شود. +- این endpoint مناسب hero cards و summary cards فرانت است. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_key": "sensor-7-1", + "range": 7, + "last_updated_at": "2026-04-29T10:20:00Z", + "summary": { + "soil_moisture": { + "label": "رطوبت خاک", + "value": 31.2, + "unit": "%", + "trend": "up", + "change": 2.1, + "change_unit": "%" + }, + "soil_temperature": { + "label": "دمای خاک", + "value": 22.8, + "unit": "°C", + "trend": "stable", + "change": 0.3, + "change_unit": "°C" + }, + "soil_ph": { + "label": "pH خاک", + "value": 6.9, + "unit": "", + "trend": "down", + "change": -0.1, + "change_unit": "" + }, + "electrical_conductivity": { + "label": "هدایت الکتریکی", + "value": 1.4, + "unit": "mS/cm", + "trend": "stable", + "change": 0.0, + "change_unit": "mS/cm" + }, + "nitrogen": { + "label": "نیتروژن", + "value": 28.0, + "unit": "mg/kg", + "trend": "up", + "change": 1.8, + "change_unit": "mg/kg" + }, + "phosphorus": { + "label": "فسفر", + "value": 14.5, + "unit": "mg/kg", + "trend": "stable", + "change": 0.4, + "change_unit": "mg/kg" + }, + "potassium": { + "label": "پتاسیم", + "value": 21.7, + "unit": "mg/kg", + "trend": "down", + "change": -0.9, + "change_unit": "mg/kg" + } + } + } +} +``` + +### فیلدهای خروجی + +- `farm_uuid`: شناسه مزرعه +- `sensor_key`: کلید سنسور انتخاب‌شده از `sensor_payload` +- `range`: بازه واقعی استفاده‌شده +- `last_updated_at`: زمان آخرین reading یا آخرین به‌روزرسانی +- `summary`: آبجکت شامل KPIهای اصلی + +### ساختار هر KPI + +- `label`: عنوان فارسی +- `value`: آخرین مقدار +- `unit`: واحد +- `trend`: یکی از `up | down | stable | unknown` +- `change`: اختلاف با ابتدای بازه یا میانگین بازه +- `change_unit`: واحد اختلاف + +--- + +## 3) GET /api/sensors/comparison-chart/ + +### هدف + +برگرداندن داده chart مقایسه‌ای برای چند پارامتر سنسور در طول بازه زمانی. + +### Query Params + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض `7` + +### منطق پاسخ + +- فقط یک سنسور مبنا از روی `farm_uuid` انتخاب می‌شود. +- برای همان سنسور، سری‌های چند متریک در طول بازه برگردانده می‌شود. +- فرانت می‌تواند آن را به line chart یا multi-series chart تبدیل کند. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_key": "sensor-7-1", + "range": 7, + "categories": [ + "2026-04-23", + "2026-04-24", + "2026-04-25", + "2026-04-26", + "2026-04-27", + "2026-04-28", + "2026-04-29" + ], + "series": [ + { + "key": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + "data": [29.1, 28.7, 30.4, 30.0, 31.1, 31.0, 31.2] + }, + { + "key": "soil_temperature", + "label": "دمای خاک", + "unit": "°C", + "data": [21.4, 21.8, 22.0, 22.1, 22.2, 22.6, 22.8] + }, + { + "key": "electrical_conductivity", + "label": "هدایت الکتریکی", + "unit": "mS/cm", + "data": [1.2, 1.3, 1.3, 1.4, 1.4, 1.4, 1.4] + } + ] + } +} +``` + +### فیلدهای خروجی + +- `categories`: برچسب‌های محور زمان +- `series`: آرایه سری‌ها + +### ساختار هر سری + +- `key`: کلید متریک +- `label`: نام نمایشی +- `unit`: واحد +- `data`: آرایه مقادیر هم‌طول با `categories` + +--- + +## 4) GET /api/sensors/radar-chart/ + +### هدف + +دادن داده مناسب radar chart برای مقایسه هم‌زمان وضعیت متریک‌های اصلی سنسور. + +### Query Params + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض `7` + +### منطق پاسخ + +- برای هر متریک، یک مقدار خلاصه از بازه ساخته می‌شود؛ مثلا: + - آخرین مقدار + - میانگین بازه + - یا score نرمال‌شده 0 تا 100 +- برای radar chart پیشنهاد می‌شود score نهایی نرمال‌شده برگردانده شود. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_key": "sensor-7-1", + "range": 7, + "labels": [ + "رطوبت خاک", + "دمای خاک", + "pH خاک", + "هدایت الکتریکی", + "نیتروژن", + "فسفر", + "پتاسیم" + ], + "series": [ + { + "name": "وضعیت فعلی", + "data": [72, 64, 81, 58, 69, 61, 74] + } + ], + "raw_metrics": [ + { + "key": "soil_moisture", + "label": "رطوبت خاک", + "value": 31.2, + "unit": "%", + "score": 72 + }, + { + "key": "soil_temperature", + "label": "دمای خاک", + "value": 22.8, + "unit": "°C", + "score": 64 + }, + { + "key": "soil_ph", + "label": "pH خاک", + "value": 6.9, + "unit": "", + "score": 81 + } + ] + } +} +``` + +### فیلدهای خروجی + +- `labels`: برچسب‌های radar +- `series`: داده آماده برای chart +- `raw_metrics`: داده خام برای tooltip و جزئیات بیشتر + +--- + +## 5) GET /api/sensors/values-list/ + +### هدف + +برگرداندن لیست tabular از مقادیر سنسور برای بازه زمانی، مناسب table یا export. + +### Query Params + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض `7` + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_key": "sensor-7-1", + "range": 7, + "count": 3, + "items": [ + { + "recorded_at": "2026-04-29T10:20:00Z", + "soil_moisture": 31.2, + "soil_temperature": 22.8, + "soil_ph": 6.9, + "electrical_conductivity": 1.4, + "nitrogen": 28.0, + "phosphorus": 14.5, + "potassium": 21.7 + }, + { + "recorded_at": "2026-04-28T10:20:00Z", + "soil_moisture": 31.0, + "soil_temperature": 22.6, + "soil_ph": 7.0, + "electrical_conductivity": 1.4, + "nitrogen": 27.5, + "phosphorus": 14.1, + "potassium": 22.1 + }, + { + "recorded_at": "2026-04-27T10:20:00Z", + "soil_moisture": 31.1, + "soil_temperature": 22.2, + "soil_ph": 7.0, + "electrical_conductivity": 1.3, + "nitrogen": 27.2, + "phosphorus": 14.0, + "potassium": 22.6 + } + ] + } +} +``` + +### فیلدهای خروجی + +- `count`: تعداد رکوردها +- `items`: لیست ردیف‌ها +- هر ردیف شامل timestamp و مقادیر متریک‌ها + +### رفتار پیشنهادی + +- ترتیب رکوردها: جدید به قدیم +- اگر داده تاریخی نداریم ولی آخرین payload فعلی موجود است، حداقل یک item با آخرین وضعیت برگردانده شود + +--- + +## 6) GET /api/sensor-external-api/logs/ + +### هدف + +نمایش لاگ‌های مربوط به سینک یا واکشی داده سنسور از API بیرونی، مناسب صفحه monitoring یا audit. + +### Query Params + +- `farm_uuid` — `uuid string` — الزامی +- `range` — `integer` — اختیاری — پیش‌فرض `7` + +### توضیح دامنه + +این endpoint برای نمایش لاگ‌های integration است، نه لزوما readingهای سنسور. + +اگر backend هنوز لاگ جداگانه برای external sensor sync نداشته باشد، پیشنهاد می‌شود ساختار زیر مبنای پیاده‌سازی قرار بگیرد. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "range": 7, + "count": 4, + "items": [ + { + "id": 104, + "status": "success", + "source": "sensor-external-api", + "sensor_key": "sensor-7-1", + "requested_at": "2026-04-29T10:20:00Z", + "finished_at": "2026-04-29T10:20:01Z", + "duration_ms": 842, + "http_status": 200, + "message": "داده سنسور با موفقیت واکشی شد." + }, + { + "id": 103, + "status": "error", + "source": "sensor-external-api", + "sensor_key": "sensor-7-1", + "requested_at": "2026-04-28T10:20:00Z", + "finished_at": "2026-04-28T10:21:00Z", + "duration_ms": 60000, + "http_status": 504, + "message": "Timeout هنگام واکشی داده سنسور." + } + ] + } +} +``` + +### فیلدهای خروجی + +- `status`: یکی از `success | error | timeout | partial` +- `source`: نام provider یا سرویس خارجی +- `requested_at`: زمان شروع درخواست +- `finished_at`: زمان پایان +- `duration_ms`: مدت زمان +- `http_status`: وضعیت HTTP سرویس بیرونی +- `message`: پیام خلاصه برای UI + +--- + +## 7) رفتار پیشنهادی در نبود `range` + +در همه endpointهای این سند: + +- اگر `range` ارسال نشده باشد: + +```json +{ + "range": 7 +} +``` + +باید به‌صورت implicit استفاده شود و endpoint نباید خطای validation برگرداند. + +--- + +## 8) رفتار پیشنهادی در نبود `physical_device_uuid` + +فرانت نباید `physical_device_uuid` ارسال کند. + +backend باید: + +- فقط `farm_uuid` را بگیرد +- سنسور را از روی `sensor_payload` یا mapping داخلی انتخاب کند +- `sensor_key` نهایی را در پاسخ برگرداند تا فرانت بداند داده از کدام سنسور آمده است + +--- + +## 9) پیشنهاد استاندارد برای متریک‌ها + +برای هماهنگی فرانت و بک، بهتر است حداقل این کلیدها در endpointها پشتیبانی شوند: + +- `soil_moisture` +- `soil_temperature` +- `soil_ph` +- `electrical_conductivity` +- `nitrogen` +- `phosphorus` +- `potassium` + +### واحدهای پیشنهادی + +- `soil_moisture` → `%` +- `soil_temperature` → `°C` +- `soil_ph` → بدون واحد +- `electrical_conductivity` → `mS/cm` +- `nitrogen` → `mg/kg` +- `phosphorus` → `mg/kg` +- `potassium` → `mg/kg` + +--- + +## 10) پیشنهاد برای وضعیت‌های فرانت + +### loading + +- هنگام request، فرانت skeleton یا spinner نشان دهد + +### empty + +- اگر `items: []` یا `series: []` برگشت: + - پیام مناسب مثل `داده‌ای برای این بازه ثبت نشده است.` نمایش داده شود + +### partial + +- اگر بعضی متریک‌ها `null` باشند: + - chart فقط seriesهای موجود را نمایش دهد + - در table برای فیلدهای خالی `—` نمایش داده شود + +--- + +## 11) جمع‌بندی قرارداد + +برای این 5 endpoint، قرارداد موردنیاز فرانت به‌صورت خلاصه: + +- ورودی اصلی فقط `farm_uuid` +- `physical_device_uuid` حذف شود +- `range` اختیاری باشد +- اگر `range` نیامد، مقدار پیش‌فرض `7` در نظر گرفته شود +- backend اولین سنسور خاک مزرعه را انتخاب کند +- `sensor_key` انتخاب‌شده در response برگردانده شود +- responseها envelope استاندارد `code/msg/data` داشته باشند + diff --git a/config/settings.py b/config/settings.py index fb0b5cc..38e8c60 100644 --- a/config/settings.py +++ b/config/settings.py @@ -191,6 +191,12 @@ WEATHER_API_BASE_URL = os.environ.get( "WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast" ) WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "") +WEATHER_DATA_PROVIDER = os.environ.get("WEATHER_DATA_PROVIDER", "mock").strip().lower() +WEATHER_MOCK_DELAY_SECONDS = float(os.environ.get("WEATHER_MOCK_DELAY_SECONDS", "0.8")) +WEATHER_TIMEOUT_SECONDS = float(os.environ.get("WEATHER_TIMEOUT_SECONDS", "60")) +SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "mock").strip().lower() +SOIL_MOCK_DELAY_SECONDS = float(os.environ.get("SOIL_MOCK_DELAY_SECONDS", "0.8")) +SOILGRIDS_TIMEOUT_SECONDS = float(os.environ.get("SOILGRIDS_TIMEOUT_SECONDS", "60")) LOGGING = { "version": 1, diff --git a/location_data/apps.py b/location_data/apps.py index fbc7554..b72f050 100644 --- a/location_data/apps.py +++ b/location_data/apps.py @@ -1,6 +1,7 @@ from functools import cached_property from django.apps import AppConfig +from django.conf import settings class SoilDataConfig(AppConfig): @@ -14,5 +15,23 @@ class SoilDataConfig(AppConfig): return NdviHealthService() + @cached_property + def soil_data_adapter(self): + from .soil_adapters import MockSoilDataAdapter, SoilGridsAdapter + + provider = getattr(settings, "SOIL_DATA_PROVIDER", "mock") + if provider == "soilgrids": + return SoilGridsAdapter( + timeout=getattr(settings, "SOILGRIDS_TIMEOUT_SECONDS", 60) + ) + if provider == "mock": + return MockSoilDataAdapter( + delay_seconds=getattr(settings, "SOIL_MOCK_DELAY_SECONDS", 0.8) + ) + raise ValueError(f"Unsupported soil data provider: {provider}") + def get_ndvi_health_service(self): return self.ndvi_health_service + + def get_soil_data_adapter(self): + return self.soil_data_adapter diff --git a/location_data/serializers.py b/location_data/serializers.py index 1c9cc31..740193e 100644 --- a/location_data/serializers.py +++ b/location_data/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import SoilDepthData, SoilLocation +from .soil_adapters import DEPTHS class SoilDataRequestSerializer(serializers.Serializer): @@ -56,8 +57,6 @@ class SoilLocationResponseSerializer(serializers.ModelSerializer): fields = ["id", "lon", "lat", "depths"] def get_depths(self, obj): - from .tasks import DEPTHS - depth_qs = obj.depths.all() order = {d: i for i, d in enumerate(DEPTHS)} sorted_depths = sorted( diff --git a/location_data/soil_adapters.py b/location_data/soil_adapters.py new file mode 100644 index 0000000..2f422c0 --- /dev/null +++ b/location_data/soil_adapters.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import hashlib +import math +import random +import time +from abc import ABC, abstractmethod + +try: + import requests +except ImportError: # pragma: no cover - handled when live adapter is used + requests = None + + +SOILGRIDS_BASE = "https://rest.isric.org/soilgrids/v2.0/properties/query" +PROPERTIES = [ + "bdod", + "cec", + "cfvo", + "clay", + "nitrogen", + "ocd", + "ocs", + "phh2o", + "sand", + "silt", + "soc", + "wv0010", + "wv0033", + "wv1500", +] +VALUES = ["Q0.5", "Q0.05", "Q0.95", "mean", "uncertainty"] +DEPTHS = ["0-5cm", "5-15cm", "15-30cm"] +DEPTH_INDEX = {depth: index for index, depth in enumerate(DEPTHS)} + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +def _round_field(name: str, value: float) -> float: + if name in {"nitrogen", "soc", "ocs", "wv0010", "wv0033", "wv1500"}: + return round(value, 3) + return round(value, 2) + + +class BaseSoilDataAdapter(ABC): + source_name = "base" + + @abstractmethod + def fetch_depth_fields(self, lon: float, lat: float, depth: str) -> dict: + """Return normalized field values for a single soil depth.""" + + +class SoilGridsAdapter(BaseSoilDataAdapter): + source_name = "soilgrids" + + def __init__(self, base_url: str = SOILGRIDS_BASE, timeout: float = 60): + self.base_url = base_url + self.timeout = timeout + + def fetch_depth_fields(self, lon: float, lat: float, depth: str) -> dict: + if requests is None: + raise RuntimeError("requests package is required for SoilGridsAdapter") + + params = { + "lon": lon, + "lat": lat, + "depth": depth, + } + for prop in PROPERTIES: + params.setdefault("property", []).append(prop) + for value in VALUES: + params.setdefault("value", []).append(value) + + response = requests.get( + self.base_url, + params=params, + headers={"accept": "application/json"}, + timeout=self.timeout, + ) + response.raise_for_status() + return self._parse_response_to_fields(response.json()) + + def _parse_response_to_fields(self, data: dict) -> dict: + fields = {prop: None for prop in PROPERTIES} + layers = data.get("properties", {}).get("layers", []) + for layer in layers: + name = layer.get("name") + if name not in fields: + continue + depths_list = layer.get("depths", []) + if not depths_list: + continue + values = depths_list[0].get("values", {}) + mean_value = values.get("mean") + if mean_value is not None: + fields[name] = float(mean_value) + return fields + + +class MockSoilDataAdapter(BaseSoilDataAdapter): + source_name = "mock" + + def __init__( + self, + delay_seconds: float = 0.8, + seed_namespace: str = "croplogic-soil", + ): + self.delay_seconds = max(0.0, delay_seconds) + self.seed_namespace = seed_namespace + + def fetch_depth_fields(self, lon: float, lat: float, depth: str) -> dict: + if depth not in DEPTH_INDEX: + raise ValueError(f"Unsupported soil depth: {depth}") + + if self.delay_seconds: + time.sleep(self.delay_seconds) + + depth_index = DEPTH_INDEX[depth] + texture_score = self._layered_noise(lon, lat, "texture") + organic_score = self._layered_noise(lon, lat, "organic") + moisture_score = self._layered_noise(lon, lat, "moisture") + mineral_score = self._layered_noise(lon, lat, "mineral") + stone_score = self._layered_noise(lon, lat, "stone") + ph_score = self._layered_noise(lon, lat, "ph") + + sand, clay, silt = self._build_texture( + texture_score=texture_score, + organic_score=organic_score, + depth_index=depth_index, + ) + soc = _clamp( + 0.7 + + (organic_score * 1.9) + + (clay * 0.012) + - (depth_index * 0.28) + + ((1 - moisture_score) * 0.08), + 0.45, + 4.2, + ) + nitrogen = _clamp( + 0.04 + + (soc * 0.085) + + ((1 - (sand / 100.0)) * 0.025) + + ((2 - depth_index) * 0.008), + 0.03, + 0.42, + ) + ocd = _clamp( + 10.0 + (soc * 8.5) + (organic_score * 4.0) - (depth_index * 2.6), + 7.0, + 46.0, + ) + ocs = _clamp( + 1.0 + (soc * 1.55) - (depth_index * 0.28) + (organic_score * 0.12), + 0.5, + 8.5, + ) + cec = _clamp( + 7.0 + + (clay * 0.33) + + (soc * 1.7) + + ((1 - (sand / 100.0)) * 2.6) + + (mineral_score * 1.4), + 5.0, + 38.0, + ) + cfvo = _clamp(1.0 + (stone_score * 12.0) + (depth_index * 2.4), 0.0, 35.0) + bdod = _clamp( + 1.06 + + (sand * 0.0038) + + (depth_index * 0.06) + - (soc * 0.035) + + (stone_score * 0.03), + 0.95, + 1.62, + ) + phh2o = _clamp( + 6.2 + + ((ph_score - 0.5) * 1.1) + + (depth_index * 0.08) + - (organic_score * 0.12), + 5.6, + 8.1, + ) + wv1500 = _clamp( + 0.05 + + (clay * 0.0016) + + (soc * 0.012) + - (sand * 0.0003) + + (depth_index * 0.004), + 0.05, + 0.22, + ) + wv0033 = _clamp( + wv1500 + 0.07 + (clay * 0.0015) + (soc * 0.01) - (sand * 0.0002), + wv1500 + 0.04, + 0.38, + ) + wv0010 = _clamp( + wv0033 + 0.03 + (soc * 0.006) + (moisture_score * 0.01), + wv0033 + 0.015, + 0.48, + ) + + fields = { + "bdod": bdod, + "cec": cec, + "cfvo": cfvo, + "clay": clay, + "nitrogen": nitrogen, + "ocd": ocd, + "ocs": ocs, + "phh2o": phh2o, + "sand": sand, + "silt": silt, + "soc": soc, + "wv0010": wv0010, + "wv0033": wv0033, + "wv1500": wv1500, + } + return {name: _round_field(name, value) for name, value in fields.items()} + + def _build_texture( + self, + texture_score: float, + organic_score: float, + depth_index: int, + ) -> tuple[float, float, float]: + sand = _clamp( + 30.0 + + (texture_score * 28.0) + + ((organic_score - 0.5) * 3.5) + - (depth_index * 2.5), + 18.0, + 72.0, + ) + clay = _clamp( + 13.0 + + ((1 - texture_score) * 18.0) + + (depth_index * 5.5) + + ((organic_score - 0.5) * 2.0), + 8.0, + 42.0, + ) + minimum_silt = 12.0 + total = sand + clay + if total > 100.0 - minimum_silt: + excess = total - (100.0 - minimum_silt) + sand -= excess * 0.65 + clay -= excess * 0.35 + silt = 100.0 - sand - clay + return sand, clay, silt + + def _layered_noise(self, lon: float, lat: float, key: str) -> float: + regional = self._smooth_noise(lon, lat, f"{key}:regional", scale=1.7) + local = self._smooth_noise(lon, lat, f"{key}:local", scale=0.32) + micro = self._smooth_noise(lon, lat, f"{key}:micro", scale=0.08) + return _clamp((regional * 0.55) + (local * 0.3) + (micro * 0.15), 0.0, 1.0) + + def _smooth_noise(self, lon: float, lat: float, key: str, scale: float) -> float: + grid_x = lon / scale + grid_y = lat / scale + x0 = math.floor(grid_x) + y0 = math.floor(grid_y) + tx = grid_x - x0 + ty = grid_y - y0 + + v00 = self._cell_noise(key, x0, y0) + v10 = self._cell_noise(key, x0 + 1, y0) + v01 = self._cell_noise(key, x0, y0 + 1) + v11 = self._cell_noise(key, x0 + 1, y0 + 1) + + tx = tx * tx * (3.0 - (2.0 * tx)) + ty = ty * ty * (3.0 - (2.0 * ty)) + + top = (v00 * (1 - tx)) + (v10 * tx) + bottom = (v01 * (1 - tx)) + (v11 * tx) + return (top * (1 - ty)) + (bottom * ty) + + def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float: + seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}" + digest = hashlib.sha256(seed_input.encode("ascii")).digest() + seed = int.from_bytes(digest[:8], "big", signed=False) + return random.Random(seed).random() diff --git a/location_data/tasks.py b/location_data/tasks.py index fb8f047..df5ed56 100644 --- a/location_data/tasks.py +++ b/location_data/tasks.py @@ -1,63 +1,22 @@ """ -تسک‌های Celery برای واکشی داده‌های خاک از API SoilGrids. +تسک‌های Celery برای واکشی داده‌های خاک. """ from decimal import Decimal -import requests from config.celery import app +from django.apps import apps from django.db import transaction from .models import SoilDepthData, SoilLocation +from .soil_adapters import DEPTHS -SOILGRIDS_BASE = "https://rest.isric.org/soilgrids/v2.0/properties/query" -PROPERTIES = [ - "bdod", "cec", "cfvo", "clay", "nitrogen", "ocd", "ocs", - "phh2o", "sand", "silt", "soc", "wv0010", "wv0033", "wv1500", -] -VALUES = ["Q0.5", "Q0.05", "Q0.95", "mean", "uncertainty"] -DEPTHS = ["0-5cm", "5-15cm", "15-30cm"] - - -def _fetch_soilgrids(lon: float, lat: float, depth: str) -> dict | None: - """درخواست به API SoilGrids برای یک عمق.""" - params = { - "lon": lon, - "lat": lat, - "depth": depth, - } - for p in PROPERTIES: - params.setdefault("property", []).append(p) - for v in VALUES: - params.setdefault("value", []).append(v) - - resp = requests.get( - SOILGRIDS_BASE, - params=params, - headers={"accept": "application/json"}, - timeout=60, - ) - resp.raise_for_status() - return resp.json() - - -def _parse_response_to_fields(data: dict) -> dict: - """ - استخراج مقادیر mean از response و ساخت dict مناسب برای SoilDepthData. - """ - fields = {p: None for p in PROPERTIES} - layers = data.get("properties", {}).get("layers", []) - for layer in layers: - name = layer.get("name") - if name not in fields: - continue - depths_list = layer.get("depths", []) - if depths_list: - values = depths_list[0].get("values", {}) - mean_val = values.get("mean") - if mean_val is not None: - fields[name] = float(mean_val) - return fields +try: + import requests +except ImportError: # pragma: no cover - handled in stripped envs + RequestException = Exception +else: + RequestException = requests.RequestException def fetch_soil_data_for_coordinates( @@ -72,6 +31,7 @@ def fetch_soil_data_for_coordinates( """ lat = Decimal(str(round(float(latitude), 6))) lon = Decimal(str(round(float(longitude), 6))) + adapter = apps.get_app_config("location_data").get_soil_data_adapter() with transaction.atomic(): location, created = SoilLocation.objects.select_for_update().get_or_create( @@ -83,18 +43,17 @@ def fetch_soil_data_for_coordinates( location.task_id = task_id location.save(update_fields=["task_id"]) - for i, depth in enumerate(DEPTHS): + for index, depth in enumerate(DEPTHS): if progress_callback is not None: progress_callback( state="PROGRESS", meta={ - "current": i + 1, + "current": index + 1, "total": len(DEPTHS), "message": f"در حال واکشی عمق {depth}...", }, ) - data = _fetch_soilgrids(float(lon), float(lat), depth) - fields = _parse_response_to_fields(data) + fields = adapter.fetch_depth_fields(float(lon), float(lat), depth) with transaction.atomic(): SoilDepthData.objects.update_or_create( soil_location=location, @@ -117,8 +76,8 @@ def fetch_soil_data_for_coordinates( @app.task(bind=True) def fetch_soil_data_task(self, latitude: float, longitude: float): """ - واکشی داده‌های خاک برای مختصات داده‌شده از SoilGrids و ذخیره در DB. - برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست جدا زده می‌شود. + واکشی داده‌های خاک برای مختصات داده‌شده و ذخیره در DB. + برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست/شبیه‌سازی جدا انجام می‌شود. """ try: return fetch_soil_data_for_coordinates( @@ -127,12 +86,12 @@ def fetch_soil_data_task(self, latitude: float, longitude: float): task_id=self.request.id, progress_callback=self.update_state, ) - except requests.RequestException as e: + except RequestException as exc: lat = Decimal(str(round(float(latitude), 6))) lon = Decimal(str(round(float(longitude), 6))) location = SoilLocation.objects.filter(latitude=lat, longitude=lon).first() return { "status": "error", "location_id": getattr(location, "id", None), - "error": str(e), + "error": str(exc), } diff --git a/location_data/test_soil_adapters.py b/location_data/test_soil_adapters.py new file mode 100644 index 0000000..adf2e05 --- /dev/null +++ b/location_data/test_soil_adapters.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from django.apps import apps +from django.test import SimpleTestCase, TestCase, override_settings + +from location_data.models import SoilDepthData, SoilLocation +from location_data.soil_adapters import ( + DEPTHS, + MockSoilDataAdapter, + SoilGridsAdapter, +) +from location_data.tasks import fetch_soil_data_for_coordinates + + +class MockSoilDataAdapterTests(SimpleTestCase): + def setUp(self): + self.adapter = MockSoilDataAdapter(delay_seconds=0) + + def test_same_coordinate_returns_same_values(self): + first = self.adapter.fetch_depth_fields(51.4, 35.71, "0-5cm") + second = self.adapter.fetch_depth_fields(51.4, 35.71, "0-5cm") + + self.assertEqual(first, second) + + def test_nearby_coordinates_produce_nearby_values(self): + first = self.adapter.fetch_depth_fields(51.4, 35.71, "0-5cm") + second = self.adapter.fetch_depth_fields(51.405, 35.715, "0-5cm") + + self.assertLess(abs(first["sand"] - second["sand"]), 4.5) + self.assertLess(abs(first["clay"] - second["clay"]), 4.5) + self.assertLess(abs(first["phh2o"] - second["phh2o"]), 0.35) + self.assertLess(abs(first["wv1500"] - second["wv1500"]), 0.03) + + def test_depth_profiles_follow_expected_trend(self): + shallow = self.adapter.fetch_depth_fields(51.4, 35.71, "0-5cm") + medium = self.adapter.fetch_depth_fields(51.4, 35.71, "5-15cm") + deep = self.adapter.fetch_depth_fields(51.4, 35.71, "15-30cm") + + self.assertGreaterEqual(deep["bdod"], medium["bdod"]) + self.assertGreaterEqual(medium["bdod"], shallow["bdod"]) + self.assertLessEqual(deep["soc"], medium["soc"]) + self.assertLessEqual(medium["soc"], shallow["soc"]) + + +class SoilDataAdapterSelectionTests(SimpleTestCase): + def tearDown(self): + apps.get_app_config("location_data").__dict__.pop("soil_data_adapter", None) + + @override_settings(SOIL_DATA_PROVIDER="mock", SOIL_MOCK_DELAY_SECONDS=0) + def test_app_config_returns_mock_adapter(self): + config = apps.get_app_config("location_data") + config.__dict__.pop("soil_data_adapter", None) + + adapter = config.get_soil_data_adapter() + + self.assertIsInstance(adapter, MockSoilDataAdapter) + + @override_settings(SOIL_DATA_PROVIDER="soilgrids", SOILGRIDS_TIMEOUT_SECONDS=12) + def test_app_config_returns_live_adapter(self): + config = apps.get_app_config("location_data") + config.__dict__.pop("soil_data_adapter", None) + + adapter = config.get_soil_data_adapter() + + self.assertIsInstance(adapter, SoilGridsAdapter) + self.assertEqual(adapter.timeout, 12) + + +@override_settings(SOIL_DATA_PROVIDER="mock", SOIL_MOCK_DELAY_SECONDS=0) +class SoilDataFetchTests(TestCase): + def test_fetch_soil_data_for_coordinates_persists_three_depths(self): + result = fetch_soil_data_for_coordinates(latitude=35.71, longitude=51.4) + + self.assertEqual(result["status"], "completed") + self.assertEqual(result["depths"], DEPTHS) + + location = SoilLocation.objects.get(latitude="35.710000", longitude="51.400000") + self.assertEqual(location.depths.count(), 3) + self.assertTrue(location.is_complete) + self.assertCountEqual( + list(location.depths.values_list("depth_label", flat=True)), + DEPTHS, + ) + self.assertTrue( + SoilDepthData.objects.filter( + soil_location=location, + depth_label="0-5cm", + sand__isnull=False, + clay__isnull=False, + wv1500__isnull=False, + ).exists() + ) diff --git a/weather/adapters.py b/weather/adapters.py new file mode 100644 index 0000000..a7cc7df --- /dev/null +++ b/weather/adapters.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import hashlib +import math +import random +import time +from abc import ABC, abstractmethod +from datetime import date, timedelta + +try: + import requests +except ImportError: # pragma: no cover - handled when live adapter is used + requests = None + + +DEFAULT_FORECAST_DAYS = 7 +DAILY_FIELDS = [ + "temperature_2m_max", + "temperature_2m_min", + "temperature_2m_mean", + "precipitation_sum", + "precipitation_probability_max", + "relative_humidity_2m_mean", + "wind_speed_10m_max", + "et0_fao_evapotranspiration", + "weather_code", +] +WMO_CODES = [0, 1, 2, 3, 45, 51, 61, 63, 65, 80, 95] + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +class BaseWeatherAdapter(ABC): + source_name = "base" + + @abstractmethod + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + """Return daily forecast data in Open-Meteo compatible shape.""" + + +class OpenMeteoWeatherAdapter(BaseWeatherAdapter): + source_name = "open-meteo" + + def __init__(self, base_url: str, api_key: str = "", timeout: float = 60): + self.base_url = base_url + self.api_key = api_key + self.timeout = timeout + + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + if requests is None: + raise RuntimeError("requests package is required for OpenMeteoWeatherAdapter") + + params = { + "latitude": latitude, + "longitude": longitude, + "forecast_days": days, + "timezone": "auto", + "daily": DAILY_FIELDS, + } + headers = {"accept": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + response = requests.get( + self.base_url, + params=params, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + + +class MockWeatherAdapter(BaseWeatherAdapter): + source_name = "mock" + + def __init__( + self, + delay_seconds: float = 0.8, + seed_namespace: str = "croplogic-weather", + ): + self.delay_seconds = max(0.0, delay_seconds) + self.seed_namespace = seed_namespace + + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + if self.delay_seconds: + time.sleep(self.delay_seconds) + + climate = self._layered_noise(latitude, longitude, "climate") + humidity_bias = self._layered_noise(latitude, longitude, "humidity") + rain_bias = self._layered_noise(latitude, longitude, "rain") + wind_bias = self._layered_noise(latitude, longitude, "wind") + temp_bias = self._layered_noise(latitude, longitude, "temp") + + start = date.today() + payload = {field: [] for field in DAILY_FIELDS} + payload["time"] = [] + + for day_index in range(days): + current_date = start + timedelta(days=day_index) + seasonal_wave = math.sin(((current_date.timetuple().tm_yday / 365.0) * math.tau) - 0.55) + daily_wave = math.sin((day_index / max(days, 1)) * math.tau) + short_term = self._layered_noise( + latitude + (day_index * 0.11), + longitude - (day_index * 0.09), + f"day:{day_index}", + ) + + temp_mean = _clamp( + 17.0 + + (seasonal_wave * 11.0) + + ((temp_bias - 0.5) * 8.0) + + (daily_wave * 2.8) + + ((short_term - 0.5) * 2.5), + -6.0, + 43.0, + ) + diurnal_range = _clamp( + 8.0 + ((1 - humidity_bias) * 4.2) + ((1 - rain_bias) * 2.0) + (short_term * 1.1), + 5.0, + 16.0, + ) + temperature_max = _clamp(temp_mean + (diurnal_range / 2.0), -3.0, 48.0) + temperature_min = _clamp(temp_mean - (diurnal_range / 2.0), -12.0, 35.0) + + humidity_mean = _clamp( + 34.0 + + (humidity_bias * 34.0) + + (rain_bias * 12.0) + - ((temperature_max - 22.0) * 0.9), + 18.0, + 96.0, + ) + precipitation_probability = _clamp( + 10.0 + + (rain_bias * 45.0) + + ((humidity_mean - 45.0) * 0.45) + + (max(0.0, 0.5 - temp_bias) * 18.0) + + ((short_term - 0.5) * 18.0), + 0.0, + 100.0, + ) + precipitation = self._precipitation_amount( + precipitation_probability=precipitation_probability, + rain_bias=rain_bias, + humidity_mean=humidity_mean, + short_term=short_term, + ) + wind_speed = _clamp( + 8.0 + + (wind_bias * 17.0) + + ((1 - rain_bias) * 2.5) + + (abs(daily_wave) * 3.0) + + (short_term * 2.0), + 3.0, + 42.0, + ) + et0 = _clamp( + 1.0 + + (max(temp_mean, 0.0) * 0.11) + + ((1 - (humidity_mean / 100.0)) * 1.7) + + (wind_speed * 0.03) + - (precipitation * 0.05), + 0.3, + 11.0, + ) + weather_code = self._weather_code( + precipitation=precipitation, + probability=precipitation_probability, + humidity=humidity_mean, + wind_speed=wind_speed, + cloudiness=(humidity_bias + rain_bias + (1 - temp_bias)) / 3.0, + ) + + payload["time"].append(current_date.isoformat()) + payload["temperature_2m_max"].append(round(temperature_max, 1)) + payload["temperature_2m_min"].append(round(temperature_min, 1)) + payload["temperature_2m_mean"].append(round(temp_mean, 1)) + payload["precipitation_sum"].append(round(precipitation, 1)) + payload["precipitation_probability_max"].append(round(precipitation_probability, 0)) + payload["relative_humidity_2m_mean"].append(round(humidity_mean, 1)) + payload["wind_speed_10m_max"].append(round(wind_speed, 1)) + payload["et0_fao_evapotranspiration"].append(round(et0, 2)) + payload["weather_code"].append(weather_code) + + return {"latitude": latitude, "longitude": longitude, "daily": payload} + + def _precipitation_amount( + self, + precipitation_probability: float, + rain_bias: float, + humidity_mean: float, + short_term: float, + ) -> float: + trigger = precipitation_probability / 100.0 + if trigger < 0.24: + return 0.0 + + amount = ( + ((trigger - 0.2) ** 1.55) * 18.0 + + (rain_bias * 1.6) + + ((humidity_mean - 50.0) * 0.035) + + (short_term * 1.3) + ) + return _clamp(amount, 0.0, 34.0) + + def _weather_code( + self, + precipitation: float, + probability: float, + humidity: float, + wind_speed: float, + cloudiness: float, + ) -> int: + if precipitation >= 10: + return 65 + if precipitation >= 4: + return 63 + if precipitation > 0.6: + return 61 + if probability >= 65 and humidity >= 70: + return 51 + if cloudiness >= 0.8: + return 3 + if cloudiness >= 0.62: + return 2 + if cloudiness >= 0.48 or wind_speed >= 28: + return 1 + return 0 + + def _layered_noise(self, latitude: float, longitude: float, key: str) -> float: + regional = self._smooth_noise(latitude, longitude, f"{key}:regional", scale=2.4) + local = self._smooth_noise(latitude, longitude, f"{key}:local", scale=0.45) + micro = self._smooth_noise(latitude, longitude, f"{key}:micro", scale=0.12) + return _clamp((regional * 0.58) + (local * 0.27) + (micro * 0.15), 0.0, 1.0) + + def _smooth_noise(self, latitude: float, longitude: float, key: str, scale: float) -> float: + grid_x = longitude / scale + grid_y = latitude / scale + x0 = math.floor(grid_x) + y0 = math.floor(grid_y) + tx = grid_x - x0 + ty = grid_y - y0 + + v00 = self._cell_noise(key, x0, y0) + v10 = self._cell_noise(key, x0 + 1, y0) + v01 = self._cell_noise(key, x0, y0 + 1) + v11 = self._cell_noise(key, x0 + 1, y0 + 1) + + tx = tx * tx * (3.0 - (2.0 * tx)) + ty = ty * ty * (3.0 - (2.0 * ty)) + top = (v00 * (1 - tx)) + (v10 * tx) + bottom = (v01 * (1 - tx)) + (v11 * tx) + return (top * (1 - ty)) + (bottom * ty) + + def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float: + seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}" + digest = hashlib.sha256(seed_input.encode("ascii")).digest() + seed = int.from_bytes(digest[:8], "big", signed=False) + return random.Random(seed).random() + + +def get_weather_adapter() -> BaseWeatherAdapter: + from django.conf import settings + + provider = getattr(settings, "WEATHER_DATA_PROVIDER", "mock") + if provider == "open-meteo": + return OpenMeteoWeatherAdapter( + base_url=settings.WEATHER_API_BASE_URL, + api_key=settings.WEATHER_API_KEY, + timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60), + ) + if provider == "mock": + return MockWeatherAdapter( + delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8) + ) + raise ValueError(f"Unsupported weather data provider: {provider}") diff --git a/weather/apps.py b/weather/apps.py index 0f1c14a..9eb8725 100644 --- a/weather/apps.py +++ b/weather/apps.py @@ -25,3 +25,12 @@ class WeatherConfig(AppConfig): def get_water_need_service(self): return self.water_need_service + + @cached_property + def weather_data_adapter(self): + from .adapters import get_weather_adapter + + return get_weather_adapter() + + def get_weather_data_adapter(self): + return self.weather_data_adapter diff --git a/weather/services.py b/weather/services.py index 59a9bbc..9e85a87 100644 --- a/weather/services.py +++ b/weather/services.py @@ -5,12 +5,12 @@ import logging from datetime import date, timedelta -import requests -from django.conf import settings +from django.apps import apps from django.db import transaction from location_data.models import SoilLocation +from .adapters import DEFAULT_FORECAST_DAYS from .models import WeatherForecast logger = logging.getLogger(__name__) @@ -18,60 +18,15 @@ logger = logging.getLogger(__name__) def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None: """ - اتصال به API هواشناسی و دریافت پیش‌بینی ۷ روزه. - - TODO: پیاده‌سازی اتصال واقعی به API (مثلاً Open-Meteo). - در حال حاضر این تابع خالی است و None برمی‌گرداند. - - پارامترها: - latitude: عرض جغرافیایی - longitude: طول جغرافیایی - - خروجی مورد انتظار (وقتی پیاده‌سازی شود): - { - "daily": { - "time": ["2025-07-01", "2025-07-02", ...], - "temperature_2m_max": [35.2, 36.1, ...], - "temperature_2m_min": [22.1, 23.0, ...], - "temperature_2m_mean": [28.6, 29.5, ...], - "precipitation_sum": [0.0, 2.5, ...], - "precipitation_probability_max": [0, 60, ...], - "relative_humidity_2m_mean": [30.0, 45.0, ...], - "wind_speed_10m_max": [15.0, 20.0, ...], - "et0_fao_evapotranspiration": [6.5, 5.8, ...], - "weather_code": [0, 61, ...], - } - } + واکشی پیش‌بینی هواشناسی از provider فعال. + خروجی در قالب سازگار با Open-Meteo daily format برگردانده می‌شود. """ - params = { - "latitude": latitude, - "longitude": longitude, - "forecast_days": 7, - "timezone": "auto", - "daily": [ - "temperature_2m_max", - "temperature_2m_min", - "temperature_2m_mean", - "precipitation_sum", - "precipitation_probability_max", - "relative_humidity_2m_mean", - "wind_speed_10m_max", - "et0_fao_evapotranspiration", - "weather_code", - ], - } - headers = {"accept": "application/json"} - if settings.WEATHER_API_KEY: - headers["Authorization"] = f"Bearer {settings.WEATHER_API_KEY}" - - response = requests.get( - settings.WEATHER_API_BASE_URL, - params=params, - headers=headers, - timeout=60, + adapter = apps.get_app_config("weather").get_weather_data_adapter() + return adapter.fetch_forecast( + latitude=latitude, + longitude=longitude, + days=DEFAULT_FORECAST_DAYS, ) - response.raise_for_status() - return response.json() def parse_weather_response(data: dict) -> list[dict]: @@ -83,37 +38,28 @@ def parse_weather_response(data: dict) -> list[dict]: times = daily.get("time", []) forecasts = [] - for i, date_str in enumerate(times): + for index, date_str in enumerate(times): forecasts.append( { "forecast_date": date_str, - "temperature_max": _safe_index( - daily.get("temperature_2m_max"), i - ), - "temperature_min": _safe_index( - daily.get("temperature_2m_min"), i - ), - "temperature_mean": _safe_index( - daily.get("temperature_2m_mean"), i - ), - "precipitation": _safe_index( - daily.get("precipitation_sum"), i - ), + "temperature_max": _safe_index(daily.get("temperature_2m_max"), index), + "temperature_min": _safe_index(daily.get("temperature_2m_min"), index), + "temperature_mean": _safe_index(daily.get("temperature_2m_mean"), index), + "precipitation": _safe_index(daily.get("precipitation_sum"), index), "precipitation_probability": _safe_index( - daily.get("precipitation_probability_max"), i + daily.get("precipitation_probability_max"), + index, ), "humidity_mean": _safe_index( - daily.get("relative_humidity_2m_mean"), i - ), - "wind_speed_max": _safe_index( - daily.get("wind_speed_10m_max"), i + daily.get("relative_humidity_2m_mean"), + index, ), + "wind_speed_max": _safe_index(daily.get("wind_speed_10m_max"), index), "et0": _safe_index( - daily.get("et0_fao_evapotranspiration"), i - ), - "weather_code": _safe_index( - daily.get("weather_code"), i + daily.get("et0_fao_evapotranspiration"), + index, ), + "weather_code": _safe_index(daily.get("weather_code"), index), } ) return forecasts @@ -147,24 +93,21 @@ def update_weather_for_location(location: SoilLocation) -> dict: } if data is None: - logger.info( - "Weather API returned no data for location %s (stub mode).", - location.id, - ) + logger.info("Weather provider returned no data for location %s.", location.id) return { "status": "no_data", "location_id": location.id, - "message": "API connection not implemented yet.", + "message": "Weather provider returned no data.", } forecasts = parse_weather_response(data) with transaction.atomic(): - for fc in forecasts: + for forecast in forecasts: WeatherForecast.objects.update_or_create( location=location, - forecast_date=fc.pop("forecast_date"), - defaults=fc, + forecast_date=forecast.pop("forecast_date"), + defaults=forecast, ) return { @@ -180,14 +123,13 @@ def update_weather_for_all_locations() -> list[dict]: """ results = [] for location in SoilLocation.objects.all(): - result = update_weather_for_location(location) - results.append(result) + results.append(update_weather_for_location(location)) return results def get_forecast_for_location( location: SoilLocation, - days: int = 7, + days: int = DEFAULT_FORECAST_DAYS, ) -> list[WeatherForecast]: """ دریافت پیش‌بینی‌های ذخیره‌شده برای یک location (تا N روز آینده). @@ -207,14 +149,6 @@ def should_irrigate_today(location: SoilLocation) -> dict: """ بررسی ساده: آیا فردا باران می‌بارد؟ اگر بارش فردا بیشتر از آستانه باشد → آبیاری لازم نیست. - - خروجی: - { - "needs_irrigation": bool | None, - "tomorrow_precipitation": float | None, - "tomorrow_date": str, - "reason": str, - } """ tomorrow = date.today() + timedelta(days=1) forecast = WeatherForecast.objects.filter( diff --git a/weather/test_adapters.py b/weather/test_adapters.py new file mode 100644 index 0000000..85a0036 --- /dev/null +++ b/weather/test_adapters.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from django.apps import apps +from django.test import SimpleTestCase, TestCase, override_settings + +from location_data.models import SoilLocation +from weather.adapters import MockWeatherAdapter, OpenMeteoWeatherAdapter +from weather.models import WeatherForecast +from weather.services import fetch_weather_from_api, update_weather_for_location + + +class MockWeatherAdapterTests(SimpleTestCase): + def setUp(self): + self.adapter = MockWeatherAdapter(delay_seconds=0) + + def test_same_coordinate_returns_same_forecast(self): + first = self.adapter.fetch_forecast(35.71, 51.4) + second = self.adapter.fetch_forecast(35.71, 51.4) + + self.assertEqual(first, second) + + def test_nearby_coordinates_produce_nearby_forecast(self): + first = self.adapter.fetch_forecast(35.71, 51.4) + second = self.adapter.fetch_forecast(35.715, 51.405) + + first_daily = first["daily"] + second_daily = second["daily"] + self.assertLess( + abs(first_daily["temperature_2m_mean"][0] - second_daily["temperature_2m_mean"][0]), + 2.5, + ) + self.assertLess( + abs(first_daily["relative_humidity_2m_mean"][0] - second_daily["relative_humidity_2m_mean"][0]), + 8.0, + ) + self.assertLess( + abs(first_daily["wind_speed_10m_max"][0] - second_daily["wind_speed_10m_max"][0]), + 6.0, + ) + + def test_shape_matches_open_meteo_daily_contract(self): + forecast = self.adapter.fetch_forecast(35.71, 51.4) + daily = forecast["daily"] + + self.assertEqual(len(daily["time"]), 7) + self.assertEqual(len(daily["temperature_2m_max"]), 7) + self.assertEqual(len(daily["weather_code"]), 7) + + +class WeatherAdapterSelectionTests(SimpleTestCase): + def tearDown(self): + apps.get_app_config("weather").__dict__.pop("weather_data_adapter", None) + + @override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0) + def test_app_config_returns_mock_adapter(self): + config = apps.get_app_config("weather") + config.__dict__.pop("weather_data_adapter", None) + + adapter = config.get_weather_data_adapter() + + self.assertIsInstance(adapter, MockWeatherAdapter) + + @override_settings(WEATHER_DATA_PROVIDER="open-meteo", WEATHER_TIMEOUT_SECONDS=12) + def test_app_config_returns_live_adapter(self): + config = apps.get_app_config("weather") + config.__dict__.pop("weather_data_adapter", None) + + adapter = config.get_weather_data_adapter() + + self.assertIsInstance(adapter, OpenMeteoWeatherAdapter) + self.assertEqual(adapter.timeout, 12) + + +@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0) +class WeatherServiceTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create( + latitude="35.710000", + longitude="51.400000", + ) + + def test_fetch_weather_from_api_uses_mock_provider(self): + payload = fetch_weather_from_api(35.71, 51.4) + + self.assertIn("daily", payload) + self.assertEqual(len(payload["daily"]["time"]), 7) + + def test_update_weather_for_location_persists_seven_days(self): + result = update_weather_for_location(self.location) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["days_updated"], 7) + self.assertEqual( + WeatherForecast.objects.filter(location=self.location).count(), + 7, + ) + self.assertTrue( + WeatherForecast.objects.filter( + location=self.location, + precipitation__isnull=False, + weather_code__isnull=False, + ).exists() + )