UPDATE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_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,
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
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
|
||||
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(
|
||||
|
||||
@@ -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