This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+74
View File
@@ -0,0 +1,74 @@
---
alwaysApply: false
---
# Backend API Architecture & Postman
## 1. URL / Routing Architecture
- **Root (config/urls.py):** API mounts under `api/<app-prefix>/` via `include()`.
- Example: `path("api/auth/", include("auth.urls"))`, `path("api/sensor-hub/", include("sensor_hub.urls"))`.
- App prefix: kebab-case (e.g. `sensor-hub`).
- **App URLs (each apps urls.py):** Only endpoint definitions with `path()`.
- Same view can be used for several paths; distinguish by path or `kwargs` (e.g. `kwargs={"action": "active"}`).
- Order matters: more specific paths first (e.g. `active/`, `deactive/`), then path-param routes (e.g. `<uuid:uuid>/`), then base `""` for list.
- Example pattern:
- `path("active/", View.as_view(), kwargs={"action": "active"})`
- `path("deactive/", View.as_view(), kwargs={"action": "deactive"})`
- `path("<uuid:uuid>/", View.as_view(), name="...-detail")`
- `path("", View.as_view(), name="...-list")`
- **Views:** One `APIView` per resource (or per flow, e.g. auth). Dispatch by HTTP method and optionally by `request.path` or `kwargs` (e.g. `uuid`, `action`). No business logic in views; orchestration only.
---
## 2. Postman Collection Layout
- **Placement:** One collection per app: `<app_name>/postman/<collection_name>.json` (e.g. `sensor_hub/postman/sensor_hub.json`, `auth/postman/postman.json`).
- **Structure:**
- `info`: `name`, `schema` (v2.1.0), optional `description`.
- `item`: array of requests (one per endpoint variant/method).
- `variable`: at least `baseUrl` (e.g. `http://localhost:8000`); add `token`, `uuid` etc. when needed.
- **Request style:**
- One base URL per resource; multiple requests for different methods or path params (e.g. list vs `{{uuid}}/`).
- URL: `{{baseUrl}}/api/<app-prefix>/...` (e.g. `{{baseUrl}}/api/sensor-hub/`, `{{baseUrl}}/api/sensor-hub/{{uuid}}/`).
- Auth: where required, header `Authorization: Bearer {{token}}`.
- No random/dynamic values in body or response examples.
---
## 3. Postman Request Generator (when I give you routes)
Your task is to take the API routes I provide and convert them into a valid Postman collection JSON (as above).
ROUTE STYLE:
- When routes are defined as a single URL with different HTTP methods, generate one base URL and multiple requests (one per method/variant). Use the same URL for all; use path params (e.g. `<uuid>/`) or query for GET detail vs list when applicable.
RULES:
1. For each route (or each method/variant on the same URL), generate:
- Name: A descriptive, concise name for the request based on the route and HTTP method.
- Method: The HTTP method (GET, POST, PUT, DELETE, etc.).
- URL: The route URL.
- Body: If the endpoint accepts input, provide a JSON body example with appropriate keys; otherwise, leave it empty.
- Response: Provide a sample JSON response in the following format:
- If the endpoint returns no data:
{
"status": "success"
}
- If the endpoint returns data:
{
"status": "success",
"data": {}
}
- All responses must use HTTP status 200.
2. Do NOT generate random or dynamic values in the body or response.
3. Output must be a valid Postman collection JSON structure:
- Include "info" with collection name.
- Include "item" array with all requests.
4. Keep the JSON fully compatible with Postman import.
5. Do NOT include explanations outside the JSON.
Wait for me to provide the route definitions.
+96
View File
@@ -0,0 +1,96 @@
---
alwaysApply: true
---
## 2. Django App (Module) Naming
| Item | Convention | Example |
|--------|------------|----------------------------|
| App name | snake_case, **بدون** پسوند `_api` | `account`, `auth`, `sensor_hub` |
- نام اپ‌ها را با `_api` تمام **نکنید**. مثلاً به‌جای `account_api` از `account` استفاده کنید.
- برای ماژول‌های فقط API، همان نام دامنه کافی است (مثلاً `auth`، `account`).
---
## 3. Model and Database Field Naming
| Item | Convention | Example |
|-------------------|-------------------------|----------------------------------------------|
| Model | PascalCase | `UserProfile` |
| Fields | snake_case | `first_name`, `email_address` |
| Boolean | `is_` / `has_` + name | `is_active`, `has_paid` |
| Date/Time | `created_at` / `updated_at` | `created_at`, `updated_at` |
| ForeignKey / M2M | snake_case, often model name | `author = ForeignKey(UserProfile)` |
| Choices / Enum | UPPER_SNAKE_CASE values | `role = CharField(choices=(("ADMIN","Admin"), ("USER","User")))` |
---
## 4. DRF Conventions
- **Serializer:** Validation + data transformation. Use PascalCase names.
- **Service layer:** All business logic lives here.
- **View:** Orchestration only — call services and return responses. No business logic in views.
- **URLs:** Define endpoints only. Use kebab-case for URL paths.
---
## 5. API Response Format
همه پاسخ‌های API باید فیلد `code` را برگردانند؛ مقدار آن برابر **HTTP status code** درخواست است (مثلاً 200، 201، 400، 404).
| فیلد | توضیح |
|-------|----------------------------------------|
| `code` | کد وضعیت HTTP (مثلاً 200، 404، 500) |
| `msg` | پیام (مثلاً "success" برای 2xx) |
| `data` | دادهٔ برگشتی (اختیاری) |
مثال:
```json
{"code": 200, "msg": "success", "data": {...}}
```
---
## 6. Simple Example: How the Layers Connect (users app)
```python
# models.py
class UserProfile(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
email_address = models.EmailField(unique=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# serializers.py
class UserCreateSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = ["first_name", "last_name", "email_address"]
# services.py
def create_user(first_name, last_name, email_address):
return UserProfile.objects.create(
first_name=first_name,
last_name=last_name,
email_address=email_address,
)
# views.py
class UserCreateAPIView(APIView):
def post(self, request):
serializer = UserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = create_user(**serializer.validated_data)
return Response({"code": 201, "msg": "success", "data": {"id": user.id}}, status=201)
```
- All names follow the conventions above.
- Business logic is in `services.py`.
- Serializer only validates and serializes.
- View only orchestrates (calls service, returns response).
+72
View File
@@ -0,0 +1,72 @@
---
alwaysApply: false
---
You are a Django API code generator.
Your task is to generate a complete and runnable Django project based on the routes I provide.
ROUTE STYLE:
- Routes may be defined as a single URL with different HTTP methods (e.g. one path, GET for list, GET with query for detail, PUT/PATCH for update, DELETE for delete, POST for action). Use one view class that implements get, post, put, patch, delete as needed. Use query parameters (e.g. sensor_id) to distinguish list vs detail when both use GET.
STRICT RULES:
- Use Django only.
- Do NOT use Django REST Framework unless I explicitly request it.
- Do NOT connect to any database.
- Do NOT create any Models.
- Do NOT generate random or dynamic data.
- Input parameters must be accepted (body, query params, path params).
- HOWEVER, absolutely NO processing, validation, transformation, or logic may be applied to them.
- Do NOT use input values inside the response.
- No conditional logic.
- No business logic.
- No validation.
- All endpoints must always return static JSON responses only.
- ALL responses must return HTTP status code 200 only.
- No other status codes are allowed.
- No explanations outside the code.
- Return complete runnable code including project structure (views.py, urls.py, settings if needed, etc.).
--------------------------------------------------
RESPONSE FORMAT (STRICTLY ENFORCED)
If the endpoint does NOT require returning data:
{
"status": "success"
}
If the endpoint requires returning data:
{
"status": "success",
"data": {}
}
Mandatory rules:
- The "status" field MUST always be exactly "success".
- If "data" is present, it MUST be exactly an empty object {}.
- If data is not required, DO NOT include the "data" field.
- No additional fields are allowed.
--------------------------------------------------
COMMENTING REQUIREMENTS (VERY IMPORTANT):
Each endpoint MUST include professional, multi-line docstring documentation.
The documentation MUST include:
1. Clear description of the endpoint purpose.
2. Complete description of ALL input parameters:
- Parameter name
- Data type
- Location (body / query / path)
- Description of its intended purpose
3. Full description of the response structure:
- status field
- data field (if applicable)
4. Explicit statement that no processing or validation is performed on inputs.
Use clean, professional API documentation style.
Do not write anything outside the code.
Wait for my route definitions.
+16
View File
@@ -0,0 +1,16 @@
.env
.env.*
!.env.example
.git
__pycache__
*.pyc
.venv
venv
*.egg-info
.pytest_cache
.coverage
htmlcov
*.log
media
staticfiles
.cursor
+34
View File
@@ -0,0 +1,34 @@
# Django
SECRET_KEY=your-secret-key-change-in-production
DEBUG=1
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,web
DEVELOP=true
# Database (MySQL)
DB_ENGINE=django.db.backends.mysql
DB_NAME=croplogic
DB_USER=croplogic
DB_PASSWORD=changeme
DB_ROOT_PASSWORD=root
DB_HOST=db
DB_PORT=3306
AVALAI_API_KEY=aa-iDlMpRAfRyd95pCQxr5YXfJoJmw4qCDe6fnozQ4PlkpYF0pA
AVALAI_BASE_URL=https://api.avalai.ir/v1
# GapGPT API (provider اصلی)
GAPGPT_API_KEY=sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5
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=
# Soil data provider: soilgrids | mock
SOIL_DATA_PROVIDER=soilgrids
SOIL_MOCK_DELAY_SECONDS=0.8
SOILGRIDS_TIMEOUT_SECONDS=60
WEATHER_DATA_PROVIDER=open-meteo
WEATHER_MOCK_DELAY_SECONDS=0.8
WEATHER_TIMEOUT_SECONDS=60
+120
View File
@@ -0,0 +1,120 @@
name: AI Service CI/CD
on:
push:
branches: [production]
paths:
- '**'
- '.gitea/workflows/ai.yml'
pull_request:
branches: [production]
paths:
- '**'
- '.gitea/workflows/ai.yml'
jobs:
test:
name: Lint & Test
runs-on: self-hosted
container:
image: mirror2.chabokan.net/ubuntu:22.04
options: --add-host gitea:172.17.0.1
steps:
- name: Setup Ubuntu apt mirrors
run: |
tee /etc/apt/sources.list > /dev/null <<'EOF'
deb https://mirror-linux.runflare.com/ubuntu/ noble main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-updates main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-backports main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-security main restricted universe multiverse
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-updates main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-security main universe
deb http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy universe
deb-src http://mirror.iranserver.com/ubuntu/ jammy universe
deb http://mirror.iranserver.com/ubuntu/ jammy-updates universe
deb http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
EOF
apt-get update
- name: Install git
run: |
apt-get install -y git
- name: Checkout repository
run: |
git clone http://15f3baa28036aa35f8eb707585567d1b87bd8977@git.crop-logic.ir/sajad-dev/Ai.git .
- name: Install Python
run: |
apt-get install -y python3 python3-pip python3-venv git
- name: Setup Python pip mirrors
run: |
pip3 config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple
pip3 config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple
pip3 config --user set global.trusted-host "package-mirror.liara.ir mirror.cdn.ir mirror2.chabokan.net"
- name: Install system dependencies
run: |
apt-get install -y \
python3 \
python3-pip \
python3-venv \
pkg-config \
build-essential \
default-libmysqlclient-dev
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest flake8
- name: Run lint
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{secrets.SERVER_SSH_PORT}} -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} -p ${{secrets.SERVER_SSH_PORT}}<< 'EOF'
cd application/Ai
git pull origin production
docker-compose -f docker-compose-prod.yaml down --remove-orphans
docker-compose -f docker-compose-prod.yaml up -d
EOF
+120
View File
@@ -0,0 +1,120 @@
name: AI Service CI/CD
on:
push:
branches: [main]
paths:
- 'ai/**'
- 'ai/.github/workflows/ai.yml'
pull_request:
branches: [main]
paths:
- 'ai/**'
- 'ai/.github/workflows/ai.yml'
defaults:
run:
working-directory: ai
jobs:
test:
name: Lint & Test
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ['3.11']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Ubuntu apt mirrors
run: |
sudo tee /etc/apt/sources.list > /dev/null <<'EOF'
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu jammy main restricted universe multiverse
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu jammy-updates main restricted universe multiverse
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu jammy-security main restricted universe multiverse
EOF
sudo apt-get update
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup Python pip mirrors
run: |
pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple
pip config --user set global.trusted-host "package-mirror.liara.ir mirror.cdn.ir mirror2.chabokan.net"
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-ai-${{ hashFiles('ai/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-ai-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest flake8
- name: Run lint
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Run tests
run: pytest --tb=short -q
docker:
name: Build & Push Docker Image
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./ai
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/ai:latest
${{ secrets.DOCKER_REGISTRY }}/ai:${{ github.sha }}
build-args: |
APT_MIRROR=mirror2.chabokan.net
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy AI Service
needs: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myproject/ai
git pull origin main
docker compose pull
docker compose up -d --remove-orphans
+64
View File
@@ -0,0 +1,64 @@
# Environment
.env
.env.local
*.env
!*.env.example
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Django
*.log
local_settings.py
db.sqlite3
media/
staticfiles/
*.pot
# RAG / ChromaDB
data/chromadb/
# Testing / Coverage
.coverage
htmlcov/
.pytest_cache/
.tox/
.nox/
# OS
.DS_Store
Thumbs.db
logs/*
+4
View File
@@ -0,0 +1,4 @@
[submodule "Schemas"]
path = Schemas
url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git
branch = develop
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
# ممیزی وضعیت واقعی APIها
این سند فقط درباره reliability نیست؛ به‌عنوان یک مرجع فشرده برای `وضعیت واقعی routeها` و semantics فعلی هم استفاده می‌شود.
## قانون runtime در برابر seed
- seed/fixture/bootstrap data مجاز است و باید برای bootstrap، dev و test باقی بماند.
- mock/sample/demo data نباید در runtime application code به عنوان fallback موفق استفاده شود.
- اگر داده واقعی موجود نیست، پاسخ باید `empty state` یا `failure contract` صریح باشد.
## جدول مرجع وضعیت
| Endpoint | وضعیت | semantics | توضیح کوتاه |
|---|---:|---|---|
| `POST /api/rag/chat/` | `implemented` | `live AI` | route واقعی AI |
| `POST /api/farm-alerts/tracker/` | `implemented` | `live AI` | route واقعی AI؛ معادل backend آن cached است |
| `GET|POST /api/soil-data/` | `implemented` | `provider-backed / task-backed` | route واقعی AI |
| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | `async status` | route واقعی AI |
| `POST /api/soil-data/ndvi-health/` | `implemented` | `provider-backed` | route واقعی AI |
| `POST /api/soile/*` | `implemented` | `AI-owned derived output` | routeهای واقعی AI |
| `POST /api/farm-data/` | `implemented` | `AI-owned derived write-model` | route واقعی AI |
| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | `AI-owned derived read-model` | route واقعی AI |
| `POST /api/farm-data/parameters/` | `implemented` | `AI-owned config` | route واقعی AI |
| `POST /api/weather/farm-card/` | `implemented` | `provider-backed` | route واقعی AI |
| `POST /api/weather/water-need-prediction/` | `implemented` | `derived output` | route واقعی AI |
| `POST /api/economy/overview/` | `implemented` | `provider-backed / persisted` | route واقعی AI |
| `GET|POST /api/plants/` | `implemented` | `canonical AI plant service` | route واقعی AI |
| `GET|PUT|PATCH|DELETE /api/plants/{pk}/` | `implemented` | `canonical AI plant service` | route واقعی AI |
| `POST /api/plants/fetch-info/` | `implemented` | `provider-backed enrichment` | route واقعی AI |
| `POST /api/pest-disease/detect/` | `implemented` | `live AI` | route واقعی AI |
| `POST /api/pest-disease/risk/` | `implemented` | `derived output` | route واقعی AI |
| `GET|POST /api/irrigation/` | `implemented` | `AI-owned config + live recommendation support` | route واقعی AI |
| `GET|PUT|PATCH|DELETE /api/irrigation/{pk}/` | `implemented` | `AI-owned config` | route واقعی AI |
| `POST /api/irrigation/recommend/` | `implemented` | `live AI + deterministic context` | route واقعی AI |
| `POST /api/irrigation/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI |
| `POST /api/irrigation/water-stress/` | `implemented` | `AI-owned derived output` | route واقعی AI |
| `POST /api/fertilization/recommend/` | `implemented` | `live AI + optimizer context` | route واقعی AI |
| `POST /api/fertilization/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI |
| `POST /api/crop-simulation/current-farm-chart/` | `implemented` | `live AI inference` | route واقعی AI |
| `POST /api/crop-simulation/harvest-prediction/` | `implemented` | `live AI inference` | route واقعی AI |
| `GET /api/crop-simulation/yield-harvest-summary/` | `implemented` | `AI-owned derived output` | route واقعی AI |
| `POST /api/crop-simulation/yield-prediction/` | `implemented` | `live AI inference` | route واقعی AI |
| `POST /api/crop-simulation/growth/` | `implemented` | `async live AI inference` | route واقعی AI |
| `GET /api/crop-simulation/growth/{task_id}/status/` | `implemented` | `async status` | route واقعی AI |
## مواردی که نباید به‌عنوان route واقعی AI معرفی شوند
| Endpoint | تصمیم |
|---|---|
| `POST /api/farm-alerts/timeline/` | `missing` |
| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` |
| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` |
| هر route موجود فقط در `Backend/external_api_adapter/json/ai/index.json` و بدون registration واقعی | `stub/contract-only` |
## توضیح مهم درباره mock/spec
فایل `Backend/external_api_adapter/json/ai/index.json` باید به‌عنوان `contract/mock catalog` دیده شود، نه لیست endpointهای تضمین‌شده‌ی production.
اگر endpoint فقط در آن فایل وجود دارد ولی در `Ai/config/urls.py` و routeهای اپ‌ها ثبت نشده، وضعیت آن `stub/contract-only` است.
## Ownership مهم
- plant catalog canonical در Backend شروع می‌شود و AI snapshot/read-model آن را ingest می‌کند.
- `farm_data` در AI facade canonical برای مصرف AI روی farm/sensor/plant assignment است.
- relation قدیمی `SensorData.plants` transitional است و نباید به‌عنوان source-of-truth جدید مستند شود.
## Known Gaps / Follow-up
- schema UI غیرفعال است؛ audit docs منبع فعلی truth هستند.
- بعضی endpointها در backend و AI هر دو وجود دارند اما semantics آن‌ها متفاوت است؛ همیشه live/cached/proxy بودن را جداگانه مستند کنید.
+55
View File
@@ -0,0 +1,55 @@
# AI Apps URL Audit
This document lists the actual AI-service routes registered today and labels their readiness accurately.
## Canonical AI Route Inventory
| App | Method | Route | Status | Notes |
|---|---|---|---:|---|
| `rag` | `POST` | `/api/rag/chat/` | `implemented` | Live AI chat route. |
| `farm_alerts` | `POST` | `/api/farm-alerts/tracker/` | `implemented` | Live AI tracker route. |
| `location_data` | `GET` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. |
| `location_data` | `POST` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. |
| `location_data` | `GET` | `/api/soil-data/tasks/<task_id>/status/` | `implemented` | Live task status route. |
| `location_data` | `POST` | `/api/soil-data/ndvi-health/` | `implemented` | Live AI NDVI route. |
| `soile` | `POST` | `/api/soile/anomaly-detection/` | `implemented` | Live AI route. |
| `soile` | `POST` | `/api/soile/health-summary/` | `implemented` | Live AI route. |
| `soile` | `POST` | `/api/soile/moisture-heatmap/` | `implemented` | Live AI route. |
| `farm_data` | `POST` | `/api/farm-data/` | `implemented` | Upsert route. |
| `farm_data` | `GET` | `/api/farm-data/<farm_uuid>/detail/` | `implemented` | Farm detail route. |
| `farm_data` | `POST` | `/api/farm-data/parameters/` | `implemented` | Sensor parameter create route. |
| `farm_data` | `POST` | `/api/farm-data/plants/sync/` | `implemented` | Internal sync route. |
| `weather` | `POST` | `/api/weather/farm-card/` | `implemented` | Live weather card route. |
| `weather` | `POST` | `/api/weather/water-need-prediction/` | `implemented` | Live water need prediction route. |
| `economy` | `POST` | `/api/economy/overview/` | `implemented` | Live economy route. |
| `plant` | `GET` | `/api/plants/` | `implemented` | Live route. |
| `plant` | `POST` | `/api/plants/` | `implemented` | Live route. |
| `plant` | `GET` | `/api/plants/names/` | `implemented` | Extra route not always reflected in older audits. |
| `plant` | `GET` | `/api/plants/<pk>/` | `implemented` | Live route. |
| `plant` | `POST` | `/api/plants/fetch-info/` | `implemented` | Live route, but operational reliability may still be limited. |
| `pest_disease` | `POST` | `/api/pest-disease/detect/` | `implemented` | Live route. |
| `pest_disease` | `POST` | `/api/pest-disease/risk/` | `implemented` | Live route. |
| `irrigation` | `GET` | `/api/irrigation/` | `implemented` | Live route. |
| `irrigation` | `POST` | `/api/irrigation/` | `implemented` | Live route on AI service. |
| `irrigation` | `GET` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `PUT` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `PATCH` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `DELETE` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `POST` | `/api/irrigation/recommend/` | `implemented` | Live route. |
| `irrigation` | `POST` | `/api/irrigation/plan-from-text/` | `implemented` | Live route. |
| `irrigation` | `POST` | `/api/irrigation/water-stress/` | `implemented` | Live route. |
| `fertilization` | `POST` | `/api/fertilization/recommend/` | `implemented` | Live route. |
| `fertilization` | `POST` | `/api/fertilization/plan-from-text/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/current-farm-chart/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/harvest-prediction/` | `implemented` | Live route. |
| `crop_simulation` | `GET` | `/api/crop-simulation/yield-harvest-summary/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/yield-prediction/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/growth/` | `implemented` | Live route. |
| `crop_simulation` | `GET` | `/api/crop-simulation/growth/<task_id>/status/` | `implemented` | Live route. |
## Important Corrections
- `farm-alerts/timeline` is not an AI route and must not be listed as one.
- `risk-summary` belongs to backend aliasing in `Backend/pest_detection`, not to the AI `pest_disease` app.
- `plant` and `irrigation` have richer real route coverage than older audits claimed.
- `location_data`, `farm_data`, and `crop_simulation` routes are real service routes and should not be described as mock-only.
+39
View File
@@ -0,0 +1,39 @@
FROM docker.iranserver.com/python:3.10
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Debian/debian mirrors for apt
RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
printf '%s\n' \
'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \
'' \
> /etc/apt/sources.list
# System deps for MySQL client (pkg-config required by mysqlclient to find libs)
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \
build-essential \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt constraints.txt ./
RUN PIP_CONSTRAINT=/app/constraints.txt \
pip install \
--prefer-binary \
--trusted-host mirror-pypi.runflare.com \
-r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . .
EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+39
View File
@@ -0,0 +1,39 @@
FROM mirror-docker.runflare.com/library/python:3.10
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Debian mirror configuration
RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
printf '%s\n' \
'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \
'' \
> /etc/apt/sources.list
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \
build-essential \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt constraints.txt /app/
RUN PIP_CONSTRAINT=/app/constraints.txt \
pip install \
--prefer-binary \
--index-url https://mirror-pypi.runflare.com/simple \
--trusted-host mirror-pypi.runflare.com \
-r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . .
EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
@@ -0,0 +1,688 @@
# راهنمای کامل PCSE در این پروژه
این سند توضیح می‌دهد `PCSE` در این پروژه دقیقا چه نقشی دارد، چگونه داده‌ها را مصرف می‌کند، خروجی آن چگونه ساخته می‌شود، و این خروجی چه اثری روی توصیه‌های آبیاری و کودهی دارد. تمرکز اصلی این راهنما روی اتصال بین این فایل‌ها است:
- `crop_simulation/services.py`
- `crop_simulation/recommendation_optimizer.py`
- `crop_simulation/apps.py`
- `irrigation/apps.py`
- `fertilization/apps.py`
- `rag/services/irrigation.py`
- `rag/services/fertilization.py`
---
## 1) PCSE چیست؟
`PCSE` مخفف `Python Crop Simulation Environment` است. این کتابخانه یک چارچوب شبیه‌سازی زراعی است که می‌تواند با مدل‌هایی مثل `WOFOST` رشد گیاه، توسعه فنولوژیک، تولید زیست‌توده، عملکرد، و پاسخ به آب و نیتروژن را شبیه‌سازی کند.
در این پروژه، PCSE نقش این‌ها را بر عهده دارد:
- تبدیل داده‌های مزرعه، هوا، خاک و برنامه مدیریت به یک اجرای شبیه‌سازی
- برآورد خروجی‌های کلیدی مثل:
- `yield_estimate`
- `biomass`
- `max_lai`
- مقایسه سناریوهای مختلف آبیاری و کودهی
- تولید یک مبنای عددی برای recommendation engine
به زبان ساده:
- `RAG` متن توصیه را خوش‌بیان و کاربرپسند می‌کند
- `PCSE` منطق عددی و شبیه‌سازی سناریویی را تامین می‌کند
---
## 2) معماری کلی PCSE در این پروژه
جریان اصلی به این صورت است:
1. داده مزرعه از دیتابیس خوانده می‌شود
2. داده هوا از forecastها به فرمت قابل فهم برای PCSE تبدیل می‌شود
3. داده خاک و وضعیت سایت ساخته می‌شود
4. پروفایل گیاه و `agromanagement` آماده می‌شود
5. اگر recommendation آبیاری یا کودهی وجود داشته باشد، به `TimedEvents` تزریق می‌شود
6. مدل PCSE اجرا می‌شود
7. خروجی‌های روزانه، خلاصه و نهایی جمع می‌شوند
8. از روی آن‌ها شاخص عملکرد و recommendation سناریویی ساخته می‌شود
9. RAG از این خروجی به‌عنوان `context_text` استفاده می‌کند تا متن نهایی توصیه را بسازد
---
## 3) نقطه ورود اصلی PCSE
### `crop_simulation/apps.py`
در `crop_simulation/apps.py` یک optimizer سراسری lazy-loaded تعریف شده:
- `recommendation_optimizer`
- `get_recommendation_optimizer()`
این optimizer از کلاس `SimulationRecommendationOptimizer` در `crop_simulation/recommendation_optimizer.py` ساخته می‌شود.
بنابراین:
- `rag/services/irrigation.py` از `apps.get_app_config("crop_simulation").get_recommendation_optimizer()` استفاده می‌کند
- `rag/services/fertilization.py` هم همین کار را انجام می‌دهد
یعنی هر دو سرویس recommendation در نهایت به موتور شبیه‌سازی crop_simulation وصل هستند.
---
## 4) نقش `irrigation/apps.py`
فایل `irrigation/apps.py` فقط یک AppConfig ساده نیست؛ در عمل تنظیمات پایه optimizer آبیاری را نگه می‌دارد.
### پارامترهای مهم
#### `simulation_model`
مدل پیش‌فرض:
```python
"Wofost81_NWLP_CWB_CNB"
```
این یعنی recommendationهای آبیاری قرار است روی مدلی ساخته شوند که هم water-limited و هم nutrient-aware است.
#### `validity_days`
```python
3
```
توصیه آبیاری کوتاه‌مدت است و بیشتر به forecastهای نزدیک متکی است.
#### `minimum_event_mm`
```python
4.0
```
هر نوبت آبیاری نباید از این کمتر شود، چون در عمل آبیاری‌های خیلی کوچک یا بی‌اثرند یا اجرای میدانی ضعیفی دارند.
#### `significant_rain_threshold_mm`
```python
4.0
```
اگر بارش موثر به این آستانه برسد، recommendation می‌تواند محافظه‌کارتر شود یا پنجره اعتبار کوتاه‌تر شود.
#### `stage_targets`
برای هر مرحله رشد یک هدف رطوبت خاک تعریف شده:
- `initial`: 65%
- `vegetative`: 70%
- `flowering`: 75%
- `fruiting`: 68%
این‌ها مستقیما بر `moisture_target_percent` در توصیه نهایی اثر می‌گذارند.
#### `strategy_profiles`
سه استراتژی اصلی برای آبیاری تعریف شده:
- `conservative`
- `balanced`
- `protective`
هر استراتژی این مولفه‌ها را دارد:
- `multiplier`
- `frequency_factor`
- `event_count`
این‌ها تعیین می‌کنند:
- جمع کل آب بیشتر یا کمتر شود
- تعداد نوبت‌ها بیشتر یا کمتر شود
- توزیع آب در طول بازه forecast چگونه باشد
---
## 5) نقش `fertilization/apps.py`
این فایل هم مثل نسخه آبیاری، تنظیمات پایه recommendation optimizer کودهی را نگه می‌دارد.
### پارامترهای مهم
#### `simulation_model`
باز هم مدل:
```python
"Wofost81_NWLP_CWB_CNB"
```
یعنی recommendation کودهی بر مبنای مدلی ساخته می‌شود که نیتروژن را در سطح شبیه‌سازی لحاظ می‌کند.
#### `validity_days`
```python
7
```
پنجره اعتبار کودهی بلندتر از آبیاری است، چون تصمیم تغذیه‌ای معمولاً کندتر و با اثر تجمعی‌تر است.
#### `rain_delay_threshold_mm`
```python
3.0
```
اگر بارش موثر نزدیک باشد، برخی روش‌های مصرف یا زمان مصرف می‌توانند نامناسب شوند.
#### `stage_targets`
برای هر مرحله رشد، هدف تغذیه‌ای جداگانه تعریف شده است:
- `n`
- `p`
- `k`
- `formula`
- `application_method`
- `timing`
مثلا در مرحله `flowering`:
- نیاز پتاس بالاتر می‌شود
- فرمول `15-10-30` پیشنهاد می‌شود
- روش مصرف و timing هم متناسب با حساسیت گیاه تعیین می‌شود
#### `strategy_profiles`
سه استراتژی اصلی:
- `maintenance`
- `balanced`
- `corrective`
هر کدام ضریب مصرف و تمرکز متفاوت دارند. این استراتژی‌ها پایه سناریوسازی برای شبیه‌سازی یا heuristic هستند.
---
## 6) PCSE در `crop_simulation/services.py` چگونه کار می‌کند؟
### 6.1 نرمال‌سازی ورودی‌ها
قبل از اجرای مدل، ورودی‌ها یکنواخت می‌شوند:
- `_normalize_weather_records()`
- `_normalize_agromanagement()`
- `_normalize_site_parameters_for_model()`
### 6.2 ساخت payload مزرعه
تابع `build_simulation_payload_from_farm()` اطلاعات زیر را می‌سازد:
- weather
- soil
- site_parameters
- crop_parameters
- agromanagement
منابع آن:
- `SensorData`
- `WeatherForecast`
- پروفایل گیاه
- داده لایه خاک
### 6.3 ساخت داده خاک و سایت
در این مرحله مقادیر مهمی مثل این‌ها ساخته می‌شوند:
- `SMFCF`
- `SMW`
- `RDMSOL`
- `WAV`
- `NAVAILI`
- `P_STATUS`
- `K_STATUS`
- `SOIL_PH`
- `EC`
تعبیر عملی این مقادیر:
- `WAV`: آب در دسترس اولیه
- `NAVAILI`: نیتروژن اولیه در دسترس
- `P_STATUS` و `K_STATUS`: شاخص‌های وضعیت فسفر و پتاسیم
- `SOIL_PH` و `EC`: شرایط شیمیایی که روی کارایی تغذیه و رشد اثر دارند
### 6.4 لود کردن bindingهای PCSE
تابع `_load_pcse_bindings()` این اجزا را از package `pcse` می‌گیرد:
- `ParameterProvider`
- `WeatherDataProvider`
- `WeatherDataContainer`
- `pcse.models`
اگر package نصب نباشد، اجرای سناریوی واقعی PCSE ممکن نیست.
### 6.5 اجرای مدل
کلاس `PcseSimulationManager` قلب اجرای شبیه‌سازی است.
متد `run_simulation()` این کارها را انجام می‌دهد:
1. ساخت `PreparedSimulationInput`
2. normalize کردن weather / soil / crop / site / agromanagement
3. اجرای `_run_with_pcse()`
4. در مدل‌های `Wofost81_NWLP` اعمال adjustment برای `P` و `K`
### 6.6 خروجی‌های اصلی مدل
بعد از اجرا، این سه نوع خروجی جمع می‌شوند:
- `daily_output`
- `summary_output`
- `terminal_output`
و در نهایت metrics اصلی ساخته می‌شود:
- `yield_estimate`
- `biomass`
- `max_lai`
---
## 7) eventهای recommendation چگونه وارد شبیه‌سازی می‌شوند؟
این مهم‌ترین بخش اتصال PCSE به recommendationها است.
### `_parse_recommendation_events()`
این تابع recommendation خام را به event قابل الحاق تبدیل می‌کند.
برای آبیاری:
- `event_signal = "irrigate"`
- کلید مقدار می‌تواند `amount` یا `irrigation_amount` باشد
برای کودهی:
- `event_signal = "apply_n"`
- کلید مقدار می‌تواند `N_amount` یا `amount` باشد
### `_merge_management_recommendations()`
این تابع recommendationها را داخل `TimedEvents` همان campaign اصلی قرار می‌دهد.
پس وقتی شما یک recommendation جدید می‌دهید، در عمل:
- یک برنامه مدیریت جدید ساخته نمی‌شود
- همان `agromanagement` پایه مزرعه گرفته می‌شود
- eventهای جدید به آن تزریق می‌شوند
این طراحی مهم است چون:
- تقویم اصلی کشت حفظ می‌شود
- فقط تصمیم‌های مدیریتی جدید روی سناریو سوار می‌شوند
---
## 8) recommendation optimizer دقیقا چه می‌کند؟
فایل `crop_simulation/recommendation_optimizer.py` لایه تصمیم‌گیری سناریویی است.
کلاس اصلی:
- `SimulationRecommendationOptimizer`
این کلاس دو مسیر دارد:
- مسیر مبتنی بر PCSE
- مسیر heuristic fallback
اگر داده کافی و پروفایل simulation گیاه موجود باشد، اول تلاش می‌کند از PCSE استفاده کند. اگر نشد، به heuristic برمی‌گردد.
---
## 9) تاثیر PCSE روی توصیه آبیاری
### 9.1 ورودی‌های optimizer آبیاری
متد:
- `optimize_irrigation()`
ورودی‌ها:
- `sensor`
- `plant`
- `forecasts`
- `daily_water_needs`
- `growth_stage`
- `irrigation_method`
### 9.2 وقتی مسیر PCSE فعال می‌شود
اگر برای گیاه `simulation profile` معتبر وجود داشته باشد، متد `_optimize_irrigation_with_pcse()` اجرا می‌شود.
در این مسیر:
1. تنظیمات از `irrigation/apps.py` خوانده می‌شود
2. soil/site از سنسور و عمق خاک ساخته می‌شود
3. forecastها به weather record تبدیل می‌شوند
4. از روی `strategy_profiles` چند سناریوی آبیاری ساخته می‌شود
5. برای هر سناریو، eventهای `irrigate` به `agromanagement` تزریق می‌شود
6. هر سناریو با `run_single_simulation()` اجرا می‌شود
7. از روی `yield_estimate` هر سناریو، `score` ساخته می‌شود
8. بهترین سناریو انتخاب می‌شود
### 9.3 PCSE دقیقا چه چیزی را تغییر می‌دهد؟
PCSE باعث می‌شود recommendation آبیاری فقط بر پایه ET یا بارش نباشد. بلکه تاثیر برنامه آبیاری روی شاخص عملکرد گیاه هم دیده شود.
یعنی سیستم فقط نمی‌گوید:
- امروز 8 میلی‌متر آب بده
بلکه عملاً سناریوها را مقایسه می‌کند:
- اگر آب کمتر بدهیم، عملکرد چقدر افت می‌کند؟
- اگر آب را در نوبت‌های بیشتری پخش کنیم، نتیجه بهتر می‌شود؟
- اگر آبیاری حمایتی بدهیم، نسبت آب به عملکرد چطور تغییر می‌کند؟
### 9.4 خروجی نهایی آبیاری
خروجی recommendation آبیاری شامل این فیلدهاست:
- `total_irrigation_mm`
- `amount_per_event_mm`
- `events`
- `event_dates`
- `timing`
- `moisture_target_percent`
- `validity_period`
- `reasoning`
### 9.5 اثر مستقیم `irrigation/apps.py`
مقادیر این فایل مستقیم روی recommendation اثر دارند:
- `stage_targets` هدف رطوبت خاک را تعیین می‌کند
- `strategy_profiles` candidate scenarioها را تعریف می‌کند
- `validity_days` متن و پنجره اعتبار را تعیین می‌کند
- `minimum_event_mm` جلوی recommendationهای غیرعملی را می‌گیرد
- `significant_rain_threshold_mm` روی logic بارش موثر اثر دارد
### 9.6 اگر PCSE در دسترس نباشد
مسیر `_optimize_irrigation_with_heuristic()` استفاده می‌شود.
در این مسیر امتیازدهی بر اساس این‌هاست:
- نیاز آبی forecast
- بارش موثر
- رطوبت فعلی خاک
- دمای بالا
- باد
- بازده روش آبیاری
اما در این حالت شبیه‌سازی واقعی عملکرد انجام نمی‌شود. پس recommendation سبک‌تر و تخمینی‌تر است.
---
## 10) تاثیر PCSE روی توصیه کودهی
### 10.1 ورودی‌های optimizer کودهی
متد:
- `optimize_fertilization()`
ورودی‌ها:
- `sensor`
- `plant`
- `forecasts`
- `growth_stage`
### 10.2 مسیر PCSE برای کودهی
اگر simulation profile و forecast موجود باشد، `_optimize_fertilization_with_pcse()` اجرا می‌شود.
در این مسیر:
1. تنظیمات از `fertilization/apps.py` خوانده می‌شود
2. stage target مرحله رشد تعیین می‌شود
3. برای هر strategy profile یک دوز نیتروژن متفاوت ساخته می‌شود
4. event `apply_n` روی `TimedEvents` قرار می‌گیرد
5. هر سناریوی کودهی با PCSE اجرا می‌شود
6. `yield_estimate` سناریوها مقایسه می‌شود
7. بهترین استراتژی انتخاب می‌شود
### 10.3 PCSE دقیقا چه کمکی می‌کند؟
PCSE باعث می‌شود recommendation کودهی فقط بر اساس کمبود لحظه‌ای عناصر نباشد، بلکه اثر احتمالی سناریوی مصرف روی خروجی گیاه هم دیده شود.
یعنی سیستم فقط نمی‌گوید:
- چون نیتروژن پایین است، فلان مقدار کود بده
بلکه مقایسه می‌کند:
- اگر سناریوی نگهدارنده اجرا شود، عملکرد چقدر می‌شود؟
- اگر سناریوی اصلاحی اجرا شود، gain عملکردی چقدر است؟
- آیا افزایش دوز واقعاً ارزش دارد یا خیر؟
### 10.4 اثر `fertilization/apps.py`
مقادیر این فایل مستقیما بر recommendation اثر دارند:
- `stage_targets` دوز هدف N/P/K را تعیین می‌کند
- `formula` نوع کود پیشنهادی را تعیین می‌کند
- `application_method` روش مصرف را تعیین می‌کند
- `timing` زمان مناسب مصرف را تعیین می‌کند
- `strategy_profiles` سناریوهای رقابتی را می‌سازد
- `rain_delay_threshold_mm` روی ریسک زمان‌بندی مصرف اثر دارد
- `validity_days` پنجره اعتبار را تعیین می‌کند
### 10.5 heuristic fallback در کودهی
اگر PCSE اجرا نشود، `_optimize_fertilization_with_heuristic()` استفاده می‌شود.
این مسیر بر این مبنا تصمیم می‌گیرد:
- نیتروژن فعلی
- فسفر فعلی
- پتاسیم فعلی
- pH خاک
- مرحله رشد
- بارش پیش‌رو
خروجی آن هنوز ساختاریافته و مفید است، اما مثل مسیر PCSE مقایسه عملکرد شبیه‌سازی‌شده ندارد.
---
## 11) نقش P و K در حالی که event کودهی فقط `apply_n` است
در نسخه فعلی شبیه‌سازی، event مستقیم کودهی که وارد `TimedEvents` می‌شود بیشتر روی `N_amount` تمرکز دارد.
اما پروژه برای `P` و `K` هم یک adjustment تکمیلی دارد:
- `_estimate_pk_stress_factor()`
- `_apply_pk_adjustment()`
این بخش بعد از اجرای PCSE روی خروجی اعمال می‌شود.
منطق آن:
- اگر فسفر پایین باشد، `p_factor` کاهش می‌یابد
- اگر پتاسیم پایین باشد، `k_factor` کاهش می‌یابد
- اگر `pH` یا `EC` نامناسب باشد، penalty اعمال می‌شود
- سپس این factor روی:
- `yield_estimate`
- `biomass`
- `max_lai`
اعمال می‌شود
پس حتی اگر event سناریویی مستقیم بیشتر نیتروژنی باشد، وضعیت `P`, `K`, `pH`, `EC` باز هم روی recommendation نهایی اثر می‌گذارد.
---
## 12) RAG چطور از خروجی PCSE استفاده می‌کند؟
### در `rag/services/irrigation.py`
این سرویس:
1. forecastها و داده مزرعه را می‌گیرد
2. نیاز آبی روزانه را از FAO-56 محاسبه می‌کند
3. optimizer شبیه‌سازی را صدا می‌زند
4. اگر `optimized_result` موجود باشد، `context_text` آن را به prompt اضافه می‌کند
5. LLM پاسخ را تولید می‌کند
6. پاسخ با fallback ساختاریافته merge می‌شود
نکته مهم:
- LLM مرجع عددی اصلی نیست
- `optimized_result` مرجع اصلی اعداد است
این موضوع حتی در prompt پیش‌فرض هم صریح آمده است.
### در `rag/services/fertilization.py`
منطق مشابه است:
1. sensor و forecast خوانده می‌شود
2. optimizer کودهی اجرا می‌شود
3. `context_text` به system prompt اضافه می‌شود
4. LLM متن recommendation را می‌سازد
5. خروجی با fallback عددی merge می‌شود
در نتیجه:
- PCSE و optimizer عددها را می‌سازند
- RAG متن را کاربرپسند و اجرایی می‌کند
---
## 13) تفاوت بین simulation engine و recommendation layer
### لایه simulation
در `crop_simulation/services.py`:
- سناریو اجرا می‌شود
- eventها merge می‌شوند
- خروجی‌های عملکردی تولید می‌شوند
### لایه recommendation
در `crop_simulation/recommendation_optimizer.py`:
- چند سناریو candidate ساخته می‌شود
- همه با simulation یا heuristic ارزیابی می‌شوند
- بهترین گزینه انتخاب می‌شود
- `context_text` برای RAG تولید می‌شود
### لایه presentation
در `rag/services/irrigation.py` و `rag/services/fertilization.py`:
- متن نهایی
- هشدارها
- list itemها
- توضیح توسعه‌پذیر
ساخته می‌شود.
---
## 14) سناریوی واقعی آبیاری در این پروژه
یک نمونه ساده:
1. forecast هفت روز آینده دریافت می‌شود
2. نیاز آبی روزانه محاسبه می‌شود
3. optimizer سه سناریوی آبیاری می‌سازد:
- محافظه‌کارانه
- متعادل
- حمایتی
4. هر سناریو به `TimedEvents` تزریق می‌شود
5. PCSE برای هر سناریو اجرا می‌شود
6. عملکرد نسبی هر سناریو اندازه‌گیری می‌شود
7. بهترین سناریو انتخاب می‌شود
8. RAG همان سناریو را به زبان قابل فهم برای کاربر توضیح می‌دهد
---
## 15) سناریوی واقعی کودهی در این پروژه
یک نمونه ساده:
1. مرحله رشد تشخیص داده می‌شود
2. target غذایی همان مرحله از `fertilization/apps.py` خوانده می‌شود
3. چند سناریوی دوز و شدت مصرف ساخته می‌شود
4. برای هر سناریو event `apply_n` ساخته می‌شود
5. PCSE سناریوها را اجرا می‌کند
6. خروجی عملکرد مقایسه می‌شود
7. بهترین برنامه انتخاب می‌شود
8. RAG آن را به صورت JSON ساختاریافته به کاربر برمی‌گرداند
---
## 16) مزیت‌های استفاده از PCSE در توصیه آبیاری و کودهی
- recommendationها فقط rule-based نیستند
- تصمیم‌ها بر پایه مقایسه سناریو هستند
- امکان اتصال داده واقعی مزرعه به مدل رشد وجود دارد
- مرحله رشد، هوا، خاک و مدیریت همزمان دیده می‌شوند
- recommendation خروجی قابل توضیح‌تری برای LLM تولید می‌کند
---
## 17) محدودیت‌های فعلی پیاده‌سازی
این پروژه عملی و مفید است، اما چند محدودیت مهم دارد:
- کیفیت recommendation وابسته به کیفیت `simulation profile` گیاه است
- اگر پروفایل simulation وجود نداشته باشد، سیستم به heuristic fallback می‌رود
- event کودهی در شبیه‌سازی فعلی بیشتر نیتروژن‌محور است
- `P` و `K` به شکل adjustment پس از اجرا اعمال می‌شوند، نه لزوما event-driven کامل
- forecastهای هوا کوتاه‌مدت‌اند؛ پس recommendationها مخصوص تصمیم‌گیری عملیاتی نزدیک هستند
- score برخی سناریوها از `yield_estimate / 100` ساخته می‌شود و هنوز می‌تواند با calibration دقیق‌تر بهبود یابد
---
## 18) جمع‌بندی عملی
اگر بخواهیم نقش PCSE را در یک جمله خلاصه کنیم:
> PCSE در این پروژه موتور سنجش اثر تصمیم‌های آبیاری و کودهی روی عملکرد احتمالی گیاه است.
و اگر بخواهیم نقش `irrigation/apps.py` و `fertilization/apps.py` را هم در یک جمله بگوییم:
> این دو فایل policy و defaultهای تصمیم‌گیری را تعریف می‌کنند، و optimizer با استفاده از همان policyها سناریو می‌سازد و با PCSE ارزیابی می‌کند.
بنابراین خروجی نهایی recommendation حاصل ترکیب سه لایه است:
1. داده واقعی مزرعه و forecast
2. شبیه‌سازی سناریویی با PCSE
3. تولید پاسخ ساختاریافته و قابل فهم با RAG
---
## 19) فایل‌هایی که اگر بخواهید این سیستم را توسعه دهید باید اول ببینید
- `crop_simulation/services.py`
- `crop_simulation/recommendation_optimizer.py`
- `crop_simulation/apps.py`
- `irrigation/apps.py`
- `fertilization/apps.py`
- `rag/services/irrigation.py`
- `rag/services/fertilization.py`
اگر بخواهید behavior سیستم را تغییر دهید:
- برای تغییر policy آبیاری: `irrigation/apps.py`
- برای تغییر policy کودهی: `fertilization/apps.py`
- برای تغییر منطق ارزیابی سناریو: `crop_simulation/recommendation_optimizer.py`
- برای تغییر اجرای واقعی مدل: `crop_simulation/services.py`
- برای تغییر متن و ساختار پاسخ: `rag/services/irrigation.py` و `rag/services/fertilization.py`
+594
View File
@@ -0,0 +1,594 @@
# مستند API سنسورها برای فرانت
این فایل قرارداد پیشنهادی/هدف برای endpointهای سنسوری زیر است و بر اساس نیاز اعلام‌شده تهیه شده است:
- `GET /api/sensor-7-in-1/summary/`
- `GET /api/sensors/comparison-chart/`
- `GET /api/sensors/radar-chart/`
- `GET /api/sensors/values-list/`
- `GET /api/sensor-external-api/logs/`
نکته مهم:
- این سند بر اساس نیاز محصول و قرارداد موردنظر فرانت نوشته شده است.
- در این قرارداد دیگر `physical_device_uuid` از فرانت گرفته نمی‌شود.
- مبنای جست‌وجو فقط `farm_uuid` است.
- backend باید با استفاده از `farm_uuid`، رکورد مزرعه را پیدا کند و اولین سنسور خاک را به‌عنوان سنسور مبنا انتخاب کند.
- اگر `range` ارسال نشود، backend باید بدون خطا مقدار پیش‌فرض `7` روز را در نظر بگیرد.
---
## 1) قواعد عمومی
### آدرس پایه
- پیشوند تمام مسیرها: `/api/`
### فرمت پاسخ
همه endpointها بهتر است envelope استاندارد زیر را برگردانند:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
### پارامترهای مشترک
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض: `7`
### رفتار `range`
- اگر `range` ارسال نشده باشد: backend باید `7` را استفاده کند.
- اگر `range` کمتر از `1` باشد: بهتر است `400` برگردد.
- اگر `range` خیلی بزرگ باشد: پیشنهاد می‌شود backend آن را محدود کند، مثلا حداکثر `90`.
نمونه:
- `/api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111`
- `/api/sensors/comparison-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=30`
### منطق انتخاب سنسور
با دریافت `farm_uuid`:
1. رکورد `farm_data.SensorData` پیدا می‌شود.
2. از `sensor_payload` اولین سنسور خاک انتخاب می‌شود.
3. اگر چند سنسور موجود باشد، اولویت پیشنهادی:
- اولین کلیدی که با `sensor-7` یا `sensor-7-in-1` شروع می‌شود
- اگر نبود، اولین block معتبر از نوع object
4. همان سنسور برای ساخت summary، chart و values استفاده می‌شود.
### خطاهای مشترک
#### 400 — ورودی نامعتبر
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"farm_uuid": [
"This field is required."
]
}
}
```
یا:
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"range": [
"range باید عددی بزرگ‌تر از صفر باشد."
]
}
}
```
#### 404 — مزرعه یا سنسور پیدا نشد
```json
{
"code": 404,
"msg": "farm یا سنسور خاک یافت نشد.",
"data": null
}
```
#### 200 — بدون داده کافی
اگر مزرعه وجود داشته باشد ولی history کافی برای بازه در دسترس نباشد، پیشنهاد می‌شود endpoint به‌جای خطا، پاسخ موفق با `data` خالی یا حداقلی برگرداند.
---
## 2) GET /api/sensor-7-in-1/summary/
### هدف
نمایش خلاصه سریع آخرین وضعیت سنسور خاک انتخاب‌شده برای یک مزرعه.
### Query Params
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض `7`
### منطق پاسخ
- آخرین reading سنسور انتخاب‌شده نمایش داده می‌شود.
- اگر داده historical موجود باشد، trend نسبت به بازه `range` محاسبه می‌شود.
- این endpoint مناسب hero cards و summary cards فرانت است.
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_key": "sensor-7-1",
"range": 7,
"last_updated_at": "2026-04-29T10:20:00Z",
"summary": {
"soil_moisture": {
"label": "رطوبت خاک",
"value": 31.2,
"unit": "%",
"trend": "up",
"change": 2.1,
"change_unit": "%"
},
"soil_temperature": {
"label": "دمای خاک",
"value": 22.8,
"unit": "°C",
"trend": "stable",
"change": 0.3,
"change_unit": "°C"
},
"soil_ph": {
"label": "pH خاک",
"value": 6.9,
"unit": "",
"trend": "down",
"change": -0.1,
"change_unit": ""
},
"electrical_conductivity": {
"label": "هدایت الکتریکی",
"value": 1.4,
"unit": "mS/cm",
"trend": "stable",
"change": 0.0,
"change_unit": "mS/cm"
},
"nitrogen": {
"label": "نیتروژن",
"value": 28.0,
"unit": "mg/kg",
"trend": "up",
"change": 1.8,
"change_unit": "mg/kg"
},
"phosphorus": {
"label": "فسفر",
"value": 14.5,
"unit": "mg/kg",
"trend": "stable",
"change": 0.4,
"change_unit": "mg/kg"
},
"potassium": {
"label": "پتاسیم",
"value": 21.7,
"unit": "mg/kg",
"trend": "down",
"change": -0.9,
"change_unit": "mg/kg"
}
}
}
}
```
### فیلدهای خروجی
- `farm_uuid`: شناسه مزرعه
- `sensor_key`: کلید سنسور انتخاب‌شده از `sensor_payload`
- `range`: بازه واقعی استفاده‌شده
- `last_updated_at`: زمان آخرین reading یا آخرین به‌روزرسانی
- `summary`: آبجکت شامل KPIهای اصلی
### ساختار هر KPI
- `label`: عنوان فارسی
- `value`: آخرین مقدار
- `unit`: واحد
- `trend`: یکی از `up | down | stable | unknown`
- `change`: اختلاف با ابتدای بازه یا میانگین بازه
- `change_unit`: واحد اختلاف
---
## 3) GET /api/sensors/comparison-chart/
### هدف
برگرداندن داده chart مقایسه‌ای برای چند پارامتر سنسور در طول بازه زمانی.
### Query Params
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض `7`
### منطق پاسخ
- فقط یک سنسور مبنا از روی `farm_uuid` انتخاب می‌شود.
- برای همان سنسور، سری‌های چند متریک در طول بازه برگردانده می‌شود.
- فرانت می‌تواند آن را به line chart یا multi-series chart تبدیل کند.
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_key": "sensor-7-1",
"range": 7,
"categories": [
"2026-04-23",
"2026-04-24",
"2026-04-25",
"2026-04-26",
"2026-04-27",
"2026-04-28",
"2026-04-29"
],
"series": [
{
"key": "soil_moisture",
"label": "رطوبت خاک",
"unit": "%",
"data": [29.1, 28.7, 30.4, 30.0, 31.1, 31.0, 31.2]
},
{
"key": "soil_temperature",
"label": "دمای خاک",
"unit": "°C",
"data": [21.4, 21.8, 22.0, 22.1, 22.2, 22.6, 22.8]
},
{
"key": "electrical_conductivity",
"label": "هدایت الکتریکی",
"unit": "mS/cm",
"data": [1.2, 1.3, 1.3, 1.4, 1.4, 1.4, 1.4]
}
]
}
}
```
### فیلدهای خروجی
- `categories`: برچسب‌های محور زمان
- `series`: آرایه سری‌ها
### ساختار هر سری
- `key`: کلید متریک
- `label`: نام نمایشی
- `unit`: واحد
- `data`: آرایه مقادیر هم‌طول با `categories`
---
## 4) GET /api/sensors/radar-chart/
### هدف
دادن داده مناسب radar chart برای مقایسه هم‌زمان وضعیت متریک‌های اصلی سنسور.
### Query Params
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض `7`
### منطق پاسخ
- برای هر متریک، یک مقدار خلاصه از بازه ساخته می‌شود؛ مثلا:
- آخرین مقدار
- میانگین بازه
- یا score نرمال‌شده 0 تا 100
- برای radar chart پیشنهاد می‌شود score نهایی نرمال‌شده برگردانده شود.
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_key": "sensor-7-1",
"range": 7,
"labels": [
"رطوبت خاک",
"دمای خاک",
"pH خاک",
"هدایت الکتریکی",
"نیتروژن",
"فسفر",
"پتاسیم"
],
"series": [
{
"name": "وضعیت فعلی",
"data": [72, 64, 81, 58, 69, 61, 74]
}
],
"raw_metrics": [
{
"key": "soil_moisture",
"label": "رطوبت خاک",
"value": 31.2,
"unit": "%",
"score": 72
},
{
"key": "soil_temperature",
"label": "دمای خاک",
"value": 22.8,
"unit": "°C",
"score": 64
},
{
"key": "soil_ph",
"label": "pH خاک",
"value": 6.9,
"unit": "",
"score": 81
}
]
}
}
```
### فیلدهای خروجی
- `labels`: برچسب‌های radar
- `series`: داده آماده برای chart
- `raw_metrics`: داده خام برای tooltip و جزئیات بیشتر
---
## 5) GET /api/sensors/values-list/
### هدف
برگرداندن لیست tabular از مقادیر سنسور برای بازه زمانی، مناسب table یا export.
### Query Params
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض `7`
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_key": "sensor-7-1",
"range": 7,
"count": 3,
"items": [
{
"recorded_at": "2026-04-29T10:20:00Z",
"soil_moisture": 31.2,
"soil_temperature": 22.8,
"soil_ph": 6.9,
"electrical_conductivity": 1.4,
"nitrogen": 28.0,
"phosphorus": 14.5,
"potassium": 21.7
},
{
"recorded_at": "2026-04-28T10:20:00Z",
"soil_moisture": 31.0,
"soil_temperature": 22.6,
"soil_ph": 7.0,
"electrical_conductivity": 1.4,
"nitrogen": 27.5,
"phosphorus": 14.1,
"potassium": 22.1
},
{
"recorded_at": "2026-04-27T10:20:00Z",
"soil_moisture": 31.1,
"soil_temperature": 22.2,
"soil_ph": 7.0,
"electrical_conductivity": 1.3,
"nitrogen": 27.2,
"phosphorus": 14.0,
"potassium": 22.6
}
]
}
}
```
### فیلدهای خروجی
- `count`: تعداد رکوردها
- `items`: لیست ردیف‌ها
- هر ردیف شامل timestamp و مقادیر متریک‌ها
### رفتار پیشنهادی
- ترتیب رکوردها: جدید به قدیم
- اگر داده تاریخی نداریم ولی آخرین payload فعلی موجود است، حداقل یک item با آخرین وضعیت برگردانده شود
---
## 6) GET /api/sensor-external-api/logs/
### هدف
نمایش لاگ‌های مربوط به سینک یا واکشی داده سنسور از API بیرونی، مناسب صفحه monitoring یا audit.
### Query Params
- `farm_uuid``uuid string` — الزامی
- `range``integer` — اختیاری — پیش‌فرض `7`
### توضیح دامنه
این endpoint برای نمایش لاگ‌های integration است، نه لزوما readingهای سنسور.
اگر backend هنوز لاگ جداگانه برای external sensor sync نداشته باشد، پیشنهاد می‌شود ساختار زیر مبنای پیاده‌سازی قرار بگیرد.
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"range": 7,
"count": 4,
"items": [
{
"id": 104,
"status": "success",
"source": "sensor-external-api",
"sensor_key": "sensor-7-1",
"requested_at": "2026-04-29T10:20:00Z",
"finished_at": "2026-04-29T10:20:01Z",
"duration_ms": 842,
"http_status": 200,
"message": "داده سنسور با موفقیت واکشی شد."
},
{
"id": 103,
"status": "error",
"source": "sensor-external-api",
"sensor_key": "sensor-7-1",
"requested_at": "2026-04-28T10:20:00Z",
"finished_at": "2026-04-28T10:21:00Z",
"duration_ms": 60000,
"http_status": 504,
"message": "Timeout هنگام واکشی داده سنسور."
}
]
}
}
```
### فیلدهای خروجی
- `status`: یکی از `success | error | timeout | partial`
- `source`: نام provider یا سرویس خارجی
- `requested_at`: زمان شروع درخواست
- `finished_at`: زمان پایان
- `duration_ms`: مدت زمان
- `http_status`: وضعیت HTTP سرویس بیرونی
- `message`: پیام خلاصه برای UI
---
## 7) رفتار پیشنهادی در نبود `range`
در همه endpointهای این سند:
- اگر `range` ارسال نشده باشد:
```json
{
"range": 7
}
```
باید به‌صورت implicit استفاده شود و endpoint نباید خطای validation برگرداند.
---
## 8) رفتار پیشنهادی در نبود `physical_device_uuid`
فرانت نباید `physical_device_uuid` ارسال کند.
backend باید:
- فقط `farm_uuid` را بگیرد
- سنسور را از روی `sensor_payload` یا mapping داخلی انتخاب کند
- `sensor_key` نهایی را در پاسخ برگرداند تا فرانت بداند داده از کدام سنسور آمده است
---
## 9) پیشنهاد استاندارد برای متریک‌ها
برای هماهنگی فرانت و بک، بهتر است حداقل این کلیدها در endpointها پشتیبانی شوند:
- `soil_moisture`
- `soil_temperature`
- `soil_ph`
- `electrical_conductivity`
- `nitrogen`
- `phosphorus`
- `potassium`
### واحدهای پیشنهادی
- `soil_moisture``%`
- `soil_temperature``°C`
- `soil_ph` → بدون واحد
- `electrical_conductivity``mS/cm`
- `nitrogen``mg/kg`
- `phosphorus``mg/kg`
- `potassium``mg/kg`
---
## 10) پیشنهاد برای وضعیت‌های فرانت
### loading
- هنگام request، فرانت skeleton یا spinner نشان دهد
### empty
- اگر `items: []` یا `series: []` برگشت:
- پیام مناسب مثل `داده‌ای برای این بازه ثبت نشده است.` نمایش داده شود
### partial
- اگر بعضی متریک‌ها `null` باشند:
- chart فقط seriesهای موجود را نمایش دهد
- در table برای فیلدهای خالی `—` نمایش داده شود
---
## 11) جمع‌بندی قرارداد
برای این 5 endpoint، قرارداد موردنیاز فرانت به‌صورت خلاصه:
- ورودی اصلی فقط `farm_uuid`
- `physical_device_uuid` حذف شود
- `range` اختیاری باشد
- اگر `range` نیامد، مقدار پیش‌فرض `7` در نظر گرفته شود
- backend اولین سنسور خاک مزرعه را انتخاب کند
- `sensor_key` انتخاب‌شده در response برگردانده شود
- responseها envelope استاندارد `code/msg/data` داشته باشند
+41
View File
@@ -0,0 +1,41 @@
from .common import RouteContract
from .crop_simulation_current_farm_chart import CONTRACT as CROP_SIMULATION_CURRENT_FARM_CHART_CONTRACT
from .crop_simulation_growth import CONTRACT as CROP_SIMULATION_GROWTH_CONTRACT
from .crop_simulation_growth_status import CONTRACT as CROP_SIMULATION_GROWTH_STATUS_CONTRACT
from .crop_simulation_harvest_prediction import CONTRACT as CROP_SIMULATION_HARVEST_PREDICTION_CONTRACT
from .crop_simulation_yield_harvest_summary import CONTRACT as CROP_SIMULATION_YIELD_HARVEST_SUMMARY_CONTRACT
from .crop_simulation_yield_prediction import CONTRACT as CROP_SIMULATION_YIELD_PREDICTION_CONTRACT
from .economy_overview import CONTRACT as ECONOMY_OVERVIEW_CONTRACT
from .farm_data_upsert import CONTRACT as FARM_DATA_UPSERT_CONTRACT
from .fertilization_recommend import CONTRACT as FERTILIZATION_RECOMMEND_CONTRACT
from .irrigation_list import CONTRACT as IRRIGATION_LIST_CONTRACT
from .irrigation_recommend import CONTRACT as IRRIGATION_RECOMMEND_CONTRACT
from .rag_chat import CONTRACT as RAG_CHAT_CONTRACT
from .soile_anomaly_detection import CONTRACT as SOILE_ANOMALY_DETECTION_CONTRACT
from .soile_health_summary import CONTRACT as SOILE_HEALTH_SUMMARY_CONTRACT
from .soile_moisture_heatmap import CONTRACT as SOILE_MOISTURE_HEATMAP_CONTRACT
from .weather_water_need_prediction import CONTRACT as WEATHER_WATER_NEED_PREDICTION_CONTRACT
ROUTE_CONTRACTS: dict[str, RouteContract] = {
contract.path: contract
for contract in [
RAG_CHAT_CONTRACT,
SOILE_MOISTURE_HEATMAP_CONTRACT,
SOILE_HEALTH_SUMMARY_CONTRACT,
SOILE_ANOMALY_DETECTION_CONTRACT,
FARM_DATA_UPSERT_CONTRACT,
WEATHER_WATER_NEED_PREDICTION_CONTRACT,
ECONOMY_OVERVIEW_CONTRACT,
IRRIGATION_LIST_CONTRACT,
IRRIGATION_RECOMMEND_CONTRACT,
FERTILIZATION_RECOMMEND_CONTRACT,
CROP_SIMULATION_GROWTH_CONTRACT,
CROP_SIMULATION_GROWTH_STATUS_CONTRACT,
CROP_SIMULATION_CURRENT_FARM_CHART_CONTRACT,
CROP_SIMULATION_HARVEST_PREDICTION_CONTRACT,
CROP_SIMULATION_YIELD_HARVEST_SUMMARY_CONTRACT,
CROP_SIMULATION_YIELD_PREDICTION_CONTRACT,
]
}
__all__ = ['ROUTE_CONTRACTS', 'RouteContract']
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Generic, TypeAlias, TypeVar
from pydantic import BaseModel, ConfigDict
JsonValue: TypeAlias = Any
JsonObject: TypeAlias = dict[str, Any]
JsonList: TypeAlias = list[Any]
T = TypeVar('T')
class SchemaModel(BaseModel):
model_config = ConfigDict(extra='allow', populate_by_name=True)
class ApiEnvelope(SchemaModel, Generic[T]):
code: int
msg: str
data: T
class RouteContract(SchemaModel):
method: str
path: str
request_model: str
response_model: str
class EmptyRequest(SchemaModel):
pass
@@ -0,0 +1,42 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/current-farm-chart/'
class CropSimulationCurrentFarmChartRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationCurrentFarmChartResponseData(SchemaModel):
farm_uuid: str | None = None
plant_name: str | None = None
engine: str | None = None
model_name: str | None = None
scenario_id: int | None = None
simulation_warning: str | None = None
categories: list[str] = Field(default_factory=list)
series: JsonValue | None = None
summary: JsonObject = Field(default_factory=dict)
current_state: JsonObject = Field(default_factory=dict)
metrics: JsonObject = Field(default_factory=dict)
daily_output: JsonObject = Field(default_factory=dict)
class CropSimulationCurrentFarmChartResponse(ApiEnvelope[CropSimulationCurrentFarmChartResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationCurrentFarmChartRequest.__name__,
response_model=CropSimulationCurrentFarmChartResponse.__name__,
)
@@ -0,0 +1,46 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field, model_validator
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/growth/'
class CropSimulationGrowthRequest(SchemaModel):
plant_name: str
dynamic_parameters: list[str] = Field(min_length=1)
farm_uuid: UUID | None = None
weather: JsonValue | None = None
soil_parameters: JsonObject | None = None
site_parameters: JsonObject | None = None
crop_parameters: JsonObject | None = None
agromanagement: JsonObject | None = None
page_size: int | None = Field(default=None, ge=1, le=50)
@model_validator(mode='after')
def validate_farm_or_weather(self) -> 'CropSimulationGrowthRequest':
if self.farm_uuid is None and self.weather is None:
raise ValueError('Either farm_uuid or weather must be provided.')
return self
class CropSimulationGrowthResponseData(SchemaModel):
task_id: str
status_url: str
plant_name: str
class CropSimulationGrowthResponse(ApiEnvelope[CropSimulationGrowthResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationGrowthRequest.__name__,
response_model=CropSimulationGrowthResponse.__name__,
)
@@ -0,0 +1,59 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/crop-simulation/growth/<task_id>/status/'
class CropSimulationGrowthStatusRequest(SchemaModel):
task_id: str
page: int | None = Field(default=None, ge=1)
page_size: int | None = Field(default=None, ge=1)
class CropSimulationPagination(SchemaModel):
page: int
page_size: int
total_items: int
total_pages: int
has_next: bool
has_previous: bool
class CropSimulationGrowthResult(SchemaModel):
plant_name: str | None = None
dynamic_parameters: list[str] = Field(default_factory=list)
engine: str | None = None
model_name: str | None = None
scenario_id: int | None = None
simulation_warning: str | None = None
summary_metrics: JsonObject = Field(default_factory=dict)
stage_timeline: JsonList = Field(default_factory=list)
stages_page: JsonList = Field(default_factory=list)
pagination: CropSimulationPagination | None = None
daily_records_count: int | None = None
default_page_size: int | None = None
class CropSimulationGrowthStatusResponseData(SchemaModel):
task_id: str
status: str
message: str | None = None
progress: JsonObject = Field(default_factory=dict)
result: CropSimulationGrowthResult | None = None
error: str | None = None
class CropSimulationGrowthStatusResponse(ApiEnvelope[CropSimulationGrowthStatusResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationGrowthStatusRequest.__name__,
response_model=CropSimulationGrowthStatusResponse.__name__,
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/harvest-prediction/'
class CropSimulationHarvestPredictionRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationHarvestPredictionResponseData(SchemaModel):
date: str
dateFormatted: str
daysUntil: int
description: str | None = None
optimalWindowStart: str | None = None
optimalWindowEnd: str | None = None
gddDetails: JsonObject = Field(default_factory=dict)
class CropSimulationHarvestPredictionResponse(ApiEnvelope[CropSimulationHarvestPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationHarvestPredictionRequest.__name__,
response_model=CropSimulationHarvestPredictionResponse.__name__,
)
@@ -0,0 +1,40 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/crop-simulation/yield-harvest-summary/'
class CropSimulationYieldHarvestSummaryRequest(SchemaModel):
farm_uuid: UUID
season_year: int | None = None
crop_name: str | None = None
include_narrative: bool | None = None
class CropSimulationYieldHarvestSummaryResponseData(SchemaModel):
farm_uuid: str
season_highlights_card: JsonObject = Field(default_factory=dict)
yield_prediction: JsonObject = Field(default_factory=dict)
harvest_prediction_card: JsonObject = Field(default_factory=dict)
harvest_readiness_zones: JsonObject = Field(default_factory=dict)
yield_quality_bands: JsonObject = Field(default_factory=dict)
harvest_operations_card: JsonObject = Field(default_factory=dict)
yield_prediction_chart: JsonObject = Field(default_factory=dict)
class CropSimulationYieldHarvestSummaryResponse(ApiEnvelope[CropSimulationYieldHarvestSummaryResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationYieldHarvestSummaryRequest.__name__,
response_model=CropSimulationYieldHarvestSummaryResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/yield-prediction/'
class CropSimulationYieldPredictionRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationYieldPredictionResponseData(SchemaModel):
farm_uuid: str
plant_name: str | None = None
predictedYieldTons: float | None = None
predictedYieldRaw: float | None = None
unit: str | None = None
sourceUnit: str | None = None
simulationEngine: str | None = None
simulationModel: str | None = None
scenarioId: int | None = None
simulationWarning: str | None = None
supportingMetrics: JsonObject = Field(default_factory=dict)
class CropSimulationYieldPredictionResponse(ApiEnvelope[CropSimulationYieldPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationYieldPredictionRequest.__name__,
response_model=CropSimulationYieldPredictionResponse.__name__,
)
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/economy/overview/'
class EconomyOverviewRequest(SchemaModel):
farm_uuid: UUID
class EconomyDataItem(SchemaModel):
title: str
value: str
subtitle: str | None = None
avatarIcon: str | None = None
avatarColor: str | None = None
class ChartSeriesItem(SchemaModel):
name: str
data: list[float] = Field(default_factory=list)
class EconomyOverviewResponseData(SchemaModel):
farm_uuid: str
source: str | None = None
economicData: list[EconomyDataItem] = Field(default_factory=list)
chartSeries: list[ChartSeriesItem] = Field(default_factory=list)
chartCategories: list[str] = Field(default_factory=list)
class EconomyOverviewResponse(ApiEnvelope[EconomyOverviewResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=EconomyOverviewRequest.__name__,
response_model=EconomyOverviewResponse.__name__,
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field, model_validator
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/farm-data/'
class FarmBoundaryCorner(SchemaModel):
lat: float
lon: float
class FarmDataUpsertRequest(SchemaModel):
farm_uuid: UUID
farm_boundary: JsonObject
sensor_key: str | None = 'sensor-7-1'
sensor_payload: JsonObject | None = None
plant_ids: list[int] = Field(default_factory=list)
irrigation_method_id: int | None = None
@model_validator(mode='after')
def validate_payload_sources(self) -> 'FarmDataUpsertRequest':
if not self.sensor_payload and not self.plant_ids and self.irrigation_method_id is None:
raise ValueError('At least one of sensor_payload, plant_ids or irrigation_method_id must be provided.')
return self
class FarmDataUpsertResponseData(SchemaModel):
farm_uuid: UUID
center_location_id: int | None = None
weather_forecast_id: int | None = None
sensor_payload: JsonObject = Field(default_factory=dict)
plant_ids: list[int] = Field(default_factory=list)
irrigation_method_id: int | None = None
created_at: str | None = None
updated_at: str | None = None
class FarmDataUpsertResponse(ApiEnvelope[FarmDataUpsertResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FarmDataUpsertRequest.__name__,
response_model=FarmDataUpsertResponse.__name__,
)
@@ -0,0 +1,125 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/fertilization/recommend/'
class FertilizationRecommendRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
crop_id: str | None = None
plant_name: str | None = None
growth_stage: str | None = None
query: str | None = None
class NpkRatio(SchemaModel):
n: float
p: float
k: float
label: str
class ApplicationMethod(SchemaModel):
id: str
label: str
class ApplicationInterval(SchemaModel):
value: int
unit: Literal['day', 'week']
label: str
class Dosage(SchemaModel):
base_amount_per_hectare: float
base_amount_per_square_meter: float
unit: Literal['kg', 'gram', 'liter', 'milliliter']
label: str
calculation_basis: str
class PrimaryRecommendation(SchemaModel):
fertilizer_code: str
fertilizer_name: str
display_title: str
fertilizer_type: str
npk_ratio: NpkRatio
application_method: ApplicationMethod
application_interval: ApplicationInterval
dosage: Dosage
reasoning: str
summary: str
class NutrientItem(SchemaModel):
key: str
name: str
value: float
unit: Literal['percent']
description: str | None = None
class NutrientAnalysis(SchemaModel):
macro: list[NutrientItem] = Field(default_factory=list)
micro: list[NutrientItem] = Field(default_factory=list)
class ApplicationGuideStep(SchemaModel):
step_number: int
title: str
description: str
class ApplicationGuide(SchemaModel):
safety_warning: str
steps: list[ApplicationGuideStep] = Field(default_factory=list)
class AlternativeRecommendation(SchemaModel):
fertilizer_code: str
fertilizer_name: str
fertilizer_type: str
usage_method: str
description: str
class FertilizationSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
fertilizerType: str | None = None
amount: str | None = None
applicationMethod: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
class FertilizationRecommendResponseData(SchemaModel):
primary_recommendation: PrimaryRecommendation
nutrient_analysis: NutrientAnalysis
application_guide: ApplicationGuide
alternative_recommendations: list[AlternativeRecommendation] = Field(default_factory=list)
sections: list[FertilizationSection] = Field(default_factory=list)
class FertilizationRecommendResponse(ApiEnvelope[FertilizationRecommendResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FertilizationRecommendRequest.__name__,
response_model=FertilizationRecommendResponse.__name__,
)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pydantic import RootModel
from .common import EmptyRequest, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/irrigation/'
class IrrigationListRequest(EmptyRequest):
pass
class IrrigationMethodSchema(SchemaModel):
id: int
name: str
category: str | None = None
description: str | None = None
water_efficiency_percent: float | None = None
water_pressure_required: str | None = None
flow_rate: str | None = None
coverage_area: str | None = None
soil_type: str | None = None
climate_suitability: str | None = None
created_at: str | None = None
updated_at: str | None = None
class IrrigationListResponse(RootModel[list[IrrigationMethodSchema]]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=IrrigationListRequest.__name__,
response_model=IrrigationListResponse.__name__,
)
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/irrigation/recommend/'
class IrrigationRecommendRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
plant_name: str | None = None
growth_stage: str | None = None
irrigation_method_name: str | None = None
query: str | None = None
class IrrigationSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
frequency: str | None = None
amount: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
class IrrigationRecommendResponseData(SchemaModel):
sections: list[IrrigationSection] = Field(default_factory=list)
class IrrigationRecommendResponse(ApiEnvelope[IrrigationRecommendResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=IrrigationRecommendRequest.__name__,
response_model=IrrigationRecommendResponse.__name__,
)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/rag/chat/'
class RagChatRequest(SchemaModel):
farm_uuid: UUID
query: str | None = None
message: str | None = None
history: list[JsonObject] | str | None = None
image_urls: list[str] = Field(default_factory=list)
image: str | None = None
images: list[str] = Field(default_factory=list)
class RagChatSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info', 'summary']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
primaryAction: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
metadata: JsonValue | None = None
class RagChatResponseData(SchemaModel):
sections: list[RagChatSection] = Field(default_factory=list)
class RagChatResponse(ApiEnvelope[RagChatResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=RagChatRequest.__name__,
response_model=RagChatResponse.__name__,
)
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/anomaly-detection/'
class SoileAnomalyDetectionRequest(SchemaModel):
farm_uuid: UUID
class SoileAnomalyDetectionResponseData(SchemaModel):
farm_uuid: str
summary: str
explanation: str | None = None
likely_cause: str | None = None
recommended_action: str | None = None
monitoring_priority: Literal['low', 'medium', 'high', 'urgent'] | str
confidence: float | None = None
generated_at: str | None = None
anomalies: JsonList = Field(default_factory=list)
interpretation: JsonObject = Field(default_factory=dict)
knowledge_base: str | None = None
raw_response: str | None = None
class SoileAnomalyDetectionResponse(ApiEnvelope[SoileAnomalyDetectionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileAnomalyDetectionRequest.__name__,
response_model=SoileAnomalyDetectionResponse.__name__,
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/health-summary/'
class SoileHealthSummaryRequest(SchemaModel):
farm_uuid: UUID
class SoileHealthSummaryResponseData(SchemaModel):
farm_uuid: str
healthScore: int | float
profileSource: str | None = None
healthScoreDetails: JsonObject = Field(default_factory=dict)
healthLanguage: JsonObject = Field(default_factory=dict)
avgSoilMoisture: int | float | None = None
avgSoilMoistureRaw: float | None = None
avgSoilMoistureStatus: str | None = None
class SoileHealthSummaryResponse(ApiEnvelope[SoileHealthSummaryResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileHealthSummaryRequest.__name__,
response_model=SoileHealthSummaryResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/moisture-heatmap/'
class SoileMoistureHeatmapRequest(SchemaModel):
farm_uuid: UUID
class SoileMoistureHeatmapResponseData(SchemaModel):
farm_uuid: str
location: JsonObject = Field(default_factory=dict)
current_sensor: JsonObject = Field(default_factory=dict)
soil_profile: JsonList = Field(default_factory=list)
timestamp: str | None = None
grid_resolution: JsonObject = Field(default_factory=dict)
grid_cells: JsonList = Field(default_factory=list)
sensor_points: JsonList = Field(default_factory=list)
quality_legend: JsonObject = Field(default_factory=dict)
depth_layers: JsonList = Field(default_factory=list)
model_metadata: JsonObject = Field(default_factory=dict)
summary: JsonObject = Field(default_factory=dict)
class SoileMoistureHeatmapResponse(ApiEnvelope[SoileMoistureHeatmapResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileMoistureHeatmapRequest.__name__,
response_model=SoileMoistureHeatmapResponse.__name__,
)
@@ -0,0 +1,46 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/weather/water-need-prediction/'
class WeatherWaterNeedPredictionRequest(SchemaModel):
farm_uuid: UUID
class WaterNeedInsight(SchemaModel):
summary: str | None = None
irrigation_outlook: str | None = None
recommended_action: str | None = None
risk_note: str | None = None
confidence: float | None = None
class WeatherWaterNeedPredictionResponseData(SchemaModel):
farm_uuid: str
totalNext7Days: float | None = None
unit: str | None = None
categories: list[str] = Field(default_factory=list)
series: JsonList = Field(default_factory=list)
dailyBreakdown: JsonList = Field(default_factory=list)
insight: WaterNeedInsight = Field(default_factory=WaterNeedInsight)
knowledge_base: str | None = None
raw_response: str | None = None
class WeatherWaterNeedPredictionResponse(ApiEnvelope[WeatherWaterNeedPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=WeatherWaterNeedPredictionRequest.__name__,
response_model=WeatherWaterNeedPredictionResponse.__name__,
)
+18
View File
@@ -0,0 +1,18 @@
try:
import pymysql
except ImportError: # pragma: no cover - optional fallback when mysqlclient is unavailable
pymysql = None
else: # pragma: no cover - import side effect
# Django 5's MySQL backend checks the mysqlclient version string during import.
# PyMySQL exposes a legacy compatibility version, so override it before installing
# the MySQLdb shim.
pymysql.version_info = (2, 2, 1, "final", 0)
pymysql.__version__ = "2.2.1"
pymysql.install_as_MySQLdb()
try:
from .celery import app as celery_app
except ImportError: # pragma: no cover - fallback for test environments
celery_app = None
__all__ = ("celery_app",)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()
+56
View File
@@ -0,0 +1,56 @@
import os
from types import SimpleNamespace
from uuid import uuid4
try:
from celery import Celery
except ImportError: # pragma: no cover - test/dev fallback when celery is absent
Celery = None
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
if Celery is not None:
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
else:
class _FallbackCeleryApp:
def config_from_object(self, *_args, **_kwargs):
return None
def autodiscover_tasks(self, *_args, **_kwargs):
return None
def task(self, *decorator_args, **decorator_kwargs):
bind = decorator_kwargs.get("bind", False)
def decorator(func):
def delay(*args, **kwargs):
task_id = f"missing-celery-{uuid4()}"
return SimpleNamespace(
id=task_id,
status="FAILURE",
result={"error": "Celery is not installed."},
)
if bind:
def wrapped(*args, **kwargs):
dummy_self = SimpleNamespace(
request=SimpleNamespace(id=f"missing-celery-{uuid4()}"),
update_state=lambda **_kw: None,
)
return func(dummy_self, *args, **kwargs)
wrapped.delay = delay
wrapped.__name__ = func.__name__
wrapped.__doc__ = func.__doc__
return wrapped
func.delay = delay
return func
if decorator_args and callable(decorator_args[0]) and len(decorator_args) == 1:
return decorator(decorator_args[0])
return decorator
app = _FallbackCeleryApp()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _isoformat(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def build_integration_meta(
*,
flow_type: str,
source_type: str,
source_service: str,
ownership: str,
live: bool,
cached: bool,
generated_at: Any = None,
snapshot_at: Any = None,
notes: list[str] | None = None,
) -> dict[str, Any]:
meta = {
"flow_type": flow_type,
"source_type": source_type,
"source_service": source_service,
"ownership": ownership,
"live": live,
"cached": cached,
}
if generated_at is not None:
meta["generated_at"] = _isoformat(generated_at)
if snapshot_at is not None:
meta["snapshot_at"] = _isoformat(snapshot_at)
if notes:
meta["notes"] = notes
return meta
@@ -0,0 +1,3 @@
# پایگاه دانش CropLogic
فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند.
@@ -0,0 +1,3 @@
# پایگاه دانش CropLogic
فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند.
@@ -0,0 +1,19 @@
# دانش پایه خاک برای کشاورزی
## انواع خاک
خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است.
## pH خاک
مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند.
## رطوبت خاک
رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود.
## NPK و عناصر غذایی
نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است.
## هدایت الکتریکی (EC)
EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است.
## عمق خاک
داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند.
@@ -0,0 +1,14 @@
در این پایگاه دانش، هشدارهای مزرعه باید به سه سطح استاندارد تقسیم شوند:
- danger: خطر فوری که به اقدام سریع نیاز دارد.
- warning: هشدار مهم که باید در کوتاه مدت پیگیری شود.
- info: اطلاع رسانی برای پایش، ثبت، یا اقدام کم ریسک.
قاعده های کلی:
1. اگر تنش می تواند باعث آسیب سریع به گیاه، ریشه، یا عملکرد شود، سطح danger مناسب است.
2. اگر تنش هنوز بحرانی نیست ولی روند آن نگران کننده است، سطح warning مناسب است.
3. اگر فقط برای پایش یا آگاهی اپراتور مفید است، سطح info مناسب است.
4. پیام ها باید کوتاه، اجرایی، و بدون اغراق باشند.
5. اگر داده کافی نیست، باید عدم قطعیت به صراحت بیان شود.
6. در متن نهایی فقط از داده های ساختاریافته مزرعه و هشدارهای محاسبه شده استفاده شود.
7. زمان، شدت، و اقدام پیشنهادی باید با وضعیت واقعی مزرعه همخوان باشد.
8. برای timeline باید ترتیب زمانی رویدادها حفظ شود و هر رویداد توضیح دهد چرا برای مزرعه مهم است.
@@ -0,0 +1,142 @@
بخش دوم: راهنمای کوددهی گوجه‌فرنگی
گوجه‌فرنگی گیاهی پرمصرف است و به عناصر درشت‌مغذی (نیتروژن، فسفر، پتاسیم - NPK) و ریزمغذی‌ها (به ویژه کلسیم و منیزیم) نیاز دارد.
۱. مراحل مختلف کوددهی:
قبل از کاشت (آماده‌سازی خاک):
افزودن کود دامی پوسیده یا ورمی‌کمپوست جهت بهبود بافت خاک.
استفاده از کودهای پایه فسفر بالا (برای ریشه‌زایی) و پتاسیم.
مرحله رشد رویشی (قبل از گل‌دهی):
نیاز به نیتروژن (
𝑁
N
) برای رشد برگ‌ها و ساقه‌ها بیشتر است.
احتیاط: نیتروژن بیش از حد باعث رشد علفی گیاه شده و گل‌دهی را به تاخیر می‌اندازد. استفاده از کود متعادل مانند
20
20
20
202020
با غلظت مناسب توصیه می‌شود.
مرحله گل‌دهی و تشکیل میوه:
در این مرحله نیاز به نیتروژن کاهش و نیاز به فسفر (
𝑃
P
) و پتاسیم (
𝐾
K
) به شدت افزایش می‌یابد. پتاسیم برای کیفیت، اندازه و رنگ میوه ضروری است.
کودهای پتاس‌بالا (مانند
12
12
36
121236
) مناسب هستند.
مرحله رشد و رسیدن میوه:
ادامه تغذیه با پتاسیم بالا.
محلول‌پاشی کلسیم در این مرحله بسیار حیاتی است.
۲. عناصر کلیدی و ریزمغذی‌های ضروری:
کلسیم (
𝐶
𝑎
Ca
): کمبود کلسیم (یا عدم جذب آن به دلیل نوسانات آبیاری) باعث عارضه پوسیدگی گلگاه (سیاه شدن ته گوجه‌فرنگی) می‌شود. استفاده از کود نیترات کلسیم به صورت کودآبیاری یا محلول‌پاشی ضروری است.
منیزیم (
𝑀
𝑔
Mg
): کمبود آن باعث زرد شدن برگ‌های پیر (در حالی که رگبرگ‌ها سبز می‌مانند) می‌شود. سولفات منیزیم برای رفع این مشکل مفید است.
آهن (
𝐹
𝑒
Fe
) و روی (
𝑍
𝑛
Zn
): برای شادابی و فتوسنتز گیاه لازم هستند و معمولاً به صورت محلول‌پاشی یا کودهای کلاته استفاده می‌شوند.
خلاصه نکات طلایی پایگاه دانش:
پوسیدگی گلگاه: ترکیبی از کمبود کلسیم و آبیاری نامنظم است. همیشه رطوبت خاک را یکنواخت نگه دارید و از کلسیم استفاده کنید.
ترک‌خوردگی میوه: ناشی از تغییر ناگهانی رطوبت خاک (مثلاً آبیاری سنگین بعد از یک دوره خشکی) است.
تنظیم
𝑝
𝐻
pH
خاک: گوجه‌فرنگی در خاکی با
𝑝
𝐻
pH
بین
6.0
6.0
تا
6.8
6.8
بهترین جذب مواد مغذی را دارد.
فاصله کوددهی کلسیم و فسفر: کودهای حاوی کلسیم را هرگز با کودهای حاوی فسفر یا سولفات همزمان مخلوط نکنید (رسوب می‌کنند).
راهنمای کوددهی هویج
قاعده کلی برای هویج: هویج به نیتروژن (
𝑁
N
) کم تا متوسط، اما به فسفر (
𝑃
P
) و به ویژه پتاسیم (
𝐾
K
) بسیار بالایی نیاز دارد.
مراحل کوددهی:
آماده‌سازی خاک (قبل از کاشت): استفاده از کودهای پایه فسفر و پتاسیم. هشدار مهم: به هیچ وجه از کود دامی تازه استفاده نکنید! کود دامی باید کاملاً پوسیده باشد. کود حیوانی تازه باعث دو یا چند شاخه شدن هویج و ایجاد ریشه‌های مویی زائد می‌شود.
رشد رویشی (اوایل رشد): استفاده محدود از نیتروژن برای رشد برگ‌ها. نیتروژن بیش از حد باعث می‌شود گیاه تمام انرژی خود را صرف تولید برگ کند و ریشه (بخش خوراکی) نازک و کوچک بماند.
رشد و حجم گرفتن ریشه (اواسط تا اواخر رشد): استفاده از کودهای پتاس‌بالا (مانند سولوپتاس یا کودهای
12
12
36
121236
) برای افزایش سایز، بهبود رنگ، طعم شیرین‌تر و تردی هویج.
عناصر ریزمغذی کلیدی:
بُر (
𝐵
B
): یکی از مهم‌ترین عناصر برای هویج است. کمبود بُر باعث ایجاد شکاف در ریشه، سیاه شدن مغز هویج و کاهش بازارپسندی می‌شود.
کلسیم (
𝐶
𝑎
Ca
): برای استحکام بافت ریشه و جلوگیری از بیماری‌ها در انبار مهم است.
خلاصه نکات طلایی و مشکلات رایج
دو یا چند شاخه شدن هویج (Forking): ناشی از استفاده از کود دامی تازه، وجود سنگ و کلوخ در خاک، یا خاک‌های بسیار سفت و رسی است. خاک هویج باید تا عمق حداقل
25
25
سانتی‌متری پوک و سبک باشد.
ترک‌خوردگی ریشه: ناشی از نوسانات آبیاری (خاک خشک شود و ناگهان غرقاب گردد) یا دریافت بیش از حد نیتروژن در اواخر رشد.
ریشه‌های مویی فراوان روی هویج: ناشی از مصرف بیش از حد کودهای نیتروژنه (
𝑁
N
) یا رطوبت دائمی و بیش از حد خاک است.
تنظیم
𝑝
𝐻
pH
خاک: هویج در خاک‌هایی با
𝑝
𝐻
pH
بین
6.0
6.0
تا
6.8
6.8
بهترین رشد را دارد. در
𝑝
𝐻
pH
پایین‌تر (خاک اسیدی)، رشد ریشه متوقف می‌شود.
@@ -0,0 +1,28 @@
راهنمای استخراج برنامه کودهی از متن آزاد
هدف:
تبدیل توضیح متنی کشاورز درباره برنامه کودهی به JSON ساختاریافته.
اطلاعات کلیدی که معمولا باید استخراج شوند:
- نام محصول
- مرحله رشد
- هدف مصرف
- نام یا فرمول کود
- مقدار مصرف
- روش مصرف
- زمان مصرف
- فاصله بین نوبت ها
- توضیح تکمیلی یا هشدار
نمونه عبارت های رایج:
- هر 10 روز یک بار
- بعد از آبیاری
- به صورت کودآبیاری
- سرک
- محلول پاشی
- 35 کیلوگرم در هکتار
- 20-20-20
- برای تقویت رشد رویشی
- برای شروع گلدهی
اگر متن ناقص بود، باید فقط سوال های لازم برای تکمیل برنامه نهایی پرسیده شود و از حدس زدن خودداری شود.
@@ -0,0 +1,26 @@
بخش اول: راهنمای آبیاری گوجه‌فرنگی (آب‌دهی)
نیاز آبی گوجه‌فرنگی به مرحله رشد، نوع خاک و شرایط آب و هوایی بستگی دارد. مهم‌ترین اصل در آبیاری گوجه‌فرنگی نظم و یکنواختی است.
۱. مراحل مختلف رشد و نیاز آبی:
مرحله نشاء و رشد اولیه: خاک باید مرطوب (نه غرقاب) نگه داشته شود تا ریشه‌ها به خوبی مستقر شوند. آبیاری سطحی و مکرر توصیه می‌شود.
مرحله گل‌دهی: تنش آبی در این مرحله باعث ریزش گل‌ها می‌شود. آبیاری باید منظم باشد.
مرحله تشکیل و بزرگ شدن میوه: بیشترین نیاز آبی در این مرحله است. آبیاری باید عمیق و منظم باشد تا از مشکلاتی مانند ترک‌خوردگی میوه و پوسیدگی گلگاه جلوگیری شود.
مرحله رسیدن میوه: با شروع رنگ گرفتن گوجه‌ها، آبیاری را کمی کاهش دهید. این کار باعث افزایش قند، بهبود طعم و جلوگیری از ترک خوردن میوه می‌شود.
۲. نکات کلیدی در آبیاری:
روش آبیاری: بهترین روش، آبیاری قطره‌ای است. آبیاری بارانی باعث خیس شدن برگ‌ها و افزایش خطر بیماری‌های قارچی می‌شود.
زمان آبیاری: بهترین زمان، صبح زود است تا گیاه در طول روز رطوبت کافی داشته باشد و برگ‌ها تا شب خشک شوند.
عمق آبیاری: آبیاری باید عمیق باشد تا ریشه‌ها به عمق خاک نفوذ کنند (حداقل ۱۵ تا ۲۰ سانتی‌متر).
مالچ‌پاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری می‌کند.
راهنمای آبیاری هویج
اهمیت رطوبت در هویج: هویج یک گیاه ریشه‌ای است و کیفیت ریشه آن ارتباط مستقیمی با نحوه آبیاری دارد. نوسانات رطوبتی باعث افت شدید کیفیت محصول می‌شود.
نیاز آبی در مراحل مختلف رشد:
کاشت و جوانه‌زنی: بذر هویج بسیار ریز است و در عمق کم کاشته می‌شود. در این مرحله خاک باید دائماً مرطوب (اما نه غرقاب) باشد تا بذرها خشک نشوند. خشکی در این مرحله باعث عدم سبز شدن بذرها می‌شود.
رشد اولیه و توسعه ریشه: پس از سبز شدن، آبیاری باید عمیق‌تر و با فواصل بیشتر انجام شود تا ریشه گیاه برای پیدا کردن آب به عمق خاک نفوذ کند. آبیاری سطحی باعث کوتاه ماندن هویج می‌شود.
حجم گرفتن ریشه (غده‌بندی): نیاز آبی در این مرحله بالاست. رطوبت باید یکنواخت باشد.
نزدیک به برداشت: کاهش آبیاری در اواخر دوره رشد ضروری است. آبیاری زیاد در این مرحله باعث ترک‌خوردگی هویج‌ها می‌شود.
روش‌های آبیاری:
بهترین روش: آبیاری قطره‌ای (نوار تیپ) زیرا رطوبت را به صورت یکنواخت در اختیار ریشه قرار می‌دهد و از بیماری‌های برگی جلوگیری می‌کند.
تنش آبی: خشک و خیس شدن پیاپی خاک، عامل اصلی دو شاخه شدن و ترک خوردن هویج است.
@@ -0,0 +1,28 @@
راهنمای استخراج برنامه آبیاری از متن آزاد
هدف:
تبدیل توضیح متنی کشاورز درباره برنامه آبیاری به JSON ساختاریافته.
اطلاعات کلیدی که معمولا باید استخراج شوند:
- نام محصول
- مرحله رشد
- روش آبیاری
- مقدار آب در هر نوبت
- مدت زمان هر نوبت
- فاصله یا تعداد دفعات آبیاری
- زمان مناسب اجرا در روز
- تاریخ شروع یا شرایط شروع
- ناحیه یا سطح هدف
- نکات تکمیلی
نمونه عبارت های رایج:
- هر سه روز یک بار
- هفته ای دو نوبت
- صبح زود
- بعد از غروب
- 20 لیتر برای هر بوته
- 25 دقیقه
- فقط در ردیف های جنوبی
- اگر هوا خیلی گرم شد یک نوبت اضافه شود
اگر متن ناقص بود، باید فقط درباره اطلاعاتی سوال شود که برای ساخت برنامه قابل استفاده لازم هستند.
@@ -0,0 +1,13 @@
این پایگاه دانش برای تحلیل آفات و بیماری های گیاهی استفاده می شود.
قواعد اصلی:
1. فقط بر اساس شواهد تصویری، داده های مزرعه، و اطلاعات بازیابی شده نتیجه گیری کن.
2. اگر تصویر برای تشخیص قطعی کافی نیست، عدم قطعیت را شفاف بگو.
3. تشخیص باید بین این حالت ها تفکیک کند: no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown.
4. در تحلیل تصویری، نشانه های قابل مشاهده مثل لکه، پوسیدگی، پیچیدگی برگ، سوراخ شدگی، تغییر رنگ، کپک، یا آفت قابل مشاهده ذکر شود.
5. در پیش بینی ریسک، شرایط دما، رطوبت، بارش، رطوبت خاک، pH، EC، و مرحله رشد لحاظ شوند.
6. سطح ریسک فقط یکی از low, medium, high باشد.
7. اقدام های پیشنهادی باید کوتاه، عملیاتی، و محافظه کارانه باشند و از ارائه نسخه درمان قطعی بدون داده کافی خودداری شود.
8. اگر آلودگی قارچی محتمل است، به رطوبت بالا و ماندگاری رطوبت اشاره کن.
9. اگر فشار آفت محتمل است، به گرما، خشکی، ضعف گیاه، و الگوی خسارت برگ اشاره کن.
10. همیشه خلاصه ای از دلیل نتیجه گیری ارائه بده.
@@ -0,0 +1,23 @@
تحليل ناهنجاري خاک و سنسور
هدف اين دانشنامه کمک به تفسير ناهنجاري هاي آماري در داده هاي خاک و سنسور مزرعه است.
اصول کلي:
- ناهنجاري آماري به معناي مشکل قطعي مزرعه نيست؛ اول بايد پايداري رخداد، شدت انحراف، و سازگاري آن با ساير شاخص ها بررسي شود.
- وقتي رطوبت خاک و دماي خاک همزمان ناهنجار مي شوند، احتمال تنش ريشه، آبياري نامناسب، يا موج گرما بيشتر است.
- وقتي EC و رطوبت خاک با هم ناهنجار شوند، فشار شوري، تجمع نمک، کيفيت نامناسب آب يا برنامه کوددهي نامتوازن بايد بررسي شود.
- اگر pH از محدوده معمول مزرعه فاصله بگيرد، دسترسي عناصر غذايي و کارايي جذب ريشه مي تواند تحت تاثير قرار بگيرد.
- ناهنجاري رطوبت هوا در کنار دما و رطوبت خاک مي تواند نشانه شرايط مساعد براي بيماري يا افزايش تبخير-تعرق باشد.
راهنماي تفسير شدت:
- low: انحراف خفيف يا کوتاه مدت؛ معمولا نياز به پايش دارد.
- medium: انحراف قابل توجه؛ بايد با شرايط مزرعه و آبياري تطبيق داده شود.
- high: انحراف مهم؛ بازبيني سريع سنسور و عمليات مزرعه لازم است.
- critical: رخداد شديد يا پرتکرار؛ نياز به اقدام فوري و بررسي ميداني دارد.
اقدامات پيشنهادي عمومي:
- وضعيت آخرين آبياري، زمان بندي و يکنواختي توزيع آب بررسي شود.
- کاليبراسيون سنسور و سلامت سخت افزاري آن در رخدادهاي ناگهاني کنترل شود.
- تغييرات اخير در کوددهي، شوري آب، بارش موثر و دماي محيط در تحليل لحاظ شود.
- اگر ناهنجاري در چند شاخص همزمان ديده شد، اولويت پايش و مداخله بالاتر در نظر گرفته شود.
- اگر ناهنجاري در داده هاي محدود يا ناقص ديده شد، قبل از توصيه قطعي کمبود داده صريح گفته شود.
@@ -0,0 +1,19 @@
# دانش پایه خاک برای کشاورزی
## انواع خاک
خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است.
## pH خاک
مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند.
## رطوبت خاک
رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود.
## NPK و عناصر غذایی
نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است.
## هدایت الکتریکی (EC)
EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است.
## عمق خاک
داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند.
@@ -0,0 +1,17 @@
تحليل نياز آبي کوتاه مدت مزرعه
اين دانشنامه براي تفسير خروجي محاسبات نياز آبي روزهاي آينده استفاده مي شود.
اصول کلي:
- `et0` تبخير-تعرق مرجع است و نشان مي دهد شرايط اقليمي هر روز چه ميزان تقاضاي تبخير-تعرق ايجاد مي کند.
- `etc` از ضرب `et0` در ضريب گياهي `kc` به دست مي آيد و تخمين مناسب تري از نياز آبي محصول مي دهد.
- `effective_rainfall` بخشي از بارش است که واقعا در تامين نياز آبي گياه موثر واقع مي شود.
- `net_irrigation_mm` نياز آبي خالص پس از کسر بارش موثر است.
- `gross_irrigation_mm` نياز آبي واقعي اجرايي با درنظر گرفتن راندمان سامانه آبياري است.
راهنماي تفسير:
- اگر `gross_irrigation_mm` در چند روز پياپي بالا باشد، برنامه آبياري بايد فشرده تر و منظم تر تنظيم شود.
- اگر راندمان آبياري پايين باشد، اختلاف بين نياز خالص و ناخالص بيشتر مي شود و اتلاف آب بالاتر است.
- در روزهاي گرم، پر باد يا کم بارش، بهتر است اجراي آبياري به صبح زود يا نزديک غروب منتقل شود.
- اگر بارش موثر پيش بيني شده باشد، بخشي از نياز آبي مي تواند بدون آبياري اضافي تامين شود.
- توصيه ها بايد عملياتي، کوتاه مدت، و همسو با forecast فعلي باشند و در صورت عدم قطعيت، آن را صريح بيان کنند.
+103
View File
@@ -0,0 +1,103 @@
import copy
from drf_spectacular.utils import OpenApiResponse
from rest_framework import serializers
def _build_schema_field(schema, *, many=False, required=True, allow_null=False):
if schema is None:
return serializers.JSONField(required=required, allow_null=allow_null)
if isinstance(schema, serializers.Field):
field = copy.deepcopy(schema)
field.required = required
if hasattr(field, "allow_null"):
field.allow_null = allow_null
return field
if isinstance(schema, serializers.BaseSerializer):
serializer = copy.deepcopy(schema)
serializer.required = required
serializer.allow_null = allow_null
return serializer
if isinstance(schema, type) and issubclass(schema, serializers.BaseSerializer):
return schema(many=many, required=required, allow_null=allow_null)
raise TypeError(f"Unsupported schema type: {type(schema)!r}")
def build_message_response_serializer(name):
return type(
name,
(serializers.Serializer,),
{
"__module__": __name__,
"code": serializers.IntegerField(),
"msg": serializers.CharField(),
},
)
def build_envelope_serializer(
name,
data_schema=None,
*,
many=False,
data_required=True,
allow_null=False,
):
return type(
name,
(serializers.Serializer,),
{
"__module__": __name__,
"code": serializers.IntegerField(),
"msg": serializers.CharField(),
"data": _build_schema_field(
data_schema,
many=many,
required=data_required,
allow_null=allow_null,
),
},
)
def build_task_queue_data_serializer(name, extra_fields=None):
fields = {
"__module__": __name__,
"task_id": serializers.CharField(),
"status_url": serializers.CharField(),
}
if extra_fields:
fields.update(extra_fields)
return type(name, (serializers.Serializer,), fields)
def build_task_status_data_serializer(name, result_schema=None):
result_field = (
_build_schema_field(result_schema, required=False, allow_null=True)
if result_schema is not None
else serializers.JSONField(required=False)
)
return type(
name,
(serializers.Serializer,),
{
"__module__": __name__,
"task_id": serializers.CharField(),
"status": serializers.CharField(),
"message": serializers.CharField(required=False),
"progress": serializers.DictField(
child=serializers.JSONField(),
required=False,
),
"result": result_field,
"error": serializers.CharField(required=False),
},
)
def build_response(serializer, description):
return OpenApiResponse(response=serializer, description=description)
+239
View File
@@ -0,0 +1,239 @@
# تنظیمات RAG برای پایگاه دانش CropLogic
embedding:
provider: "arvancloud" # gapgpt یا avalai یا arvancloud
model: "Bge-m3-smka5"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
batch_size: 32
# تنظیمات Avalai (برای fallback)
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
# تنظیمات ArvanCloud AI برای BGE-M3
arvancloud_api_key: "7c4c4eb9-5183-530a-b589-d31c79472847"
arvancloud_base_url: "https://arvancloudai.ir/gateway/models/Bge-m3/rBA2PgcTC2sfhXwamupI4NvQ8crddUGTYXOsuKVye91PoNuGhbRgpHHNY8sMHBVQWWerZSAi4a0AijUL6YBqY9EW-Y1LhW_0ec6Mxr85GQy41lXiV6M8Od4mvLIeDF-wLRUHIervod0O5ZqGj2MOX8z1zdUpXkCrIS2uDjHlfHBZofledZjsOVDmFZU7IYfvkA__ljQqNeKXSFgpwUR7SmsbRUXGTDB2moLdeRq9zBpQIw/v1"
arvancloud_api_key_env: "ARVANCLOUD_EMBEDDING_API_KEY"
# فاز یک: Qdrant به‌عنوان vector store
qdrant:
host: "localhost" # یا qdrant در Docker
port: 6333
collection_name: "croplogic_kb"
vector_size: 1024 # متناسب با BGE-M3
chunking:
max_chunk_tokens: 500
overlap_tokens: 50
# تنظیمات مدل چت (LLM) — Avalai
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
# سه پایگاه دانش مجزا
knowledge_bases:
chat:
path: "config/knowledge_base/chat"
tone_file: "config/tones/chat_tone.txt"
description: "پایگاه دانش عمومی برای چت با کاربران"
irrigation:
path: "config/knowledge_base/irrigation"
tone_file: "config/tones/irrigation_tone.txt"
description: "پایگاه دانش توصیه آبیاری"
fertilization:
path: "config/knowledge_base/fertilization"
tone_file: "config/tones/fertilization_tone.txt"
description: "پایگاه دانش توصیه کودهی"
irrigation_plan_parser:
path: "config/knowledge_base/irrigation_plan_parser"
tone_file: "config/tones/irrigation_plan_parser_tone.txt"
description: "پایگاه دانش استخراج برنامه آبیاری از متن آزاد کاربر"
fertilization_plan_parser:
path: "config/knowledge_base/fertilization_plan_parser"
tone_file: "config/tones/fertilization_plan_parser_tone.txt"
description: "پایگاه دانش استخراج برنامه کودهی از متن آزاد کاربر"
farm_alerts:
path: "config/knowledge_base/farm_alerts"
tone_file: "config/tones/farm_alerts_tone.txt"
description: "پایگاه دانش تحلیل هشدار و اعلان مزرعه"
pest_disease:
path: "config/knowledge_base/pest_disease"
tone_file: "config/tones/pest_disease_tone.txt"
description: "پایگاه دانش تشخیص و پیش بینی آفات و بیماری گیاهی"
soil_anomaly:
path: "config/knowledge_base/soil_anomaly"
tone_file: "config/tones/soil_anomaly_tone.txt"
description: "پایگاه دانش تحلیل ناهنجاری آماری داده های خاک و سنسور"
water_need_prediction:
path: "config/knowledge_base/water_need_prediction"
tone_file: "config/tones/water_need_prediction_tone.txt"
description: "پایگاه دانش تفسير نياز آبي کوتاه مدت و برنامه ريزي آبياري"
yield_harvest:
path: "config/knowledge_base/chat"
tone_file: "config/tones/yield_harvest_tone.txt"
description: "پایگاه دانش روایت کاربرپسند برای داشبورد Yield & Harvest Summary"
services:
support_bot:
knowledge_base: "chat"
tone_file: "config/tones/chat_tone.txt"
use_user_embeddings: false
description: "سرویس پشتیبانی عمومی"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
system_prompt: "You are a friendly support assistant. Answer clearly and helpfully."
chat:
knowledge_base: "chat"
tone_file: "config/tones/chat_tone.txt"
use_user_embeddings: true
description: "چت عمومی با داده‌های کاربر"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
irrigation:
knowledge_base: "irrigation"
tone_file: "config/tones/irrigation_tone.txt"
use_user_embeddings: true
description: "سرویس توصیه آبیاری"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
fertilization:
knowledge_base: "fertilization"
tone_file: "config/tones/fertilization_tone.txt"
use_user_embeddings: true
description: "سرویس توصیه کودهی"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
irrigation_plan_parser:
knowledge_base: "irrigation_plan_parser"
tone_file: "config/tones/irrigation_plan_parser_tone.txt"
use_user_embeddings: false
description: "سرویس استخراج برنامه آبیاری از متن کاربر"
system_prompt: "Only return valid JSON for irrigation plan extraction and clarification."
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
fertilization_plan_parser:
knowledge_base: "fertilization_plan_parser"
tone_file: "config/tones/fertilization_plan_parser_tone.txt"
use_user_embeddings: false
description: "سرویس استخراج برنامه کودهی از متن کاربر"
system_prompt: "Only return valid JSON for fertilization plan extraction and clarification."
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
farm_alerts:
knowledge_base: "farm_alerts"
tone_file: "config/tones/farm_alerts_tone.txt"
use_user_embeddings: true
description: "سرویس تحلیل tracker و timeline هشدارهای مزرعه"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
pest_disease:
knowledge_base: "pest_disease"
tone_file: "config/tones/pest_disease_tone.txt"
use_user_embeddings: true
description: "سرویس تشخیص و پیش بینی آفات و بیماری"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
soil_anomaly:
knowledge_base: "soil_anomaly"
tone_file: "config/tones/soil_anomaly_tone.txt"
use_user_embeddings: true
description: "سرویس تفسير ناهنجاري هاي آماري خاک و سنسور"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
water_need_prediction:
knowledge_base: "water_need_prediction"
tone_file: "config/tones/water_need_prediction_tone.txt"
use_user_embeddings: true
description: "سرویس تفسير نياز آبي کوتاه مدت مزرعه"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
yield_harvest:
knowledge_base: "yield_harvest"
tone_file: "config/tones/yield_harvest_tone.txt"
use_user_embeddings: true
description: "سرویس روایت داشبورد عملکرد و برداشت"
fallback_behavior:
on_invalid_json: "raise_validation_error"
on_missing_context: "use_only_deterministic_data"
on_number_conflict: "prefer_deterministic_data"
prompt_template: "config/tones/yield_harvest_tone.txt"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
+250
View File
@@ -0,0 +1,250 @@
import os
import importlib.util
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional in stripped test envs
def load_dotenv():
return False
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = Path(os.environ.get("LOG_DIR", BASE_DIR / "logs"))
def _can_use_file_logging(log_dir: Path) -> bool:
try:
log_dir.mkdir(parents=True, exist_ok=True)
probe_file = log_dir / ".write_test"
with probe_file.open("a", encoding="utf-8"):
pass
probe_file.unlink(missing_ok=True)
return True
except OSError:
return False
FILE_LOGGING_ENABLED = _can_use_file_logging(LOG_DIR)
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1"
DEVELOP = os.environ.get("DEVELOP", "false").strip().lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"farm_alerts.apps.FarmAlertsConfig",
"rag",
"location_data",
"soile.apps.SoileConfig",
"farm_data.apps.FarmDataConfig",
"weather",
"economy.apps.EconomyConfig",
"plant",
"pest_disease.apps.PestDiseaseConfig",
"irrigation",
"fertilization",
"crop_simulation.apps.CropSimulationConfig",
]
for optional_app in [
"rest_framework",
"corsheaders",
"drf_spectacular",
"drf_spectacular_sidecar",
]:
if importlib.util.find_spec(optional_app):
INSTALLED_APPS.insert(6, optional_app)
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
if importlib.util.find_spec("corsheaders"):
MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware")
if importlib.util.find_spec("whitenoise"):
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": os.environ.get("DB_ENGINE", "django.db.backends.mysql"),
"NAME": os.environ.get("DB_NAME", "ai"),
"USER": os.environ.get("DB_USER", "ai"),
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("DB_PORT", "3306"),
}
}
if DATABASES["default"]["ENGINE"].endswith("mysql"):
DATABASES["default"]["OPTIONS"] = {"charset": "utf8mb4"}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
if importlib.util.find_spec("whitenoise"):
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "croplogic-auth-otp",
}
}
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "CropLogic AI API",
"DESCRIPTION": "Swagger/OpenAPI documentation for all CropLogic AI API endpoints.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
"SCHEMA_PATH_PREFIX": r"/api/",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
},
}
CORS_ALLOW_ALL_ORIGINS = DEBUG
# Celery
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
# Celery Beat — embed دیتای کاربران هر ۶ ساعت
CELERY_BEAT_SCHEDULE = {
"rag-ingest-periodic": {
"task": "rag.tasks.rag_ingest_task",
"schedule": 6 * 60 * 60, # ۶ ساعت
},
"weather-fetch-periodic": {
"task": "weather.tasks.fetch_weather_all_locations_task",
"schedule": 6 * 60 * 60, # ۶ ساعت
},
}
# Weather API
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", "open-meteo").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", "soilgrids").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"))
SUBDIVISION_CHUNK_SQM = int(os.environ.get("SUBDIVISION_CHUNK_SQM", "900"))
BACKEND_PLANT_SYNC_BASE_URL = os.environ.get("BACKEND_PLANT_SYNC_BASE_URL", "")
BACKEND_PLANT_SYNC_API_KEY = os.environ.get("BACKEND_PLANT_SYNC_API_KEY", "")
BACKEND_PLANT_SYNC_TIMEOUT = int(os.environ.get("BACKEND_PLANT_SYNC_TIMEOUT", "20"))
if not (DEBUG or DEVELOP):
if WEATHER_DATA_PROVIDER == "mock":
raise ImproperlyConfigured("WEATHER_DATA_PROVIDER=mock is allowed only in dev/test environments.")
if SOIL_DATA_PROVIDER == "mock":
raise ImproperlyConfigured("SOIL_DATA_PROVIDER=mock is allowed only in dev/test environments.")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.environ.get("DJANGO_LOG_LEVEL", "INFO"),
"propagate": False,
},
"rag": {
"handlers": ["console"],
"level": os.environ.get("RAG_LOG_LEVEL", "INFO"),
"propagate": False,
},
},
"root": {
"handlers": ["console"],
"level": os.environ.get("ROOT_LOG_LEVEL", "INFO"),
},
}
if FILE_LOGGING_ENABLED:
LOGGING["handlers"]["file"] = {
"class": "logging.handlers.TimedRotatingFileHandler",
"filename": str(LOG_DIR / "app.log"),
"when": "midnight",
"backupCount": 14,
"encoding": "utf-8",
"formatter": "standard",
}
LOGGING["loggers"]["django"]["handlers"].append("file")
LOGGING["loggers"]["rag"]["handlers"].append("file")
LOGGING["root"]["handlers"].append("file")
+15
View File
@@ -0,0 +1,15 @@
from .settings import * # noqa: F403,F401
ROOT_URLCONF = "config.test_urls"
LOGGING = { # noqa: F405
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "WARNING"},
}
REST_FRAMEWORK = { # noqa: F405
**REST_FRAMEWORK, # noqa: F405
"DEFAULT_AUTHENTICATION_CLASSES": [],
}
+15
View File
@@ -0,0 +1,15 @@
from django.http import HttpResponse
from django.urls import include, path
def test_view(_request):
return HttpResponse("ok")
urlpatterns = [
path("__test__/", test_view),
path("api/rag/", include("rag.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/pest-disease/", include("pest_disease.urls")),
path("api/farm-data/", include("farm_data.urls")),
]
+7
View File
@@ -0,0 +1,7 @@
# فایل لحن / سبک پاسخ‌های RAG
لحن و سبک پاسخ‌ها:
- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن.
- واژگان: از اصطلاحات رایج کشاورزی و خاک‌شناسی استفاده کن، در صورت نیاز معادل فارسی بیاور.
- طول: پاسخ‌ها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن.
- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده.
+152
View File
@@ -0,0 +1,152 @@
You are a general farm assistant for CropLogic.
## GOAL
Generate a Persian response that fits the CropLogic frontend.
Stay strictly relevant to the user's intent.
Support three UI output modes based on the users need:
- pureText
- textOnly (light explanation card)
- actionCard (full recommendation card)
## HARD RULES
1) If an optimizer block exists, it is the single source of truth.
2) Never produce actions unless the user asks OR a clear critical issue exists.
3) Output must be exactly one JSON object with a top-level "sections" array.
4) No text outside JSON.
## INTENT CLASSIFICATION
Determine user intent as one of:
- "pure_info" → کاربر فقط اطلاعات یا توضیح می‌خواهد (مثال: «قبلاً کاهو و پیاز کاشتم، تأثیرش چیه؟»)
- "diagnostic_or_info" → کاربر دلیل یا ماهیت را می‌پرسد («چرا برگ زرد شده؟»)
- "advisory_or_operational" → کاربر اقدام و توصیه می‌خواهد («چه کودی بدم؟»)
---
# UI MODES (۳ حالت)
### 1) uiMode = "pureText"
استفاده شود وقتی:
- intent = pure_info
و هیچ نیازی به کارت توصیه یا لیست وجود ندارد.
فرانت باید فقط یک متن ساده نمایش دهد.
در این حالت:
sections = [
{
"type": "pureText",
"content": "متن کامل و یکپارچه پاسخ"
}
]
هیچ recommendation، list یا warning نباید وجود داشته باشد.
---
### 2) uiMode = "textOnly"
استفاده شود وقتی:
- intent = diagnostic_or_info
- نیازی به اقدام عملی نیست
- اما ساختار کارت سبک لازم است
در این حالت:
- type = "recommendation"
- uiMode = "textOnly"
- content = متن اصلی (۲–۴ جمله)
- primaryAction, timing, validityPeriod = null
- expandableExplanation = توضیحات اختیاری
---
### 3) uiMode = "actionCard"
استفاده شود وقتی:
- intent = advisory_or_operational
یا
- یک مشکل بحرانی وجود دارد (براساس داده)
در این حالت:
- content = خلاصه کوتاه
- expandableExplanation = توضیح کامل
- primaryAction/timing/validityPeriod → مقدار مناسب
همچنین چند recommendation و چند warning مجاز است.
---
# MULTIPLE RECOMMENDATION & WARNING
- Any number of "recommendation" cards allowed
- Any number of "warning" cards allowed
- Each must be a separate object in "sections"
---
# DATA USE RULES
Use data only when relevant.
If data missing → create a warning section.
---
# OUTPUT CONTRACT
### حالت pureText
{
"sections": [
{
"type": "pureText",
"content": "متن کامل و ساده پاسخ"
}
]
}
### حالت textOnly یا actionCard
{
"sections": [
{
"type": "recommendation",
"uiMode": "textOnly | actionCard",
"title": "جمع بندی اصلی",
"icon": "message-circle",
"content": "string",
"primaryAction": "string|null",
"timing": "string|null",
"validityPeriod": "string|null",
"expandableExplanation": "string|null"
},
{
"type": "list",
"title": "نکات اجرایی یا بررسی",
"icon": "list",
"items": ["string", "string"]
},
{
"type": "warning",
"title": "هشدار یا محدودیت",
"icon": "alert-triangle",
"content": "string"
}
]
}
---
# WRITING RULES
- No markdown
- No greetings
- No external chatter
- Response must be fully inside the JSON
- Focus exactly on the user's question
- Never force farm actions unless needed
---
# CHAT TITLE RULE
- Always include a separate section at the start of "sections" for the chat title.
- The title section must be completely separate from the answer section.
- Use this exact structure for the first section:
{
"type": "chatTitle",
"title": "یک عنوان کوتاه، طبیعی، و مرتبط با سوال کاربر"
}
- After the title section, return the actual answer sections.
- Never merge the chat title into a recommendation, warning, list, or pureText section.
@@ -0,0 +1,65 @@
شما دستيار تخصصي تحليل هشدارهاي مزرعه براي CropLogic هستيد.
قواعد عمومي:
- فقط و فقط JSON معتبر برگردان. هيچ متن اضافه، توضيح، markdown يا code fence توليد نکن.
- لحن حرفه اي، دقيق، کوتاه و اجرايي باشد.
- از اغراق، ترساندن بي دليل و توصيه مبهم خودداري کن.
- اگر داده ناکافي است، اين محدوديت را داخل همان JSON و با متن شفاف بيان کن.
- سطح ها فقط از مقادير مجاز استفاده شوند.
قرارداد خروجي:
1) اگر مسئله مربوط به tracker هشدارها بود، خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"headline": "جمع بندي کوتاه وضعيت هشدارها",
"overview": "توضيح کوتاه و اجرايي از مهم ترين وضعيت مزرعه",
"status_level": "danger | warning | info",
"notifications": [
{
"level": "danger | warning | info",
"title": "عنوان هشدار",
"message": "شرح کوتاه و روشن هشدار",
"suggested_action": "اقدام پيشنهادي مشخص",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
]
}
2) اگر مسئله مربوط به timeline هشدارها بود، خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"headline": "عنوان کوتاه timeline",
"overview": "شرح کوتاه روند هشدارها",
"timeline": [
{
"timestamp": "ISO timestamp يا null",
"level": "danger | warning | info",
"title": "عنوان رخداد",
"description": "توضيح رخداد و اثر آن",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
],
"notifications": [
{
"level": "danger | warning | info",
"title": "عنوان هشدار",
"message": "شرح کوتاه و روشن هشدار",
"suggested_action": "اقدام پيشنهادي مشخص",
"source_alert_id": "شناسه هشدار يا null",
"source_metric_type": "نوع شاخص يا null"
}
]
}
قواعد تکميلي:
- در کانتکست، `incoming_alerts` ممکن است شامل alertها يا سابقه alertهاي چند ساعت تا چند روز گذشته باشد؛ آن ها را بررسي کن و notification تکراري يا هم معنا نساز.
- notification تکراري، هم معنا، يا با اقدام يکسان نسبت به alertهاي موجود در `incoming_alerts` نساز؛ مگر اينکه وضعيت واقعا جديد شده باشد يا شدت هشدار به شکل معنادار تغيير کرده باشد.
- فقط وقتي notification جديد بساز که يک رخداد مهم، جديد، غيرتکراري و قابل اقدام وجود داشته باشد.
- اگر مورد مهم يا جديدي براي گفتن وجود ندارد، `notifications` را خالي برگردان و در `headline` و `overview` صريح و کوتاه بگو که فعلا notification مهم جديدي وجود ندارد.
- اگر alertها خفيف، تکراري يا بدون اقدام تازه هستند، به جاي ساخت notification جديد، همان خروجی بدون notification بده.
- [TEMP_FORCE_MIN_NOTIFICATION_START]
- اين يک قاعده موقت است: اگر هيچ notification مهمي هم وجود نداشت، باز هم حداقل يک notification بساز.
- در اين حالت، يک notification با `level` برابر `info` توليد کن که صريح بگويد فعلا مورد مهم جديدي شناسايي نشده است.
- براي اين notification حداقلي، `title` کوتاه و خنثي باشد، `message` شفاف بگويد هشدار مهم جديدي وجود ندارد، و `suggested_action` فقط يک اقدام پايشي سبک و مشخص باشد.
- اين notification حداقلي فقط وقتي استفاده شود که خروجي در غير اين صورت خالي مي شد.
@@ -0,0 +1,93 @@
شما یک دستیار دقیق برای استخراج برنامه کودهی از متن آزاد کشاورز هستید.
هدف:
- متن آزاد کاربر را به JSON ساختاریافته برنامه کودهی تبدیل کن.
- اگر هر بخش مهمی ناقص بود، به جای حدس زدن سوال بپرس.
قواعد قطعی:
- فقط و فقط JSON معتبر برگردان.
- هیچ متن اضافه، markdown، توضیح بیرون از JSON، کدبلاک یا کلید اضافه تولید نکن.
- اگر حتی یکی از فیلدهای اصلی خالی، null، نامشخص یا مبهم بود باید `status` برابر `needs_clarification` باشد.
- در حالت `completed` هیچ فیلد null یا رشته خالی در `collected_data` و `final_plan` نباید وجود داشته باشد.
- اگر متن ناقص بود، سوال ها باید کوتاه، روشن، غیرتکراری و کاملا کاربردی باشند.
- از حدس زدن نام کود، فرمول، مقدار، روش مصرف، زمان مصرف، فاصله بین نوبت ها یا مرحله رشد خودداری کن.
- اگر کاربر `answers` و `partial_plan` داده باشد، اول آن ها را با متن جدید ادغام کن و فقط سوال های باقی مانده را بپرس.
- اگر چند کود در متن آمده بود، همه را در `applications` لیست کن.
- زبان همه `summary`، `question` و `rationale` ها فارسی باشد.
فیلدهای اصلی که برای تکمیل برنامه لازم هستند:
- `crop_name`
- `growth_stage`
- `fertilizer_name`
- `formula`
- `amount`
- `application_method`
- `timing`
- `interval_days`
ساختار دقیق JSON خروجی:
{
"status": "completed" | "needs_clarification",
"summary": "string",
"missing_fields": ["string"],
"questions": [
{
"id": "string",
"field": "string",
"question": "string",
"rationale": "string"
}
],
"collected_data": {
"crop_name": "string|null",
"growth_stage": "string|null",
"objective": "string|null",
"applications": [
{
"fertilizer_name": "string|null",
"formula": "string|null",
"amount": "string|null",
"application_method": "string|null",
"timing": "string|null",
"interval_days": "integer|null",
"purpose": "string|null"
}
],
"notes": ["string"]
},
"final_plan": {
"crop_name": "string",
"growth_stage": "string",
"objective": "string|null",
"applications": [
{
"fertilizer_name": "string",
"formula": "string",
"amount": "string",
"application_method": "string",
"timing": "string",
"interval_days": "integer",
"purpose": "string|null"
}
],
"notes": ["string"]
} | null
}
منطق وضعیت:
- اگر همه فیلدهای اصلی کامل بودند:
- `status = "completed"`
- `missing_fields = []`
- `questions = []`
- `final_plan` باید کامل و بدون null در فیلدهای اصلی باشد
- اگر حتی یکی از فیلدهای اصلی ناقص بود:
- `status = "needs_clarification"`
- `missing_fields` فقط فیلدهای ناقص را شامل شود
- `questions` برای همان فیلدهای ناقص ساخته شود
- `final_plan = null`
نمونه سوال خوب:
- "محصول الان در چه مرحله رشدی قرار دارد؟"
- "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20."
- "مقدار مصرف هر نوبت کود چقدر است؟"
- "فاصله بین نوبت های مصرف کود چند روز است؟"
@@ -0,0 +1,106 @@
You are the fertilization recommendation assistant for CropLogic.
### GOAL
Use soil data, plant stage, weather risk, retrieved knowledge, and the block named `[خروجي بهينه ساز شبيه سازي]` to produce a farmer-ready Persian fertilization response.
### SOURCE PRIORITY
1. The optimizer block is the source of truth for fertilizer formula, dosage, application method, timing, validity, and scientific priority.
2. Retrieved knowledge can enrich explanations, safety, micro nutrients, and alternative options.
3. Never invent numeric values that conflict with the optimizer block.
### HARD RULES
1. Return only valid JSON. No markdown, no code fences, no greetings.
2. The top-level object must be:
- `status`
- `data`
3. Set `status` to `success` when you can produce the recommendation.
4. Keep all text in clear practical Persian.
5. If some descriptive field is uncertain, keep it short and conservative instead of inventing precise claims.
6. Always include these objects inside `data`:
- `primary_recommendation`
- `nutrient_analysis`
- `application_guide`
- `alternative_recommendations`
### REQUIRED JSON CONTRACT
{
"status": "success",
"data": {
"primary_recommendation": {
"display_title": "عنوان نمايشي کوتاه",
"reasoning": "توضيح علمي و کاربردي بر اساس مرحله رشد، کمبود عناصر، ريسک آب و هوا و شبيه سازي",
"summary": "جمع بندي يک جمله اي مناسب براي Hero Card"
},
"nutrient_analysis": {
"macro": [
{
"key": "n",
"name": "نيتروژن (N)",
"value": 0,
"unit": "percent",
"description": "توضيح کوتاه کاربردي"
},
{
"key": "p",
"name": "فسفر (P)",
"value": 0,
"unit": "percent",
"description": "توضيح کوتاه کاربردي"
},
{
"key": "k",
"name": "پتاسيم (K)",
"value": 0,
"unit": "percent",
"description": "توضيح کوتاه کاربردي"
}
],
"micro": [
{
"key": "zn",
"name": "روي",
"value": 0,
"unit": "percent",
"description": "فقط اگر دانش زمينه اي معتبر داري پر کن"
}
]
},
"application_guide": {
"safety_warning": "هشدار ايمني کوتاه و عملياتي",
"steps": [
{
"step_number": 1,
"title": "آماده سازي",
"description": "شرح کوتاه"
},
{
"step_number": 2,
"title": "اختلاط يا تزريق",
"description": "شرح کوتاه"
},
{
"step_number": 3,
"title": "اجرا و پايش",
"description": "شرح کوتاه"
}
]
},
"alternative_recommendations": [
{
"fertilizer_code": "alt-1",
"fertilizer_name": "نام کود جايگزين",
"fertilizer_type": "NPK يا نوع کاربردي",
"usage_method": "روش مصرف",
"description": "چه زماني و چرا اين جايگزين مفيد است"
}
]
}
}
### WRITING RULES
- Repeat the dominant nutrient gap if the optimizer indicates one.
- If rain, heat, or pH creates a constraint, mention it in `reasoning` and `application_guide.safety_warning`.
- `summary` must be short, direct, and suitable for a hero card.
- `reasoning` must be richer than `summary` and connect simulation plus agronomy.
- `alternative_recommendations` should be concise and realistic; do not add many items.
- `nutrient_analysis.micro` can be an empty array when no trustworthy micronutrient detail exists.
@@ -0,0 +1,87 @@
شما یک دستیار دقیق برای استخراج برنامه آبیاری از متن آزاد کشاورز هستید.
هدف:
- متن آزاد کاربر را به JSON ساختاریافته برنامه آبیاری تبدیل کن.
- اگر هر بخش مهمی ناقص بود، به جای حدس زدن سوال بپرس.
قواعد قطعی:
- فقط و فقط JSON معتبر برگردان.
- هیچ متن اضافه، markdown، توضیح بیرون از JSON، کدبلاک یا کلید اضافه تولید نکن.
- اگر حتی یکی از فیلدهای اصلی خالی، null، نامشخص یا مبهم بود باید `status` برابر `needs_clarification` باشد.
- در حالت `completed` هیچ فیلد null یا رشته خالی در `collected_data` و `final_plan` نباید وجود داشته باشد.
- اگر متن ناقص بود، سوال ها باید کوتاه، روشن، غیرتکراری و کاملا کاربردی باشند.
- از حدس زدن مقدار آب، مدت زمان، فاصله آبیاری، زمان اجرا، مرحله رشد، تاریخ شروع یا محدوده هدف خودداری کن.
- اگر کاربر `answers` و `partial_plan` داده باشد، اول آن ها را با متن جدید ادغام کن و فقط سوال های باقی مانده را بپرس.
- زبان همه `summary`، `question` و `rationale` ها فارسی باشد.
فیلدهای اصلی که برای تکمیل برنامه لازم هستند:
- `crop_name`
- `growth_stage`
- `irrigation_method`
- `water_amount_per_event`
- `duration_minutes`
- `frequency_text`
- `interval_days`
- `preferred_time_of_day`
- `start_date`
- `target_area`
ساختار دقیق JSON خروجی:
{
"status": "completed" | "needs_clarification",
"summary": "string",
"missing_fields": ["string"],
"questions": [
{
"id": "string",
"field": "string",
"question": "string",
"rationale": "string"
}
],
"collected_data": {
"crop_name": "string|null",
"growth_stage": "string|null",
"irrigation_method": "string|null",
"water_amount_per_event": "string|null",
"duration_minutes": "integer|null",
"frequency_text": "string|null",
"interval_days": "integer|null",
"preferred_time_of_day": "string|null",
"start_date": "string|null",
"target_area": "string|null",
"trigger_conditions": ["string"],
"notes": ["string"]
},
"final_plan": {
"crop_name": "string",
"growth_stage": "string",
"irrigation_method": "string",
"water_amount_per_event": "string",
"duration_minutes": "integer",
"frequency_text": "string",
"interval_days": "integer",
"preferred_time_of_day": "string",
"start_date": "string",
"target_area": "string",
"trigger_conditions": ["string"],
"notes": ["string"]
} | null
}
منطق وضعیت:
- اگر همه فیلدهای اصلی کامل بودند:
- `status = "completed"`
- `missing_fields = []`
- `questions = []`
- `final_plan` باید کامل و بدون null باشد
- اگر حتی یکی از فیلدهای اصلی ناقص بود:
- `status = "needs_clarification"`
- `missing_fields` فقط فیلدهای ناقص را شامل شود
- `questions` برای همان فیلدهای ناقص ساخته شود
- `final_plan = null`
نمونه سوال خوب:
- "محصول الان در چه مرحله رشدی قرار دارد؟"
- "این برنامه از چه تاریخی باید شروع شود؟"
- "این برنامه برای کل مزرعه است یا فقط یک بخش خاص؟"
@@ -0,0 +1,75 @@
You are an irrigation recommendation assistant for CropLogic.
### GOAL
Turn the farm context, weather context, FAO-56 calculations, and the block named `[خروجی بهینه ساز شبیه سازی]` into a farmer-friendly Persian JSON response that matches the frontend contract exactly.
### HARD RULES
1. The optimizer block is the source of truth for amount, timing, frequency, validity period, event dates, and stress reasoning. Do not invent conflicting numbers.
2. If both optimizer data and general knowledge are present, prefer optimizer data and use knowledge only to explain why.
3. Always return only valid JSON.
4. The top-level object must contain exactly these keys:
- `plan`
- `water_balance`
- `timeline`
- `sections`
5. Do not return keys such as `raw_response`, `status`, `generated_at`, `recommendation_title`, `recommendation_subtitle`, `final_verdict`, `primary_method`, `usage_summary`, `alternative_plans`, `config`, or `history`.
6. In `sections`, only use `warning` and `tip` as `type`.
7. Write in clear Persian for a farmer. Keep sentences short and practical.
### OUTPUT CONTRACT
{
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود"
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00"
}
]
},
"timeline": [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود"
}
],
"sections": [
{
"title": "هشدار آبیاری",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "هشدار کوتاه و کاربردی"
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "یک نکته عملی کوتاه"
}
]
}
### WRITING RULES
- `plan.frequencyPerWeek`, `plan.bestTimeOfDay`, and the main warning must align with the optimizer block.
- `water_balance` must be included when FAO-56 or daily balance data is available, preserving the numeric values from the source context.
- `timeline` must be actionable and short. Use 2 to 4 steps when possible.
- If heat stress, rainfall risk, or unusual moisture is present, reflect it in a `warning` section.
- Put maintenance or efficiency advice inside `tip` sections.
- Never output markdown, code fences, greetings, or extra commentary.
@@ -0,0 +1,49 @@
شما دستيار تخصصي آفات و بيماري گياهي براي CropLogic هستيد.
قواعد عمومي:
- فقط و فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- لحن تخصصي، واضح و محتاط باشد.
- از قطعيت کاذب در تشخيص تصويري خودداري کن.
- اگر داده يا شواهد کافي نيست، اين عدم قطعيت را داخل JSON شفاف بيان کن.
- همه متن ها به فارسي و مناسب کاربر مزرعه باشند.
دو نوع خروجي مجاز وجود دارد:
1) اگر مسئله «تشخيص تصويري» بود، فقط اين ساختار JSON را برگردان:
{
"has_issue": true,
"category": "no_issue | pest | disease | nutrient_stress | abiotic_stress | unknown",
"confidence": 0.0,
"severity": "low | medium | high",
"summary": "جمع بندي کوتاه تشخيص",
"detected_signs": ["نشانه 1", "نشانه 2"],
"possible_causes": ["علت 1", "علت 2"],
"immediate_actions": ["اقدام 1", "اقدام 2"],
"reasoning": ["دليل 1", "دليل 2"]
}
2) اگر مسئله «پيش بيني ريسک» بود، فقط اين ساختار JSON را برگردان:
{
"summary": "جمع بندي کوتاه ريسک",
"forecast_window": "بازه زماني",
"overall_risk": "low | medium | high",
"disease_risk": {
"score": 0.0,
"level": "low | medium | high",
"likely_conditions": ["وضعيت 1"],
"reasoning": ["دليل 1", "دليل 2"]
},
"pest_risk": {
"score": 0.0,
"level": "low | medium | high",
"likely_conditions": ["وضعيت 1"],
"reasoning": ["دليل 1", "دليل 2"]
},
"key_drivers": ["عامل 1", "عامل 2"],
"recommended_actions": ["اقدام 1", "اقدام 2"]
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- اگر `category` برابر `unknown` يا `no_issue` بود، از توصيه هاي فوري و قطعي پرهيز کن.
- `recommended_actions` و `immediate_actions` بايد عملي، کوتاه و قابل اجرا باشند.
@@ -0,0 +1,22 @@
شما دستيار تخصصي تحليل ناهنجاري داده هاي خاک و سنسور براي CropLogic هستيد.
قواعد عمومي:
- فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- لحن تخصصي، شفاف و محتاط باشد.
- بين «نشانه آماري» و «تشخيص قطعي ميداني» تفاوت بگذار.
- اگر داده کافي نيست، اين محدوديت را داخل JSON صريح بگو.
خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"summary": "جمع بندي کوتاه ناهنجاري",
"explanation": "توضيح کوتاه از اينکه چه چيزي غيرعادي است",
"likely_cause": "محتمل ترين علت يا علت هاي اصلي",
"recommended_action": "اقدام عملي بعدي",
"monitoring_priority": "low | medium | high | urgent",
"confidence": 0.0
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- `recommended_action` بايد عملياتي و کوتاه باشد.
- اگر ناهنجاري معنادار نيست، `summary` و `explanation` بايد اين موضوع را واضح بگويند.
@@ -0,0 +1,21 @@
شما دستيار تخصصي تفسير نياز آبي کوتاه مدت مزرعه براي CropLogic هستيد.
قواعد عمومي:
- فقط JSON معتبر برگردان. هيچ متن اضافه، markdown يا code fence نده.
- عملياتي، دقيق و کوتاه باش.
- اعداد اصلي را فقط از داده ورودي بردار و عدد متناقض جديد نساز.
- اگر forecast يا راندمان آبياري باعث عدم قطعيت مي شود، آن را داخل JSON روشن بگو.
خروجي بايد دقيقا يک آبجکت JSON با اين ساختار باشد:
{
"summary": "جمع بندي نياز آبي بازه کوتاه مدت",
"irrigation_outlook": "برداشت عملياتي از روند آبياري روزهاي آينده",
"recommended_action": "اقدام عملي پيشنهادي براي آبياري",
"risk_note": "ريسک يا عدم قطعيت مهم",
"confidence": 0.0
}
قواعد تکميلي:
- `confidence` بايد عددي بين 0 و 1 باشد.
- `recommended_action` بايد مشخص و قابل اجرا باشد.
- اگر نياز آبي ناچيز است، اين موضوع را مستقيم در `summary` و `irrigation_outlook` بگو.
@@ -0,0 +1,39 @@
You are the narrative assistant for the Yield & Harvest Summary dashboard.
Golden Rule:
- Never generate, infer, estimate, or invent any new numbers, dates, percentages, KPIs, rankings, scores, or comparisons.
- Only use values that already exist in the provided deterministic_data and farm_context.
- If a number, date, or KPI is not present in the input context, do not mention it.
- Do not rewrite a numeric value into a different value, rounded estimate, or alternative unit unless that converted value already exists in the context.
Your job:
- Turn deterministic dashboard data into short, user-friendly text.
- Write subtitles, summaries, descriptions, and operation notes only.
- Keep the wording clear, calm, and practical.
- Preserve the meaning of deterministic blocks exactly.
Output rules:
- Do not add new facts.
- Do not add agronomic claims that are not directly supported by the provided context.
- Do not contradict deterministic_data.
- If the context is incomplete, stay general and say less.
- Prefer concise JSON-ready text fragments over long paragraphs.
Allowed narrative targets:
- season_highlights_card.subtitle
- harvest_prediction_card.description
- harvest_operations_card.summary
- harvest_operations_card.steps[].note
Forbidden behavior:
- No fabricated harvest dates.
- No fabricated yield values.
- No fabricated readiness percentages.
- No fabricated quality grades or market conclusions.
- No speculative recommendations that depend on missing measurements.
Tone:
- Helpful
- Professional
- Simple
- User-facing
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
urlpatterns = [
path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
# --- App APIs ---
path("api/rag/", include("rag.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/soil-data/", include("location_data.urls")),
path("api/soile/", include("soile.urls")),
path("api/farm-data/", include("farm_data.urls")),
path("api/weather/", include("weather.urls")),
path("api/economy/", include("economy.urls")),
path("api/plants/", include("plant.urls")),
path("api/pest-disease/", include("pest_disease.urls")),
path("api/irrigation/", include("irrigation.urls")),
path("api/fertilization/", include("fertilization.urls")),
path("api/crop-simulation/", include("crop_simulation.urls")),
]
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
+2
View File
@@ -0,0 +1,2 @@
# Keep build-isolated dependency resolution compatible with Python 3.10.
numpy>=1.23,<1.27
@@ -0,0 +1,822 @@
# راهنمای کامل `crop_simulation/services.py`
این فایل توضیح می‌دهد که سرویس‌های شبیه‌سازی در `crop_simulation/services.py` چه کاری انجام می‌دهند، ورودی و خروجی هر بخش چیست، و چگونه با تنظیمات موجود در `irrigation/apps.py` و `fertilization/apps.py` ارتباط می‌گیرند.
---
## نمای کلی
فایل `crop_simulation/services.py` هسته اجرای سناریوهای شبیه‌سازی محصول در پروژه است. این فایل سه مسئولیت اصلی دارد:
1. نرمال‌سازی ورودی‌ها برای موتور شبیه‌سازی
2. اجرای مدل PCSE/WOFOST
3. ذخیره و مدیریت سناریوها و runها در دیتابیس
در عمل این فایل بین داده‌های خام مزرعه/هواشناسی/مدیریتی و خروجی نهایی شبیه‌سازی قرار می‌گیرد.
---
## ساختار کلی فایل
این فایل را می‌توان به ۴ بخش تقسیم کرد:
1. توابع کمکی برای تبدیل ورودی‌ها
2. کلاس `PcseSimulationManager`
3. کلاس `CropSimulationService`
4. wrapperهای سطح ماژول برای استفاده ساده‌تر
---
## بخش اول: ثابت‌ها و Exception
### `DEFAULT_OUTPUT_VARS`
لیست متغیرهایی که از خروجی روزانه مدل می‌خواهیم:
- `DVS`
- `LAI`
- `TAGP`
- `TWSO`
- `SM`
### `DEFAULT_SUMMARY_VARS`
متغیرهای خلاصه:
- `TAGP`
- `TWSO`
- `CTRAT`
- `RD`
### `DEFAULT_TERMINAL_VARS`
متغیرهای انتهایی:
- `TAGP`
- `TWSO`
- `LAI`
- `DVS`
### `CropSimulationError`
خطای اختصاصی این ماژول است. هر جا داده ورودی یا اجرای مدل مشکل داشته باشد، معمولا این exception یا exceptionهای مشتق‌شده از آن دیده می‌شود.
---
## بخش دوم: توابع کمکی داخلی
این توابع public API نیستند، اما پایه رفتار کل سرویس را تشکیل می‌دهند.
### `_json_ready(value)`
داده‌های Python را برای ذخیره در JSON آماده می‌کند.
کارهایی که انجام می‌دهد:
- `dict`، `list` و `tuple` را recursive تبدیل می‌کند
- `date` و `datetime` را به `isoformat()` تبدیل می‌کند
موارد استفاده:
- قبل از ذخیره `input_payload`
- قبل از ذخیره `result_payload`
- قبل از ذخیره payload هر `SimulationRun`
### `_coerce_date(value)`
ورودی را به `date` تبدیل می‌کند.
ورودی قابل قبول:
- `date`
- `datetime`
- رشته ISO مثل `2026-04-01`
اگر نوع پشتیبانی نشود، `CropSimulationError` می‌دهد.
### `_normalize_weather_records(weather)`
ورودی آب‌وهوا را به فرمت استاندارد موردنیاز PCSE تبدیل می‌کند.
ورودی قابل قبول:
- یک `dict`
- یک `list[dict]`
- یک آبجکت با کلید `records`
خروجی همیشه لیستی از رکوردهای نرمال‌شده با کلیدهای زیر است:
- `DAY`
- `LAT`
- `LON`
- `ELEV`
- `IRRAD`
- `TMIN`
- `TMAX`
- `VAP`
- `WIND`
- `RAIN`
- `E0`
- `ES0`
- `ET0`
اگر رکوردها خالی باشند، خطا می‌دهد.
### `_normalize_agromanagement(agromanagement)`
ورودی agromanagement را به یک `list[dict]` تبدیل می‌کند.
ورودی قابل قبول:
- دیکشنری با کلید `AgroManagement`
- لیست
- یک دیکشنری تکی
اگر خالی باشد، خطا می‌دهد.
### `_deep_copy_json_like(value)`
نسخه deep copy ساده از objectهای JSON-like می‌سازد.
برای جلوگیری از mutation روی ورودی اصلی استفاده می‌شود.
### `_parse_recommendation_events(...)`
داده‌های توصیه آبیاری یا کودهی را به فرمت event قابل الحاق به `TimedEvents` تبدیل می‌کند.
این تابع از چند شکل ورودی پشتیبانی می‌کند:
- `events`
- `schedule`
- `applications`
- `plan`
نمونه ورودی آبیاری:
```python
{
"events": [
{"date": "2026-04-25", "amount": 2.5, "efficiency": 0.8}
]
}
```
نمونه خروجی:
```python
[
{
"event_signal": "irrigate",
"name": "irrigate recommendation",
"comment": "",
"events_table": [
{
date(2026, 4, 25): {"amount": 2.5, "efficiency": 0.8}
}
],
}
]
```
### `_merge_management_recommendations(...)`
مهم‌ترین تابع glue برای اتصال recommendationها به شبیه‌سازی است.
کار این تابع:
1. agromanagement را normalize می‌کند
2. توصیه آبیاری را به eventهای `irrigate` تبدیل می‌کند
3. توصیه کودهی را به eventهای `apply_n` تبدیل می‌کند
4. همه آن‌ها را داخل اولین campaign معتبر در `TimedEvents` merge می‌کند
این تابع همان نقطه‌ای است که recommendationهای اپ آبیاری/کودهی به سناریوی شبیه‌سازی تزریق می‌شوند.
### `_normalize_pcse_output_records(records)`
خروجی‌های مدل PCSE را به لیست تبدیل می‌کند تا کدهای بعدی همیشه با ساختار یکنواخت کار کنند.
### `_pick_first_not_none(*values)`
اولین مقدار non-null را برمی‌گرداند.
برای ساخت metricهای نهایی مثل `yield_estimate` استفاده می‌شود.
### `_extract_total_n(agromanagement)`
جمع کل `N_amount` را از eventهای کودهی استخراج می‌کند.
در نسخه فعلی این تابع برای محاسبات جانبی آماده است و نقطه مناسبی برای توسعه تحلیل استراتژی‌های تغذیه است.
### `_load_pcse_bindings()`
کلاس‌ها و ماژول‌های لازم از package `pcse` را load می‌کند:
- `ParameterProvider`
- `WeatherDataProvider`
- `WeatherDataContainer`
- `pcse.models`
اگر `pcse` نصب نباشد، `None` برمی‌گرداند.
### `_resolve_model_class(bindings, model_name)`
کلاس مدل PCSE را با نامی مثل `Wofost81_NWLP_CWB_CNB` پیدا می‌کند.
---
## `PreparedSimulationInput`
این dataclass ورودی‌های نرمال‌شده برای اجرای مدل را نگه می‌دارد:
- `weather`
- `soil`
- `crop`
- `site`
- `agromanagement`
این ساختار باعث می‌شود manager با یک payload استاندارد کار کند.
---
## بخش سوم: `PcseSimulationManager`
این کلاس فقط مسئول اجرای موتور شبیه‌سازی است و وارد منطق ذخیره سناریوها نمی‌شود.
### `__init__(model_name="Wofost81_NWLP_CWB_CNB")`
مدل PCSE مورد استفاده را مشخص می‌کند.
مدل پیش‌فرض:
```python
Wofost81_NWLP_CWB_CNB
```
### `run_simulation(...)`
ورودی خام می‌گیرد، normalize می‌کند، dependencyهای PCSE را load می‌کند، و شبیه‌سازی را اجرا می‌کند.
پارامترها:
- `weather`
- `soil`
- `crop_parameters`
- `agromanagement`
- `site_parameters`
خروجی:
```python
{
"engine": "pcse",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {...},
"daily_output": [...],
"summary_output": [...],
"terminal_output": [...]
}
```
### `_run_with_pcse(prepared, bindings)`
اجرای واقعی مدل را انجام می‌دهد.
جریان داخلی:
1. ساخت weather provider سفارشی از روی dictها
2. ساخت `ParameterProvider`
3. ساخت instance مدل PCSE
4. اجرای `run_till_terminate()` یا `run()`
5. گرفتن خروجی‌ها
6. تبدیل خروجی به فرم نهایی
### `_build_result(...)`
metricهای کلیدی را از خروجی‌های terminal/summary/daily استخراج می‌کند:
- `yield_estimate`
- `biomass`
- `max_lai`
اولویت انتخاب metricها:
1. terminal
2. summary
3. آخرین رکورد daily
---
## بخش چهارم: `CropSimulationService`
این کلاس service layer سطح بالاتر است. علاوه بر اجرای مدل، سناریوها و runها را در دیتابیس ذخیره می‌کند.
مدل‌های مرتبط:
- `SimulationScenario`
- `SimulationRun`
### `__init__(manager=None)`
اگر manager داده نشود، از `PcseSimulationManager()` پیش‌فرض استفاده می‌شود.
---
## متدهای public اصلی
### 1) `run_single_simulation(...)`
برای اجرای یک سناریوی تکی.
پارامترها:
- `weather`
- `soil`
- `crop_parameters`
- `agromanagement`
- `site_parameters`
- `irrigation_recommendation`
- `fertilization_recommendation`
- `name`
کارها:
1. merge کردن recommendationها داخل management
2. ساخت `SimulationScenario` با نوع `SINGLE`
3. ساخت `SimulationRun`
4. اجرای سناریو
مهم:
اگر recommendationهای آبیاری/کودهی بدهید، این متد آن‌ها را به eventهای مدل تبدیل می‌کند.
نمونه:
```python
from crop_simulation.services import run_single_simulation
result = run_single_simulation(
weather=weather_payload,
soil={"SMFCF": 0.34, "SMW": 0.12, "RDMSOL": 120.0},
crop_parameters={"crop_name": "wheat", "TSUM1": 800},
agromanagement=agromanagement_payload,
site_parameters={"WAV": 40.0},
irrigation_recommendation={
"events": [
{"date": "2026-04-25", "amount": 2.5, "efficiency": 0.8}
]
},
)
```
### 2) `compare_crops(...)`
برای مقایسه دو محصول.
ورودی‌های اضافه:
- `crop_a`
- `crop_b`
خروجی:
- سناریو با نوع `CROP_COMPARISON`
- دو run
- comparison شامل best run و yield gap
### 3) `recommend_best_crop(...)`
برای مقایسه چند محصول و انتخاب بهترین گزینه.
ورودی مهم:
- `crops: list[dict]`
شرط:
- حداقل دو crop باید وجود داشته باشد
خروجی ساده‌شده:
```python
{
"scenario_id": ...,
"scenario_type": "crop_comparison",
"recommended_crop": {
"run_key": "...",
"label": "...",
"expected_yield_estimate": ...
},
"candidates": [...],
"raw_result": {...}
}
```
### 4) `compare_fertilization_strategies(...)`
برای مقایسه چند strategy کودهی روی یک crop ثابت.
ورودی ویژه:
```python
strategies = [
{
"label": "base",
"agromanagement": [...]
},
{
"label": "high_n",
"agromanagement": [...]
}
]
```
این متد برای هر strategy یک run می‌سازد و بهترین استراتژی را بر اساس `yield_estimate` انتخاب می‌کند.
### 5) `get_scenario_result(scenario_id)`
نتیجه ذخیره‌شده یک سناریو را از دیتابیس برمی‌گرداند.
خروجی شامل:
- اطلاعات scenario
- اطلاعات همه runها
- status
- input payload
- result payload
- error message
---
## متدهای داخلی مهم در `CropSimulationService`
### `_execute_scenario(...)`
قلب اجرای سناریو است.
جریان:
1. status سناریو را `RUNNING` می‌کند
2. تک‌تک runها را اجرا می‌کند
3. خروجی هر run را ذخیره می‌کند
4. اگر exception رخ دهد:
- همان run را `FAILURE` می‌کند
- سناریو را `FAILURE` می‌کند
- خطا را ذخیره می‌کند
5. اگر همه چیز موفق باشد:
- `scenario_result` می‌سازد
- سناریو را `SUCCESS` می‌کند
### `_build_scenario_result(scenario, results)`
خروجی سطح سناریو را می‌سازد.
رفتار بر اساس نوع سناریو:
- `SINGLE`:
- فقط `result` برمی‌گرداند
- `CROP_COMPARISON`:
- comparison می‌سازد
- بهترین run را مشخص می‌کند
- `yield_gap` می‌سازد
- `FERTILIZATION_COMPARISON`:
- recommendation برای بهترین strategy می‌سازد
---
## wrapperهای سطح ماژول
در انتهای فایل این wrapperها وجود دارند:
- `run_single_simulation(**kwargs)`
- `compare_crops(**kwargs)`
- `recommend_best_crop(**kwargs)`
- `compare_fertilization_strategies(**kwargs)`
همه آن‌ها با `@transaction.atomic` تزئین شده‌اند.
یعنی اگر بخواهید ساده از بیرون صدا بزنید، لازم نیست خودتان instance بسازید:
```python
from crop_simulation.services import recommend_best_crop
result = recommend_best_crop(
weather=weather_payload,
soil=soil_payload,
crops=[crop_a, crop_b, crop_c],
agromanagement=agromanagement_payload,
)
```
---
## نحوه ارتباط با مدل‌های دیتابیس
### `SimulationScenario`
نماینده یک سناریوی کلی است.
مثال‌ها:
- single run
- crop comparison
- fertilization comparison
### `SimulationRun`
نماینده هر اجرای منفرد داخل یک سناریو است.
مثلا در `compare_crops`:
- یک `SimulationScenario`
- دو `SimulationRun`
---
## ارتباط `crop_simulation/services.py` با `crop_simulation/apps.py`
فایل `crop_simulation/apps.py` این متد را expose می‌کند:
```python
def get_recommendation_optimizer(self):
return self.recommendation_optimizer
```
این optimizer در فایل `crop_simulation/recommendation_optimizer.py` ساخته می‌شود و برای recommendationهای آبیاری و کودهی استفاده می‌شود.
نکته مهم:
- `services.py` موتور اجرای سناریوهاست
- `recommendation_optimizer.py` روی همین موتور سناریوهای candidate می‌سازد
- `apps.py` فقط نقطه دسترسی مرکزی به optimizer است
یعنی:
```python
optimizer = apps.get_app_config("crop_simulation").get_recommendation_optimizer()
```
و بعد optimizer در داخل خودش از `CropSimulationService` استفاده می‌کند.
---
## ارتباط با `irrigation/apps.py`
فایل `irrigation/apps.py` خودش شبیه‌سازی اجرا نمی‌کند؛ بلکه تنظیمات default برای optimizer آبیاری را نگه می‌دارد.
### فیلدهای مهم
#### `tone_file`
مسیر tone مربوط به LLM:
```python
config/tones/irrigation_tone.txt
```
#### `optimizer_defaults`
این property تنظیمات پایه بهینه‌سازی آبیاری را برمی‌گرداند:
- `validity_days`
- `minimum_event_mm`
- `significant_rain_threshold_mm`
- `stage_targets`
- `strategy_profiles`
### `stage_targets`
هدف رطوبت یا رفتار پایه برای stageهای مختلف:
- `initial`
- `vegetative`
- `flowering`
- `fruiting`
### `strategy_profiles`
سه سناریوی پایه برای optimizer:
- `conservative`
- `balanced`
- `protective`
هر سناریو مشخص می‌کند:
- ضریب آب (`multiplier`)
- ضریب تعداد دفعات (`frequency_factor`)
- تعداد event پایه (`event_count`)
### نحوه استفاده در کد
در optimizer آبیاری معمولا به شکل زیر خوانده می‌شود:
```python
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
```
سپس این defaults به سناریوهای recommendation تبدیل می‌شوند و در صورت نیاز به `run_single_simulation()` پاس داده می‌شوند.
### نقش آن در ارتباط با `services.py`
ارتباط غیرمستقیم است:
1. `irrigation/apps.py` تنظیمات baseline را می‌دهد
2. optimizer با این تنظیمات candidate strategy می‌سازد
3. strategyها به recommendation event تبدیل می‌شوند
4. `crop_simulation/services.py` آن‌ها را داخل agromanagement merge و اجرا می‌کند
---
## ارتباط با `fertilization/apps.py`
این فایل مشابه irrigation است اما برای منطق کودهی.
### `tone_file`
```python
config/tones/fertilization_tone.txt
```
### `optimizer_defaults`
این تنظیمات را می‌دهد:
- `validity_days`
- `rain_delay_threshold_mm`
- `stage_targets`
- `strategy_profiles`
### `stage_targets`
برای هر stage اطلاعات زیر مشخص می‌شود:
- `n`
- `p`
- `k`
- `formula`
- `application_method`
- `timing`
### `strategy_profiles`
سناریوهای پایه:
- `maintenance`
- `balanced`
- `corrective`
هرکدام مشخص می‌کنند:
- ضریب مصرف (`multiplier`)
- focus تغذیه‌ای
- روش مصرف
- override فرمول در صورت نیاز
### نحوه استفاده در کد
```python
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
```
سپس optimizer با این defaults چند strategy می‌سازد. اگر لازم باشد این strategyها به `compare_fertilization_strategies()` یا `run_single_simulation()` داده می‌شوند.
### ارتباط آن با `services.py`
ارتباط باز هم غیرمستقیم است:
1. `fertilization/apps.py` پروفایل stage و strategy را می‌دهد
2. optimizer از روی آن strategy تولید می‌کند
3. strategy به eventهای `apply_n` تبدیل می‌شود
4. `services.py` این eventها را داخل agromanagement merge می‌کند
5. سناریو اجرا و مقایسه می‌شود
---
## الگوی ارتباط کامل بین سه بخش
### سناریوی آبیاری
```text
irrigation/apps.py
-> optimizer_defaults
-> recommendation optimizer
-> irrigation recommendation events
-> crop_simulation/services.py:_merge_management_recommendations()
-> run_single_simulation()
-> PCSE run
-> scenario/run result
```
### سناریوی کودهی
```text
fertilization/apps.py
-> optimizer_defaults
-> recommendation optimizer
-> fertilization recommendation events
-> crop_simulation/services.py:_merge_management_recommendations()
-> compare_fertilization_strategies() / run_single_simulation()
-> PCSE run
-> best strategy result
```
---
## نمونه استفاده واقعی
### اجرای یک شبیه‌سازی ساده
```python
from crop_simulation.services import run_single_simulation
result = run_single_simulation(
weather=[
{
"DAY": "2026-04-01",
"LAT": 35.7,
"LON": 51.4,
"ELEV": 1200,
"IRRAD": 16000000,
"TMIN": 11,
"TMAX": 22,
"VAP": 12,
"WIND": 2.4,
"RAIN": 0.8,
"E0": 0.35,
"ES0": 0.3,
"ET0": 0.32,
}
],
soil={"SMFCF": 0.34, "SMW": 0.12, "RDMSOL": 120.0},
crop_parameters={"crop_name": "wheat", "TSUM1": 800, "YIELD_SCALE": 1.0},
agromanagement=[
{
"2026-04-01": {
"CropCalendar": {
"crop_name": "wheat",
"variety_name": "winter-wheat",
"crop_start_date": "2026-04-05",
"crop_start_type": "sowing",
"crop_end_date": "2026-09-01",
"crop_end_type": "harvest",
"max_duration": 180,
},
"TimedEvents": [],
"StateEvents": [],
}
}
],
site_parameters={"WAV": 40.0},
)
```
### مقایسه دو محصول
```python
from crop_simulation.services import compare_crops
result = compare_crops(
weather=weather_payload,
soil=soil_payload,
crop_a={"crop_name": "wheat", "TSUM1": 800},
crop_b={"crop_name": "maize", "TSUM1": 900},
agromanagement=agromanagement_payload,
site_parameters={"WAV": 40.0},
)
```
### مقایسه strategyهای کودهی
```python
from crop_simulation.services import compare_fertilization_strategies
result = compare_fertilization_strategies(
weather=weather_payload,
soil=soil_payload,
crop_parameters={"crop_name": "wheat", "TSUM1": 800},
strategies=[
{"label": "base", "agromanagement": agm_base},
{"label": "high_n", "agromanagement": agm_high_n},
],
site_parameters={"WAV": 40.0},
)
```
---
## نکات مهم توسعه
### 1. نقطه اصلی inject کردن توصیه‌ها
اگر بخواهید recommendationهای جدید را وارد شبیه‌سازی کنید، مهم‌ترین نقطه:
```python
_merge_management_recommendations()
```
### 2. نقطه اصلی اجرای موتور
اگر بخواهید backend engine عوض شود یا مدل جدید اضافه شود:
```python
PcseSimulationManager.run_simulation()
```
### 3. نقطه اصلی مدیریت lifecycle سناریو
اگر بخواهید queueing، logging یا audit بیشتری اضافه کنید:
```python
CropSimulationService._execute_scenario()
```
### 4. ارتباط با اپ‌های recommendation
اگر stageها یا strategyهای آبیاری/کودهی تغییر کنند، باید این فایل‌ها بررسی شوند:
- `irrigation/apps.py`
- `fertilization/apps.py`
چون optimizer از آن‌ها defaultها را می‌خواند.
---
## جمع‌بندی
اگر بخواهیم نقش هر فایل را در یک جمله بگوییم:
- `crop_simulation/services.py`: اجرای شبیه‌سازی، ساخت scenario/run، و merge کردن recommendationها با management
- `crop_simulation/apps.py`: نقطه دسترسی مرکزی به optimizer
- `irrigation/apps.py`: تنظیمات پایه برای سناریوهای بهینه‌سازی آبیاری
- `fertilization/apps.py`: تنظیمات پایه برای سناریوهای بهینه‌سازی کودهی
و زنجیره کلی این است:
```text
defaults in app config
-> optimizer
-> recommendation events
-> crop_simulation/services.py
-> PCSE execution
-> scenario result
```
اگر بخواهید، قدم بعدی می‌توانم یک فایل دوم هم بسازم که فقط نمونه request/response واقعی برای هر تابع و هر سناریو را به‌صورت cookbook نشان بدهد.
+1
View File
@@ -0,0 +1 @@
+54
View File
@@ -0,0 +1,54 @@
from functools import cached_property
from django.apps import AppConfig
class CropSimulationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "crop_simulation"
verbose_name = "Crop Simulation"
@cached_property
def recommendation_optimizer(self):
from .recommendation_optimizer import SimulationRecommendationOptimizer
return SimulationRecommendationOptimizer()
@cached_property
def current_farm_chart_simulator(self):
from .growth_simulation import CurrentFarmChartSimulator
return CurrentFarmChartSimulator()
@cached_property
def harvest_prediction_service(self):
from .harvest_prediction import HarvestPredictionService
return HarvestPredictionService()
@cached_property
def yield_prediction_service(self):
from .yield_prediction import YieldPredictionService
return YieldPredictionService()
@cached_property
def water_stress_service(self):
from .water_stress import WaterStressSimulationService
return WaterStressSimulationService()
def get_recommendation_optimizer(self):
return self.recommendation_optimizer
def get_current_farm_chart_simulator(self):
return self.current_farm_chart_simulator
def get_harvest_prediction_service(self):
return self.harvest_prediction_service
def get_yield_prediction_service(self):
return self.yield_prediction_service
def get_water_stress_service(self):
return self.water_stress_service
@@ -0,0 +1,802 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from math import exp
from typing import Any
import logging
from django.core.paginator import EmptyPage, Paginator
from farm_data.models import SensorData
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from location_data.satellite_snapshot import build_location_satellite_snapshot
from plant.gdd import calculate_daily_gdd, resolve_growth_profile
from weather.models import WeatherForecast
from .services import CropSimulationService, build_simulation_payload_from_farm
DEFAULT_DYNAMIC_PARAMETERS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
DEFAULT_PAGE_SIZE = 10
MAX_PAGE_SIZE = 50
logger = logging.getLogger(__name__)
DEFAULT_STAGE_LABELS = {
"pre_emergence": "پیش از سبز شدن",
"establishment": "استقرار",
"vegetative": "رشد رویشی",
"flowering": "گلدهی",
"reproductive": "پرشدن محصول",
"maturity": "رسیدگی",
}
ENGINE_LABELS = {
"pcse": "موتور شبیه سازی PCSE",
"growth_projection": "موتور برآورد رشد",
}
MODEL_LABELS = {
"growth_projection_v1": "مدل برآورد رشد نسخه ۱",
"wofost": "مدل ووفوست",
}
class GrowthSimulationError(Exception):
pass
def _fa_engine_name(name: str | None) -> str | None:
if not name:
return name
return ENGINE_LABELS.get(name, name)
def _fa_model_name(name: str | None) -> str | None:
if not name:
return name
return MODEL_LABELS.get(name, name)
@dataclass
class GrowthSimulationContext:
farm_uuid: str | None
plant_name: str
plant: Any
dynamic_parameters: list[str]
weather: list[dict[str, Any]]
crop_parameters: dict[str, Any]
soil_parameters: dict[str, Any]
site_parameters: dict[str, Any]
agromanagement: list[dict[str, Any]]
page_size: int
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _pick_first_not_none(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def _clamp(value: float, minimum: float, maximum: float) -> float:
if minimum > maximum:
minimum, maximum = maximum, minimum
return max(minimum, min(value, maximum))
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _coerce_date(value: Any) -> date:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
return date.fromisoformat(value)
raise GrowthSimulationError(f"Invalid date value: {value!r}")
def _json_ready(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_ready(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_ready(item) for item in value]
if isinstance(value, tuple):
return [_json_ready(item) for item in value]
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]:
if not weather:
return []
records = weather.get("records") if isinstance(weather, dict) and "records" in weather else weather
if not isinstance(records, list):
records = [records]
normalized = []
for item in records:
if not isinstance(item, dict):
raise GrowthSimulationError("Weather records must be JSON objects.")
current_date = _coerce_date(item.get("DAY") or item.get("day"))
normalized.append(
{
"DAY": current_date,
"LAT": _safe_float(item.get("LAT", item.get("lat")), 35.7),
"LON": _safe_float(item.get("LON", item.get("lon")), 51.4),
"ELEV": _safe_float(item.get("ELEV", item.get("elev")), 1200.0),
"IRRAD": _safe_float(item.get("IRRAD", item.get("irrad")), 16_000_000.0),
"TMIN": _safe_float(item.get("TMIN", item.get("tmin")), 12.0),
"TMAX": _safe_float(item.get("TMAX", item.get("tmax")), 24.0),
"VAP": _safe_float(item.get("VAP", item.get("vap")), 12.0),
"WIND": _safe_float(item.get("WIND", item.get("wind")), 2.0),
"RAIN": _safe_float(item.get("RAIN", item.get("rain")), 0.0),
"E0": _safe_float(item.get("E0", item.get("e0")), 0.35),
"ES0": _safe_float(item.get("ES0", item.get("es0")), 0.3),
"ET0": _safe_float(item.get("ET0", item.get("et0")), 0.32),
}
)
if not normalized:
raise GrowthSimulationError("At least one weather record is required.")
return normalized
def _build_weather_from_farm(sensor: SensorData) -> list[dict[str, Any]]:
forecasts = list(
WeatherForecast.objects.filter(location=sensor.center_location)
.order_by("forecast_date")[:14]
)
if not forecasts:
raise GrowthSimulationError("No forecast data found for the selected farm.")
records = []
for forecast in forecasts:
records.append(
{
"DAY": forecast.forecast_date,
"LAT": float(sensor.center_location.latitude),
"LON": float(sensor.center_location.longitude),
"ELEV": 1200.0,
"IRRAD": 16_000_000.0,
"TMIN": _safe_float(forecast.temperature_min, 12.0),
"TMAX": _safe_float(forecast.temperature_max, 24.0),
"VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0),
"WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6,
# WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day.
"RAIN": _mm_to_cm_day(forecast.precipitation, 0.0),
"E0": _mm_to_cm_day(forecast.et0, 0.35),
"ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1),
"ET0": _mm_to_cm_day(forecast.et0, 0.35),
}
)
return records
def _build_soil_and_site_from_farm(sensor: SensorData) -> tuple[dict[str, Any], dict[str, Any]]:
satellite_metrics = build_location_satellite_snapshot(sensor.center_location).get("resolved_metrics") or {}
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
smfcf = _safe_float(ndwi, 0.34)
smw = max(round(smfcf * 0.45, 3), 0.12)
sm0 = min(max(smfcf + 0.08, smw + 0.12), 0.6)
soil_moisture = None
payload = sensor.sensor_payload or {}
if isinstance(payload, dict):
for block in payload.values():
if isinstance(block, dict) and block.get("soil_moisture") is not None:
soil_moisture = _safe_float(block.get("soil_moisture"))
break
site = {
"WAV": soil_moisture if soil_moisture is not None else 40.0,
"IFUNRN": 0,
"NOTINF": 0.0,
"SSI": 0.0,
"SSMAX": 0.0,
"SMLIM": round(_clamp(smfcf, smw, sm0), 3),
}
soil = {
"SMFCF": smfcf,
"SMW": smw,
"SM0": sm0,
"RDMSOL": 120.0,
"CRAIRC": 0.06,
"SOPE": 10.0,
"KSUB": 10.0,
}
return soil, site
def _build_default_crop_parameters(plant: Any) -> dict[str, Any]:
profile = resolve_growth_profile(plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
return {
"crop_name": plant.name,
"TSUM1": round(required_gdd * 0.45, 3),
"TSUM2": round(required_gdd * 0.55, 3),
"YIELD_SCALE": 1.0,
"MAX_LAI": 5.0,
"MAX_BIOMASS": 12000.0,
}
def _build_default_agromanagement(plant_name: str, weather: list[dict[str, Any]]) -> list[dict[str, Any]]:
first_day = weather[0]["DAY"]
last_day = weather[-1]["DAY"]
crop_start = first_day
crop_end = max(last_day, crop_start + timedelta(days=1))
return [
{
first_day: {
"CropCalendar": {
"crop_name": plant_name,
"variety_name": "default",
"crop_start_date": crop_start,
"crop_start_type": "sowing",
"crop_end_date": crop_end,
"crop_end_type": "harvest",
"max_duration": max((crop_end - crop_start).days, 1),
},
"TimedEvents": [],
"StateEvents": [],
}
},
{},
]
def _resolve_plant_simulation_defaults(plant: Any) -> tuple[dict[str, Any] | None, list[dict[str, Any]] | None]:
for attr in ("growth_profile", "irrigation_profile", "health_profile"):
profile = getattr(plant, attr, None) or {}
if not isinstance(profile, dict):
continue
simulation = profile.get("simulation")
if not isinstance(simulation, dict):
continue
crop_parameters = simulation.get("crop_parameters")
agromanagement = simulation.get("agromanagement")
if isinstance(crop_parameters, dict) and agromanagement:
return deepcopy(crop_parameters), deepcopy(agromanagement)
return None, None
def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
plant_name = payload["plant_name"]
from plant.models import Plant
plant = Plant.objects.filter(name=plant_name).first()
if plant is None:
raise GrowthSimulationError("Plant not found.")
dynamic_parameters = payload.get("dynamic_parameters") or DEFAULT_DYNAMIC_PARAMETERS
page_size = min(max(int(payload.get("page_size") or DEFAULT_PAGE_SIZE), 1), MAX_PAGE_SIZE)
sensor = None
resolved_farm_uuid = str(payload["farm_uuid"]) if payload.get("farm_uuid") else None
if payload.get("farm_uuid"):
sensor = (
SensorData.objects.select_related("center_location")
.filter(farm_uuid=payload["farm_uuid"])
.first()
)
if sensor is None:
raise GrowthSimulationError("Farm not found.")
if resolved_farm_uuid:
farm_payload = build_simulation_payload_from_farm(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
weather=payload.get("weather"),
soil=payload.get("soil_parameters"),
crop_parameters=payload.get("crop_parameters"),
agromanagement=payload.get("agromanagement"),
site_parameters=payload.get("site_parameters"),
)
weather = farm_payload["weather"]
crop_parameters = farm_payload["crop_parameters"]
soil_parameters = farm_payload["soil"]
site_parameters = farm_payload["site_parameters"]
agromanagement = farm_payload["agromanagement"]
plant = farm_payload["plant"] or plant
return GrowthSimulationContext(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
plant=plant,
dynamic_parameters=dynamic_parameters,
weather=weather,
crop_parameters=crop_parameters,
soil_parameters=soil_parameters,
site_parameters=site_parameters,
agromanagement=agromanagement,
page_size=page_size,
)
weather = (
_normalize_weather_records(payload["weather"])
if payload.get("weather")
else _build_weather_from_farm(sensor)
if sensor is not None
else []
)
if not weather:
raise GrowthSimulationError("Weather input is required.")
default_crop_parameters, default_agromanagement = _resolve_plant_simulation_defaults(plant)
crop_parameters = deepcopy(payload.get("crop_parameters") or default_crop_parameters or _build_default_crop_parameters(plant))
crop_parameters.setdefault("crop_name", plant.name)
soil_parameters = deepcopy(payload.get("soil_parameters") or {})
site_parameters = deepcopy(payload.get("site_parameters") or {})
if sensor is not None:
farm_soil, farm_site = _build_soil_and_site_from_farm(sensor)
soil_parameters = {**farm_soil, **soil_parameters}
site_parameters = {**farm_site, **site_parameters}
soil_parameters.setdefault("SMFCF", 0.34)
soil_parameters.setdefault("SMW", 0.14)
soil_parameters.setdefault("SM0", 0.42)
soil_parameters.setdefault("RDMSOL", 120.0)
soil_parameters.setdefault("CRAIRC", 0.06)
soil_parameters.setdefault("SOPE", 10.0)
soil_parameters.setdefault("KSUB", 10.0)
site_parameters.setdefault("WAV", 40.0)
site_parameters.setdefault("IFUNRN", 0)
site_parameters.setdefault("NOTINF", 0.0)
site_parameters.setdefault("SSI", 0.0)
site_parameters.setdefault("SSMAX", 0.0)
site_parameters.setdefault(
"SMLIM",
round(
_clamp(
_safe_float(site_parameters.get("SMLIM"), soil_parameters.get("SMFCF", 0.34)),
_safe_float(soil_parameters.get("SMW"), 0.14),
_safe_float(soil_parameters.get("SM0"), 0.42),
),
3,
),
)
agromanagement = deepcopy(
payload.get("agromanagement")
or default_agromanagement
or _build_default_agromanagement(plant.name, weather)
)
return GrowthSimulationContext(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
plant=plant,
dynamic_parameters=dynamic_parameters,
weather=weather,
crop_parameters=crop_parameters,
soil_parameters=soil_parameters,
site_parameters=site_parameters,
agromanagement=agromanagement,
page_size=page_size,
)
def _derive_stage(dvs: float) -> tuple[str, str]:
if dvs < 0:
return "pre_emergence", DEFAULT_STAGE_LABELS["pre_emergence"]
if dvs < 0.2:
return "establishment", DEFAULT_STAGE_LABELS["establishment"]
if dvs < 1.0:
return "vegetative", DEFAULT_STAGE_LABELS["vegetative"]
if dvs < 1.3:
return "flowering", DEFAULT_STAGE_LABELS["flowering"]
if dvs < 2.0:
return "reproductive", DEFAULT_STAGE_LABELS["reproductive"]
return "maturity", DEFAULT_STAGE_LABELS["maturity"]
def _logistic(value: float, midpoint: float, steepness: float, upper: float) -> float:
return upper / (1.0 + exp(-steepness * (value - midpoint)))
def _run_projection_engine(context: GrowthSimulationContext) -> dict[str, Any]:
profile = resolve_growth_profile(context.plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
current_gdd = _safe_float(profile.get("current_cumulative_gdd"), 0.0)
base_temperature = _safe_float(profile.get("base_temperature"), 10.0)
max_lai = _safe_float(context.crop_parameters.get("MAX_LAI"), 5.0)
max_biomass = _safe_float(context.crop_parameters.get("MAX_BIOMASS"), 12000.0)
soil_moisture = _safe_float(context.site_parameters.get("WAV"), 40.0)
daily_output = []
for record in context.weather:
tmax = _safe_float(record.get("TMAX"), 24.0)
tmin = _safe_float(record.get("TMIN"), 12.0)
rain = _safe_float(record.get("RAIN"), 0.0)
et0 = _safe_float(record.get("ET0"), 0.32)
daily_gdd = calculate_daily_gdd(tmax=tmax, tmin=tmin, tbase=base_temperature)
current_gdd += daily_gdd
dvs = min(max((current_gdd / max(required_gdd, 1.0)) * 2.0, 0.0), 2.0)
if dvs <= 1.0:
lai = _logistic(dvs, midpoint=0.55, steepness=7.5, upper=max_lai)
else:
decline_factor = max(0.25, 1.0 - ((dvs - 1.0) / 1.1))
lai = max_lai * decline_factor
biomass_factor = min(current_gdd / max(required_gdd, 1.0), 1.25)
weather_modifier = max(0.65, min(1.15, 1.0 + (rain * 0.02) - (et0 * 0.08)))
tagp = max_biomass * biomass_factor * weather_modifier
twso = tagp * max(min((dvs - 0.95) / 0.85, 0.55), 0.0)
soil_moisture = max(5.0, min(95.0, soil_moisture + (rain * 0.7) - (et0 * 8.5)))
entry = {
"DAY": record["DAY"],
"DVS": round(dvs, 4),
"LAI": round(lai, 4),
"TAGP": round(tagp, 4),
"TWSO": round(twso, 4),
"SM": round(soil_moisture / 100.0, 4),
"GDD": round(daily_gdd, 4),
"TMIN": round(tmin, 4),
"TMAX": round(tmax, 4),
"RAIN": round(rain, 4),
"ET0": round(et0, 4),
}
daily_output.append(entry)
final_entry = daily_output[-1] if daily_output else {}
return {
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"metrics": {
"yield_estimate": round(_safe_float(final_entry.get("TWSO"), 0.0), 4),
"biomass": round(_safe_float(final_entry.get("TAGP"), 0.0), 4),
"max_lai": round(max((_safe_float(item.get("LAI"), 0.0) for item in daily_output), default=0.0), 4),
},
"daily_output": _json_ready(daily_output),
"summary_output": [],
"terminal_output": [_json_ready(final_entry)] if final_entry else [],
}
def _run_simulation(
context: GrowthSimulationContext,
*,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], int | None, str | None]:
try:
response = CropSimulationService().run_single_simulation(
farm_uuid=context.farm_uuid,
plant_name=context.plant_name,
weather=context.weather,
soil=context.soil_parameters,
crop_parameters=context.crop_parameters,
agromanagement=context.agromanagement,
site_parameters=context.site_parameters,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
name=f"growth:{context.plant_name}",
)
return response["result"], response.get("scenario_id"), None
except Exception as exc:
logger.warning(
"Falling back to projection engine for farm_uuid=%s plant_name=%s because PCSE failed: %s",
context.farm_uuid,
context.plant_name,
exc,
)
fallback_result = _run_projection_engine(context)
warning = f"موتور شبیه سازی با خطا مواجه شد و برآورد جایگزین استفاده شد: {exc}"
return fallback_result, None, warning
def summarize_growth_stages(
daily_output: list[dict[str, Any]],
dynamic_parameters: list[str],
) -> list[dict[str, Any]]:
if not daily_output:
return []
stage_items = []
current = None
for raw in daily_output:
record = dict(raw)
day = _coerce_date(record.get("DAY") or record.get("day"))
dvs = _safe_float(record.get("DVS"), 0.0)
stage_code, stage_name = _derive_stage(dvs)
parameter_values = {}
for param in dynamic_parameters:
if record.get(param) is not None:
parameter_values[param] = _safe_float(record.get(param))
if current is None or current["stage_code"] != stage_code:
if current is not None:
stage_items.append(current)
current = {
"stage_code": stage_code,
"stage_name": stage_name,
"start_date": day,
"end_date": day,
"days_count": 1,
"raw_days": [
{
"date": day,
"parameters": parameter_values,
}
],
}
continue
current["end_date"] = day
current["days_count"] += 1
current["raw_days"].append({"date": day, "parameters": parameter_values})
if current is not None:
stage_items.append(current)
summarized = []
for index, item in enumerate(stage_items, start=1):
metrics = {}
for param in dynamic_parameters:
values = [
day_item["parameters"][param]
for day_item in item["raw_days"]
if param in day_item["parameters"]
]
if not values:
continue
metrics[param] = {
"start": round(values[0], 4),
"end": round(values[-1], 4),
"min": round(min(values), 4),
"max": round(max(values), 4),
"avg": round(sum(values) / len(values), 4),
}
summarized.append(
{
"order": index,
"stage_code": item["stage_code"],
"stage_name": item["stage_name"],
"start_date": item["start_date"].isoformat(),
"end_date": item["end_date"].isoformat(),
"days_count": item["days_count"],
"metrics": metrics,
}
)
return summarized
def paginate_growth_stages(
stage_timeline: list[dict[str, Any]],
*,
page: int,
page_size: int,
) -> dict[str, Any]:
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
if not stage_timeline:
return {
"items": [],
"pagination": {
"page": 1,
"page_size": page_size,
"total_items": 0,
"total_pages": 0,
"has_next": False,
"has_previous": False,
},
}
paginator = Paginator(stage_timeline, page_size)
try:
page_obj = paginator.page(page)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages or 1)
return {
"items": list(page_obj.object_list),
"pagination": {
"page": page_obj.number,
"page_size": page_size,
"total_items": paginator.count,
"total_pages": paginator.num_pages,
"has_next": page_obj.has_next(),
"has_previous": page_obj.has_previous(),
},
}
def run_growth_simulation(payload: dict[str, Any], progress_callback=None) -> dict[str, Any]:
context = build_growth_context(payload)
if progress_callback is not None:
progress_callback(
state="PROGRESS",
meta={"current": 1, "total": 3, "message": "simulation input resolved"},
)
simulation_result, scenario_id, simulation_error = _run_simulation(
context,
irrigation_recommendation=payload.get("irrigation_recommendation"),
fertilization_recommendation=payload.get("fertilization_recommendation"),
)
if progress_callback is not None:
progress_callback(
state="PROGRESS",
meta={"current": 2, "total": 3, "message": "simulation finished"},
)
stage_timeline = summarize_growth_stages(
daily_output=simulation_result.get("daily_output", []),
dynamic_parameters=context.dynamic_parameters,
)
if progress_callback is not None:
progress_callback(
state="PROGRESS",
meta={"current": 3, "total": 3, "message": "growth stages prepared"},
)
paginated = paginate_growth_stages(
stage_timeline,
page=1,
page_size=context.page_size,
)
return {
"plant_name": context.plant_name,
"dynamic_parameters": context.dynamic_parameters,
"engine": _fa_engine_name(simulation_result.get("engine")),
"model_name": _fa_model_name(simulation_result.get("model_name")),
"scenario_id": scenario_id,
"simulation_warning": simulation_error,
"summary_metrics": simulation_result.get("metrics", {}),
"stage_timeline": stage_timeline,
"stages_page": paginated["items"],
"pagination": paginated["pagination"],
"daily_records_count": len(simulation_result.get("daily_output", [])),
"default_page_size": context.page_size,
}
def _estimate_leaf_count(lai: float) -> float:
return max(lai, 0.0) * 12000.0
def _build_current_farm_chart_payload(
context: GrowthSimulationContext,
simulation_result: dict[str, Any],
scenario_id: int | None,
simulation_warning: str | None,
) -> dict[str, Any]:
daily_output = simulation_result.get("daily_output") or []
categories = [str(item.get("DAY")) for item in daily_output]
leaf_count_series = [round(_estimate_leaf_count(_safe_float(item.get("LAI"))), 2) for item in daily_output]
biomass_series = [round(_safe_float(item.get("TAGP")), 2) for item in daily_output]
storage_weight_series = [round(_safe_float(item.get("TWSO")), 2) for item in daily_output]
lai_series = [round(_safe_float(item.get("LAI")), 4) for item in daily_output]
moisture_series = [round(_safe_float(item.get("SM")) * 100.0, 2) for item in daily_output]
latest = daily_output[-1] if daily_output else {}
latest_lai = _safe_float(latest.get("LAI"), 0.0)
latest_biomass = _safe_float(latest.get("TAGP"), 0.0)
latest_storage = _safe_float(latest.get("TWSO"), 0.0)
latest_moisture = _safe_float(latest.get("SM"), 0.0) * 100.0
summary = [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": round(_estimate_leaf_count(latest_lai), 2),
"unit": "برگ",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
},
{
"title": "وزن بیوماس",
"subtitle": "برآورد فعلی",
"amount": round(latest_biomass, 2),
"unit": "کیلوگرم در هکتار",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "وزن محصول",
"subtitle": "برآورد فعلی",
"amount": round(latest_storage, 2),
"unit": "کیلوگرم در هکتار",
"avatarColor": "warning",
"avatarIcon": "tabler-scale",
},
{
"title": "رطوبت خاک",
"subtitle": "آخرین روز",
"amount": round(latest_moisture, 2),
"unit": "%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
},
]
return {
"farm_uuid": context.farm_uuid,
"plant_name": context.plant_name,
"engine": _fa_engine_name(simulation_result.get("engine")),
"model_name": _fa_model_name(simulation_result.get("model_name")),
"scenario_id": scenario_id,
"simulation_warning": simulation_warning,
"categories": categories,
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": leaf_count_series},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": biomass_series},
{"name": "وزن محصول", "key": "storage_organ_weight", "data": storage_weight_series},
{"name": "شاخص سطح برگ", "key": "lai", "data": lai_series},
{"name": "رطوبت خاک", "key": "soil_moisture_percent", "data": moisture_series},
],
"summary": summary,
"current_state": {
"date": latest.get("DAY"),
"leaf_count_estimate": round(_estimate_leaf_count(latest_lai), 2),
"leaf_area_index": round(latest_lai, 4),
"biomass_weight": round(latest_biomass, 2),
"storage_organ_weight": round(latest_storage, 2),
"soil_moisture_percent": round(latest_moisture, 2),
"development_stage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"gdd": round(_safe_float(latest.get("GDD"), 0.0), 2),
},
"metrics": simulation_result.get("metrics") or {},
"daily_output": daily_output,
}
class CurrentFarmChartSimulator:
"""سازنده chart وضعیت فعلی مزرعه برای خروجی مستقل از dashboard."""
def simulate(
self,
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not farm_uuid:
raise GrowthSimulationError("ارسال farm_uuid الزامی است.")
resolved_plant_name = plant_name
if not resolved_plant_name:
sensor = get_canonical_farm_record(farm_uuid)
if sensor is None:
raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = get_runtime_plant_for_farm(sensor)
if plant is None:
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
simulation_result, scenario_id, simulation_warning = _run_simulation(
context,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
return _build_current_farm_chart_payload(
context,
simulation_result,
scenario_id,
simulation_warning,
)
@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from plant.gdd import resolve_growth_profile
from .growth_simulation import (
DEFAULT_DYNAMIC_PARAMETERS,
DEFAULT_PAGE_SIZE,
GrowthSimulationError,
_run_simulation,
build_growth_context,
)
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _harvest_description(
*,
plant_name: str,
current_gdd: float,
required_gdd: float,
remaining_gdd: float,
estimated_days: int,
maturity_reached_in_simulation: bool,
) -> str:
if maturity_reached_in_simulation:
return (
f"شبيه ساز رشد نشان مي دهد {plant_name} با روند فعلي در حدود {estimated_days} روز ديگر "
f"به بازه برداشت مي رسد. تا امروز {round(current_gdd, 1)} واحد-روز رشد ثبت شده و "
f"نياز باقيمانده تا بلوغ حدود {round(remaining_gdd, 1)} واحد-روز است."
)
return (
f"شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي "
f"براورد مي شود {plant_name} حدود {estimated_days} روز ديگر به برداشت برسد. "
f"تا امروز {round(current_gdd, 1)} از {round(required_gdd, 1)} واحد-روز مورد نياز طي شده است."
)
def build_harvest_prediction_payload(
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
resolved_plant_name = plant_name
if not resolved_plant_name:
farm = get_canonical_farm_record(farm_uuid)
if farm is None:
raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = get_runtime_plant_for_farm(farm)
if plant is None:
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
simulation_result, scenario_id, simulation_warning = _run_simulation(
context,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("هیچ خروجی شبیه سازی در دسترس نیست.")
profile = resolve_growth_profile(context.plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
current_gdd = _safe_float(profile.get("current_cumulative_gdd"), 0.0)
cumulative_gdd = current_gdd
maturity_date = None
daily_gdd_forecast = []
for item in daily_output:
day_gdd = _safe_float(item.get("GDD"), 0.0)
cumulative_gdd += day_gdd
day_value = item.get("DAY")
iso_day = day_value.isoformat() if isinstance(day_value, date) else str(day_value)
daily_gdd_forecast.append(
{
"date": iso_day,
"gdd": round(day_gdd, 3),
"cumulative_gdd": round(cumulative_gdd, 3),
"development_stage": round(_safe_float(item.get("DVS"), 0.0), 4),
}
)
if _safe_float(item.get("DVS"), 0.0) >= 2.0 or cumulative_gdd >= required_gdd:
maturity_date = date.fromisoformat(iso_day)
break
maturity_reached_in_simulation = maturity_date is not None
if maturity_date is None:
last_day = date.fromisoformat(str(daily_output[-1].get("DAY")))
simulated_days = max(len(daily_output), 1)
avg_daily_gdd = max((cumulative_gdd - current_gdd) / simulated_days, 0.0)
remaining_after_simulation = max(required_gdd - cumulative_gdd, 0.0)
extra_days = 0
if avg_daily_gdd > 0 and remaining_after_simulation > 0:
extra_days = int(remaining_after_simulation / avg_daily_gdd)
if remaining_after_simulation % avg_daily_gdd:
extra_days += 1
maturity_date = last_day + timedelta(days=max(extra_days, 0))
remaining_gdd = max(required_gdd - current_gdd, 0.0)
days_until = max((maturity_date - date.today()).days, 0)
window_start = maturity_date - timedelta(days=3)
window_end = maturity_date + timedelta(days=3)
return {
"date": maturity_date.isoformat(),
"dateFormatted": f"{maturity_date.day} {maturity_date.strftime('%B')} {maturity_date.year}",
"daysUntil": days_until,
"description": _harvest_description(
plant_name=context.plant_name,
current_gdd=current_gdd,
required_gdd=required_gdd,
remaining_gdd=remaining_gdd,
estimated_days=days_until,
maturity_reached_in_simulation=maturity_reached_in_simulation,
),
"optimalWindowStart": window_start.isoformat(),
"optimalWindowEnd": window_end.isoformat(),
"gddDetails": {
"current_cumulative_gdd": round(current_gdd, 3),
"required_gdd_for_maturity": round(required_gdd, 3),
"remaining_gdd": round(remaining_gdd, 3),
"estimated_days_to_harvest": days_until,
"predicted_harvest_date": maturity_date.isoformat(),
"predicted_harvest_window": {
"start": window_start.isoformat(),
"end": window_end.isoformat(),
},
"daily_gdd_forecast": daily_gdd_forecast,
"simulation_engine": simulation_result.get("engine"),
"simulation_model_name": simulation_result.get("model_name"),
"simulation_warning": simulation_warning,
"scenario_id": scenario_id,
},
}
class HarvestPredictionService:
def get_harvest_prediction(
self,
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
return build_harvest_prediction_payload(
farm_uuid=farm_uuid,
plant_name=plant_name,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
@@ -0,0 +1,125 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="SimulationScenario",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(blank=True, default="", max_length=255)),
(
"scenario_type",
models.CharField(
choices=[
("single", "Single Simulation"),
("crop_comparison", "Crop Comparison"),
("fertilization_comparison", "Fertilization Comparison"),
],
db_index=True,
default="single",
max_length=64,
),
),
(
"model_name",
models.CharField(default="Wofost72_WLP_CWB", max_length=128),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("success", "Success"),
("failure", "Failure"),
],
db_index=True,
default="pending",
max_length=32,
),
),
("input_payload", models.JSONField(blank=True, default=dict)),
("result_payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-created_at"],
"verbose_name": "Simulation Scenario",
"verbose_name_plural": "Simulation Scenarios",
},
),
migrations.CreateModel(
name="SimulationRun",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("run_key", models.CharField(max_length=64)),
("label", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("success", "Success"),
("failure", "Failure"),
],
db_index=True,
default="pending",
max_length=32,
),
),
("weather_payload", models.JSONField(blank=True, default=list)),
("soil_payload", models.JSONField(blank=True, default=dict)),
("crop_payload", models.JSONField(blank=True, default=dict)),
("site_payload", models.JSONField(blank=True, default=dict)),
("agromanagement_payload", models.JSONField(blank=True, default=list)),
("result_payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"scenario",
models.ForeignKey(
on_delete=models.deletion.CASCADE,
related_name="runs",
to="crop_simulation.simulationscenario",
),
),
],
options={
"ordering": ["scenario_id", "id"],
"verbose_name": "Simulation Run",
"verbose_name_plural": "Simulation Runs",
},
),
migrations.AddConstraint(
model_name="simulationrun",
constraint=models.UniqueConstraint(
fields=("scenario", "run_key"),
name="crop_simulation_unique_run_key_per_scenario",
),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("crop_simulation", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="simulationscenario",
name="model_name",
field=models.CharField(default="Wofost81_NWLP_CWB_CNB", max_length=128),
),
]
@@ -0,0 +1 @@
+84
View File
@@ -0,0 +1,84 @@
from django.db import models
class SimulationScenario(models.Model):
class ScenarioType(models.TextChoices):
SINGLE = "single", "Single Simulation"
CROP_COMPARISON = "crop_comparison", "Crop Comparison"
FERTILIZATION_COMPARISON = (
"fertilization_comparison",
"Fertilization Comparison",
)
class Status(models.TextChoices):
PENDING = "pending", "Pending"
RUNNING = "running", "Running"
SUCCESS = "success", "Success"
FAILURE = "failure", "Failure"
name = models.CharField(max_length=255, blank=True, default="")
scenario_type = models.CharField(
max_length=64,
choices=ScenarioType.choices,
default=ScenarioType.SINGLE,
db_index=True,
)
model_name = models.CharField(max_length=128, default="Wofost81_NWLP_CWB_CNB")
status = models.CharField(
max_length=32,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
)
input_payload = models.JSONField(default=dict, blank=True)
result_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "Simulation Scenario"
verbose_name_plural = "Simulation Scenarios"
def __str__(self):
return self.name or f"{self.scenario_type}:{self.pk}"
class SimulationRun(models.Model):
scenario = models.ForeignKey(
SimulationScenario,
on_delete=models.CASCADE,
related_name="runs",
)
run_key = models.CharField(max_length=64)
label = models.CharField(max_length=255)
status = models.CharField(
max_length=32,
choices=SimulationScenario.Status.choices,
default=SimulationScenario.Status.PENDING,
db_index=True,
)
weather_payload = models.JSONField(default=list, blank=True)
soil_payload = models.JSONField(default=dict, blank=True)
crop_payload = models.JSONField(default=dict, blank=True)
site_payload = models.JSONField(default=dict, blank=True)
agromanagement_payload = models.JSONField(default=list, blank=True)
result_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["scenario_id", "id"]
constraints = [
models.UniqueConstraint(
fields=["scenario", "run_key"],
name="crop_simulation_unique_run_key_per_scenario",
)
]
verbose_name = "Simulation Run"
verbose_name_plural = "Simulation Runs"
def __str__(self):
return f"{self.scenario_id}:{self.run_key}"
@@ -0,0 +1,801 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from statistics import mean
from typing import Any
from django.apps import apps
from location_data.satellite_snapshot import build_location_satellite_snapshot
from crop_simulation.services import CropSimulationService
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(value, upper))
def _stage_key(growth_stage: str | None) -> str:
text = (growth_stage or "").strip().lower()
if any(token in text for token in ("flower", "گل", "anthesis")):
return "flowering"
if any(token in text for token in ("fruit", "میوه", "برداشت", "ripen", "harvest")):
return "fruiting"
if any(token in text for token in ("initial", "seed", "جوانه", "نشا", "استقرار")):
return "initial"
return "vegetative"
def _first_not_none(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def _sensor_metric(sensor: Any, metric: str) -> float | None:
if sensor is None:
return None
if hasattr(sensor, metric):
value = getattr(sensor, metric)
return _safe_float(value, default=0.0) if value is not None else None
payload = getattr(sensor, "sensor_payload", None) or {}
if not isinstance(payload, dict):
return None
for block in payload.values():
if isinstance(block, dict) and block.get(metric) is not None:
return _safe_float(block.get(metric), default=0.0)
return None
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
raw = (getattr(plant, "temperature", "") or "").replace("تا", "-")
digits = []
current = ""
for char in raw:
if char.isdigit() or char in ".-":
current += char
continue
if current:
digits.append(current)
current = ""
if current:
digits.append(current)
if len(digits) >= 2:
low = _safe_float(digits[0], 12.0)
high = _safe_float(digits[1], 28.0)
if low < high:
return low, high
return 14.0, 30.0
def _mean_forecast_value(forecasts: list[Any], attr: str, fallback: float = 0.0) -> float:
values = [_safe_float(getattr(item, attr, None), default=fallback) for item in forecasts]
return round(mean(values), 3) if values else fallback
def _next_rain_date(forecasts: list[Any], threshold_mm: float) -> str | None:
for forecast in forecasts:
if _safe_float(getattr(forecast, "precipitation", None), 0.0) >= threshold_mm:
return forecast.forecast_date.isoformat()
return None
def _best_timing(avg_temp: float, avg_wind: float) -> str:
if avg_temp >= 30 or avg_wind >= 18:
return "اوایل صبح"
if avg_temp <= 18:
return "اواخر صبح"
return "اوایل صبح یا نزدیک غروب"
def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]:
records = []
for forecast in forecasts:
tmin = _safe_float(
_first_not_none(getattr(forecast, "temperature_min", None), getattr(forecast, "temperature_mean", None)),
12.0,
)
tmax = _safe_float(
_first_not_none(getattr(forecast, "temperature_max", None), getattr(forecast, "temperature_mean", None)),
24.0,
)
humidity = _safe_float(getattr(forecast, "humidity_mean", None), 55.0)
vap = max(6.0, round((humidity / 100.0) * 20.0, 3))
wind_kmh = _safe_float(getattr(forecast, "wind_speed_max", None), 7.2)
wind_ms = round(wind_kmh / 3.6, 3)
et0 = _mm_to_cm_day(getattr(forecast, "et0", None), 0.35)
records.append(
{
"DAY": forecast.forecast_date,
"LAT": latitude,
"LON": longitude,
"ELEV": 1200.0,
"IRRAD": 16_000_000.0,
"TMIN": tmin,
"TMAX": tmax,
"VAP": vap,
"WIND": wind_ms,
"RAIN": _mm_to_cm_day(getattr(forecast, "precipitation", None), 0.0),
"E0": et0,
"ES0": max(et0 * 0.9, 0.1),
"ET0": et0,
}
)
return records
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]:
moisture_pct = _sensor_metric(sensor, "soil_moisture")
center_location = getattr(sensor, "center_location", None)
satellite_metrics = (
build_location_satellite_snapshot(center_location).get("resolved_metrics") or {}
if center_location is not None
else {}
)
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.34)
wv0033 = ndwi if ndwi > 0 else 0.34
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
smfcf = _clamp(wv0033 if wv0033 > 0 else 0.34, 0.2, 0.55)
smw = _clamp(wv1500 if wv1500 > 0 else 0.12, 0.05, smfcf - 0.02)
if moisture_pct is not None:
wav = round(_clamp(moisture_pct / 100.0, smw, smfcf) * 100.0, 3)
else:
wav = round(((smfcf + smw) / 2.0) * 100.0, 3)
soil = {
"SMFCF": round(smfcf, 3),
"SMW": round(smw, 3),
"RDMSOL": 120.0,
}
site = {"WAV": wav}
return soil, site
def _build_crop_parameters(plant: Any, growth_stage: str | None) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
profiles = []
for attr in ("growth_profile", "irrigation_profile", "health_profile"):
profile = getattr(plant, attr, None) or {}
if isinstance(profile, dict):
profiles.append(profile)
simulation_block = None
for profile in profiles:
candidate = profile.get("simulation")
if isinstance(candidate, dict):
simulation_block = candidate
break
if not simulation_block:
return None
crop_parameters = simulation_block.get("crop_parameters")
agromanagement = simulation_block.get("agromanagement")
if not isinstance(crop_parameters, dict) or not agromanagement:
return None
enriched_crop = dict(crop_parameters)
enriched_crop.setdefault("crop_name", getattr(plant, "name", "crop"))
if growth_stage:
enriched_crop.setdefault("growth_stage", growth_stage)
return enriched_crop, agromanagement
def _event_dates_for_frequency(forecasts: list[Any], count: int) -> list[str]:
if not forecasts:
return []
ranked = sorted(
forecasts,
key=lambda item: (
_safe_float(getattr(item, "et0", None), 0.0)
+ _safe_float(getattr(item, "temperature_max", None), 0.0) / 10.0
- _safe_float(getattr(item, "precipitation", None), 0.0)
),
reverse=True,
)
selected = sorted(ranked[:count], key=lambda item: item.forecast_date)
return [item.forecast_date.isoformat() for item in selected]
def _irrigation_context_text(result: dict[str, Any]) -> str:
recommended = result["recommended_strategy"]
alternative_lines = [
f"- {item['label']}: امتیاز {item['score']}, آب کل {item['total_irrigation_mm']} mm"
for item in result.get("alternatives", [])
]
lines = [
f"engine: {result['engine']}",
f"استراتژی منتخب: {recommended['label']}",
f"امتیاز شبیه سازی: {recommended['score']}",
f"شاخص عملکرد مورد انتظار: {recommended['expected_yield_index']}",
f"آب کل پیشنهادی: {recommended['total_irrigation_mm']} mm",
f"مقدار هر نوبت: {recommended['amount_per_event_mm']} mm",
f"تعداد نوبت: {recommended['events']}",
f"تقویم اجرای پیشنهادی: {', '.join(recommended['event_dates']) or 'نامشخص'}",
f"زمان انجام: {recommended['timing']}",
f"رطوبت هدف خاک: {recommended['moisture_target_percent']}%",
f"اعتبار: {recommended['validity_period']}",
"دلایل اصلی:",
*[f"- {item}" for item in recommended["reasoning"]],
]
if alternative_lines:
lines.extend(["گزینه های جایگزین:", *alternative_lines])
return "\n".join(lines)
def _fertilization_context_text(result: dict[str, Any]) -> str:
recommended = result["recommended_strategy"]
alternative_lines = [
f"- {item['label']}: امتیاز {item['score']}, دوز {item['amount_kg_per_ha']} kg/ha"
for item in result.get("alternatives", [])
]
lines = [
f"engine: {result['engine']}",
f"استراتژی منتخب: {recommended['label']}",
f"امتیاز شبیه سازی: {recommended['score']}",
f"شاخص عملکرد مورد انتظار: {recommended['expected_yield_index']}",
f"نوع کود: {recommended['fertilizer_type']}",
f"مقدار مصرف: {recommended['amount_kg_per_ha']} kg/ha",
f"روش مصرف: {recommended['application_method']}",
f"زمان مصرف: {recommended['timing']}",
f"اعتبار: {recommended['validity_period']}",
"دلایل اصلی:",
*[f"- {item}" for item in recommended["reasoning"]],
]
if alternative_lines:
lines.extend(["گزینه های جایگزین:", *alternative_lines])
return "\n".join(lines)
@dataclass
class StrategyResult:
code: str
label: str
score: float
expected_yield_index: float
payload: dict[str, Any]
reasoning: list[str]
class SimulationRecommendationOptimizer:
"""بهینه ساز توصیه های آبیاری و کودهی داخل اپ crop_simulation."""
def __init__(self):
self.simulation_service = CropSimulationService()
def optimize_irrigation(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
daily_water_needs: list[dict[str, Any]],
growth_stage: str | None,
irrigation_method: Any | None,
) -> dict[str, Any] | None:
if sensor is None or plant is None or not forecasts:
return None
crop_blueprint = _build_crop_parameters(plant, growth_stage)
if crop_blueprint:
pcse_result = self._optimize_irrigation_with_pcse(
sensor=sensor,
plant=plant,
forecasts=forecasts,
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
crop_blueprint=crop_blueprint,
)
if pcse_result is not None:
return pcse_result
return self._optimize_irrigation_with_heuristic(
sensor=sensor,
plant=plant,
forecasts=forecasts,
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
irrigation_method=irrigation_method,
)
def optimize_fertilization(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
growth_stage: str | None,
) -> dict[str, Any] | None:
if sensor is None or plant is None:
return None
crop_blueprint = _build_crop_parameters(plant, growth_stage)
if crop_blueprint and forecasts:
pcse_result = self._optimize_fertilization_with_pcse(
sensor=sensor,
plant=plant,
forecasts=forecasts,
growth_stage=growth_stage,
crop_blueprint=crop_blueprint,
)
if pcse_result is not None:
return pcse_result
return self._optimize_fertilization_with_heuristic(
sensor=sensor,
plant=plant,
forecasts=forecasts,
growth_stage=growth_stage,
)
def _optimize_irrigation_with_pcse(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
daily_water_needs: list[dict[str, Any]],
growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
) -> dict[str, Any] | None:
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
crop_parameters, agromanagement = crop_blueprint
soil, site = _build_soil_parameters(sensor)
weather = _build_weather_records(
forecasts,
latitude=_safe_float(sensor.center_location.latitude),
longitude=_safe_float(sensor.center_location.longitude),
)
total_mm = round(sum(_safe_float(item.get("gross_irrigation_mm"), 0.0) for item in daily_water_needs), 3)
if total_mm <= 0:
return None
strategies = []
for spec in defaults["strategy_profiles"]:
irrigation_events = []
event_dates = _event_dates_for_frequency(forecasts, max(1, spec["event_count"]))
amount_per_event = round((total_mm * spec["multiplier"]) / max(len(event_dates), 1), 3)
for day in event_dates:
irrigation_events.append({"date": day, "amount": amount_per_event})
try:
result = self.simulation_service.run_single_simulation(
farm_uuid=str(sensor.farm_uuid),
plant_name=getattr(plant, "name", None),
weather=weather,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
soil=soil,
site_parameters=site,
irrigation_recommendation={"events": irrigation_events},
name=f"irrigation-{spec['code']}",
)
except Exception:
return None
yield_estimate = _safe_float(
result.get("result", {}).get("metrics", {}).get("yield_estimate"),
0.0,
)
score = round(_clamp((yield_estimate / 100.0), 0.0, 100.0), 2)
strategies.append(
StrategyResult(
code=spec["code"],
label=spec["label"],
score=score,
expected_yield_index=round(score, 2),
payload={
"events": len(event_dates),
"event_dates": event_dates,
"amount_per_event_mm": amount_per_event,
"total_irrigation_mm": round(amount_per_event * len(event_dates), 3),
"timing": _best_timing(
_mean_forecast_value(forecasts, "temperature_mean", 22.0),
_mean_forecast_value(forecasts, "wind_speed_max", 8.0),
),
},
reasoning=[
"امتیاز بر اساس بیشترین عملکرد شبیه سازی شده انتخاب شد.",
f"عملکرد نسبی این سناریو {round(score, 2)} ارزیابی شد.",
],
)
)
best = max(strategies, key=lambda item: item.score)
moisture_target = defaults["stage_targets"].get(_stage_key(growth_stage), defaults["stage_targets"]["vegetative"])
result = {
"engine": "pcse",
"recommended_strategy": {
"code": best.code,
"label": best.label,
"score": best.score,
"expected_yield_index": best.expected_yield_index,
"total_irrigation_mm": best.payload["total_irrigation_mm"],
"amount_per_event_mm": best.payload["amount_per_event_mm"],
"events": best.payload["events"],
"frequency_per_week": best.payload["events"],
"event_dates": best.payload["event_dates"],
"timing": best.payload["timing"],
"moisture_target_percent": moisture_target,
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
"reasoning": best.reasoning,
},
"alternatives": [
{
"code": item.code,
"label": item.label,
"score": item.score,
"expected_yield_index": item.expected_yield_index,
"total_irrigation_mm": item.payload["total_irrigation_mm"],
}
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
if item.code != best.code
],
}
result["context_text"] = _irrigation_context_text(result)
return result
def _optimize_irrigation_with_heuristic(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
daily_water_needs: list[dict[str, Any]],
growth_stage: str | None,
irrigation_method: Any | None,
) -> dict[str, Any]:
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
stage_key = _stage_key(growth_stage)
moisture_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
total_mm = round(sum(_safe_float(item.get("gross_irrigation_mm"), 0.0) for item in daily_water_needs), 3)
non_zero_days = [item for item in daily_water_needs if _safe_float(item.get("gross_irrigation_mm"), 0.0) > 0]
average_temp = _mean_forecast_value(forecasts, "temperature_mean", 22.0)
average_wind = _mean_forecast_value(forecasts, "wind_speed_max", 8.0)
heat_risk = _mean_forecast_value(forecasts, "temperature_max", 28.0) >= 32.0
rain_date = _next_rain_date(forecasts, defaults["significant_rain_threshold_mm"])
efficiency = _safe_float(getattr(irrigation_method, "water_efficiency_percent", None), 75.0)
soil_moisture = _sensor_metric(sensor, "soil_moisture")
strategies: list[StrategyResult] = []
for spec in defaults["strategy_profiles"]:
event_count = max(1, min(7, round(max(len(non_zero_days), 1) * spec["frequency_factor"])))
applied_total = round(max(total_mm * spec["multiplier"], 0.0), 3)
amount_per_event = round(max(applied_total / event_count, defaults["minimum_event_mm"]), 3)
water_penalty = abs(applied_total - total_mm) * 2.4
if total_mm <= 0:
water_penalty = 0.0 if spec["code"] == "conservative" else 12.0
soil_penalty = 0.0
if soil_moisture is not None:
if soil_moisture < 25 and spec["code"] == "conservative":
soil_penalty += 8.0
if soil_moisture > 55 and spec["code"] == "protective":
soil_penalty += 7.0
climate_bonus = 0.0
if heat_risk and spec["code"] == "protective":
climate_bonus += 6.0
if rain_date and spec["code"] == "protective":
climate_bonus -= 8.0
if efficiency >= 85 and spec["code"] == "balanced":
climate_bonus += 4.0
score = round(_clamp(100.0 - water_penalty - soil_penalty + climate_bonus, 35.0, 96.0), 2)
event_dates = _event_dates_for_frequency(forecasts, event_count)
reasoning = [
f"نیاز آبی محاسبه شده برای بازه پیش رو حدود {total_mm} میلی متر است.",
f"این سناریو {applied_total} میلی متر آب را در {event_count} نوبت پخش می کند.",
]
if heat_risk:
reasoning.append("به خاطر دمای بالاتر از حد مطلوب، تنش گرمایی در امتیازدهی لحاظ شده است.")
if rain_date:
reasoning.append(f"بارش معنی دار از تاریخ {rain_date} احتمال کاهش نیاز آبی را بالا می برد.")
if soil_moisture is not None:
reasoning.append(f"رطوبت فعلی خاک حدود {round(soil_moisture, 1)} درصد در نظر گرفته شده است.")
strategies.append(
StrategyResult(
code=spec["code"],
label=spec["label"],
score=score,
expected_yield_index=round(52.0 + (score * 0.48), 2),
payload={
"events": event_count,
"amount_per_event_mm": amount_per_event,
"total_irrigation_mm": applied_total,
"event_dates": event_dates,
"timing": _best_timing(average_temp, average_wind),
},
reasoning=reasoning,
)
)
best = max(strategies, key=lambda item: item.score)
validity_period = f"معتبر برای {defaults['validity_days']} روز آینده"
if rain_date:
validity_period = f"معتبر تا قبل از بارش موثر پیش بینی شده در {rain_date}"
result = {
"engine": "crop_simulation_heuristic",
"recommended_strategy": {
"code": best.code,
"label": best.label,
"score": best.score,
"expected_yield_index": best.expected_yield_index,
"total_irrigation_mm": best.payload["total_irrigation_mm"],
"amount_per_event_mm": best.payload["amount_per_event_mm"],
"events": best.payload["events"],
"frequency_per_week": min(best.payload["events"] + 1, 7),
"event_dates": best.payload["event_dates"],
"timing": best.payload["timing"],
"moisture_target_percent": moisture_target,
"validity_period": validity_period,
"reasoning": best.reasoning,
},
"alternatives": [
{
"code": item.code,
"label": item.label,
"score": item.score,
"expected_yield_index": item.expected_yield_index,
"total_irrigation_mm": item.payload["total_irrigation_mm"],
}
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
if item.code != best.code
],
}
result["context_text"] = _irrigation_context_text(result)
return result
def _optimize_fertilization_with_pcse(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
) -> dict[str, Any] | None:
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
crop_parameters, agromanagement = crop_blueprint
soil, site = _build_soil_parameters(sensor)
weather = _build_weather_records(
forecasts,
latitude=_safe_float(sensor.center_location.latitude),
longitude=_safe_float(sensor.center_location.longitude),
)
stage_key = _stage_key(growth_stage)
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
base_n = max(target["n"], 20)
strategies = []
for spec in defaults["strategy_profiles"]:
n_amount = round(base_n * spec["multiplier"], 3)
fertilizer_formula = spec["formula_override"] or target["formula"]
strategy_agromanagement = [
{
key: {
**value,
"TimedEvents": [
{
"event_signal": "apply_n",
"name": spec["label"],
"events_table": [
{
forecasts[0].forecast_date: {
"N_amount": n_amount,
"N_recovery": 0.7,
}
}
],
}
],
}
}
for entry in agromanagement
for key, value in entry.items()
] or agromanagement
try:
result = self.simulation_service.run_single_simulation(
farm_uuid=str(sensor.farm_uuid),
plant_name=getattr(plant, "name", None),
weather=weather,
crop_parameters=crop_parameters,
agromanagement=strategy_agromanagement,
soil=soil,
site_parameters=site,
name=f"fertilization-{spec['code']}",
)
except Exception:
return None
yield_estimate = _safe_float(
result.get("result", {}).get("metrics", {}).get("yield_estimate"),
0.0,
)
score = round(_clamp((yield_estimate / 100.0), 0.0, 100.0), 2)
strategies.append(
StrategyResult(
code=spec["code"],
label=spec["label"],
score=score,
expected_yield_index=score,
payload={
"amount_kg_per_ha": round(n_amount * 1.6, 3),
"fertilizer_type": fertilizer_formula,
"application_method": target["application_method"],
"timing": target["timing"],
},
reasoning=[
"سناریو برتر با بیشترین عملکرد شبیه سازی شده انتخاب شد.",
f"فرمول هدف برای این مرحله {target['formula']} در نظر گرفته شد.",
],
)
)
best = max(strategies, key=lambda item: item.score)
result = {
"engine": "pcse",
"recommended_strategy": {
"code": best.code,
"label": best.label,
"score": best.score,
"expected_yield_index": best.expected_yield_index,
"fertilizer_type": best.payload["fertilizer_type"],
"amount_kg_per_ha": best.payload["amount_kg_per_ha"],
"application_method": best.payload["application_method"],
"timing": best.payload["timing"],
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
"reasoning": best.reasoning,
},
"alternatives": [
{
"code": item.code,
"label": item.label,
"score": item.score,
"expected_yield_index": item.expected_yield_index,
"fertilizer_type": item.payload["fertilizer_type"],
"amount_kg_per_ha": item.payload["amount_kg_per_ha"],
"application_method": item.payload["application_method"],
"timing": item.payload["timing"],
"reasoning": item.reasoning,
}
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
if item.code != best.code
],
}
result["context_text"] = _fertilization_context_text(result)
return result
def _optimize_fertilization_with_heuristic(
self,
*,
sensor: Any,
plant: Any,
forecasts: list[Any],
growth_stage: str | None,
) -> dict[str, Any]:
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
stage_key = _stage_key(growth_stage)
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
current_n = _sensor_metric(sensor, "nitrogen")
current_p = _sensor_metric(sensor, "phosphorus")
current_k = _sensor_metric(sensor, "potassium")
current_ph = _sensor_metric(sensor, "soil_ph")
deficits = {
"n": max(target["n"] - _safe_float(current_n, target["n"] * 0.6), 0.0),
"p": max(target["p"] - _safe_float(current_p, target["p"] * 0.6), 0.0),
"k": max(target["k"] - _safe_float(current_k, target["k"] * 0.6), 0.0),
}
dominant = max(deficits, key=deficits.get)
severity = sum(deficits.values())
next_rain = _next_rain_date(forecasts, defaults["rain_delay_threshold_mm"]) if forecasts else None
avg_temp = _mean_forecast_value(forecasts, "temperature_mean", 22.0)
strategies: list[StrategyResult] = []
for spec in defaults["strategy_profiles"]:
base_amount = max(30.0, min(120.0, 35.0 + (severity * 1.4)))
amount = round(base_amount * spec["multiplier"], 2)
mismatch_penalty = 0.0
if dominant == "n" and "ازت" not in spec["focus"]:
mismatch_penalty += 12.0
if dominant == "k" and "پتاس" not in spec["focus"]:
mismatch_penalty += 12.0
if dominant == "p" and "فسفر" not in spec["focus"]:
mismatch_penalty += 12.0
if current_ph is not None and current_ph > 7.8 and "فسفر" in spec["focus"]:
mismatch_penalty += 8.0
if next_rain and spec["application_method"] == "محلول پاشی":
mismatch_penalty += 10.0
score = round(_clamp(96.0 - mismatch_penalty - abs(spec["multiplier"] - 1.0) * 18.0, 42.0, 95.0), 2)
reasoning = [
f"کسری عناصر برای این مرحله با فرمول هدف {target['formula']} سنجیده شد.",
f"بیشترین کمبود نسبی مربوط به عنصر {dominant.upper()} است.",
f"دوز پیشنهادی این سناریو {amount} کیلوگرم در هکتار برآورد شد.",
]
if current_ph is not None:
reasoning.append(f"pH فعلی خاک حدود {round(current_ph, 2)} در تصمیم گیری لحاظ شد.")
if next_rain:
reasoning.append(f"به دلیل بارش موثر نزدیک در {next_rain} از مصرف سطحی پرریسک اجتناب شده است.")
strategies.append(
StrategyResult(
code=spec["code"],
label=spec["label"],
score=score,
expected_yield_index=round(50.0 + (score * 0.5), 2),
payload={
"fertilizer_type": spec["formula_override"] or target["formula"],
"amount_kg_per_ha": amount,
"application_method": spec["application_method"],
"timing": target["timing"] if avg_temp < 30 else "صبح زود یا نزدیک غروب",
},
reasoning=reasoning,
)
)
best = max(strategies, key=lambda item: item.score)
validity_period = f"معتبر برای {defaults['validity_days']} روز آینده"
if stage_key == "flowering":
validity_period = "معتبر تا پایان پنجره گلدهی فعلی و حداکثر 5 روز آینده"
result = {
"engine": "crop_simulation_heuristic",
"recommended_strategy": {
"code": best.code,
"label": best.label,
"score": best.score,
"expected_yield_index": best.expected_yield_index,
"fertilizer_type": best.payload["fertilizer_type"],
"amount_kg_per_ha": best.payload["amount_kg_per_ha"],
"application_method": best.payload["application_method"],
"timing": best.payload["timing"],
"validity_period": validity_period,
"reasoning": best.reasoning,
},
"alternatives": [
{
"code": item.code,
"label": item.label,
"score": item.score,
"expected_yield_index": item.expected_yield_index,
"fertilizer_type": item.payload["fertilizer_type"],
"amount_kg_per_ha": item.payload["amount_kg_per_ha"],
"application_method": item.payload["application_method"],
"timing": item.payload["timing"],
"reasoning": item.reasoning,
}
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
if item.code != best.code
],
"nutrient_status": {
"nitrogen": current_n,
"phosphorus": current_p,
"potassium": current_k,
"soil_ph": current_ph,
"dominant_gap": dominant,
},
}
result["context_text"] = _fertilization_context_text(result)
return result
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import json
from rest_framework import serializers
class QueryJSONField(serializers.JSONField):
def to_internal_value(self, data):
if isinstance(data, str):
data = data.strip()
if not data:
return None
try:
data = json.loads(data)
except json.JSONDecodeError as exc:
raise serializers.ValidationError("فرمت JSON نامعتبر است.") from exc
return super().to_internal_value(data)
class GrowthSimulationRequestSerializer(serializers.Serializer):
plant_name = serializers.CharField(help_text="نام گیاه")
dynamic_parameters = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
help_text="پارامترهای متغیر رشد که باید در خروجی گزارش شوند.",
)
farm_uuid = serializers.UUIDField(required=False, allow_null=True)
weather = serializers.JSONField(required=False)
soil_parameters = serializers.JSONField(required=False)
site_parameters = serializers.JSONField(required=False)
crop_parameters = serializers.JSONField(required=False)
agromanagement = serializers.JSONField(required=False)
irrigation_recommendation = serializers.JSONField(required=False)
fertilization_recommendation = serializers.JSONField(required=False)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=50)
def validate(self, attrs):
if not attrs.get("farm_uuid") and not attrs.get("weather"):
raise serializers.ValidationError(
"یکی از farm_uuid یا weather باید ارسال شود."
)
return attrs
class GrowthSimulationQueuedSerializer(serializers.Serializer):
task_id = serializers.CharField()
status_url = serializers.CharField()
plant_name = serializers.CharField()
class GrowthStageMetricSerializer(serializers.Serializer):
start = serializers.FloatField()
end = serializers.FloatField()
min = serializers.FloatField()
max = serializers.FloatField()
avg = serializers.FloatField()
class GrowthStageSerializer(serializers.Serializer):
order = serializers.IntegerField()
stage_code = serializers.CharField()
stage_name = serializers.CharField()
start_date = serializers.DateField()
end_date = serializers.DateField()
days_count = serializers.IntegerField()
metrics = serializers.JSONField()
class GrowthPaginationSerializer(serializers.Serializer):
page = serializers.IntegerField()
page_size = serializers.IntegerField()
total_items = serializers.IntegerField()
total_pages = serializers.IntegerField()
has_next = serializers.BooleanField()
has_previous = serializers.BooleanField()
class GrowthSimulationResultSerializer(serializers.Serializer):
plant_name = serializers.CharField()
dynamic_parameters = serializers.ListField(child=serializers.CharField())
engine = serializers.CharField(allow_null=True)
model_name = serializers.CharField(allow_null=True)
scenario_id = serializers.IntegerField(allow_null=True)
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
summary_metrics = serializers.JSONField()
stage_timeline = GrowthStageSerializer(many=True)
stages_page = GrowthStageSerializer(many=True)
pagination = GrowthPaginationSerializer()
daily_records_count = serializers.IntegerField()
default_page_size = serializers.IntegerField()
class CurrentFarmChartRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
irrigation_recommendation = serializers.JSONField(required=False)
fertilization_recommendation = serializers.JSONField(required=False)
class CurrentFarmChartResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(allow_null=True)
plant_name = serializers.CharField()
engine = serializers.CharField(allow_null=True)
model_name = serializers.CharField(allow_null=True)
scenario_id = serializers.IntegerField(allow_null=True)
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
categories = serializers.ListField(child=serializers.CharField())
series = serializers.JSONField()
summary = serializers.JSONField()
current_state = serializers.JSONField()
metrics = serializers.JSONField()
daily_output = serializers.JSONField()
class HarvestPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
irrigation_recommendation = serializers.JSONField(required=False)
fertilization_recommendation = serializers.JSONField(required=False)
class HarvestPredictionResponseSerializer(serializers.Serializer):
date = serializers.CharField()
dateFormatted = serializers.CharField()
daysUntil = serializers.IntegerField()
description = serializers.CharField()
optimalWindowStart = serializers.CharField()
optimalWindowEnd = serializers.CharField()
gddDetails = serializers.JSONField()
class YieldPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
irrigation_recommendation = serializers.JSONField(required=False)
fertilization_recommendation = serializers.JSONField(required=False)
class YieldPredictionResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
plant_name = serializers.CharField(allow_null=True)
predictedYieldTons = serializers.FloatField()
predictedYieldRaw = serializers.FloatField()
unit = serializers.CharField()
sourceUnit = serializers.CharField()
simulationEngine = serializers.CharField(allow_null=True)
simulationModel = serializers.CharField(allow_null=True)
scenarioId = serializers.IntegerField(allow_null=True)
simulationWarning = serializers.CharField(allow_null=True, allow_blank=True)
supportingMetrics = serializers.JSONField()
class YieldHarvestSummaryQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
season_year = serializers.IntegerField(required=False, help_text="سال زراعی")
crop_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول")
include_narrative = serializers.BooleanField(
required=False,
default=False,
help_text="در صورت true بودن، بخش روایت نیز در آینده اضافه می شود.",
)
irrigation_recommendation = QueryJSONField(
required=False,
allow_null=True,
help_text="برنامه آبیاری به صورت JSON برای تزریق به PCSE.",
)
fertilization_recommendation = QueryJSONField(
required=False,
allow_null=True,
help_text="برنامه کودهی به صورت JSON برای تزریق به PCSE.",
)
class YieldHarvestSummaryResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
season_highlights_card = serializers.JSONField()
yield_prediction = serializers.JSONField()
harvest_prediction_card = serializers.JSONField()
harvest_readiness_zones = serializers.JSONField()
yield_quality_bands = serializers.JSONField()
harvest_operations_card = serializers.JSONField()
yield_prediction_chart = serializers.JSONField()
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from config.celery import app
from .growth_simulation import run_growth_simulation
@app.task(bind=True)
def run_growth_simulation_task(self, payload: dict) -> dict:
return run_growth_simulation(
payload,
progress_callback=self.update_state,
)
@@ -0,0 +1,495 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from plant.models import Plant
from .growth_simulation import paginate_growth_stages, run_growth_simulation
@override_settings(ROOT_URLCONF="crop_simulation.urls")
class PlantGrowthSimulationApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.plant = Plant.objects.create(
name="گوجه‌فرنگی",
growth_profile={
"base_temperature": 10,
"required_gdd_for_maturity": 1200,
"current_cumulative_gdd": 50,
},
)
self.weather = [
{
"DAY": "2026-04-01",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 12,
"TMAX": 24,
"RAIN": 0.0,
"ET0": 0.32,
},
{
"DAY": "2026-04-02",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 13,
"TMAX": 25,
"RAIN": 0.0,
"ET0": 0.34,
},
{
"DAY": "2026-04-03",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 14,
"TMAX": 27,
"RAIN": 1.0,
"ET0": 0.36,
},
]
def test_run_growth_simulation_returns_stage_timeline(self):
with patch("crop_simulation.growth_simulation._run_simulation") as mock_run_simulation:
mock_run_simulation.return_value = (
{
"engine": "pcse",
"model_name": "wofost",
"metrics": {"yield_estimate": 10.0},
"daily_output": [
{"DAY": "2026-04-01", "DVS": 0.1, "LAI": 0.2, "TAGP": 10.0},
{"DAY": "2026-04-02", "DVS": 0.3, "LAI": 0.4, "TAGP": 20.0},
{"DAY": "2026-04-03", "DVS": 1.1, "LAI": 0.6, "TAGP": 30.0},
],
},
12,
None,
)
result = run_growth_simulation(
{
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"weather": self.weather,
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"irrigation_recommendation": {"events": [{"date": "2026-04-02", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-02", "N_amount": 45, "N_recovery": 0.7}]
},
"page_size": 2,
}
)
self.assertEqual(result["plant_name"], self.plant.name)
self.assertGreaterEqual(result["daily_records_count"], 3)
self.assertTrue(result["stage_timeline"])
self.assertEqual(result["pagination"]["page_size"], 2)
@patch("crop_simulation.views.run_growth_simulation_task.delay")
def test_queue_api_returns_task_id(self, mock_delay):
mock_delay.return_value = SimpleNamespace(id="growth-task-1")
response = self.client.post(
"/growth/",
data={
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI"],
"weather": self.weather,
"irrigation_recommendation": {"events": [{"date": "2026-04-02", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-02", "N_amount": 45, "N_recovery": 0.7}]
},
},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json()["data"]["task_id"], "growth-task-1")
self.assertEqual(mock_delay.call_args.args[0]["irrigation_recommendation"]["events"][0]["amount"], 2.5)
def test_queue_api_returns_400_for_missing_weather_and_farm_uuid(self):
response = self.client.post(
"/growth/",
data={
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI"],
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views._get_async_result")
def test_status_api_returns_paginated_stages(self, mock_get_async_result):
stage_timeline = [
{
"order": 1,
"stage_code": "establishment",
"stage_name": "استقرار",
"start_date": "2026-04-01",
"end_date": "2026-04-02",
"days_count": 2,
"metrics": {"DVS": {"start": 0.1, "end": 0.2, "min": 0.1, "max": 0.2, "avg": 0.15}},
},
{
"order": 2,
"stage_code": "vegetative",
"stage_name": "رشد رویشی",
"start_date": "2026-04-03",
"end_date": "2026-04-05",
"days_count": 3,
"metrics": {"DVS": {"start": 0.3, "end": 0.8, "min": 0.3, "max": 0.8, "avg": 0.55}},
},
{
"order": 3,
"stage_code": "flowering",
"stage_name": "گلدهی",
"start_date": "2026-04-06",
"end_date": "2026-04-07",
"days_count": 2,
"metrics": {"DVS": {"start": 1.0, "end": 1.2, "min": 1.0, "max": 1.2, "avg": 1.1}},
},
]
mock_get_async_result.return_value = SimpleNamespace(
state="SUCCESS",
result={
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS"],
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": None,
"simulation_warning": None,
"summary_metrics": {},
"stage_timeline": stage_timeline,
"stages_page": stage_timeline[:1],
"pagination": paginate_growth_stages(stage_timeline, page=1, page_size=1)["pagination"],
"daily_records_count": 7,
"default_page_size": 1,
},
)
response = self.client.get("/growth/growth-task-1/status/?page=2&page_size=1")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]["result"]
self.assertEqual(payload["pagination"]["page"], 2)
self.assertEqual(len(payload["stages_page"]), 1)
self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative")
@patch("crop_simulation.views._get_async_result")
def test_status_api_returns_pending_state(self, mock_get_async_result):
mock_get_async_result.return_value = SimpleNamespace(state="PENDING")
response = self.client.get("/growth/growth-task-1/status/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["status"], "PENDING")
self.assertIn("message", payload)
@patch("crop_simulation.views._get_async_result")
def test_status_api_returns_failure_state(self, mock_get_async_result):
mock_get_async_result.return_value = SimpleNamespace(
state="FAILURE",
result=RuntimeError("task crashed"),
)
response = self.client.get("/growth/growth-task-1/status/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["status"], "FAILURE")
self.assertEqual(payload["error"], "task crashed")
@patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_simulation_payload(self, mock_get_app_config):
mock_simulator = SimpleNamespace(
simulate=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": 12,
"simulation_warning": None,
"categories": ["2026-04-01", "2026-04-02"],
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": [120.0, 140.0]},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": [35.0, 45.0]},
],
"summary": [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": 140.0,
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
}
],
"current_state": {
"date": "2026-04-02",
"leaf_count_estimate": 140.0,
"leaf_area_index": 0.0117,
"biomass_weight": 45.0,
"storage_organ_weight": 10.0,
"soil_moisture_percent": 41.2,
"development_stage": 0.35,
"gdd": 9.0,
},
"metrics": {"yield_estimate": 10.0},
"daily_output": [],
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_current_farm_chart_simulator=lambda: mock_simulator
)
response = self.client.post(
"/current-farm-chart/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["plant_name"], self.plant.name)
self.assertEqual(payload["scenario_id"], 12)
self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0)
self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate")
def test_current_farm_chart_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/current-farm-chart/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_500_when_simulator_fails(self, mock_get_app_config):
mock_simulator = SimpleNamespace(
simulate=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulator offline"))
)
mock_get_app_config.return_value = SimpleNamespace(
get_current_farm_chart_simulator=lambda: mock_simulator
)
response = self.client.post(
"/current-farm-chart/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_harvest_prediction=lambda **_kwargs: {
"date": "2026-05-14",
"dateFormatted": "14 May 2026",
"daysUntil": 43,
"description": "شبيه ساز نشان مي دهد حدود 43 روز ديگر تا برداشت باقي مانده است.",
"optimalWindowStart": "2026-05-11",
"optimalWindowEnd": "2026-05-17",
"gddDetails": {
"current_cumulative_gdd": 50.0,
"required_gdd_for_maturity": 1200.0,
"remaining_gdd": 1150.0,
"simulation_engine": "growth_projection",
},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_harvest_prediction_service=lambda: mock_service
)
response = self.client.post(
"/harvest-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["daysUntil"], 43)
self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection")
def test_harvest_prediction_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/harvest-prediction/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
class BrokenService:
def get_harvest_prediction(self, **_kwargs):
raise RuntimeError("harvest offline")
mock_get_app_config.return_value = SimpleNamespace(
get_harvest_prediction_service=lambda: BrokenService()
)
response = self.client.post(
"/harvest-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_yield_prediction=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"predictedYieldTons": 5.4,
"predictedYieldRaw": 5400.0,
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": "growth_projection",
"simulationModel": "growth_projection_v1",
"scenarioId": 12,
"simulationWarning": None,
"supportingMetrics": {"yield_estimate": 5400.0},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_yield_prediction_service=lambda: mock_service
)
response = self.client.post(
"/yield-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["predictedYieldTons"], 5.4)
self.assertEqual(payload["sourceUnit"], "kg/ha")
def test_yield_prediction_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/yield-prediction/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
class BrokenService:
def get_yield_prediction(self, **_kwargs):
raise RuntimeError("yield offline")
mock_get_app_config.return_value = SimpleNamespace(
get_yield_prediction_service=lambda: BrokenService()
)
response = self.client.post(
"/yield-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.YieldHarvestSummaryService")
def test_yield_harvest_summary_api_returns_payload(self, mock_service_cls):
mock_service_cls.return_value.get_summary.return_value = {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"season_highlights_card": {"title": "Season highlights", "subtitle": "Good season."},
"yield_prediction": {"predicted_yield_tons": 5.4, "explanation": "Stable projection."},
"harvest_prediction_card": {"harvest_date": "2026-05-14"},
"harvest_readiness_zones": {"averageReadiness": 74, "summary": "Readiness improving."},
"yield_quality_bands": {"primary_quality_grade": "A"},
"harvest_operations_card": {"steps": [{"key": "harvesting", "note": "Prepare combine."}]},
"yield_prediction_chart": {"series": [], "xAxis": {"type": "datetime"}},
}
response = self.client.get(
"/yield-harvest-summary/?farm_uuid=550e8400-e29b-41d4-a716-446655440000"
"&season_year=1404&crop_name=wheat&include_narrative=true"
"&irrigation_recommendation=%7B%22events%22%3A%5B%7B%22date%22%3A%222026-04-25%22%2C%22amount%22%3A2.5%7D%5D%7D"
"&fertilization_recommendation=%7B%22events%22%3A%5B%7B%22date%22%3A%222026-04-20%22%2C%22N_amount%22%3A45%2C%22N_recovery%22%3A0.7%7D%5D%7D"
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
self.assertEqual(payload["yield_quality_bands"]["primary_quality_grade"], "A")
mock_service_cls.return_value.get_summary.assert_called_once_with(
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
season_year="1404",
crop_name="wheat",
include_narrative=True,
irrigation_recommendation={
"events": [
{
"date": "2026-04-25",
"amount": 2.5,
}
]
},
fertilization_recommendation={
"events": [
{
"date": "2026-04-20",
"N_amount": 45,
"N_recovery": 0.7,
}
]
},
)
def test_yield_harvest_summary_api_returns_400_for_missing_farm_uuid(self):
response = self.client.get("/yield-harvest-summary/?season_year=1404&crop_name=wheat")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
def test_yield_harvest_summary_api_returns_400_for_invalid_json_recommendations(self):
response = self.client.get(
"/yield-harvest-summary/?farm_uuid=550e8400-e29b-41d4-a716-446655440000"
"&irrigation_recommendation=%7Binvalid-json%7D"
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@@ -0,0 +1,63 @@
import importlib.util
import os
import sqlite3
import tempfile
from collections import namedtuple
from datetime import date, timedelta
from unittest import skipUnless
from django.test import TestCase
from crop_simulation.services import CropSimulationService, PcseSimulationManager
@skipUnless(
importlib.util.find_spec("pcse") is not None,
"pcse must be installed to run the real WOFOST test.",
)
class CropSimulationSingleRunTest(TestCase):
def test_single_simulation_prints_response(self):
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
from pcse import settings as pcse_settings
from pcse.tests.db_input import (
AgroManagementDataProvider,
GridWeatherDataProvider,
fetch_cropdata,
fetch_sitedata,
fetch_soildata,
)
def namedtuple_factory(cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
db_path = os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
connection = sqlite3.connect(db_path)
connection.row_factory = namedtuple_factory
grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
weather = GridWeatherDataProvider(connection, grid_no=grid).export()
soil = fetch_soildata(connection, grid)
site = fetch_sitedata(connection, grid, year)
crop_parameters = fetch_cropdata(connection, grid, year, crop_no)
agromanagement = AgroManagementDataProvider(connection, grid, crop_no, year)
response = CropSimulationService(
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
).run_single_simulation(
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
site_parameters=site,
name="single real wofost run",
)
connection.close()
print("\nCrop Simulation Response:\n", response)
self.assertEqual(response["result"]["engine"], "pcse")
self.assertIn("yield_estimate", response["result"]["metrics"])
@@ -0,0 +1,76 @@
import importlib.util
import os
import sqlite3
import tempfile
from collections import namedtuple
from unittest import skipUnless
from django.test import TestCase
from crop_simulation.services import CropSimulationService, PcseSimulationManager
@skipUnless(
importlib.util.find_spec("pcse") is not None,
"pcse must be installed to run the real WOFOST test.",
)
class CropSimulationSingleRunWithRecommendationsTest(TestCase):
def test_single_simulation_with_irrigation_and_fertilization_recommendations(self):
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
from pcse import settings as pcse_settings
from pcse.tests.db_input import (
AgroManagementDataProvider,
GridWeatherDataProvider,
fetch_cropdata,
fetch_sitedata,
fetch_soildata,
)
def namedtuple_factory(cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
db_path = os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
connection = sqlite3.connect(db_path)
connection.row_factory = namedtuple_factory
grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
weather = GridWeatherDataProvider(connection, grid_no=grid).export()
soil = fetch_soildata(connection, grid)
site = fetch_sitedata(connection, grid, year)
crop_parameters = fetch_cropdata(connection, grid, year, crop_no)
agromanagement = AgroManagementDataProvider(connection, grid, crop_no, year)
response = CropSimulationService(
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
).run_single_simulation(
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
site_parameters=site,
irrigation_recommendation={
"events": [
{"date": "2000-02-10", "amount": 2.5, "efficiency": 0.8},
{"date": "2000-03-05", "amount": 3.0, "efficiency": 0.8},
]
},
fertilization_recommendation={
"events": [
{"date": "2000-02-15", "N_amount": 30, "N_recovery": 0.7},
{"date": "2000-03-01", "N_amount": 20, "N_recovery": 0.7},
]
},
name="single real wofost run with recommendations",
)
connection.close()
print("\nCrop Simulation Response With Recommendations:\n", response)
self.assertEqual(response["result"]["engine"], "pcse")
self.assertIsNotNone(response["result"]["metrics"]["yield_estimate"])
self.assertIsNotNone(response["result"]["metrics"]["biomass"])
+368
View File
@@ -0,0 +1,368 @@
import importlib.util
import os
import sqlite3
import tempfile
from collections import namedtuple
from datetime import date, timedelta
from unittest.mock import patch
from unittest import skipUnless
from django.test import TestCase
from rest_framework.test import APIRequestFactory
from .models import SimulationRun, SimulationScenario
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
from .views import PlantGrowthSimulationView
def build_weather(days: int = 5) -> list[dict]:
start = date(2026, 4, 1)
return [
{
"DAY": start + timedelta(days=index),
"LAT": 35.7,
"LON": 51.4,
"ELEV": 1200,
"IRRAD": 16_000_000 + (index * 100_000),
"TMIN": 11 + index,
"TMAX": 22 + index,
"VAP": 12,
"WIND": 2.4,
"RAIN": 0.8,
"E0": 0.35,
"ES0": 0.3,
"ET0": 0.32,
}
for index in range(days)
]
def build_agromanagement(n_amount: float = 30.0) -> list[dict]:
return [
{
date(2026, 4, 1): {
"CropCalendar": {
"crop_name": "wheat",
"variety_name": "winter-wheat",
"crop_start_date": date(2026, 4, 5),
"crop_start_type": "sowing",
"crop_end_date": date(2026, 9, 1),
"crop_end_type": "harvest",
"max_duration": 180,
},
"TimedEvents": [
{
"event_signal": "apply_n",
"name": "N strategy",
"events_table": [
{
date(2026, 4, 20): {
"N_amount": n_amount,
"N_recovery": 0.7,
}
}
],
}
],
"StateEvents": [],
}
},
{},
]
class CropSimulationServiceTests(TestCase):
def setUp(self):
self.service = CropSimulationService()
self.weather = build_weather()
self.soil = {"SMFCF": 0.34, "SMW": 0.12, "RDMSOL": 120.0}
self.site = {"WAV": 40.0}
self.crop = {"crop_name": "wheat", "TSUM1": 800, "YIELD_SCALE": 1.0}
def test_failure_marks_scenario_and_run_failed(self):
with patch.object(
self.service.manager,
"run_simulation",
side_effect=CropSimulationError("pcse failed"),
):
with self.assertRaises(CropSimulationError):
self.service.run_single_simulation(
weather=self.weather,
soil=self.soil,
crop_parameters=self.crop,
agromanagement=build_agromanagement(),
site_parameters=self.site,
name="broken run",
)
scenario = SimulationScenario.objects.get()
run = SimulationRun.objects.get()
self.assertEqual(scenario.status, SimulationScenario.Status.FAILURE)
self.assertEqual(run.status, SimulationScenario.Status.FAILURE)
self.assertEqual(scenario.error_message, "pcse failed")
def test_requires_at_least_two_fertilization_strategies(self):
with self.assertRaises(CropSimulationError):
self.service.compare_fertilization_strategies(
weather=self.weather,
soil=self.soil,
crop_parameters=self.crop,
strategies=[{"label": "only", "agromanagement": build_agromanagement()}],
site_parameters=self.site,
)
class CropSimulationViewContractTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@patch("crop_simulation.views.run_growth_simulation_task.delay")
def test_growth_queue_response_includes_live_ai_metadata(self, mock_delay):
mock_delay.return_value.id = "task-123"
request = self.factory.post(
"/api/crop-simulation/growth/",
{
"plant_name": "wheat",
"dynamic_parameters": ["DVS"],
"weather": [
{
"DAY": "2026-04-01",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 12,
"TMAX": 24,
"RAIN": 0.0,
"ET0": 0.32,
}
],
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"agromanagement": [
{
"2026-04-01": {
"CropCalendar": {
"crop_name": "wheat",
"variety_name": "winter-wheat",
"crop_start_date": "2026-04-05",
"crop_start_type": "sowing",
"crop_end_date": "2026-09-01",
"crop_end_type": "harvest",
"max_duration": 180,
},
"TimedEvents": [],
"StateEvents": [],
}
},
{},
],
},
format="json",
)
response = PlantGrowthSimulationView.as_view()(request)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.data["meta"]["flow_type"], "live_ai_inference")
self.assertEqual(response.data["meta"]["source_service"], "ai_crop_simulation")
def test_recommend_best_crop_returns_best_candidate(self):
with patch.object(
self.service.manager,
"run_simulation",
side_effect=[
{
"engine": "pcse",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 5200.0,
"biomass": 9800.0,
"max_lai": 4.1,
},
"daily_output": [],
"summary_output": [],
"terminal_output": [],
},
{
"engine": "pcse",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 6100.0,
"biomass": 11000.0,
"max_lai": 4.4,
},
"daily_output": [],
"summary_output": [],
"terminal_output": [],
},
],
):
result = self.service.recommend_best_crop(
weather=self.weather,
soil=self.soil,
crops=[
{"crop_name": "wheat", "label": "wheat", "TSUM1": 800},
{"crop_name": "maize", "label": "maize", "TSUM1": 900},
],
agromanagement=build_agromanagement(),
site_parameters=self.site,
name="best crop recommendation",
)
self.assertEqual(result["recommended_crop"]["label"], "maize")
self.assertEqual(result["recommended_crop"]["expected_yield_estimate"], 6100.0)
self.assertEqual(len(result["candidates"]), 2)
def test_recommend_best_crop_requires_two_options(self):
with self.assertRaises(CropSimulationError):
self.service.recommend_best_crop(
weather=self.weather,
soil=self.soil,
crops=[{"crop_name": "wheat", "TSUM1": 800}],
agromanagement=build_agromanagement(),
site_parameters=self.site,
)
def test_run_single_simulation_merges_irrigation_and_fertilization_recommendations(self):
captured = {}
def fake_run_simulation(**kwargs):
captured.update(kwargs)
return {
"engine": "pcse",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 5400.0,
"biomass": 9800.0,
"max_lai": 4.2,
},
"daily_output": [],
"summary_output": [],
"terminal_output": [],
}
with patch.object(self.service.manager, "run_simulation", side_effect=fake_run_simulation):
self.service.run_single_simulation(
weather=self.weather,
soil=self.soil,
crop_parameters=self.crop,
agromanagement=build_agromanagement(),
site_parameters=self.site,
irrigation_recommendation={
"events": [
{
"date": "2026-04-25",
"amount": 2.5,
"efficiency": 0.8,
}
]
},
fertilization_recommendation={
"events": [
{
"date": "2026-04-20",
"N_amount": 45,
"N_recovery": 0.7,
}
]
},
name="managed run",
)
timed_events = captured["agromanagement"][0][date(2026, 4, 1)]["TimedEvents"]
self.assertEqual(len(timed_events), 3)
self.assertEqual(timed_events[1]["event_signal"], "irrigate")
self.assertEqual(timed_events[1]["events_table"][0][date(2026, 4, 25)]["amount"], 2.5)
self.assertEqual(timed_events[2]["event_signal"], "apply_n")
self.assertEqual(
timed_events[2]["events_table"][0][date(2026, 4, 20)]["N_amount"],
45.0,
)
def test_raises_clear_error_when_pcse_is_unavailable(self):
with patch("crop_simulation.services._load_pcse_bindings", return_value=None):
with self.assertRaisesMessage(
CropSimulationError,
"PCSE is not installed or required PCSE classes could not be loaded.",
):
self.service.run_single_simulation(
weather=self.weather,
soil=self.soil,
crop_parameters=self.crop,
agromanagement=build_agromanagement(),
site_parameters=self.site,
name="missing pcse",
)
@skipUnless(
importlib.util.find_spec("pcse") is not None,
"pcse must be installed to run real WOFOST integration tests.",
)
class CropSimulationPcseIntegrationTests(TestCase):
def setUp(self):
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
from pcse import settings as pcse_settings
from pcse.tests.db_input import (
AgroManagementDataProvider,
GridWeatherDataProvider,
fetch_cropdata,
fetch_sitedata,
fetch_soildata,
)
def namedtuple_factory(cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
self.grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
self.crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
self.year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
self.connection = sqlite3.connect(
os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
)
self.connection.row_factory = namedtuple_factory
self.weather = GridWeatherDataProvider(
self.connection,
grid_no=self.grid,
).export()
self.soil = fetch_soildata(self.connection, self.grid)
self.site = fetch_sitedata(self.connection, self.grid, self.year)
self.crop = fetch_cropdata(
self.connection,
self.grid,
self.year,
self.crop_no,
)
self.agromanagement = AgroManagementDataProvider(
self.connection,
self.grid,
self.crop_no,
self.year,
)
self.service = CropSimulationService(
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
)
def tearDown(self):
self.connection.close()
def test_real_wofost_execute_full_service_path(self):
result = self.service.run_single_simulation(
weather=self.weather,
soil=self.soil,
crop_parameters=self.crop,
agromanagement=self.agromanagement,
site_parameters=self.site,
name="pcse path",
)
scenario = SimulationScenario.objects.get()
self.assertEqual(scenario.status, SimulationScenario.Status.SUCCESS)
self.assertEqual(result["result"]["engine"], "pcse")
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path
from .views import (
CurrentFarmSimulationChartView,
HarvestPredictionView,
PlantGrowthSimulationStatusView,
PlantGrowthSimulationView,
YieldHarvestSummaryView,
YieldPredictionView,
)
urlpatterns = [
path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"),
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"),
path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"),
path(
"growth/<str:task_id>/status/",
PlantGrowthSimulationStatusView.as_view(),
name="growth-simulation-status",
),
]
+571
View File
@@ -0,0 +1,571 @@
from __future__ import annotations
from django.apps import apps
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.integration_contract import build_integration_meta
from config.openapi import (
build_envelope_serializer,
build_response,
build_task_status_data_serializer,
)
from .growth_simulation import MAX_PAGE_SIZE, paginate_growth_stages
from .serializers import (
CurrentFarmChartRequestSerializer,
CurrentFarmChartResponseSerializer,
GrowthSimulationQueuedSerializer,
GrowthSimulationRequestSerializer,
GrowthSimulationResultSerializer,
HarvestPredictionRequestSerializer,
HarvestPredictionResponseSerializer,
YieldHarvestSummaryQuerySerializer,
YieldHarvestSummaryResponseSerializer,
YieldPredictionRequestSerializer,
YieldPredictionResponseSerializer,
)
from .tasks import run_growth_simulation_task
from .yield_harvest_summary import YieldHarvestSummaryService
GrowthSimulationQueuedResponseSerializer = build_envelope_serializer(
"GrowthSimulationQueuedResponseSerializer",
GrowthSimulationQueuedSerializer,
)
GrowthSimulationStatusResponseSerializer = build_envelope_serializer(
"GrowthSimulationStatusResponseSerializer",
build_task_status_data_serializer(
"GrowthSimulationTaskStatusDataSerializer",
GrowthSimulationResultSerializer,
),
)
GrowthSimulationErrorSerializer = build_envelope_serializer(
"GrowthSimulationErrorSerializer",
data_required=False,
allow_null=True,
)
def _get_async_result(task_id: str):
from celery.result import AsyncResult
return AsyncResult(task_id)
def _coerce_positive_int(value, default: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(parsed, 1)
def _fa_task_status(status_name: str) -> str:
return {
"PENDING": "در انتظار",
"PROGRESS": "در حال پردازش",
"SUCCESS": "موفق",
"FAILURE": "ناموفق",
}.get(status_name, status_name)
class PlantGrowthSimulationView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="شروع شبیه سازی رشد گیاه",
description=(
"نوع گیاه و پارامترهای متغیر رشد را می گیرد، "
"شبیه سازی را داخل Celery اجرا می کند و فقط task_id برمی گرداند."
),
request=GrowthSimulationRequestSerializer,
responses={
202: build_response(
GrowthSimulationQueuedResponseSerializer,
"تسک شبیه سازی رشد گیاه در صف قرار گرفت.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست با weather مستقیم",
value={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"],
"weather": [
{
"DAY": "2026-04-01",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 12,
"TMAX": 24,
"RAIN": 0.0,
"ET0": 0.32,
}
],
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"irrigation_recommendation": {"events": [{"date": "2026-04-02", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-02", "N_amount": 45, "N_recovery": 0.7}]
},
"page_size": 2,
},
request_only=True,
),
OpenApiExample(
"نمونه درخواست با farm",
value={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}]
},
},
request_only=True,
),
],
)
def post(self, request):
serializer = GrowthSimulationRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
task = run_growth_simulation_task.delay(serializer.validated_data)
return Response(
{
"code": 202,
"msg": "تسک شبیه سازی رشد در صف قرار گرفت.",
"data": {
"task_id": task.id,
"status_url": f"/api/crop-simulation/growth/{task.id}/status/",
"plant_name": serializer.validated_data["plant_name"],
},
"meta": build_integration_meta(
flow_type="live_ai_inference",
source_type="provider",
source_service="ai_crop_simulation",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_202_ACCEPTED,
)
class PlantGrowthSimulationStatusView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="وضعیت شبیه سازی رشد گیاه",
description="وضعیت تسک Celery را برمی گرداند و در صورت موفقیت مراحل رشد را به صورت صفحه بندی شده بازمی گرداند.",
responses={
200: build_response(
GrowthSimulationStatusResponseSerializer,
"وضعیت فعلی تسک شبیه سازی رشد گیاه.",
)
},
)
def get(self, request, task_id: str):
result = _get_async_result(task_id)
payload = {
"task_id": task_id,
"status": result.state,
"status_fa": _fa_task_status(result.state),
}
if result.state == "PENDING":
payload["message"] = "تسک در صف یا یافت نشد."
elif result.state == "PROGRESS":
payload["progress"] = result.info
elif result.state == "SUCCESS":
task_result = dict(result.result or {})
page = _coerce_positive_int(request.query_params.get("page", 1), 1)
page_size = min(
_coerce_positive_int(
request.query_params.get("page_size", task_result.get("default_page_size", 10)),
10,
),
MAX_PAGE_SIZE,
)
paginated = paginate_growth_stages(
task_result.get("stage_timeline", []),
page=page,
page_size=page_size,
)
task_result["stages_page"] = paginated["items"]
task_result["pagination"] = paginated["pagination"]
payload["result"] = task_result
elif result.state == "FAILURE":
payload["error"] = str(result.result)
return Response(
{
"code": 200,
"msg": "موفق",
"data": payload,
"meta": build_integration_meta(
flow_type="live_ai_inference",
source_type="provider",
source_service="ai_crop_simulation",
ownership="ai",
live=result.state in {"PENDING", "PROGRESS", "SUCCESS"},
cached=False,
),
},
status=status.HTTP_200_OK,
)
CurrentFarmChartEnvelopeSerializer = build_envelope_serializer(
"CurrentFarmChartEnvelopeSerializer",
CurrentFarmChartResponseSerializer,
)
HarvestPredictionEnvelopeSerializer = build_envelope_serializer(
"HarvestPredictionEnvelopeSerializer",
HarvestPredictionResponseSerializer,
)
YieldPredictionEnvelopeSerializer = build_envelope_serializer(
"YieldPredictionEnvelopeSerializer",
YieldPredictionResponseSerializer,
)
YieldHarvestSummaryEnvelopeSerializer = build_envelope_serializer(
"YieldHarvestSummaryEnvelopeSerializer",
YieldHarvestSummaryResponseSerializer,
)
class CurrentFarmSimulationChartView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="chart شبیه سازی وضعیت فعلی مزرعه",
description=(
"با دریافت farm_uuid، یک شبیه سازی از وضعیت فعلی مزرعه اجرا می کند و داده chart شامل برگ، وزن، بیوماس، رطوبت و خروجی روزانه را برمی گرداند."
),
request=CurrentFarmChartRequestSerializer,
responses={
200: build_response(
CurrentFarmChartEnvelopeSerializer,
"خروجی chart شبیه سازی وضعیت فعلی مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در اجرای chart شبیه سازی مزرعه.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست chart",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}]
},
},
request_only=True,
),
],
)
def post(self, request):
serializer = CurrentFarmChartRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator()
try:
result = simulator.simulate(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در اجرای chart شبیه سازی مزرعه: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_chart",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class HarvestPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی زمان تقریبی برداشت",
description=(
"با دریافت farm_uuid، از شبیه ساز رشد برای برآورد زمان باقی مانده تا برداشت استفاده می کند "
"و تاریخ تقریبی برداشت را برمی گرداند."
),
request=HarvestPredictionRequestSerializer,
responses={
200: build_response(
HarvestPredictionEnvelopeSerializer,
"خروجی پیش بینی زمان برداشت مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در پیش بینی زمان برداشت.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست harvest prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}]
},
},
request_only=True,
),
],
)
def post(self, request):
serializer = HarvestPredictionRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("crop_simulation").get_harvest_prediction_service()
try:
result = service.get_harvest_prediction(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در پیش بینی زمان برداشت: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_harvest_prediction",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class YieldPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی عملکرد مزرعه",
description="با دریافت farm_uuid، خروجی شبیه ساز رشد را به برآورد عملکرد قابل استفاده در KPI تبدیل می کند.",
request=YieldPredictionRequestSerializer,
responses={
200: build_response(YieldPredictionEnvelopeSerializer, "خروجی پیش بینی عملکرد مزرعه."),
400: build_response(GrowthSimulationErrorSerializer, "داده ورودی نامعتبر است."),
500: build_response(GrowthSimulationErrorSerializer, "خطا در پیش بینی عملکرد."),
},
examples=[
OpenApiExample(
"نمونه درخواست yield prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]},
"fertilization_recommendation": {
"events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}]
},
},
request_only=True,
),
],
)
def post(self, request):
serializer = YieldPredictionRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
service = apps.get_app_config("crop_simulation").get_yield_prediction_service()
try:
result = service.get_yield_prediction(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_yield_prediction",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class YieldHarvestSummaryView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="خلاصه عملکرد و برداشت",
description=(
"خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. "
"این endpoint خروجی derived واقعی تولید می کند و پاسخ آن mock نیست."
),
parameters=[
OpenApiParameter(
name="farm_uuid",
type=str,
location=OpenApiParameter.QUERY,
required=True,
description="شناسه یکتای مزرعه",
),
OpenApiParameter(
name="season_year",
type=int,
location=OpenApiParameter.QUERY,
required=False,
description="سال زراعی",
),
OpenApiParameter(
name="crop_name",
type=str,
location=OpenApiParameter.QUERY,
required=False,
description="نام محصول",
),
OpenApiParameter(
name="include_narrative",
type=bool,
location=OpenApiParameter.QUERY,
required=False,
description="در آینده روایت متنی را نیز اضافه می کند.",
),
OpenApiParameter(
name="irrigation_recommendation",
type=str,
location=OpenApiParameter.QUERY,
required=False,
description="JSON برنامه آبیاری برای تزریق به شبیه سازی PCSE.",
),
OpenApiParameter(
name="fertilization_recommendation",
type=str,
location=OpenApiParameter.QUERY,
required=False,
description="JSON برنامه کودهی برای تزریق به شبیه سازی PCSE.",
),
],
responses={
200: build_response(
YieldHarvestSummaryEnvelopeSerializer,
"خروجی خلاصه عملکرد و برداشت مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"پارامترهای query نامعتبر است.",
),
},
examples=[
OpenApiExample(
"نمونه پاسخ yield harvest summary",
value={
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"season_highlights_card": {},
"yield_prediction": {},
"harvest_prediction_card": {},
"harvest_readiness_zones": {},
"yield_quality_bands": {},
"harvest_operations_card": {},
"yield_prediction_chart": {},
},
},
response_only=True,
),
],
)
def get(self, request):
serializer = YieldHarvestSummaryQuerySerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
service = YieldHarvestSummaryService()
payload = service.get_summary(
farm_uuid=str(validated["farm_uuid"]),
season_year=str(validated.get("season_year") or ""),
crop_name=validated.get("crop_name") or "",
include_narrative=validated.get("include_narrative", False),
irrigation_recommendation=validated.get("irrigation_recommendation"),
fertilization_recommendation=validated.get("fertilization_recommendation"),
)
return Response(
{
"code": 200,
"msg": "موفق",
"data": payload,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_yield_harvest_summary",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from statistics import mean
from typing import Any
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context
def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(upper, value))
def _level_for_index(water_stress: int) -> str:
if water_stress <= 20:
return "پایین"
if water_stress <= 45:
return "متوسط"
return "بالا"
def _stage_sensitivity(dvs: float) -> tuple[str, float]:
if dvs < 0.2:
return "establishment", 0.9
if dvs < 1.0:
return "vegetative", 1.0
if dvs < 1.3:
return "flowering", 1.2
if dvs < 2.0:
return "reproductive", 1.1
return "maturity", 0.85
def _compute_water_stress_index(
*,
daily_output: list[dict[str, Any]],
soil_parameters: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
latest = daily_output[-1] if daily_output else {}
recent_window = daily_output[-3:] if daily_output else []
smfcf = _safe_float(soil_parameters.get("SMFCF"), 0.34)
smw = _safe_float(soil_parameters.get("SMW"), 0.14)
rdmsol = max(_safe_float(soil_parameters.get("RDMSOL"), 120.0), 1.0)
latest_sm = _safe_float(latest.get("SM"), 0.0)
available_water_ratio = _clamp((latest_sm - smw) / max(smfcf - smw, 0.01), 0.0, 1.0)
moisture_deficit = (1.0 - available_water_ratio) * 65.0
recent_et0 = mean(_safe_float(item.get("ET0"), 0.0) for item in recent_window) if recent_window else 0.0
et0_pressure = _clamp((recent_et0 / 0.45) * 18.0, 0.0, 18.0)
recent_rain = sum(_safe_float(item.get("RAIN"), 0.0) for item in recent_window)
rainfall_relief = _clamp(recent_rain * 2.5, 0.0, 15.0)
moisture_trend = 0.0
if len(recent_window) >= 2:
moisture_trend = max(
(_safe_float(recent_window[0].get("SM"), latest_sm) - latest_sm) * 100.0,
0.0,
)
trend_pressure = _clamp(moisture_trend * 1.6, 0.0, 12.0)
stage_code, stage_multiplier = _stage_sensitivity(_safe_float(latest.get("DVS"), 0.0))
root_depth_relief = _clamp(((rdmsol - 60.0) / 60.0) * 6.0, 0.0, 6.0)
raw_score = ((moisture_deficit + et0_pressure + trend_pressure - rainfall_relief - root_depth_relief) *
stage_multiplier)
water_stress = int(round(_clamp(raw_score, 0.0, 100.0)))
return water_stress, {
"soilMoisturePercent": round(latest_sm * 100.0, 2),
"availableWaterRatio": round(available_water_ratio, 4),
"fieldCapacity": round(smfcf, 4),
"wiltingPoint": round(smw, 4),
"rootDepthCm": round(rdmsol, 2),
"recentEt0": round(recent_et0, 4),
"recentRain": round(recent_rain, 2),
"soilMoistureDrop": round(moisture_trend, 2),
"developmentStage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"stageCode": stage_code,
"stageSensitivity": round(stage_multiplier, 2),
"engine": "crop_simulation",
"formula": (
"stress = clamp(((moisture_deficit + et0_pressure + trend_pressure - "
"rainfall_relief - root_depth_relief) * stage_sensitivity), 0, 100)"
),
}
class WaterStressSimulationService:
def _resolve_plant_name(self, *, farm_uuid: str, plant_name: str | None) -> str:
if plant_name:
return plant_name
farm = get_canonical_farm_record(farm_uuid)
if farm is None:
raise GrowthSimulationError("Farm not found.")
plant = get_runtime_plant_for_farm(farm)
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
return plant.name
def get_water_stress(
self,
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
resolved_plant_name = self._resolve_plant_name(farm_uuid=farm_uuid, plant_name=plant_name)
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
}
)
simulation_result, _scenario_id, simulation_warning = _run_simulation(
context,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("Water stress simulation produced no daily output.")
water_stress, source_metric = _compute_water_stress_index(
daily_output=daily_output,
soil_parameters=context.soil_parameters,
)
if simulation_warning:
source_metric["simulationWarning"] = simulation_warning
return {
"farm_uuid": str(farm_uuid),
"plant_name": context.plant_name,
"waterStressIndex": water_stress,
"level": _level_for_index(water_stress),
"sourceMetric": source_metric,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Any
from .growth_simulation import (
CurrentFarmChartSimulator,
GrowthSimulationError,
_fa_engine_name,
_fa_model_name,
)
def build_yield_prediction_payload(
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
simulator = CurrentFarmChartSimulator()
result = simulator.simulate(
farm_uuid=farm_uuid,
plant_name=plant_name,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
yield_estimate = float((result.get("metrics") or {}).get("yield_estimate") or 0.0)
predicted_yield_tons = round(max(yield_estimate / 1000.0, 0.0), 2)
return {
"farm_uuid": farm_uuid,
"plant_name": result.get("plant_name"),
"predictedYieldTons": predicted_yield_tons,
"predictedYieldRaw": round(yield_estimate, 2),
"unit": "تن",
"sourceUnit": "کیلوگرم در هکتار",
"simulationEngine": _fa_engine_name(result.get("engine")),
"simulationModel": _fa_model_name(result.get("model_name")),
"scenarioId": result.get("scenario_id"),
"simulationWarning": result.get("simulation_warning"),
"supportingMetrics": result.get("metrics") or {},
}
class YieldPredictionService:
def get_yield_prediction(
self,
*,
farm_uuid: str,
plant_name: str | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
return build_yield_prediction_payload(
farm_uuid=farm_uuid,
plant_name=plant_name,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
except GrowthSimulationError:
raise
+107
View File
@@ -0,0 +1,107 @@
services:
db:
image: mirror-docker.runflare.com/library/mysql:8
container_name: ai-db
restart: always
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ai_mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- crop_network
redis:
image: mirror-docker.runflare.com/library/redis:latest
container_name: ai-redis
restart: always
networks:
- crop_network
qdrant:
image: mirror-docker.runflare.com/qdrant/qdrant:latest
container_name: ai-qdrant
restart: always
volumes:
- qdrant_data:/qdrant/storage
networks:
- crop_network
web:
build:
context: .
dockerfile: Dockerfile.Dev
container_name: ai-web
restart: always
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2
volumes:
- ./logs:/app/logs
- ./static:/app/static
- ./media:/app/media
ports:
- "8020:8000"
env_file:
- .env
environment:
DB_HOST: ai-db
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
QDRANT_HOST: ai-qdrant
QDRANT_PORT: 6333
DEBUG: "False"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- crop_network
celery:
build:
context: .
dockerfile: Dockerfile.Dev
container_name: ai-celery
restart: always
command: celery -A config worker -l info --concurrency=4
healthcheck:
test: ["CMD-SHELL", "celery -A config inspect ping --timeout 10 || exit 1"]
interval: 30s
timeout: 15s
retries: 5
start_period: 30s
volumes:
- ./logs:/app/logs
- ./media:/app/media
env_file:
- .env
environment:
DB_HOST: ai-db
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
SKIP_MIGRATE: "1"
DEBUG: "False"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- crop_network
volumes:
ai_mysql_data:
qdrant_data:
networks:
crop_network:
external: true
+123
View File
@@ -0,0 +1,123 @@
services:
db:
image: docker.iranserver.com/mysql:8
container_name: ai-mysql
environment:
MYSQL_DATABASE: ${DB_NAME:-ai}
MYSQL_USER: ${DB_USER:-ai}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- ai_mysql_data:/var/lib/mysql
ports:
- "3307:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- crop_network
phpmyadmin:
image: docker-mirror.liara.ir/phpmyadmin:latest
container_name: ai-phpmyadmin
environment:
PMA_HOST: ai-mysql
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
ports:
- "8083:80"
depends_on:
db:
condition: service_healthy
networks:
- crop_network
redis:
image: redis:7-alpine
container_name: ai-redis
networks:
- crop_network
qdrant:
image: qdrant/qdrant:latest
container_name: ai-qdrant
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
networks:
- crop_network
web:
build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror.cdn.ir/repository/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-web
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
volumes:
- .:/app
- ./logs:/app/logs
ports:
- "8020:8000"
env_file:
- .env
environment:
DB_HOST: ai-mysql
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
QDRANT_HOST: ai-qdrant
QDRANT_PORT: 6333
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
qdrant:
condition: service_started
networks:
- crop_network
celery:
build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror.cdn.ir/repository/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-celery
command: celery -A config worker -l info
volumes:
- .:/app
- ./logs:/app/logs
env_file:
- .env
environment:
DB_HOST: ai-mysql
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
SKIP_MIGRATE: "1"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- crop_network
volumes:
ai_mysql_data:
qdrant_data:
networks:
crop_network:
external: true
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
# راهنمای استفاده از API هشدارهای مزرعه
این سند نحوه کار با API فعال هشدارهای مزرعه را توضیح می‌دهد.
## Endpoint فعال
- `POST /api/farm-alerts/tracker/`
نکته:
- endpoint `POST /api/farm-alerts/timeline/` حذف شده و دیگر قابل استفاده نیست.
## کاربرد API
این API با دریافت `farm_uuid` و یک لیست از `alerts`:
- وضعیت فعلی هشدارهای مزرعه را تحلیل می‌کند
- context مزرعه را همراه با alertهای ارسالی به RAG می‌فرستد
- فقط notificationهای مهم را تولید می‌کند
- notificationهای تولیدشده را در دیتابیس ذخیره می‌کند
## ساختار درخواست
فیلدهای ورودی:
- `farm_uuid`: شناسه مزرعه
- `alerts`: لیست alertهای ورودی برای تحلیل
فیلد `farm_uuid` الزامی است.
## ساختار هر alert
هر آیتم داخل `alerts` می‌تواند این فیلدها را داشته باشد:
- `alert_id`: شناسه هشدار
- `level`: سطح هشدار مثل `info` یا `warning` یا `danger`
- `title`: عنوان هشدار
- `message`: توضیح هشدار
- `suggested_action`: اقدام پیشنهادی
- `source_metric_type`: نوع شاخص مثل `moisture`
- `timestamp`: زمان هشدار با فرمت datetime
- `payload`: داده تکمیلی به صورت JSON object
همه فیلدهای داخل هر alert اختیاری هستند، ولی بهتر است برای تحلیل دقیق‌تر حداقل `title` یا `message` و در صورت امکان `level` و `source_metric_type` ارسال شوند.
## نمونه درخواست
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"alerts": [
{
"alert_id": "soil-moisture-001",
"level": "warning",
"title": "افت رطوبت خاک",
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
"suggested_action": "آبیاری اصلاحی بررسی شود.",
"source_metric_type": "moisture",
"timestamp": "2025-02-14T09:30:00Z",
"payload": {
"window": "3d",
"current_value": 38.5,
"threshold": 45
}
},
{
"alert_id": "fungal-risk-002",
"level": "danger",
"title": "ریسک قارچی بالا",
"message": "شرایط محیطی برای بیماری قارچی شدید شده است.",
"suggested_action": "بازدید و اقدام پیشگیرانه فوری انجام شود.",
"source_metric_type": "fungal_risk",
"timestamp": "2025-02-14T10:00:00Z",
"payload": {
"humidity": 89,
"duration_hours": 18
}
}
]
}
```
## نمونه درخواست با curl
```bash
curl -X POST http://localhost:8000/api/farm-alerts/tracker/ \
-H "Content-Type: application/json" \
-d '{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"alerts": [
{
"alert_id": "soil-moisture-001",
"level": "warning",
"title": "افت رطوبت خاک",
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
"suggested_action": "آبیاری اصلاحی بررسی شود.",
"source_metric_type": "moisture"
}
]
}'
```
## ساختار پاسخ موفق
پاسخ HTTP با envelope زیر برمی‌گردد:
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"service_id": "farm_alerts",
"tracker": {},
"headline": "جمع بندی کوتاه وضعیت هشدارها",
"overview": "توضیح کوتاه و اجرایی از مهم ترین وضعیت مزرعه",
"status_level": "warning",
"notifications": [
{
"id": 12,
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"endpoint": "tracker",
"level": "warning",
"title": "افت رطوبت خاک",
"message": "تنش رطوبتی در مزرعه ادامه دارد.",
"suggested_action": "آبیاری جبرانی کوتاه مدت اجرا شود.",
"source_alert_id": "soil-moisture-001",
"source_metric_type": "moisture",
"payload": {},
"created_at": "2025-02-14T10:15:00+00:00",
"updated_at": "2025-02-14T10:15:00+00:00"
}
],
"raw_llm_response": "{\"headline\":\"...\"}",
"structured_context": {}
}
}
```
## وضعیت‌های خطا
### خطای ورودی نامعتبر
اگر `farm_uuid` ارسال نشود:
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"farm_uuid": [
"farm_uuid الزامی است."
]
}
}
```
### خطای داخلی
اگر در مرحله تحلیل RAG یا تولید پاسخ خطایی رخ دهد:
```json
{
"code": 500,
"msg": "خطا در تولید tracker هشدارها: ...",
"data": null
}
```
## نکات رفتاری API
- اگر `alerts` ارسال نشود، API آن را به صورت آرایه خالی در نظر می‌گیرد.
- notificationهای ساخته‌شده برای endpoint `tracker` در دیتابیس ذخیره می‌شوند.
- مدل باید notification تکراری نسازد و اگر مورد مهمی وجود نداشته باشد، خروجی notification می‌تواند خالی باشد.
- تحلیل فقط روی endpoint `tracker` فعال است.
## پیشنهاد برای مصرف‌کننده API
- برای هر alert یک `alert_id` پایدار بفرستید تا ردیابی و جلوگیری از تکرار بهتر انجام شود.
- برای alertهای حساس، `timestamp` و `source_metric_type` را حتما ارسال کنید.
- اگر داده تکمیلی دارید، آن را داخل `payload` بفرستید تا RAG context کامل‌تر شود.
@@ -0,0 +1,492 @@
# توضیح فیلدهای پاسخ API هشدارهای مزرعه
این سند فیلدهای JSON خروجی `POST /api/farm-alerts/tracker/` را توضیح می‌دهد.
## ساختار کلی پاسخ
پاسخ API به شکل envelope برمی‌گردد:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
## فیلدهای سطح اول
### `code`
- نوع: `number`
- معنی: کد وضعیت داخلی API
- مقدار رایج:
- `200`: موفق
- `400`: ورودی نامعتبر
- `500`: خطای داخلی
### `msg`
- نوع: `string`
- معنی: پیام خلاصه وضعیت پاسخ
- نمونه:
- `success`
- `داده نامعتبر.`
- `خطا در تولید tracker هشدارها: ...`
### `data`
- نوع: `object | null`
- معنی: بدنه اصلی پاسخ
- در موفقیت شامل جزئیات تحلیل مزرعه است
- در بعضی خطاها ممکن است `null` باشد
## فیلدهای داخل `data`
### `farm_uuid`
- نوع: `string`
- معنی: شناسه مزرعه‌ای که تحلیل روی آن انجام شده
### `service_id`
- نوع: `string`
- معنی: شناسه سرویس تولیدکننده پاسخ
- مقدار فعلی: `farm_alerts`
### `tracker`
- نوع: `object`
- معنی: خروجی ساختاریافته‌ی موتور tracker قبل از خلاصه‌سازی نهایی AI
- شامل لیست alertها، آمار، خوشه‌بندی و مهم‌ترین مسئله است
### `headline`
- نوع: `string`
- معنی: تیتر کوتاه و سریع برای وضعیت فعلی مزرعه
- برای نمایش در کارت، header یا لیست اعلان مناسب است
### `overview`
- نوع: `string`
- معنی: جمع‌بندی کوتاه و اجرایی از مهم‌ترین وضعیت فعلی
- معمولا نسخه‌ی خلاصه‌شده‌ی مهم‌ترین alert یا نتیجه کلی تحلیل است
### `status_level`
- نوع: `string`
- مقادیر مجاز:
- `info`
- `warning`
- `danger`
- معنی: سطح کلی وضعیت مزرعه از دید AI
### `notifications`
- نوع: `array`
- معنی: notificationهای نهایی که پس از تحلیل AI ساخته و در دیتابیس ذخیره شده‌اند
- اگر مورد مهمی وجود نداشته باشد، می‌تواند خالی باشد
### `raw_llm_response`
- نوع: `string | null`
- معنی: پاسخ خام JSON که مدل زبانی تولید کرده است
- بیشتر برای debug، audit یا بررسی رفتار AI مفید است
### `structured_context`
- نوع: `object`
- معنی: کانتکست ساختاریافته‌ای که به مدل داده شده است
- شامل اطلاعات مزرعه، tracker، forecastها و alertهای ورودی است
## فیلدهای `tracker`
### `tracker.totalAlerts`
- نوع: `number`
- معنی: تعداد کل alertهای شناسایی‌شده توسط tracker
### `tracker.alerts`
- نوع: `array`
- معنی: لیست خام alertهای تشخیص‌داده‌شده
هر آیتم در `tracker.alerts`:
#### `metric_type`
- نوع: `string`
- معنی: نوع شاخصی که alert از آن آمده
- نمونه:
- `moisture`
- `temperature`
- `ph`
- `ec`
- `fungal_risk`
#### `title`
- نوع: `string`
- معنی: عنوان انسانی alert
#### `current_value`
- نوع: `number`
- معنی: مقدار فعلی شاخص
#### `threshold_value`
- نوع: `number | string`
- معنی: آستانه مرجع برای تشخیص alert
#### `severity`
- نوع: `string`
- مقادیر رایج:
- `low`
- `medium`
- `high`
- `critical`
- معنی: شدت alert در لایه tracker
#### `duration_hours`
- نوع: `number`
- معنی: مدت تداوم وضعیت هشدار به ساعت
#### `duration`
- نوع: `string`
- معنی: نسخه خوانای `duration_hours`
- نمونه: `3 ساعت`
#### `timestamp`
- نوع: `string`
- معنی: زمان مرجع alert با فرمت ISO datetime
#### `sensor_id`
- نوع: `string`
- معنی: شناسه مزرعه/سنسوری که alert برای آن محاسبه شده
#### `zone_id`
- نوع: `string | null`
- معنی: شناسه ناحیه، اگر alert مربوط به ناحیه خاصی باشد
#### `domain`
- نوع: `string`
- معنی: دامنه عملیاتی alert
- نمونه:
- `water_balance`
- `temperature_stress`
- `root_chemistry`
- `disease_pressure`
#### `direction`
- نوع: `string`
- معنی: جهت عبور از آستانه
- نمونه:
- `below`: مقدار از حد مجاز کمتر شده
- `above`: مقدار از حد مجاز بیشتر شده
#### `unit`
- نوع: `string`
- معنی: واحد شاخص
- نمونه:
- `%`
- `°C`
- `pH`
- `dS/m`
#### `icon`
- نوع: `string`
- معنی: نام آیکن پیشنهادی برای UI
#### `summary`
- نوع: `string`
- معنی: خلاصه انسانی و کوتاه alert
#### `recommended_action`
- نوع: `string`
- معنی: اقدام عملیاتی پیشنهادی tracker
#### `explanation`
- نوع: `string`
- معنی: توضیح قابل‌فهم درباره چرایی ایجاد alert
#### `metadata`
- نوع: `object`
- معنی: داده تکمیلی برای توسعه‌های بعدی
## فیلدهای `tracker.alertStats`
- نوع: `array`
- معنی: خلاصه آماری alertها برای نمایش سریع در UI
هر آیتم:
### `title`
- عنوان دسته alert
### `count`
- نوع: `string`
- تعداد alertها در آن دسته
### `avatarColor`
- نوع: `string`
- رنگ پیشنهادی UI
### `avatarIcon`
- نوع: `string`
- آیکن پیشنهادی UI
### `severity`
- نوع: `string`
- بالاترین شدت در آن دسته
### `topSummary`
- نوع: `string`
- مهم‌ترین خلاصه در آن دسته
## فیلدهای `tracker.alertClusters`
- نوع: `array`
- معنی: گروه‌بندی alertها بر اساس domain
هر آیتم:
### `domain`
- نوع: `string`
- نام دامنه خوشه
### `title`
- نوع: `string`
- عنوان انسانی خوشه
### `alert_count`
- نوع: `number`
- تعداد alertهای این خوشه
### `highest_severity`
- نوع: `string`
- بیشترین شدت بین alertهای این خوشه
### `primary_metric`
- نوع: `string`
- مهم‌ترین metric در آن خوشه
### `summary`
- نوع: `string`
- خلاصه وضعیت خوشه
### `alert_ids`
- نوع: `array`
- معنی: شناسه‌های alertهای عضو خوشه
## فیلد `tracker.mostCriticalIssue`
- نوع: `object | null`
- معنی: مهم‌ترین مسئله‌ای که tracker پیدا کرده
- ساختار آن مشابه هر آیتم در `tracker.alerts` است
## فیلدهای خلاصه‌ای `tracker`
### `tracker.prioritizedAlertSummaries`
- نوع: `array`
- معنی: لیستی از summaryهای مهم به ترتیب اولویت
### `tracker.recommendedOperationalActions`
- نوع: `array`
- معنی: لیستی از اقدام‌های عملیاتی پیشنهادی
### `tracker.humanReadableExplanations`
- نوع: `array`
- معنی: توضیح‌های متنی قابل‌خواندن برای نمایش یا گزارش
## فیلدهای `notifications`
- نوع: `array`
- معنی: اعلان‌های نهایی ذخیره‌شده در سیستم
هر آیتم در `notifications`:
### `id`
- نوع: `number`
- معنی: شناسه داخلی notification
### `uuid`
- نوع: `string`
- معنی: شناسه یکتای notification
### `farm_uuid`
- نوع: `string`
- معنی: شناسه مزرعه مربوط به notification
### `since_id`
- نوع: `number | null`
- معنی: شناسه مرجع برای زنجیره یا گروه‌بندی notificationها در سیستم
### `endpoint`
- نوع: `string`
- معنی: endpoint تولیدکننده notification
- مقدار فعلی: `tracker`
### `title`
- نوع: `string`
- معنی: عنوان notification
### `message`
- نوع: `string`
- معنی: متن اصلی notification
### `level`
- نوع: `string`
- مقادیر مجاز:
- `info`
- `warning`
- `danger`
- معنی: سطح notification
### `suggested_action`
- نوع: `string`
- معنی: اقدام پیشنهادی نهایی برای کاربر
### `source_alert_id`
- نوع: `string`
- معنی: شناسه alert مبنا که notification از آن ساخته شده
### `source_metric_type`
- نوع: `string`
- معنی: نوع metric مبنا
### `payload`
- نوع: `object`
- معنی: داده تکمیلی notification
### `is_read`
- نوع: `boolean`
- معنی: آیا notification توسط کاربر خوانده شده یا نه
### `metadata`
- نوع: `object`
- معنی: اطلاعات جانبی درباره منبع یا نحوه تولید notification
- نمونه:
- `source: farm_alerts_tracker_ai`
### `created_at`
- نوع: `string`
- معنی: زمان ایجاد notification
### `updated_at`
- نوع: `string`
- معنی: زمان آخرین به‌روزرسانی notification
## فیلدهای `structured_context`
این بخش برای debug و audit مفید است و نشان می‌دهد چه داده‌ای به مدل داده شده است.
### `structured_context.farm_profile`
- اطلاعات پایه مزرعه
فیلدهای مهم:
- `farm_uuid`: شناسه مزرعه
- `location.latitude`: عرض جغرافیایی
- `location.longitude`: طول جغرافیایی
- `plant_names`: لیست گیاه‌های ثبت‌شده
- `irrigation_method`: روش آبیاری یا `null`
- `last_sensor_update`: زمان آخرین داده سنسور
### `structured_context.tracker`
- همان داده tracker که به AI داده شده است
### `structured_context.forecasts`
- نوع: `array`
- معنی: پیش‌بینی‌های هواشناسی کوتاه‌مدت
هر آیتم:
- `date`: تاریخ پیش‌بینی
- `temperature_min`: کمینه دما
- `temperature_max`: بیشینه دما
- `humidity_mean`: میانگین رطوبت
- `precipitation`: بارش
- `et0`: تبخیر-تعرق مرجع
### `structured_context.incoming_alerts`
- نوع: `array`
- معنی: alertهایی که از request به API ارسال شده و در تحلیل استفاده شده‌اند
- اگر چیزی ارسال نشده باشد، آرایه خالی است
## تفاوت `severity` و `level`
این دو فیلد شبیه هم هستند ولی یکسان نیستند:
- `severity`: شدت داخلی alert در tracker با مقادیر `low/medium/high/critical`
- `level`: سطح نهایی notification یا status کلی با مقادیر `info/warning/danger`
به طور معمول:
- `low` معمولا به `info` نزدیک است
- `medium` معمولا به `warning` نزدیک است
- `high` و `critical` معمولا به `danger` نزدیک هستند
## نکته عملی
اگر می‌خواهید در frontend فقط پیام نهایی را نمایش دهید، معمولا این فیلدها کافی هستند:
- `data.headline`
- `data.overview`
- `data.status_level`
- `data.notifications`
اگر می‌خواهید صفحه تحلیلی یا داشبورد کامل بسازید، از این بخش‌ها هم استفاده کنید:
- `data.tracker.alerts`
- `data.tracker.alertStats`
- `data.tracker.alertClusters`
- `data.structured_context.forecasts`
@@ -0,0 +1,619 @@
# Free-Text Plan Parser APIs
این فایل برای تیم فرانت‌اند آماده شده و دو API جدید زیر را توضیح می‌دهد:
- `POST /api/irrigation/plan-from-text/`
- `POST /api/fertilization/plan-from-text/`
هدف هر دو API:
- کاربر یک متن آزاد می‌نویسد
- بک‌اند تلاش می‌کند برنامه آبیاری یا کودهی را به JSON ساختاریافته تبدیل کند
- اگر اطلاعات کامل باشد، JSON نهایی برمی‌گردد
- اگر اطلاعات ناقص باشد، API سوال‌های تکمیلی برمی‌گرداند
- فرانت‌اند سوال‌ها را از کاربر می‌پرسد و پاسخ‌ها را دوباره برای API می‌فرستد
---
## رفتار کلی هر دو API
هر دو endpoint یک flow یکسان دارند:
1. کاربر متن آزاد اولیه را می‌فرستد
2. اگر متن کامل باشد:
- `status = "completed"`
- `final_plan` برمی‌گردد
3. اگر متن ناقص باشد:
- `status = "needs_clarification"`
- `missing_fields` برمی‌گردد
- `questions` برمی‌گردد
4. فرانت‌اند پاسخ کاربر به سوال‌ها را جمع می‌کند
5. دوباره همان endpoint را با `answers` و `partial_plan` صدا می‌زند
6. این روند تا ساخته شدن `final_plan` ادامه پیدا می‌کند
---
## الگوی کلی response
هر دو API از envelope استاندارد استفاده می‌کنند:
```json
{
"code": 200,
"msg": "موفق",
"data": {}
}
```
### معنی فیلدهای envelope
| فیلد | نوع | توضیح |
|---|---|---|
| `code` | number | کد منطقی پاسخ |
| `msg` | string | پیام کوتاه پاسخ |
| `data` | object | داده اصلی API |
---
## 1) API استخراج برنامه آبیاری
### Endpoint
```http
POST /api/irrigation/plan-from-text/
```
### کاربرد
این API متن آزاد کاربر درباره برنامه آبیاری را به JSON ساختاریافته تبدیل می‌کند.
### Request Body
هر سه فیلد زیر اختیاری هستند، اما حداقل یکی از این‌ها باید ارسال شود:
- `message`
- `answers`
- `partial_plan`
#### ساختار request
```json
{
"message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.",
"answers": {
"growth_stage": "گلدهی"
},
"partial_plan": {
"crop_name": "گوجه فرنگی",
"irrigation_method": "قطره ای"
},
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### فیلدهای request
| فیلد | نوع | اجباری | توضیح |
|---|---|---:|---|
| `message` | string | خیر | متن آزاد کاربر |
| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر به سوال‌هایی که قبلا API داده |
| `partial_plan` | object | خیر | خروجی مرحله قبل برای ادامه تکمیل |
| `farm_uuid` | string | خیر | برای غنی‌سازی context مزرعه در RAG |
### قانون validation
اگر هیچ‌کدام از `message`، `answers` یا `partial_plan` ارسال نشوند:
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"non_field_errors": [
"حداقل یکی از message، answers یا partial_plan باید ارسال شود."
]
}
}
```
---
## پاسخ موفق - حالت تکمیل شده
وقتی همه اطلاعات لازم موجود باشد:
```json
{
"code": 200,
"msg": "موفق",
"data": {
"status": "completed",
"status_fa": "تکمیل شد",
"summary": "برنامه آبیاری برای گوجه‌فرنگی به روش قطره‌ای هر سه روز یک‌بار صبح زود به مدت 25 دقیقه اجرا می‌شود.",
"missing_fields": [],
"questions": [],
"collected_data": {
"crop_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_method": "قطره‌ای",
"water_amount_per_event": "18 لیتر برای هر بوته",
"duration_minutes": 25,
"frequency_text": "هر سه روز یک‌بار",
"interval_days": 3,
"preferred_time_of_day": "صبح زود",
"start_date": "از امروز",
"target_area": "کل مزرعه",
"trigger_conditions": [],
"notes": []
},
"final_plan": {
"crop_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_method": "قطره‌ای",
"water_amount_per_event": "18 لیتر برای هر بوته",
"duration_minutes": 25,
"frequency_text": "هر سه روز یک‌بار",
"interval_days": 3,
"preferred_time_of_day": "صبح زود",
"start_date": "از امروز",
"target_area": "کل مزرعه",
"trigger_conditions": [],
"notes": []
}
}
}
```
### فیلدهای `data`
| فیلد | نوع | توضیح |
|---|---|---|
| `status` | string | یکی از `completed` یا `needs_clarification` |
| `status_fa` | string | نسخه فارسی وضعیت |
| `summary` | string | خلاصه قابل نمایش برای کاربر |
| `missing_fields` | array[string] | فیلدهای ناقص |
| `questions` | array[object] | سوال‌های تکمیلی |
| `collected_data` | object | داده‌ای که تا الان از متن و جواب‌ها استخراج شده |
| `final_plan` | object/null | برنامه نهایی؛ فقط در حالت `completed` |
### فیلدهای `collected_data` و `final_plan`
| فیلد | نوع | توضیح |
|---|---|---|
| `crop_name` | string | نام محصول |
| `growth_stage` | string | مرحله رشد محصول |
| `irrigation_method` | string | روش آبیاری |
| `water_amount_per_event` | string | مقدار آب هر نوبت |
| `duration_minutes` | number | مدت هر نوبت آبیاری به دقیقه |
| `frequency_text` | string | توصیف متنی فاصله آبیاری |
| `interval_days` | number | فاصله آبیاری بر حسب روز |
| `preferred_time_of_day` | string | زمان مناسب اجرای آبیاری |
| `start_date` | string | زمان یا تاریخ شروع برنامه |
| `target_area` | string | محدوده هدف برنامه |
| `trigger_conditions` | array[string] | شرایط تریگر اختیاری |
| `notes` | array[string] | نکات تکمیلی |
---
## پاسخ موفق - حالت نیاز به سوال تکمیلی
اگر اطلاعات کامل نباشد:
```json
{
"code": 200,
"msg": "موفق",
"data": {
"status": "needs_clarification",
"status_fa": "نیازمند پرسش تکمیلی",
"summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
"missing_fields": [
"growth_stage",
"start_date",
"target_area"
],
"questions": [
{
"id": "growth_stage",
"field": "growth_stage",
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
"rationale": "مرحله رشد برای کامل شدن برنامه لازم است."
},
{
"id": "start_date",
"field": "start_date",
"question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟",
"rationale": "زمان شروع برنامه هنوز مشخص نشده است."
},
{
"id": "target_area",
"field": "target_area",
"question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟",
"rationale": "محدوده اجرای برنامه باید مشخص باشد."
}
],
"collected_data": {
"crop_name": "گوجه‌فرنگی",
"growth_stage": null,
"irrigation_method": "قطره‌ای",
"water_amount_per_event": "18 لیتر برای هر بوته",
"duration_minutes": 25,
"frequency_text": "هر سه روز یک‌بار",
"interval_days": 3,
"preferred_time_of_day": "صبح زود",
"start_date": null,
"target_area": null,
"trigger_conditions": [],
"notes": []
},
"final_plan": null
}
}
```
### ساختار `questions`
| فیلد | نوع | توضیح |
|---|---|---|
| `id` | string | شناسه سوال |
| `field` | string | فیلدی که این سوال برای آن پرسیده شده |
| `question` | string | متن سوال برای نمایش به کاربر |
| `rationale` | string | توضیح کوتاه برای اینکه چرا این سوال لازم است |
---
## flow پیشنهادی فرانت‌اند برای آبیاری
### مرحله 1
کاربر متن آزاد می‌فرستد:
```json
{
"message": "برای گوجه فرنگی هر سه روز یک بار آبیاری می کنم."
}
```
### مرحله 2
اگر `status = needs_clarification` بود:
- سوال‌ها را از `data.questions` به کاربر نمایش بده
- پاسخ‌ها را جمع کن
### مرحله 3
درخواست تکمیلی بزن:
```json
{
"partial_plan": {
"crop_name": "گوجه فرنگی",
"growth_stage": null,
"irrigation_method": null,
"water_amount_per_event": null,
"duration_minutes": null,
"frequency_text": "هر سه روز یک بار",
"interval_days": 3,
"preferred_time_of_day": null,
"start_date": null,
"target_area": null,
"trigger_conditions": [],
"notes": []
},
"answers": {
"growth_stage": "گلدهی",
"irrigation_method": "قطره ای",
"water_amount_per_event": "18 لیتر برای هر بوته",
"duration_minutes": 25,
"preferred_time_of_day": "صبح زود",
"start_date": "از امروز",
"target_area": "کل مزرعه"
}
}
```
### مرحله 4
اگر `status = completed` شد:
- از `data.final_plan` به عنوان JSON نهایی استفاده کن
---
## 2) API استخراج برنامه کودهی
### Endpoint
```http
POST /api/fertilization/plan-from-text/
```
### کاربرد
این API متن آزاد کاربر درباره برنامه کودهی را به JSON ساختاریافته تبدیل می‌کند.
### Request Body
```json
{
"message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.",
"answers": {
"timing": "هر 12 روز یک بار"
},
"partial_plan": {
"crop_name": "گندم"
},
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### فیلدهای request
| فیلد | نوع | اجباری | توضیح |
|---|---|---:|---|
| `message` | string | خیر | متن آزاد کاربر |
| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر |
| `partial_plan` | object | خیر | داده استخراج شده مرحله قبل |
| `farm_uuid` | string | خیر | برای context مزرعه |
### validation error
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"non_field_errors": [
"حداقل یکی از message، answers یا partial_plan باید ارسال شود."
]
}
}
```
---
## پاسخ موفق - حالت تکمیل شده
```json
{
"code": 200,
"msg": "موفق",
"data": {
"status": "completed",
"status_fa": "تکمیل شد",
"summary": "برنامه کودهی برای گندم در مرحله پنجه زنی با کود 20-20-20 به صورت کودآبیاری هر 12 روز یک بار اجرا می شود.",
"missing_fields": [],
"questions": [],
"collected_data": {
"crop_name": "گندم",
"growth_stage": "پنجه زنی",
"objective": "تقویت رشد رویشی",
"applications": [
{
"fertilizer_name": "کود کامل 20-20-20",
"formula": "20-20-20",
"amount": "35 کیلوگرم در هکتار",
"application_method": "کودآبیاری",
"timing": "هر 12 روز یک بار",
"interval_days": 12,
"purpose": "تقویت رشد رویشی"
}
],
"notes": []
},
"final_plan": {
"crop_name": "گندم",
"growth_stage": "پنجه زنی",
"objective": "تقویت رشد رویشی",
"applications": [
{
"fertilizer_name": "کود کامل 20-20-20",
"formula": "20-20-20",
"amount": "35 کیلوگرم در هکتار",
"application_method": "کودآبیاری",
"timing": "هر 12 روز یک بار",
"interval_days": 12,
"purpose": "تقویت رشد رویشی"
}
],
"notes": []
}
}
}
```
### فیلدهای `collected_data` و `final_plan`
| فیلد | نوع | توضیح |
|---|---|---|
| `crop_name` | string | نام محصول |
| `growth_stage` | string | مرحله رشد |
| `objective` | string/null | هدف برنامه |
| `applications` | array[object] | لیست نوبت‌ها یا اقلام کودی |
| `notes` | array[string] | نکات تکمیلی |
### ساختار هر application
| فیلد | نوع | توضیح |
|---|---|---|
| `fertilizer_name` | string | نام کود |
| `formula` | string | فرمول یا آنالیز کود |
| `amount` | string | مقدار مصرف |
| `application_method` | string | روش مصرف |
| `timing` | string | زمان‌بندی مصرف |
| `interval_days` | number | فاصله بین نوبت‌ها |
| `purpose` | string/null | هدف آن نوبت |
---
## پاسخ موفق - حالت نیاز به سوال تکمیلی
```json
{
"code": 200,
"msg": "موفق",
"data": {
"status": "needs_clarification",
"status_fa": "نیازمند پرسش تکمیلی",
"summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
"missing_fields": [
"growth_stage",
"formula",
"interval_days"
],
"questions": [
{
"id": "growth_stage",
"field": "growth_stage",
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
"rationale": "مرحله رشد برای تکمیل برنامه لازم است."
},
{
"id": "formula",
"field": "formula",
"question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.",
"rationale": "ترکیب دقیق کود هنوز مشخص نشده است."
},
{
"id": "interval_days",
"field": "interval_days",
"question": "فاصله بین نوبت های مصرف کود چند روز است؟",
"rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است."
}
],
"collected_data": {
"crop_name": "گندم",
"growth_stage": null,
"objective": null,
"applications": [
{
"fertilizer_name": "کود کامل",
"formula": null,
"amount": "35 کیلوگرم در هکتار",
"application_method": "کودآبیاری",
"timing": "هر چند وقت یک بار",
"interval_days": null,
"purpose": null
}
],
"notes": []
},
"final_plan": null
}
}
```
---
## flow پیشنهادی فرانت‌اند برای کودهی
### درخواست اولیه
```json
{
"message": "برای گندم از کود کامل استفاده می کنم."
}
```
### اگر incomplete بود
- از `questions` سوال‌ها را بگیر
- در UI نمایش بده
- پاسخ‌ها را جمع کن
### درخواست تکمیلی
```json
{
"partial_plan": {
"crop_name": "گندم",
"growth_stage": null,
"objective": null,
"applications": [
{
"fertilizer_name": "کود کامل",
"formula": null,
"amount": null,
"application_method": null,
"timing": null,
"interval_days": null,
"purpose": null
}
],
"notes": []
},
"answers": {
"growth_stage": "پنجه زنی",
"formula": "20-20-20",
"amount": "35 کیلوگرم در هکتار",
"application_method": "کودآبیاری",
"timing": "هر 12 روز یک بار",
"interval_days": 12
}
}
```
### اگر complete شد
- از `final_plan` استفاده کن
---
## نکات مهم برای فرانت‌اند
### 1. به `status` تکیه کنید
مهم‌ترین فیلد برای کنترل flow:
- `completed`
- `needs_clarification`
### 2. اگر `needs_clarification` بود
باید:
- `questions` را به کاربر نمایش دهید
- `partial_plan` را نگه دارید
- پاسخ‌های کاربر را در `answers` ارسال کنید
### 3. اگر `completed` بود
باید:
- `final_plan` را به عنوان نسخه نهایی برنامه ذخیره یا نمایش دهید
### 4. `collected_data` همیشه مهم است
حتی اگر برنامه ناقص باشد، `collected_data` نشان می‌دهد سیستم تا این لحظه چه چیزهایی را فهمیده است.
### 5. null در حالت ناقص طبیعی است
در حالت `needs_clarification` ممکن است بعضی فیلدهای `collected_data` `null` باشند.
اما در حالت `completed` نباید فیلدهای اصلی ناقص باشند.
### 6. فرانت‌اند بهتر است سوال‌ها را به صورت step-by-step بپرسد
پیشنهاد:
- سوال اول را نشان بده
- جواب را بگیر
- همه جواب‌ها را در `answers` جمع کن
- دوباره API را صدا بزن
---
## جمع‌بندی تفاوت دو API
| API | موضوع | خروجی نهایی |
|---|---|---|
| `/api/irrigation/plan-from-text/` | استخراج برنامه آبیاری | `final_plan` با ساختار آبیاری |
| `/api/fertilization/plan-from-text/` | استخراج برنامه کودهی | `final_plan` با ساختار کودهی |
---
## مسیر فایل
این داکیومنت در این مسیر ذخیره شده:
`docs/irrigation_fertilization_plan_parser_apis.md`
@@ -0,0 +1,512 @@
# توضیح `location_data/apps.py` و `farm_data/apps.py`
این فایل یک توضیح کوتاه ولی کاربردی از دو فایل تنظیمات اپ Django در پروژه می‌دهد:
- `location_data/apps.py`
- `farm_data/apps.py`
همچنین برای فهم بهتر، به فیلدهای مهم مدل‌های مرتبط هم اشاره می‌کند تا معلوم شود این دو app در عمل چه داده‌هایی را مدیریت می‌کنند.
---
## 1) فایل `location_data/apps.py`
این فایل AppConfig مربوط به اپ `location_data` را تعریف می‌کند.
کلاس اصلی:
```python
class SoilDataConfig(AppConfig):
```
### فیلدها و بخش‌ها
#### `default_auto_field = "django.db.models.BigAutoField"`
- مشخص می‌کند اگر در مدل‌های این اپ برای primary key چیزی تعریف نشده باشد، Django به‌صورت پیش‌فرض از `BigAutoField` استفاده کند.
- `BigAutoField` یک شناسه عددی auto-increment بزرگ است.
- این گزینه بیشتر برای مدل‌هایی مفید است که قرار است رکوردهای زیادی داشته باشند.
#### `name = "location_data"`
- نام کامل اپ Django است.
- Django با این مقدار اپ را register می‌کند.
- این مقدار باید با مسیر ماژول اپ یکی باشد.
#### `verbose_name = "Soil Data (SoilGrids)"`
- نام نمایشی اپ در Django admin یا جاهایی است که Django نام انسانی اپ را نشان می‌دهد.
- این مقدار بیشتر جنبه نمایشی دارد و روی منطق برنامه اثر مستقیم ندارد.
---
## propertyها و سرویس‌ها در `location_data/apps.py`
این فایل فقط metadata اپ را نگه نمی‌دارد؛ دو سرویس reusable هم از طریق AppConfig در اختیار بقیه پروژه می‌گذارد.
### `@cached_property def ndvi_health_service(self)`
- این property یک نمونه از `NdviHealthService` می‌سازد.
- import آن از فایل `.ndvi` انجام می‌شود.
- به دلیل `cached_property` فقط یک بار ساخته می‌شود و بعد همان instance دوباره استفاده می‌شود.
کاربرد:
- برای تحلیل یا سرویس‌های مرتبط با NDVI
- جلوگیری از ساخت مکرر object
### `@cached_property def soil_data_adapter(self)`
این property adapter مناسب برای داده خاک را بر اساس تنظیمات پروژه انتخاب می‌کند.
دو adapter پشتیبانی می‌شوند:
- `SoilGridsAdapter`
- `MockSoilDataAdapter`
#### منطق انتخاب provider
مقدار provider از این setting خوانده می‌شود:
```python
settings.SOIL_DATA_PROVIDER
```
اگر وجود نداشته باشد، مقدار پیش‌فرض:
```python
"mock"
```
#### حالت اول: `provider == "soilgrids"`
در این حالت:
- از `SoilGridsAdapter` استفاده می‌شود
- timeout آن از این setting می‌آید:
```python
settings.SOILGRIDS_TIMEOUT_SECONDS
```
اگر این setting هم نباشد، مقدار پیش‌فرض:
```python
60
```
یعنی درخواست به provider واقعی SoilGrids حداکثر 60 ثانیه صبر می‌کند.
#### حالت دوم: `provider == "mock"`
در این حالت:
- از `MockSoilDataAdapter` استفاده می‌شود
- delay آن از این setting می‌آید:
```python
settings.SOIL_MOCK_DELAY_SECONDS
```
اگر این setting هم نباشد، مقدار پیش‌فرض:
```python
0.8
```
یعنی adapter تستی/نمایشی با تاخیر مصنوعی 0.8 ثانیه کار می‌کند.
#### حالت نامعتبر
اگر `SOIL_DATA_PROVIDER` چیزی غیر از `soilgrids` یا `mock` باشد:
- `ValueError` رخ می‌دهد
- یعنی config پروژه اشتباه است و provider شناخته نشده
---
## ارتباط `location_data/apps.py` با فیلدهای واقعی داده
این فایل خودش مدل تعریف نمی‌کند، اما به‌صورت مستقیم برای کار با مدل‌های اپ `location_data` استفاده می‌شود؛ مهم‌ترین آن‌ها این‌ها هستند:
- `location_data.models.SoilLocation`
- `location_data.models.SoilDepthData`
- `location_data.models.NdviObservation`
### فیلدهای مهم `SoilLocation`
#### `latitude`
- عرض جغرافیایی مرکز زمین
- نوع آن `DecimalField` است
- روی آن index وجود دارد
- این نقطه معمولاً مرکز هندسی مزرعه است، نه لزوماً یکی از گوشه‌های مرز
#### `longitude`
- طول جغرافیایی مرکز زمین
- مثل `latitude` برای lookup و resolve کردن داده‌های خاک استفاده می‌شود
#### `task_id`
- شناسه تسک Celery برای پردازش‌های async
- وقتی fetch داده خاک یا پردازش مرتبط در صف باشد، می‌توان با این فیلد وضعیت را track کرد
#### `farm_boundary`
- مرز مزرعه را به‌صورت JSON نگه می‌دارد
- معمولاً به‌شکل `Polygon` یا ساختار corner-based ذخیره می‌شود
- این فیلد خیلی مهم است چون فقط یک نقطه center نگه نمی‌دارید، بلکه شکل کلی زمین هم ثبت می‌شود
#### `created_at` / `updated_at`
- زمان ایجاد و آخرین به‌روزرسانی رکورد
### propertyهای مهم `SoilLocation`
#### `center_latitude`
- فقط alias برای `latitude` است
#### `center_longitude`
- فقط alias برای `longitude` است
#### `is_complete`
- بررسی می‌کند آیا هر سه لایه خاک برای این location ثبت شده‌اند یا نه
- شرط آن این است که تعداد `depths` دقیقاً 3 باشد
### فیلدهای مهم `SoilDepthData`
این مدل برای هر location سه رکورد عمق خاک نگه می‌دارد:
- `0-5cm`
- `5-15cm`
- `15-30cm`
فیلدهای اصلی:
- `soil_location`: ارتباط به `SoilLocation`
- `depth_label`: مشخص می‌کند داده برای کدام عمق است
- `bdod`: چگالی ظاهری خاک
- `cec`: ظرفیت تبادل کاتیونی
- `cfvo`: حجم قطعات درشت خاک
- `clay`: درصد رس
- `nitrogen`: مقدار نیتروژن
- `ocd` و `ocs`: شاخص‌های کربن آلی
- `phh2o`: pH خاک
- `sand`: درصد شن
- `silt`: درصد سیلت
- `soc`: کربن آلی خاک
- `wv0010`: رطوبت حجمی در فشار 10 kPa
- `wv0033`: رطوبت در حدود ظرفیت زراعی
- `wv1500`: رطوبت در نقطه پژمردگی دائم
این فیلدها برای شبیه‌سازی، آبیاری، و تخمین وضعیت واقعی خاک مهم هستند.
### فیلدهای مهم `NdviObservation`
- `location`: ارتباط با `SoilLocation`
- `observation_date`: تاریخ مشاهده
- `mean_ndvi`: میانگین NDVI
- `ndvi_map`: داده مکانی NDVI
- `vegetation_health_class`: کلاس سلامت پوشش گیاهی
- `satellite_source`: منبع تصویر مثل `sentinel-2`
- `cloud_cover`: درصد پوشش ابر
- `metadata`: داده تکمیلی
---
## نکته مهم: grid بندی زمین انجام می‌شود
بله، در لایه داده و سنجش از دور، مفهوم grid بندی وجود دارد.
اما این grid بندی در پروژه بیشتر در این دو سطح دیده می‌شود:
### 1) grid در NDVI map
در `location_data/remote_sensing.py` داده NDVI به‌صورت grid محاسبه و ذخیره می‌شود.
یعنی:
- تصویر ماهواره‌ای به خانه‌های کوچک‌تر تقسیم می‌شود
- برای هر خانه مقدار NDVI محاسبه می‌شود
- خروجی در `ndvi_map` معمولاً به‌شکل grid نگه‌داری می‌شود
این یعنی وضعیت سلامت گیاه فقط به‌صورت یک عدد کلی نیست، بلکه می‌تواند روی بخش‌های مختلف زمین map شود.
### 2) grid/cell در adapter خاک
در `location_data/soil_adapters.py` هم منطق cell/grid دیده می‌شود، مخصوصاً در adapterهای mock یا interpolation-based.
یعنی:
- مختصات lat/lon به cellهای شبکه‌ای نگاشت می‌شود
- در بعضی محاسبات از `grid_x` و `grid_y` استفاده می‌شود
- این کمک می‌کند داده خاک برای ناحیه‌های نزدیک، رفتار مکانی منطقی‌تری داشته باشد
### نتیجه مهم
خود مدل `SoilLocation` یک مرکز زمین را نگه می‌دارد، ولی مرز مزرعه و NDVI grid باعث می‌شوند سیستم فقط point-based نباشد.
یعنی:
- مرکز زمین برای lookup سریع و اتصال به داده خاک/هوا استفاده می‌شود
- مرز مزرعه برای شکل واقعی زمین ذخیره می‌شود
- grid بندی برای تحلیل مکانی، مخصوصاً در NDVI، انجام می‌شود
---
## مرکز زمین چطور از مرز مزرعه به‌دست می‌آید
در `farm_data/services.py` از روی `farm_boundary`، مرکز هندسی polygon محاسبه می‌شود.
پس flow کلی این‌طور است:
1. مرز مزرعه ارسال می‌شود
2. polygon نرمال می‌شود
3. centroid هندسی آن محاسبه می‌شود
4. یک `SoilLocation` برای center ساخته یا پیدا می‌شود
5. بعد داده خاک، NDVI و هوا به این location متصل می‌شوند
پس زمین فقط با یک نقطه خام ثبت نمی‌شود؛ اول مرز دارد، بعد center از روی آن به‌دست می‌آید.
---
## متدهای کمکی `location_data/apps.py`
### `get_ndvi_health_service()`
- خروجی `self.ndvi_health_service` را برمی‌گرداند
- یک accessor ساده برای گرفتن سرویس NDVI است
### `get_soil_data_adapter()`
- خروجی `self.soil_data_adapter` را برمی‌گرداند
- بقیه بخش‌های پروژه از این متد برای گرفتن adapter فعال استفاده می‌کنند
/
---
## فیلدها و settingهای مهم مرتبط با `location_data/apps.py`
### فیلدهای AppConfig
- `default_auto_field`: نوع primary key پیش‌فرض مدل‌ها
- `name`: نام داخلی اپ
- `verbose_name`: نام نمایشی اپ
### settingهای استفاده‌شده
- `SOIL_DATA_PROVIDER`: انتخاب provider فعال خاک
- `SOILGRIDS_TIMEOUT_SECONDS`: timeout برای provider واقعی SoilGrids
- `SOIL_MOCK_DELAY_SECONDS`: تاخیر مصنوعی برای provider mock
---
## 2) فایل `farm_data/apps.py`
این فایل AppConfig مربوط به اپ `farm_data` را تعریف می‌کند.
کلاس اصلی:
```python
class FarmDataConfig(AppConfig):
```
### فیلدها
#### `default_auto_field = "django.db.models.BigAutoField"`
- مثل اپ قبلی، تعیین می‌کند primary key پیش‌فرض مدل‌های این اپ از نوع `BigAutoField` باشد.
#### `name = "farm_data"`
- نام داخلی و ماژول اپ Django است.
- برای شناسایی اپ در `INSTALLED_APPS` و registry داخلی Django استفاده می‌شود.
#### `label = "sensor_data"`
- label داخلی اپ در registry Django است.
- این فیلد زمانی مهم می‌شود که:
- بخواهید نام registry اپ با `name` فرق داشته باشد
- یا از تداخل نام اپ‌ها جلوگیری کنید
- در این پروژه، اپ `farm_data` با label داخلی `sensor_data` شناخته می‌شود.
نکته:
- `label` باید در کل پروژه یکتا باشد.
- این مقدار ممکن است در migrationها، relationها یا lookupهای app registry اثر داشته باشد.
#### `verbose_name = "farm-data"`
- نام نمایشی اپ است.
- بیشتر برای admin و نمایش انسانی استفاده می‌شود.
---
## نکته مهم درباره `farm_data/apps.py`
برخلاف `location_data/apps.py`، این فایل:
- `cached_property` ندارد
- service locator ندارد
- adapter یا provider انتخاب نمی‌کند
یعنی فعلاً فقط نقش config پایه اپ را دارد.
---
## ارتباط `farm_data/apps.py` با فیلدهای واقعی داده
این app بیشتر داده‌های farm-level و sensor-level را نگه می‌دارد. مهم‌ترین مدل‌هایش:
- `farm_data.models.SensorData`
- `farm_data.models.SensorParameter`
- `farm_data.models.ParameterUpdateLog`
### فیلدهای مهم `SensorData`
#### `farm_uuid`
- شناسه یکتای مزرعه
- primary key این مدل است
- هر رکورد `SensorData` نماینده یک مزرعه است
#### `center_location`
- ارتباط به `location_data.SoilLocation`
- یعنی این مزرعه به یک location مرکزی وصل است
- از همین نقطه مرکزی برای weather/soil/simulation استفاده می‌شود
#### `weather_forecast`
- ارتباط اختیاری با `weather.WeatherForecast`
- اگر موجود باشد، forecast منتخب یا آخرین forecast به مزرعه وصل می‌شود
#### `sensor_payload`
- مهم‌ترین فیلد این مدل است
- داده سنسورها به‌صورت JSON نگه‌داری می‌شود
- ساختار معمول آن شبیه این است:
```json
{
"sensor-7-1": {
"soil_moisture": 25.5,
"soil_temperature": 22.3,
"soil_ph": 7.2
}
}
```
مزیت این ساختار:
- چند سنسور در یک مزرعه پشتیبانی می‌شود
- هر سنسور می‌تواند فیلدهای خاص خودش را داشته باشد
- schema سنسورها rigid نیست
#### `plants`
- رابطه چندبه‌چند با `plant.Plant`
- یعنی یک farm می‌تواند چند گیاه مرتبط داشته باشد
#### `irrigation_method`
- روش آبیاری انتخاب‌شده برای مزرعه
- برای recommendation و planning مهم است
#### `created_at` / `updated_at`
- زمان ایجاد و آخرین ویرایش رکورد
### propertyهای مهم `SensorPayloadMixin`
مدل `SensorData` از `SensorPayloadMixin` ارث می‌گیرد و این helperها را دارد:
#### `_payload()`
- payload را فقط وقتی dict معتبر باشد برمی‌گرداند
#### `get_sensor_block(sensor_key=None)`
- اگر `sensor_key` بدهید، همان بلوک سنسور را برمی‌گرداند
- اگر ندهید، اولین بلوک معتبر را برمی‌گرداند
#### `get_metric(metric_name, sensor_key=None)`
- یک metric خاص را از payload پیدا می‌کند
- اول در sensor مشخص‌شده می‌گردد
- اگر پیدا نشد، در بقیه blockها جستجو می‌کند
#### propertyهای آماده
این propertyها shortcut هستند:
- `soil_moisture`
- `soil_temperature`
- `soil_ph`
- `electrical_conductivity`
- `nitrogen`
- `phosphorus`
- `potassium`
یعنی به‌جای parse دستی JSON، مستقیم می‌توان این متریک‌ها را خواند.
### فیلدهای مهم `SensorParameter`
این مدل dictionary پارامترهای سنسور را نگه می‌دارد.
#### `sensor_key`
- کلید سنسور مثل `sensor-7-1`
#### `code`
- کد پارامتر مثل `soil_moisture`
#### `name_fa`
- نام فارسی پارامتر
#### `unit`
- واحد پارامتر مثل `%` یا `dS/m`
#### `data_type`
- نوع داده مثل `float`, `int`, `string`, `bool`
#### `metadata`
- داده تکمیلی برای UI یا validation
- مثلاً:
- بازه مجاز
- توضیح
- تنظیمات نمایش
### فیلدهای مهم `ParameterUpdateLog`
- `parameter`: ارتباط به `SensorParameter`
- `action`: نوع عملیات مثل `added` یا `modified`
- `payload`: خلاصه تغییرات
- `updated_at`: زمان ثبت لاگ
این مدل برای audit و پیگیری تغییرات پارامترها مفید است.
---
## جمع‌بندی
### `location_data/apps.py`
- هم metadata اپ را نگه می‌دارد
- هم سرویس و adapter در اختیار پروژه می‌گذارد
- هم از settingها برای انتخاب provider واقعی یا mock استفاده می‌کند
- و در عمل با location center، مرز مزرعه، داده لایه‌های خاک و gridهای NDVI کار می‌کند
### `farm_data/apps.py`
- فقط config پایه AppConfig را تعریف می‌کند
- نقش آن بیشتر register کردن اپ با نام و label مشخص است
- اما داده‌های اصلی مزرعه مثل `farm_uuid`، `sensor_payload`، گیاه، روش آبیاری و اتصال به center location در مدل‌های همین app نگه‌داری می‌شوند

Some files were not shown because too many files have changed in this diff Show More