UPDATE
This commit is contained in:
@@ -22,3 +22,11 @@ GAPGPT_BASE_URL=https://api.gapgpt.app/v1
|
|||||||
# Weather API (Open-Meteo)
|
# Weather API (Open-Meteo)
|
||||||
WEATHER_API_BASE_URL=https://api.open-meteo.com/v1/forecast
|
WEATHER_API_BASE_URL=https://api.open-meteo.com/v1/forecast
|
||||||
WEATHER_API_KEY=
|
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
|
||||||
|
|||||||
@@ -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` داشته باشند
|
||||||
|
|
||||||
@@ -191,6 +191,12 @@ WEATHER_API_BASE_URL = os.environ.get(
|
|||||||
"WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast"
|
"WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast"
|
||||||
)
|
)
|
||||||
WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "")
|
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 = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class SoilDataConfig(AppConfig):
|
class SoilDataConfig(AppConfig):
|
||||||
@@ -14,5 +15,23 @@ class SoilDataConfig(AppConfig):
|
|||||||
|
|
||||||
return NdviHealthService()
|
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):
|
def get_ndvi_health_service(self):
|
||||||
return self.ndvi_health_service
|
return self.ndvi_health_service
|
||||||
|
|
||||||
|
def get_soil_data_adapter(self):
|
||||||
|
return self.soil_data_adapter
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import SoilDepthData, SoilLocation
|
from .models import SoilDepthData, SoilLocation
|
||||||
|
from .soil_adapters import DEPTHS
|
||||||
|
|
||||||
|
|
||||||
class SoilDataRequestSerializer(serializers.Serializer):
|
class SoilDataRequestSerializer(serializers.Serializer):
|
||||||
@@ -56,8 +57,6 @@ class SoilLocationResponseSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["id", "lon", "lat", "depths"]
|
fields = ["id", "lon", "lat", "depths"]
|
||||||
|
|
||||||
def get_depths(self, obj):
|
def get_depths(self, obj):
|
||||||
from .tasks import DEPTHS
|
|
||||||
|
|
||||||
depth_qs = obj.depths.all()
|
depth_qs = obj.depths.all()
|
||||||
order = {d: i for i, d in enumerate(DEPTHS)}
|
order = {d: i for i, d in enumerate(DEPTHS)}
|
||||||
sorted_depths = sorted(
|
sorted_depths = sorted(
|
||||||
|
|||||||
@@ -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
@@ -1,63 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
تسکهای Celery برای واکشی دادههای خاک از API SoilGrids.
|
تسکهای Celery برای واکشی دادههای خاک.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import requests
|
|
||||||
from config.celery import app
|
from config.celery import app
|
||||||
|
from django.apps import apps
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from .models import SoilDepthData, SoilLocation
|
from .models import SoilDepthData, SoilLocation
|
||||||
|
from .soil_adapters import DEPTHS
|
||||||
|
|
||||||
SOILGRIDS_BASE = "https://rest.isric.org/soilgrids/v2.0/properties/query"
|
try:
|
||||||
PROPERTIES = [
|
import requests
|
||||||
"bdod", "cec", "cfvo", "clay", "nitrogen", "ocd", "ocs",
|
except ImportError: # pragma: no cover - handled in stripped envs
|
||||||
"phh2o", "sand", "silt", "soc", "wv0010", "wv0033", "wv1500",
|
RequestException = Exception
|
||||||
]
|
else:
|
||||||
VALUES = ["Q0.5", "Q0.05", "Q0.95", "mean", "uncertainty"]
|
RequestException = requests.RequestException
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_soil_data_for_coordinates(
|
def fetch_soil_data_for_coordinates(
|
||||||
@@ -72,6 +31,7 @@ def fetch_soil_data_for_coordinates(
|
|||||||
"""
|
"""
|
||||||
lat = Decimal(str(round(float(latitude), 6)))
|
lat = Decimal(str(round(float(latitude), 6)))
|
||||||
lon = Decimal(str(round(float(longitude), 6)))
|
lon = Decimal(str(round(float(longitude), 6)))
|
||||||
|
adapter = apps.get_app_config("location_data").get_soil_data_adapter()
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
location, created = SoilLocation.objects.select_for_update().get_or_create(
|
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.task_id = task_id
|
||||||
location.save(update_fields=["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:
|
if progress_callback is not None:
|
||||||
progress_callback(
|
progress_callback(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={
|
meta={
|
||||||
"current": i + 1,
|
"current": index + 1,
|
||||||
"total": len(DEPTHS),
|
"total": len(DEPTHS),
|
||||||
"message": f"در حال واکشی عمق {depth}...",
|
"message": f"در حال واکشی عمق {depth}...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
data = _fetch_soilgrids(float(lon), float(lat), depth)
|
fields = adapter.fetch_depth_fields(float(lon), float(lat), depth)
|
||||||
fields = _parse_response_to_fields(data)
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
SoilDepthData.objects.update_or_create(
|
SoilDepthData.objects.update_or_create(
|
||||||
soil_location=location,
|
soil_location=location,
|
||||||
@@ -117,8 +76,8 @@ def fetch_soil_data_for_coordinates(
|
|||||||
@app.task(bind=True)
|
@app.task(bind=True)
|
||||||
def fetch_soil_data_task(self, latitude: float, longitude: float):
|
def fetch_soil_data_task(self, latitude: float, longitude: float):
|
||||||
"""
|
"""
|
||||||
واکشی دادههای خاک برای مختصات دادهشده از SoilGrids و ذخیره در DB.
|
واکشی دادههای خاک برای مختصات دادهشده و ذخیره در DB.
|
||||||
برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست جدا زده میشود.
|
برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست/شبیهسازی جدا انجام میشود.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return fetch_soil_data_for_coordinates(
|
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,
|
task_id=self.request.id,
|
||||||
progress_callback=self.update_state,
|
progress_callback=self.update_state,
|
||||||
)
|
)
|
||||||
except requests.RequestException as e:
|
except RequestException as exc:
|
||||||
lat = Decimal(str(round(float(latitude), 6)))
|
lat = Decimal(str(round(float(latitude), 6)))
|
||||||
lon = Decimal(str(round(float(longitude), 6)))
|
lon = Decimal(str(round(float(longitude), 6)))
|
||||||
location = SoilLocation.objects.filter(latitude=lat, longitude=lon).first()
|
location = SoilLocation.objects.filter(latitude=lat, longitude=lon).first()
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"location_id": getattr(location, "id", None),
|
"location_id": getattr(location, "id", None),
|
||||||
"error": str(e),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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}")
|
||||||
@@ -25,3 +25,12 @@ class WeatherConfig(AppConfig):
|
|||||||
|
|
||||||
def get_water_need_service(self):
|
def get_water_need_service(self):
|
||||||
return self.water_need_service
|
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
@@ -5,12 +5,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import requests
|
from django.apps import apps
|
||||||
from django.conf import settings
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from location_data.models import SoilLocation
|
from location_data.models import SoilLocation
|
||||||
|
|
||||||
|
from .adapters import DEFAULT_FORECAST_DAYS
|
||||||
from .models import WeatherForecast
|
from .models import WeatherForecast
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,60 +18,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None:
|
def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None:
|
||||||
"""
|
"""
|
||||||
اتصال به API هواشناسی و دریافت پیشبینی ۷ روزه.
|
واکشی پیشبینی هواشناسی از provider فعال.
|
||||||
|
خروجی در قالب سازگار با Open-Meteo daily format برگردانده میشود.
|
||||||
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, ...],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
params = {
|
adapter = apps.get_app_config("weather").get_weather_data_adapter()
|
||||||
"latitude": latitude,
|
return adapter.fetch_forecast(
|
||||||
"longitude": longitude,
|
latitude=latitude,
|
||||||
"forecast_days": 7,
|
longitude=longitude,
|
||||||
"timezone": "auto",
|
days=DEFAULT_FORECAST_DAYS,
|
||||||
"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,
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_weather_response(data: dict) -> list[dict]:
|
def parse_weather_response(data: dict) -> list[dict]:
|
||||||
@@ -83,37 +38,28 @@ def parse_weather_response(data: dict) -> list[dict]:
|
|||||||
times = daily.get("time", [])
|
times = daily.get("time", [])
|
||||||
forecasts = []
|
forecasts = []
|
||||||
|
|
||||||
for i, date_str in enumerate(times):
|
for index, date_str in enumerate(times):
|
||||||
forecasts.append(
|
forecasts.append(
|
||||||
{
|
{
|
||||||
"forecast_date": date_str,
|
"forecast_date": date_str,
|
||||||
"temperature_max": _safe_index(
|
"temperature_max": _safe_index(daily.get("temperature_2m_max"), index),
|
||||||
daily.get("temperature_2m_max"), i
|
"temperature_min": _safe_index(daily.get("temperature_2m_min"), index),
|
||||||
),
|
"temperature_mean": _safe_index(daily.get("temperature_2m_mean"), index),
|
||||||
"temperature_min": _safe_index(
|
"precipitation": _safe_index(daily.get("precipitation_sum"), 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
|
|
||||||
),
|
|
||||||
"precipitation_probability": _safe_index(
|
"precipitation_probability": _safe_index(
|
||||||
daily.get("precipitation_probability_max"), i
|
daily.get("precipitation_probability_max"),
|
||||||
|
index,
|
||||||
),
|
),
|
||||||
"humidity_mean": _safe_index(
|
"humidity_mean": _safe_index(
|
||||||
daily.get("relative_humidity_2m_mean"), i
|
daily.get("relative_humidity_2m_mean"),
|
||||||
),
|
index,
|
||||||
"wind_speed_max": _safe_index(
|
|
||||||
daily.get("wind_speed_10m_max"), i
|
|
||||||
),
|
),
|
||||||
|
"wind_speed_max": _safe_index(daily.get("wind_speed_10m_max"), index),
|
||||||
"et0": _safe_index(
|
"et0": _safe_index(
|
||||||
daily.get("et0_fao_evapotranspiration"), i
|
daily.get("et0_fao_evapotranspiration"),
|
||||||
),
|
index,
|
||||||
"weather_code": _safe_index(
|
|
||||||
daily.get("weather_code"), i
|
|
||||||
),
|
),
|
||||||
|
"weather_code": _safe_index(daily.get("weather_code"), index),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return forecasts
|
return forecasts
|
||||||
@@ -147,24 +93,21 @@ def update_weather_for_location(location: SoilLocation) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
logger.info(
|
logger.info("Weather provider returned no data for location %s.", location.id)
|
||||||
"Weather API returned no data for location %s (stub mode).",
|
|
||||||
location.id,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"status": "no_data",
|
"status": "no_data",
|
||||||
"location_id": location.id,
|
"location_id": location.id,
|
||||||
"message": "API connection not implemented yet.",
|
"message": "Weather provider returned no data.",
|
||||||
}
|
}
|
||||||
|
|
||||||
forecasts = parse_weather_response(data)
|
forecasts = parse_weather_response(data)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for fc in forecasts:
|
for forecast in forecasts:
|
||||||
WeatherForecast.objects.update_or_create(
|
WeatherForecast.objects.update_or_create(
|
||||||
location=location,
|
location=location,
|
||||||
forecast_date=fc.pop("forecast_date"),
|
forecast_date=forecast.pop("forecast_date"),
|
||||||
defaults=fc,
|
defaults=forecast,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -180,14 +123,13 @@ def update_weather_for_all_locations() -> list[dict]:
|
|||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for location in SoilLocation.objects.all():
|
for location in SoilLocation.objects.all():
|
||||||
result = update_weather_for_location(location)
|
results.append(update_weather_for_location(location))
|
||||||
results.append(result)
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_forecast_for_location(
|
def get_forecast_for_location(
|
||||||
location: SoilLocation,
|
location: SoilLocation,
|
||||||
days: int = 7,
|
days: int = DEFAULT_FORECAST_DAYS,
|
||||||
) -> list[WeatherForecast]:
|
) -> list[WeatherForecast]:
|
||||||
"""
|
"""
|
||||||
دریافت پیشبینیهای ذخیرهشده برای یک location (تا N روز آینده).
|
دریافت پیشبینیهای ذخیرهشده برای یک 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)
|
tomorrow = date.today() + timedelta(days=1)
|
||||||
forecast = WeatherForecast.objects.filter(
|
forecast = WeatherForecast.objects.filter(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user