first commit

This commit is contained in:
2026-03-19 22:54:29 +03:30
parent 1a178f39b7
commit 035bc6f74d
91 changed files with 3821 additions and 130 deletions
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from .models import WeatherForecast, WeatherParameter
@admin.register(WeatherParameter)
class WeatherParameterAdmin(admin.ModelAdmin):
list_display = ("code", "name_fa", "unit", "created_at")
search_fields = ("code", "name_fa")
@admin.register(WeatherForecast)
class WeatherForecastAdmin(admin.ModelAdmin):
list_display = (
"location",
"forecast_date",
"temperature_min",
"temperature_max",
"precipitation",
"et0",
"fetched_at",
)
list_filter = ("forecast_date",)
search_fields = ("location__latitude", "location__longitude")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class WeatherConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "weather"
verbose_name = "Weather Forecast"
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,42 @@
"""
Management command to seed weather parameters.
Run: python manage.py seed_weather_parameters
"""
from django.core.management.base import BaseCommand
from weather.models import WeatherParameter
INITIAL_PARAMETERS = [
("temperature_min", "حداقل دمای هوا", "°C"),
("temperature_max", "حداکثر دمای هوا", "°C"),
("temperature_mean", "میانگین دمای هوا", "°C"),
("precipitation", "مجموع بارش", "mm"),
("precipitation_probability", "احتمال بارش", "%"),
("humidity_mean", "میانگین رطوبت نسبی", "%"),
("wind_speed_max", "حداکثر سرعت باد", "km/h"),
("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"),
("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""),
]
class Command(BaseCommand):
help = "Seed weather parameters (temperature, precipitation, ET0, etc.)"
def handle(self, *args, **options):
created_count = 0
for code, name_fa, unit in INITIAL_PARAMETERS:
_, created = WeatherParameter.objects.get_or_create(
code=code,
defaults={"name_fa": name_fa, "unit": unit},
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f" Created: {code} ({name_fa})")
)
self.stdout.write(
self.style.SUCCESS(
f"\nDone. Created {created_count} new weather parameters."
)
)
+184
View File
@@ -0,0 +1,184 @@
# Generated manually for weather
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("location_data", "0002_soildepthdata_refactor"),
]
operations = [
# ── WeatherParameter ──
migrations.CreateModel(
name="WeatherParameter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"code",
models.CharField(
db_index=True,
help_text="کد یکتا (مثلاً temperature_max)",
max_length=64,
unique=True,
),
),
(
"name_fa",
models.CharField(
help_text="نام فارسی",
max_length=128,
),
),
(
"unit",
models.CharField(
blank=True,
help_text="واحد اندازه‌گیری",
max_length=32,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["code"],
"verbose_name": "پارامتر هواشناسی",
"verbose_name_plural": "پارامترهای هواشناسی",
},
),
# ── WeatherForecast ──
migrations.CreateModel(
name="WeatherForecast",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"location",
models.ForeignKey(
help_text="موقعیت مکانی مرتبط از جدول SoilLocation",
on_delete=django.db.models.deletion.CASCADE,
related_name="weather_forecasts",
to="location_data.soillocation",
),
),
(
"forecast_date",
models.DateField(
db_index=True,
help_text="تاریخ پیش‌بینی",
),
),
(
"temperature_min",
models.FloatField(
blank=True,
help_text="حداقل دمای هوا (°C)",
null=True,
),
),
(
"temperature_max",
models.FloatField(
blank=True,
help_text="حداکثر دمای هوا (°C)",
null=True,
),
),
(
"temperature_mean",
models.FloatField(
blank=True,
help_text="میانگین دمای هوا (°C)",
null=True,
),
),
(
"precipitation",
models.FloatField(
blank=True,
help_text="مجموع بارش (mm)",
null=True,
),
),
(
"precipitation_probability",
models.FloatField(
blank=True,
help_text="احتمال بارش (%)",
null=True,
),
),
(
"humidity_mean",
models.FloatField(
blank=True,
help_text="میانگین رطوبت نسبی (%)",
null=True,
),
),
(
"wind_speed_max",
models.FloatField(
blank=True,
help_text="حداکثر سرعت باد (km/h)",
null=True,
),
),
(
"et0",
models.FloatField(
blank=True,
help_text="تبخیر-تعرق مرجع (ET₀) — mm/day",
null=True,
),
),
(
"weather_code",
models.IntegerField(
blank=True,
help_text="کد وضعیت آب‌وهوا (WMO code)",
null=True,
),
),
(
"fetched_at",
models.DateTimeField(
auto_now=True,
help_text="آخرین زمان واکشی از API",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["location", "forecast_date"],
"verbose_name": "پیش‌بینی هواشناسی",
"verbose_name_plural": "پیش‌بینی‌های هواشناسی",
},
),
migrations.AddConstraint(
model_name="weatherforecast",
constraint=models.UniqueConstraint(
fields=("location", "forecast_date"),
name="weather_unique_location_date",
),
),
]
@@ -0,0 +1,42 @@
# Seed migration: populate initial weather parameters.
from django.db import migrations
INITIAL_PARAMETERS = [
("temperature_min", "حداقل دمای هوا", "°C"),
("temperature_max", "حداکثر دمای هوا", "°C"),
("temperature_mean", "میانگین دمای هوا", "°C"),
("precipitation", "مجموع بارش", "mm"),
("precipitation_probability", "احتمال بارش", "%"),
("humidity_mean", "میانگین رطوبت نسبی", "%"),
("wind_speed_max", "حداکثر سرعت باد", "km/h"),
("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"),
("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""),
]
def seed_parameters(apps, schema_editor):
WeatherParameter = apps.get_model("weather", "WeatherParameter")
for code, name_fa, unit in INITIAL_PARAMETERS:
WeatherParameter.objects.get_or_create(
code=code,
defaults={"name_fa": name_fa, "unit": unit},
)
def unseed_parameters(apps, schema_editor):
WeatherParameter = apps.get_model("weather", "WeatherParameter")
codes = [p[0] for p in INITIAL_PARAMETERS]
WeatherParameter.objects.filter(code__in=codes).delete()
class Migration(migrations.Migration):
dependencies = [
("weather", "0001_initial"),
]
operations = [
migrations.RunPython(seed_parameters, unseed_parameters),
]
@@ -0,0 +1,137 @@
# Seed migration: populate sample 7-day weather forecasts for existing SoilLocations.
from datetime import timedelta
from django.db import migrations
from django.utils import timezone
SAMPLE_DAILY_DATA = [
{
"day_offset": 0,
"temperature_min": 18.5,
"temperature_max": 33.2,
"temperature_mean": 25.8,
"precipitation": 0.0,
"precipitation_probability": 5.0,
"humidity_mean": 28.0,
"wind_speed_max": 12.0,
"et0": 6.8,
"weather_code": 0,
},
{
"day_offset": 1,
"temperature_min": 19.0,
"temperature_max": 34.5,
"temperature_mean": 26.7,
"precipitation": 0.0,
"precipitation_probability": 10.0,
"humidity_mean": 30.0,
"wind_speed_max": 14.0,
"et0": 7.1,
"weather_code": 1,
},
{
"day_offset": 2,
"temperature_min": 20.2,
"temperature_max": 32.0,
"temperature_mean": 26.1,
"precipitation": 3.5,
"precipitation_probability": 65.0,
"humidity_mean": 52.0,
"wind_speed_max": 18.0,
"et0": 5.2,
"weather_code": 61,
},
{
"day_offset": 3,
"temperature_min": 17.8,
"temperature_max": 28.5,
"temperature_mean": 23.1,
"precipitation": 12.0,
"precipitation_probability": 85.0,
"humidity_mean": 70.0,
"wind_speed_max": 22.0,
"et0": 3.8,
"weather_code": 63,
},
{
"day_offset": 4,
"temperature_min": 16.5,
"temperature_max": 27.0,
"temperature_mean": 21.7,
"precipitation": 5.0,
"precipitation_probability": 55.0,
"humidity_mean": 60.0,
"wind_speed_max": 16.0,
"et0": 4.5,
"weather_code": 61,
},
{
"day_offset": 5,
"temperature_min": 18.0,
"temperature_max": 31.0,
"temperature_mean": 24.5,
"precipitation": 0.0,
"precipitation_probability": 8.0,
"humidity_mean": 35.0,
"wind_speed_max": 10.0,
"et0": 6.2,
"weather_code": 2,
},
{
"day_offset": 6,
"temperature_min": 19.5,
"temperature_max": 34.0,
"temperature_mean": 26.7,
"precipitation": 0.0,
"precipitation_probability": 3.0,
"humidity_mean": 25.0,
"wind_speed_max": 8.0,
"et0": 7.0,
"weather_code": 0,
},
]
def seed_forecasts(apps, schema_editor):
SoilLocation = apps.get_model("location_data", "SoilLocation")
WeatherForecast = apps.get_model("weather", "WeatherForecast")
today = timezone.now().date()
for location in SoilLocation.objects.all():
for daily in SAMPLE_DAILY_DATA:
forecast_date = today + timedelta(days=daily["day_offset"])
WeatherForecast.objects.get_or_create(
location=location,
forecast_date=forecast_date,
defaults={
"temperature_min": daily["temperature_min"],
"temperature_max": daily["temperature_max"],
"temperature_mean": daily["temperature_mean"],
"precipitation": daily["precipitation"],
"precipitation_probability": daily["precipitation_probability"],
"humidity_mean": daily["humidity_mean"],
"wind_speed_max": daily["wind_speed_max"],
"et0": daily["et0"],
"weather_code": daily["weather_code"],
},
)
def unseed_forecasts(apps, schema_editor):
WeatherForecast = apps.get_model("weather", "WeatherForecast")
WeatherForecast.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("weather", "0002_seed_weather_parameters"),
("location_data", "0002_soildepthdata_refactor"),
]
operations = [
migrations.RunPython(seed_forecasts, unseed_forecasts),
]
+1
View File
@@ -0,0 +1 @@
+107
View File
@@ -0,0 +1,107 @@
from django.db import models
class WeatherParameter(models.Model):
"""
تعریف پارامترهای هواشناسی (مثلاً دما، بارش، تبخیر-تعرق، ...).
"""
code = models.CharField(
max_length=64,
unique=True,
db_index=True,
help_text="کد یکتا (مثلاً temperature_max)",
)
name_fa = models.CharField(max_length=128, help_text="نام فارسی")
unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["code"]
verbose_name = "پارامتر هواشناسی"
verbose_name_plural = "پارامترهای هواشناسی"
def __str__(self):
return f"{self.code} ({self.name_fa})"
class WeatherForecast(models.Model):
"""
پیش‌بینی هواشناسی روزانه (تا ۷ روز آینده) برای یک SoilLocation.
داده‌ها شامل دما، بارش، رطوبت، باد و تبخیر-تعرق مرجع (ET0) هستند.
"""
location = models.ForeignKey(
"location_data.SoilLocation",
on_delete=models.CASCADE,
related_name="weather_forecasts",
help_text="موقعیت مکانی مرتبط از جدول SoilLocation",
)
forecast_date = models.DateField(
db_index=True,
help_text="تاریخ پیش‌بینی",
)
temperature_min = models.FloatField(
null=True, blank=True, help_text="حداقل دمای هوا (°C)"
)
temperature_max = models.FloatField(
null=True, blank=True, help_text="حداکثر دمای هوا (°C)"
)
temperature_mean = models.FloatField(
null=True, blank=True, help_text="میانگین دمای هوا (°C)"
)
precipitation = models.FloatField(
null=True, blank=True, help_text="مجموع بارش (mm)"
)
precipitation_probability = models.FloatField(
null=True, blank=True, help_text="احتمال بارش (%)"
)
humidity_mean = models.FloatField(
null=True, blank=True, help_text="میانگین رطوبت نسبی (%)"
)
wind_speed_max = models.FloatField(
null=True, blank=True, help_text="حداکثر سرعت باد (km/h)"
)
et0 = models.FloatField(
null=True,
blank=True,
help_text="تبخیر-تعرق مرجع (ET₀) — mm/day",
)
weather_code = models.IntegerField(
null=True,
blank=True,
help_text="کد وضعیت آب‌وهوا (WMO code)",
)
fetched_at = models.DateTimeField(
auto_now=True,
help_text="آخرین زمان واکشی از API",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["location", "forecast_date"],
name="weather_unique_location_date",
)
]
ordering = ["location", "forecast_date"]
verbose_name = "پیش‌بینی هواشناسی"
verbose_name_plural = "پیش‌بینی‌های هواشناسی"
def __str__(self):
return f"WeatherForecast({self.location_id}, {self.forecast_date})"
@property
def will_rain(self):
"""آیا بارندگی پیش‌بینی شده است؟"""
if self.precipitation is not None:
return self.precipitation > 0.0
return None
+224
View File
@@ -0,0 +1,224 @@
"""
سرویس‌های هواشناسی — واکشی پیش‌بینی ۷ روزه و ذخیره در دیتابیس.
"""
import logging
from datetime import date, timedelta
from django.conf import settings
from django.db import transaction
from location_data.models import SoilLocation
from .models import WeatherForecast
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, ...],
}
}
"""
# TODO: اتصال واقعی به API هواشناسی
# api_url = settings.WEATHER_API_BASE_URL
# api_key = settings.WEATHER_API_KEY
return None
def parse_weather_response(data: dict) -> list[dict]:
"""
تبدیل پاسخ API به لیست dict برای ذخیره در WeatherForecast.
فرمت ورودی: Open-Meteo daily format.
"""
daily = data.get("daily", {})
times = daily.get("time", [])
forecasts = []
for i, 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
),
"precipitation_probability": _safe_index(
daily.get("precipitation_probability_max"), i
),
"humidity_mean": _safe_index(
daily.get("relative_humidity_2m_mean"), i
),
"wind_speed_max": _safe_index(
daily.get("wind_speed_10m_max"), i
),
"et0": _safe_index(
daily.get("et0_fao_evapotranspiration"), i
),
"weather_code": _safe_index(
daily.get("weather_code"), i
),
}
)
return forecasts
def _safe_index(lst: list | None, index: int):
"""مقدار index را از لیست برمی‌گرداند یا None."""
if lst is None or index >= len(lst):
return None
return lst[index]
def update_weather_for_location(location: SoilLocation) -> dict:
"""
واکشی و ذخیره پیش‌بینی هواشناسی ۷ روزه برای یک SoilLocation.
خروجی:
{"status": "success"|"no_data"|"error", "location_id": int, ...}
"""
lat = float(location.latitude)
lon = float(location.longitude)
try:
data = fetch_weather_from_api(lat, lon)
except Exception as exc:
logger.error("Weather API error for location %s: %s", location.id, exc)
return {
"status": "error",
"location_id": location.id,
"error": str(exc),
}
if data is None:
logger.info(
"Weather API returned no data for location %s (stub mode).",
location.id,
)
return {
"status": "no_data",
"location_id": location.id,
"message": "API connection not implemented yet.",
}
forecasts = parse_weather_response(data)
with transaction.atomic():
for fc in forecasts:
WeatherForecast.objects.update_or_create(
location=location,
forecast_date=fc.pop("forecast_date"),
defaults=fc,
)
return {
"status": "success",
"location_id": location.id,
"days_updated": len(forecasts),
}
def update_weather_for_all_locations() -> list[dict]:
"""
واکشی پیش‌بینی هواشناسی برای تمام SoilLocation‌های موجود.
"""
results = []
for location in SoilLocation.objects.all():
result = update_weather_for_location(location)
results.append(result)
return results
def get_forecast_for_location(
location: SoilLocation,
days: int = 7,
) -> list[WeatherForecast]:
"""
دریافت پیش‌بینی‌های ذخیره‌شده برای یک location (تا N روز آینده).
"""
today = date.today()
end_date = today + timedelta(days=days)
return list(
WeatherForecast.objects.filter(
location=location,
forecast_date__gte=today,
forecast_date__lte=end_date,
).order_by("forecast_date")
)
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(
location=location,
forecast_date=tomorrow,
).first()
if forecast is None:
return {
"needs_irrigation": None,
"tomorrow_precipitation": None,
"tomorrow_date": str(tomorrow),
"reason": "داده پیش‌بینی فردا موجود نیست.",
}
rain_threshold_mm = 2.0
if forecast.precipitation is not None and forecast.precipitation >= rain_threshold_mm:
return {
"needs_irrigation": False,
"tomorrow_precipitation": forecast.precipitation,
"tomorrow_date": str(tomorrow),
"reason": (
f"فردا {forecast.precipitation} mm بارش پیش‌بینی شده — "
"نیاز به آبیاری نیست."
),
}
return {
"needs_irrigation": True,
"tomorrow_precipitation": forecast.precipitation,
"tomorrow_date": str(tomorrow),
"reason": "بارش فردا ناچیز یا صفر — آبیاری توصیه می‌شود.",
}
+34
View File
@@ -0,0 +1,34 @@
"""
تسک‌های Celery برای واکشی داده‌های هواشناسی.
"""
from config.celery import app
from location_data.models import SoilLocation
from .services import update_weather_for_location, update_weather_for_all_locations
@app.task(bind=True)
def fetch_weather_task(self, location_id: int):
"""
واکشی پیش‌بینی هواشناسی ۷ روزه برای یک location مشخص.
"""
try:
location = SoilLocation.objects.get(pk=location_id)
except SoilLocation.DoesNotExist:
return {
"status": "error",
"error": f"SoilLocation with id={location_id} not found.",
}
return update_weather_for_location(location)
@app.task(bind=True)
def fetch_weather_all_locations_task(self):
"""
واکشی پیش‌بینی هواشناسی برای تمام location‌ها.
مناسب برای Celery Beat (مثلاً هر ۶ ساعت).
"""
return update_weather_for_all_locations()