UPDATE
This commit is contained in:
@@ -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 app’s 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
*.log
|
||||
media
|
||||
staticfiles
|
||||
.cursor
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+120
@@ -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
|
||||
@@ -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/*
|
||||
@@ -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
@@ -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 بودن را جداگانه مستند کنید.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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`
|
||||
|
||||
@@ -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` داشته باشند
|
||||
|
||||
@@ -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']
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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",)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
20−20−20
|
||||
با غلظت مناسب توصیه میشود.
|
||||
مرحله گلدهی و تشکیل میوه:
|
||||
در این مرحله نیاز به نیتروژن کاهش و نیاز به فسفر (
|
||||
𝑃
|
||||
P
|
||||
) و پتاسیم (
|
||||
𝐾
|
||||
K
|
||||
) به شدت افزایش مییابد. پتاسیم برای کیفیت، اندازه و رنگ میوه ضروری است.
|
||||
کودهای پتاسبالا (مانند
|
||||
12
|
||||
−
|
||||
12
|
||||
−
|
||||
36
|
||||
12−12−36
|
||||
) مناسب هستند.
|
||||
مرحله رشد و رسیدن میوه:
|
||||
ادامه تغذیه با پتاسیم بالا.
|
||||
محلولپاشی کلسیم در این مرحله بسیار حیاتی است.
|
||||
۲. عناصر کلیدی و ریزمغذیهای ضروری:
|
||||
|
||||
کلسیم (
|
||||
𝐶
|
||||
𝑎
|
||||
Ca
|
||||
): کمبود کلسیم (یا عدم جذب آن به دلیل نوسانات آبیاری) باعث عارضه پوسیدگی گلگاه (سیاه شدن ته گوجهفرنگی) میشود. استفاده از کود نیترات کلسیم به صورت کودآبیاری یا محلولپاشی ضروری است.
|
||||
منیزیم (
|
||||
𝑀
|
||||
𝑔
|
||||
Mg
|
||||
): کمبود آن باعث زرد شدن برگهای پیر (در حالی که رگبرگها سبز میمانند) میشود. سولفات منیزیم برای رفع این مشکل مفید است.
|
||||
آهن (
|
||||
𝐹
|
||||
𝑒
|
||||
Fe
|
||||
) و روی (
|
||||
𝑍
|
||||
𝑛
|
||||
Zn
|
||||
): برای شادابی و فتوسنتز گیاه لازم هستند و معمولاً به صورت محلولپاشی یا کودهای کلاته استفاده میشوند.
|
||||
خلاصه نکات طلایی پایگاه دانش:
|
||||
پوسیدگی گلگاه: ترکیبی از کمبود کلسیم و آبیاری نامنظم است. همیشه رطوبت خاک را یکنواخت نگه دارید و از کلسیم استفاده کنید.
|
||||
ترکخوردگی میوه: ناشی از تغییر ناگهانی رطوبت خاک (مثلاً آبیاری سنگین بعد از یک دوره خشکی) است.
|
||||
تنظیم
|
||||
𝑝
|
||||
𝐻
|
||||
pH
|
||||
خاک: گوجهفرنگی در خاکی با
|
||||
𝑝
|
||||
𝐻
|
||||
pH
|
||||
بین
|
||||
6.0
|
||||
6.0
|
||||
تا
|
||||
6.8
|
||||
6.8
|
||||
بهترین جذب مواد مغذی را دارد.
|
||||
فاصله کوددهی کلسیم و فسفر: کودهای حاوی کلسیم را هرگز با کودهای حاوی فسفر یا سولفات همزمان مخلوط نکنید (رسوب میکنند).
|
||||
|
||||
راهنمای کوددهی هویج
|
||||
قاعده کلی برای هویج: هویج به نیتروژن (
|
||||
𝑁
|
||||
N
|
||||
) کم تا متوسط، اما به فسفر (
|
||||
𝑃
|
||||
P
|
||||
) و به ویژه پتاسیم (
|
||||
𝐾
|
||||
K
|
||||
) بسیار بالایی نیاز دارد.
|
||||
مراحل کوددهی:
|
||||
آمادهسازی خاک (قبل از کاشت): استفاده از کودهای پایه فسفر و پتاسیم. هشدار مهم: به هیچ وجه از کود دامی تازه استفاده نکنید! کود دامی باید کاملاً پوسیده باشد. کود حیوانی تازه باعث دو یا چند شاخه شدن هویج و ایجاد ریشههای مویی زائد میشود.
|
||||
رشد رویشی (اوایل رشد): استفاده محدود از نیتروژن برای رشد برگها. نیتروژن بیش از حد باعث میشود گیاه تمام انرژی خود را صرف تولید برگ کند و ریشه (بخش خوراکی) نازک و کوچک بماند.
|
||||
رشد و حجم گرفتن ریشه (اواسط تا اواخر رشد): استفاده از کودهای پتاسبالا (مانند سولوپتاس یا کودهای
|
||||
12
|
||||
−
|
||||
12
|
||||
−
|
||||
36
|
||||
12−12−36
|
||||
) برای افزایش سایز، بهبود رنگ، طعم شیرینتر و تردی هویج.
|
||||
عناصر ریزمغذی کلیدی:
|
||||
بُر (
|
||||
𝐵
|
||||
B
|
||||
): یکی از مهمترین عناصر برای هویج است. کمبود بُر باعث ایجاد شکاف در ریشه، سیاه شدن مغز هویج و کاهش بازارپسندی میشود.
|
||||
کلسیم (
|
||||
𝐶
|
||||
𝑎
|
||||
Ca
|
||||
): برای استحکام بافت ریشه و جلوگیری از بیماریها در انبار مهم است.
|
||||
خلاصه نکات طلایی و مشکلات رایج
|
||||
دو یا چند شاخه شدن هویج (Forking): ناشی از استفاده از کود دامی تازه، وجود سنگ و کلوخ در خاک، یا خاکهای بسیار سفت و رسی است. خاک هویج باید تا عمق حداقل
|
||||
25
|
||||
25
|
||||
سانتیمتری پوک و سبک باشد.
|
||||
ترکخوردگی ریشه: ناشی از نوسانات آبیاری (خاک خشک شود و ناگهان غرقاب گردد) یا دریافت بیش از حد نیتروژن در اواخر رشد.
|
||||
ریشههای مویی فراوان روی هویج: ناشی از مصرف بیش از حد کودهای نیتروژنه (
|
||||
𝑁
|
||||
N
|
||||
) یا رطوبت دائمی و بیش از حد خاک است.
|
||||
تنظیم
|
||||
𝑝
|
||||
𝐻
|
||||
pH
|
||||
خاک: هویج در خاکهایی با
|
||||
𝑝
|
||||
𝐻
|
||||
pH
|
||||
بین
|
||||
6.0
|
||||
6.0
|
||||
تا
|
||||
6.8
|
||||
6.8
|
||||
بهترین رشد را دارد. در
|
||||
𝑝
|
||||
𝐻
|
||||
pH
|
||||
پایینتر (خاک اسیدی)، رشد ریشه متوقف میشود.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
راهنمای استخراج برنامه کودهی از متن آزاد
|
||||
|
||||
هدف:
|
||||
تبدیل توضیح متنی کشاورز درباره برنامه کودهی به JSON ساختاریافته.
|
||||
|
||||
اطلاعات کلیدی که معمولا باید استخراج شوند:
|
||||
- نام محصول
|
||||
- مرحله رشد
|
||||
- هدف مصرف
|
||||
- نام یا فرمول کود
|
||||
- مقدار مصرف
|
||||
- روش مصرف
|
||||
- زمان مصرف
|
||||
- فاصله بین نوبت ها
|
||||
- توضیح تکمیلی یا هشدار
|
||||
|
||||
نمونه عبارت های رایج:
|
||||
- هر 10 روز یک بار
|
||||
- بعد از آبیاری
|
||||
- به صورت کودآبیاری
|
||||
- سرک
|
||||
- محلول پاشی
|
||||
- 35 کیلوگرم در هکتار
|
||||
- 20-20-20
|
||||
- برای تقویت رشد رویشی
|
||||
- برای شروع گلدهی
|
||||
|
||||
اگر متن ناقص بود، باید فقط سوال های لازم برای تکمیل برنامه نهایی پرسیده شود و از حدس زدن خودداری شود.
|
||||
@@ -0,0 +1,26 @@
|
||||
بخش اول: راهنمای آبیاری گوجهفرنگی (آبدهی)
|
||||
نیاز آبی گوجهفرنگی به مرحله رشد، نوع خاک و شرایط آب و هوایی بستگی دارد. مهمترین اصل در آبیاری گوجهفرنگی نظم و یکنواختی است.
|
||||
|
||||
۱. مراحل مختلف رشد و نیاز آبی:
|
||||
|
||||
مرحله نشاء و رشد اولیه: خاک باید مرطوب (نه غرقاب) نگه داشته شود تا ریشهها به خوبی مستقر شوند. آبیاری سطحی و مکرر توصیه میشود.
|
||||
مرحله گلدهی: تنش آبی در این مرحله باعث ریزش گلها میشود. آبیاری باید منظم باشد.
|
||||
مرحله تشکیل و بزرگ شدن میوه: بیشترین نیاز آبی در این مرحله است. آبیاری باید عمیق و منظم باشد تا از مشکلاتی مانند ترکخوردگی میوه و پوسیدگی گلگاه جلوگیری شود.
|
||||
مرحله رسیدن میوه: با شروع رنگ گرفتن گوجهها، آبیاری را کمی کاهش دهید. این کار باعث افزایش قند، بهبود طعم و جلوگیری از ترک خوردن میوه میشود.
|
||||
۲. نکات کلیدی در آبیاری:
|
||||
|
||||
روش آبیاری: بهترین روش، آبیاری قطرهای است. آبیاری بارانی باعث خیس شدن برگها و افزایش خطر بیماریهای قارچی میشود.
|
||||
زمان آبیاری: بهترین زمان، صبح زود است تا گیاه در طول روز رطوبت کافی داشته باشد و برگها تا شب خشک شوند.
|
||||
عمق آبیاری: آبیاری باید عمیق باشد تا ریشهها به عمق خاک نفوذ کنند (حداقل ۱۵ تا ۲۰ سانتیمتر).
|
||||
مالچپاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری میکند.
|
||||
|
||||
راهنمای آبیاری هویج
|
||||
اهمیت رطوبت در هویج: هویج یک گیاه ریشهای است و کیفیت ریشه آن ارتباط مستقیمی با نحوه آبیاری دارد. نوسانات رطوبتی باعث افت شدید کیفیت محصول میشود.
|
||||
نیاز آبی در مراحل مختلف رشد:
|
||||
کاشت و جوانهزنی: بذر هویج بسیار ریز است و در عمق کم کاشته میشود. در این مرحله خاک باید دائماً مرطوب (اما نه غرقاب) باشد تا بذرها خشک نشوند. خشکی در این مرحله باعث عدم سبز شدن بذرها میشود.
|
||||
رشد اولیه و توسعه ریشه: پس از سبز شدن، آبیاری باید عمیقتر و با فواصل بیشتر انجام شود تا ریشه گیاه برای پیدا کردن آب به عمق خاک نفوذ کند. آبیاری سطحی باعث کوتاه ماندن هویج میشود.
|
||||
حجم گرفتن ریشه (غدهبندی): نیاز آبی در این مرحله بالاست. رطوبت باید یکنواخت باشد.
|
||||
نزدیک به برداشت: کاهش آبیاری در اواخر دوره رشد ضروری است. آبیاری زیاد در این مرحله باعث ترکخوردگی هویجها میشود.
|
||||
روشهای آبیاری:
|
||||
بهترین روش: آبیاری قطرهای (نوار تیپ) زیرا رطوبت را به صورت یکنواخت در اختیار ریشه قرار میدهد و از بیماریهای برگی جلوگیری میکند.
|
||||
تنش آبی: خشک و خیس شدن پیاپی خاک، عامل اصلی دو شاخه شدن و ترک خوردن هویج است.
|
||||
+28
@@ -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 است.
|
||||
|
||||
## عمق خاک
|
||||
دادههای خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتیمتر اندازهگیری میشوند. لایه سطحی برای جوانهزنی و ریشههای سطحی مهم است؛ لایههای عمیقتر برای گیاهان ریشهعمیق اهمیت دارند.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
تحليل نياز آبي کوتاه مدت مزرعه
|
||||
|
||||
اين دانشنامه براي تفسير خروجي محاسبات نياز آبي روزهاي آينده استفاده مي شود.
|
||||
|
||||
اصول کلي:
|
||||
- `et0` تبخير-تعرق مرجع است و نشان مي دهد شرايط اقليمي هر روز چه ميزان تقاضاي تبخير-تعرق ايجاد مي کند.
|
||||
- `etc` از ضرب `et0` در ضريب گياهي `kc` به دست مي آيد و تخمين مناسب تري از نياز آبي محصول مي دهد.
|
||||
- `effective_rainfall` بخشي از بارش است که واقعا در تامين نياز آبي گياه موثر واقع مي شود.
|
||||
- `net_irrigation_mm` نياز آبي خالص پس از کسر بارش موثر است.
|
||||
- `gross_irrigation_mm` نياز آبي واقعي اجرايي با درنظر گرفتن راندمان سامانه آبياري است.
|
||||
|
||||
راهنماي تفسير:
|
||||
- اگر `gross_irrigation_mm` در چند روز پياپي بالا باشد، برنامه آبياري بايد فشرده تر و منظم تر تنظيم شود.
|
||||
- اگر راندمان آبياري پايين باشد، اختلاف بين نياز خالص و ناخالص بيشتر مي شود و اتلاف آب بالاتر است.
|
||||
- در روزهاي گرم، پر باد يا کم بارش، بهتر است اجراي آبياري به صبح زود يا نزديک غروب منتقل شود.
|
||||
- اگر بارش موثر پيش بيني شده باشد، بخشي از نياز آبي مي تواند بدون آبياري اضافي تامين شود.
|
||||
- توصيه ها بايد عملياتي، کوتاه مدت، و همسو با forecast فعلي باشند و در صورت عدم قطعيت، آن را صريح بيان کنند.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
# فایل لحن / سبک پاسخهای RAG
|
||||
|
||||
لحن و سبک پاسخها:
|
||||
- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن.
|
||||
- واژگان: از اصطلاحات رایج کشاورزی و خاکشناسی استفاده کن، در صورت نیاز معادل فارسی بیاور.
|
||||
- طول: پاسخها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن.
|
||||
- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده.
|
||||
@@ -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 user’s 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
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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 نشان بدهد.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user