This commit is contained in:
2026-04-29 01:27:29 +03:30
parent cb60254c81
commit 27bdad0111
12 changed files with 1443 additions and 155 deletions
+8
View File
@@ -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
+594
View File
@@ -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` داشته باشند
+6
View File
@@ -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,
+19
View File
@@ -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
+1 -2
View File
@@ -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(
+286
View File
@@ -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()
+17 -58
View File
@@ -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),
}
+92
View File
@@ -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()
)
+279
View File
@@ -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}")
+9
View File
@@ -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
+29 -95
View File
@@ -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(
+103
View File
@@ -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()
)