UPDATE
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# CropLogic Authorization Service
|
||||
|
||||
This service runs OPA as a standalone authorization engine for `backend/access_control`.
|
||||
|
||||
## Run standalone
|
||||
|
||||
```bash
|
||||
docker compose -f accsess/docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
If you want request logging only on development, start the stack with
|
||||
`APP_ENV=DEVELOP` and enable the `develop` profile. In that mode, OPA sends
|
||||
decision logs to a sidecar service, and the log file is written to
|
||||
`accsess/logs/opa.log` on the host through a Docker volume.
|
||||
|
||||
```bash
|
||||
APP_ENV=DEVELOP COMPOSE_PROFILES=develop docker compose -f accsess/docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
## Decision endpoints
|
||||
|
||||
- Single feature: `POST /v1/data/croplogic/authz/decision`
|
||||
- Batch features: `POST /v1/data/croplogic/authz/batch_decision`
|
||||
|
||||
The backend uses the batch endpoint and sends the farm context only. Users are treated as `farmer` by default inside the service, and features are allowed unless there is a feature-specific rule in `policies/authz.rego`.
|
||||
|
||||
## Example request
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8181/v1/data/croplogic/authz/batch_decision \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @- <<'EOF'
|
||||
{
|
||||
"input": {
|
||||
"resource": {
|
||||
"farm_id": "farm-1001",
|
||||
"subscription_plan_codes": ["gold"],
|
||||
"farm_types": ["greenhouse"],
|
||||
"crop_types": ["tomato"],
|
||||
"cultivation_types": ["soil"],
|
||||
"sensor_codes": ["sensor-7-in-1"],
|
||||
"power_sensor": ["main-power"],
|
||||
"customization": ["default-layout"]
|
||||
},
|
||||
"features": ["sensor-7-in-1"],
|
||||
"action": "view"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## Add new rules in code
|
||||
|
||||
Define feature-specific checks directly in `policies/authz.rego`.
|
||||
|
||||
- If a feature has no rule, every action is allowed.
|
||||
- If a feature rule exists, its conditions are evaluated and any failing condition denies access.
|
||||
- `sensor-7-in-1` currently requires `resource.sensor_codes` to include one of the supported 7-in-1 sensor codes (`sensor-7-in-1` or `sensor_7_soil_moisture_sensor_v1_2`).
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
requestlog:
|
||||
url: http://opa-log-receiver:8282/logs
|
||||
labels:
|
||||
app: croplogic-authz
|
||||
plugins: {}
|
||||
decision_logs:
|
||||
service: requestlog
|
||||
reporting:
|
||||
min_delay_seconds: 1
|
||||
max_delay_seconds: 5
|
||||
@@ -0,0 +1,4 @@
|
||||
services: {}
|
||||
labels:
|
||||
app: croplogic-authz
|
||||
plugins: {}
|
||||
@@ -0,0 +1,4 @@
|
||||
services: {}
|
||||
labels:
|
||||
app: croplogic-authz
|
||||
plugins: {}
|
||||
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
opa:
|
||||
image: mirror-docker.runflare.com/openpolicyagent/opa
|
||||
container_name: croplogic-accsess-opa
|
||||
command:
|
||||
- run
|
||||
- --server
|
||||
- --addr=0.0.0.0:8181
|
||||
- --config-file=/config/opa-config.${APP_ENV:-default}.yaml
|
||||
- /policies
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-}
|
||||
ports:
|
||||
- "8181:8181"
|
||||
volumes:
|
||||
- ./policies:/policies:ro
|
||||
- ./config/opa-config.default.yaml:/config/opa-config.default.yaml:ro
|
||||
- ./config/opa-config.DEVELOP.yaml:/config/opa-config.DEVELOP.yaml:ro
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
- crop_network
|
||||
|
||||
opa-log-receiver:
|
||||
image: docker.iranserver.com/python:3.10
|
||||
container_name: croplogic-accsess-opa-log-receiver
|
||||
profiles:
|
||||
- develop
|
||||
command:
|
||||
- python
|
||||
- /app/scripts/opa_log_receiver.py
|
||||
environment:
|
||||
OPA_REQUEST_LOG_FILE: /logs/opa.log
|
||||
OPA_REQUEST_LOG_PORT: "8282"
|
||||
volumes:
|
||||
- ./scripts/opa_log_receiver.py:/app/scripts/opa_log_receiver.py:ro
|
||||
- ./logs:/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- crop_network
|
||||
networks:
|
||||
crop_network:
|
||||
external: true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{"timestamp": "2026-04-09T20:07:30.617741+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "401", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "c211530e-d6bb-4067-abed-57fe193b6e5b", "version": "1.15.2"}, "decision_id": "3ee2fa07-ce00-4c79-9782-76edae020652", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["feature1", "feature2", "feature3"]}, "result": {"features": {"feature1": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}, "feature2": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}, "feature3": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.1:59682", "timestamp": "2026-04-09T20:07:29.762128957Z", "metrics": {"counter_server_query_cache_hit": 0, "timer_rego_input_parse_ns": 57527, "timer_rego_query_compile_ns": 93329, "timer_rego_query_eval_ns": 199196, "timer_server_handler_ns": 471175}, "req_id": 1}]}
|
||||
{"timestamp": "2026-04-09T20:08:21.624001+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "544", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "c211530e-d6bb-4067-abed-57fe193b6e5b", "version": "1.15.2"}, "decision_id": "b6ca9264-4576-4826-ac70-067ad6850019", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_management"], "resource": {"crop_types": [], "cultivation_types": [], "customization": [], "farm_id": null, "farm_types": [], "power_sensor": [], "sensor_codes": [], "subscription_plan_codes": []}, "route": "/api/farm-hub/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_management": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:35524", "timestamp": "2026-04-09T20:08:19.762624801Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 71833, "timer_rego_query_eval_ns": 127252, "timer_server_handler_ns": 231704}, "req_id": 2}]}
|
||||
{"timestamp": "2026-04-09T20:08:43.385941+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "621", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "c211530e-d6bb-4067-abed-57fe193b6e5b", "version": "1.15.2"}, "decision_id": "b52a1754-aaf8-4c7d-9bfb-1d25d803f007", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:41130", "timestamp": "2026-04-09T20:08:43.104063998Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 83718, "timer_rego_query_eval_ns": 141627, "timer_server_handler_ns": 263982}, "req_id": 3}]}
|
||||
{"timestamp": "2026-04-09T20:12:48.961473+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "c211530e-d6bb-4067-abed-57fe193b6e5b", "version": "1.15.2"}, "decision_id": "98adca8f-68fb-47d6-8162-59c2cb83d18b", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_management"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-hub/11111111-1111-1111-1111-111111111111/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_management": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:46450", "timestamp": "2026-04-09T20:12:47.941138139Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 97369, "timer_rego_query_eval_ns": 181108, "timer_server_handler_ns": 317548}, "req_id": 4}]}
|
||||
{"timestamp": "2026-04-09T20:42:43.646161+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "821", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "4db76a9f-a2b6-44a0-b1f9-cb75f9133d39", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:37396", "timestamp": "2026-04-09T20:42:41.927602811Z", "metrics": {"counter_server_query_cache_hit": 0, "timer_rego_input_parse_ns": 122530, "timer_rego_query_compile_ns": 132430, "timer_rego_query_eval_ns": 194996, "timer_server_handler_ns": 592073}, "req_id": 1}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "43b88797-57cd-4f31-90ba-c237e2a3714e", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:37408", "timestamp": "2026-04-09T20:42:41.927600148Z", "metrics": {"counter_server_query_cache_hit": 0, "timer_rego_input_parse_ns": 108570, "timer_rego_query_compile_ns": 97471, "timer_rego_query_eval_ns": 192309, "timer_server_handler_ns": 523163}, "req_id": 2}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "407f0ba2-f6df-4baa-9fa7-407791970cab", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:37416", "timestamp": "2026-04-09T20:42:41.931016741Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 73967, "timer_rego_query_eval_ns": 108157, "timer_server_handler_ns": 209895}, "req_id": 3}]}
|
||||
{"timestamp": "2026-04-09T20:47:47.651969+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "624", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "b3ad986c-eb28-444f-b1e8-c0f493b45c57", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:41406", "timestamp": "2026-04-09T20:47:45.947059039Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 147422, "timer_rego_query_eval_ns": 241268, "timer_server_handler_ns": 449636}, "req_id": 4}]}
|
||||
{"timestamp": "2026-04-09T20:48:18.700793+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "711", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "b3a6098e-fb0c-47fa-be19-1c2c68e0669a", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:48310", "timestamp": "2026-04-09T20:48:18.354416572Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 225867, "timer_rego_query_eval_ns": 432224, "timer_server_handler_ns": 756714}, "req_id": 5}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "58bd4844-fc59-4465-96d3-e6aa53b40874", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:48314", "timestamp": "2026-04-09T20:48:18.373055564Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 201358, "timer_rego_query_eval_ns": 282319, "timer_server_handler_ns": 555958}, "req_id": 6}]}
|
||||
{"timestamp": "2026-04-09T20:50:09.747146+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "627", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "680c9a39-8be5-4419-85cc-becc0010888d", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["sensor_external_api"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/sensor-external-api/logs/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"sensor_external_api": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:48356", "timestamp": "2026-04-09T20:50:07.630315688Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 190621, "timer_rego_query_eval_ns": 377687, "timer_server_handler_ns": 636782}, "req_id": 7}]}
|
||||
{"timestamp": "2026-04-09T20:53:00.381383+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "623", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "1cbf7a8c-5704-4ccb-b4a9-2d0a73f56311", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:38240", "timestamp": "2026-04-09T20:52:57.471075759Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 202996, "timer_rego_query_eval_ns": 412022, "timer_server_handler_ns": 676636}, "req_id": 8}]}
|
||||
{"timestamp": "2026-04-09T20:58:03.913608+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "624", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "b8307e1a-be3c-4b2e-b81f-371e06d6ba84", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:40004", "timestamp": "2026-04-09T20:58:01.30297863Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 428967, "timer_rego_query_eval_ns": 640043, "timer_server_handler_ns": 2784581}, "req_id": 9}]}
|
||||
{"timestamp": "2026-04-09T21:01:31.740737+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "828", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "1057c499-26c7-4621-abc9-c1831fd58171", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["crop_zoning"], "resource": {"crop_types": [], "cultivation_types": [], "customization": [], "farm_id": null, "farm_types": [], "power_sensor": [], "sensor_codes": [], "subscription_plan_codes": []}, "route": "/api/crop-zoning/products/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"crop_zoning": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:50406", "timestamp": "2026-04-09T21:01:29.230461946Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 189901, "timer_rego_query_eval_ns": 437429, "timer_server_handler_ns": 743792}, "req_id": 10}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "f5b638e4-4192-4629-8a73-18a61af2a239", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:50420", "timestamp": "2026-04-09T21:01:29.288269547Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 4126050, "timer_rego_query_eval_ns": 447210, "timer_server_handler_ns": 4687167}, "req_id": 11}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "257de564-b48d-4ad4-8eff-d4cfb08f4b45", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["crop_zoning"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/crop-zoning/area/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"crop_zoning": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:50434", "timestamp": "2026-04-09T21:01:29.305181512Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 292984, "timer_rego_query_eval_ns": 382137, "timer_server_handler_ns": 852898}, "req_id": 12}]}
|
||||
{"timestamp": "2026-04-09T21:02:40.904920+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "634", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "5b9f103a-0da8-4f97-a9d3-5d51aa7706b1", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["irrigation_recommendation"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/irrigation-recommendation/config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"irrigation_recommendation": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:60326", "timestamp": "2026-04-09T21:02:39.279986375Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 225891, "timer_rego_query_eval_ns": 434331, "timer_server_handler_ns": 734656}, "req_id": 13}]}
|
||||
{"timestamp": "2026-04-09T21:02:54.426343+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "937aff32-1b0c-46b0-b11a-cac657eba503", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:49500", "timestamp": "2026-04-09T21:02:54.24022169Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 115636, "timer_rego_query_eval_ns": 157180, "timer_server_handler_ns": 315565}, "req_id": 14}]}
|
||||
{"timestamp": "2026-04-09T21:03:06.146197+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "623", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "d370fad0-87fb-4c6e-a16c-8ac2e0d6ce37", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:58422", "timestamp": "2026-04-09T21:03:05.166520574Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 222441, "timer_rego_query_eval_ns": 434203, "timer_server_handler_ns": 729628}, "req_id": 15}]}
|
||||
{"timestamp": "2026-04-09T21:04:41.647980+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "627", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "9fa975bf-7165-4cae-afe3-e7ff3cdbfee0", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["sensor_external_api"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/sensor-external-api/logs/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"sensor_external_api": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:36278", "timestamp": "2026-04-09T21:04:40.019161288Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 179885, "timer_rego_query_eval_ns": 383747, "timer_server_handler_ns": 659616}, "req_id": 16}]}
|
||||
{"timestamp": "2026-04-09T21:08:09.965162+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "626", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "c72f1740-9674-47a3-a23b-80cc7a655932", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:48524", "timestamp": "2026-04-09T21:08:08.897327932Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 137304, "timer_rego_query_eval_ns": 294343, "timer_server_handler_ns": 496201}, "req_id": 17}]}
|
||||
{"timestamp": "2026-04-09T21:13:15.332070+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "a79b8136-54cd-42bb-8d2e-64741f066d21", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:56696", "timestamp": "2026-04-09T21:13:12.582212863Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 188803, "timer_rego_query_eval_ns": 311352, "timer_server_handler_ns": 579955}, "req_id": 18}]}
|
||||
{"timestamp": "2026-04-09T21:18:17.324979+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "ba2cb809-35b1-4bce-8d39-acae0fc6ed17", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:39430", "timestamp": "2026-04-09T21:18:16.023067852Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 187659, "timer_rego_query_eval_ns": 326240, "timer_server_handler_ns": 598344}, "req_id": 19}]}
|
||||
{"timestamp": "2026-04-09T21:23:21.107040+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "3e590f06-dcd8-4e76-aa25-073f379c411a", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:33694", "timestamp": "2026-04-09T21:23:19.61493343Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 216489, "timer_rego_query_eval_ns": 343120, "timer_server_handler_ns": 640460}, "req_id": 20}]}
|
||||
{"timestamp": "2026-04-09T21:23:51.096118+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "634", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "f36d69af-6c86-41b3-9184-692ae5757e0b", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["fertilization_recommendation"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/fertilization-recommendation/config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"fertilization_recommendation": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:46050", "timestamp": "2026-04-09T21:23:50.4594069Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 141471, "timer_rego_query_eval_ns": 253595, "timer_server_handler_ns": 441400}, "req_id": 21}]}
|
||||
{"timestamp": "2026-04-09T21:23:59.638974+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "618", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "90f17cae-9c75-477b-8e6e-62f10747c178", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:39476", "timestamp": "2026-04-09T21:23:59.139056735Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 78302, "timer_rego_query_eval_ns": 131142, "timer_server_handler_ns": 239624}, "req_id": 22}]}
|
||||
{"timestamp": "2026-04-09T21:24:13.834987+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "22b2fea3-df6f-4c67-8f5d-635018b768ce", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:44008", "timestamp": "2026-04-09T21:24:13.491294566Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 99303, "timer_rego_query_eval_ns": 133459, "timer_server_handler_ns": 269374}, "req_id": 23}]}
|
||||
{"timestamp": "2026-04-09T21:24:24.386767+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "634", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "bc769ac1-110c-4e5b-876e-3c97614d23e2", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["irrigation_recommendation"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/irrigation-recommendation/config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"irrigation_recommendation": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:60398", "timestamp": "2026-04-09T21:24:22.957633923Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 132389, "timer_rego_query_eval_ns": 273816, "timer_server_handler_ns": 458756}, "req_id": 24}]}
|
||||
{"timestamp": "2026-04-09T21:24:26.123960+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "717", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "45c886c5-6915-4f66-8128-d5cd7a55782a", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_ai_assistant"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-ai-assistant/chats/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_ai_assistant": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:60404", "timestamp": "2026-04-09T21:24:25.0271866Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 85483, "timer_rego_query_eval_ns": 139055, "timer_server_handler_ns": 260195}, "req_id": 25}, {"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "aedc4325-7aff-4e18-87f4-7d55eda31778", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_ai_assistant"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-ai-assistant/context/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_ai_assistant": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:60418", "timestamp": "2026-04-09T21:24:25.027670559Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 107674, "timer_rego_query_eval_ns": 193988, "timer_server_handler_ns": 335440}, "req_id": 26}]}
|
||||
{"timestamp": "2026-04-09T21:24:34.050416+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "548", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "ae431b9a-9b28-4925-941f-6301c30fd500", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["crop_zoning"], "resource": {"crop_types": [], "cultivation_types": [], "customization": [], "farm_id": null, "farm_types": [], "power_sensor": [], "sensor_codes": [], "subscription_plan_codes": []}, "route": "/api/crop-zoning/products/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"crop_zoning": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:33316", "timestamp": "2026-04-09T21:24:30.974139448Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 168178, "timer_rego_query_eval_ns": 301097, "timer_server_handler_ns": 551443}, "req_id": 27}]}
|
||||
{"timestamp": "2026-04-09T21:25:10.553432+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "627", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "35d98c65-5a5b-40c3-aacb-8bbb6ce99eb3", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["sensor_external_api"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/sensor-external-api/logs/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"sensor_external_api": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:60564", "timestamp": "2026-04-09T21:25:07.821441729Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 118614, "timer_rego_query_eval_ns": 171169, "timer_server_handler_ns": 333953}, "req_id": 28}]}
|
||||
{"timestamp": "2026-04-09T21:28:24.705585+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "627", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "2f0f70da-c1ee-46b7-9d9b-f2b05069f646", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:36142", "timestamp": "2026-04-09T21:28:23.492387976Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 163958, "timer_rego_query_eval_ns": 395973, "timer_server_handler_ns": 626870}, "req_id": 29}]}
|
||||
{"timestamp": "2026-04-09T21:33:28.926444+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "626", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "041e89b6-4d30-4209-93d4-6873c932e769", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:45426", "timestamp": "2026-04-09T21:33:27.151055419Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 191209, "timer_rego_query_eval_ns": 362144, "timer_server_handler_ns": 641233}, "req_id": 30}]}
|
||||
{"timestamp": "2026-04-09T21:45:19.312225+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "624", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "124be292-ce82-463f-82ef-7fe214970e29", "path": "croplogic/authz/batch_decision", "input": {"action": "edit", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:54864", "timestamp": "2026-04-09T21:45:16.835047241Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 79343, "timer_rego_query_eval_ns": 124097, "timer_server_handler_ns": 233847}, "req_id": 31}]}
|
||||
{"timestamp": "2026-04-09T22:08:21.946179+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "623", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "9f6d085f-80fa-4a44-b17d-faf6cec36a29", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:51558", "timestamp": "2026-04-09T22:08:21.419844497Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 209725, "timer_rego_query_eval_ns": 304029, "timer_server_handler_ns": 596478}, "req_id": 32}]}
|
||||
{"timestamp": "2026-04-09T22:13:25.931508+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "551", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "163bde65-a50e-4d71-9b61-244b21609523", "version": "1.15.2"}, "decision_id": "ce83dc1c-2bce-41ba-b4f1-6400625b21c3", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["pest_detection"], "resource": {"crop_types": [], "cultivation_types": [], "customization": [], "farm_id": null, "farm_types": [], "power_sensor": [], "sensor_codes": [], "subscription_plan_codes": []}, "route": "/api/pest-detection/risk-summary/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"pest_detection": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.6:46430", "timestamp": "2026-04-09T22:13:23.71054823Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 114520, "timer_rego_query_eval_ns": 313737, "timer_server_handler_ns": 478644}, "req_id": 33}]}
|
||||
{"timestamp": "2026-04-10T12:59:27.063813+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "633", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "cf7b20b1-28fa-47d9-995b-511b31430498", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:50840", "timestamp": "2026-04-10T12:59:26.376629473Z", "metrics": {"counter_server_query_cache_hit": 0, "timer_rego_input_parse_ns": 82394, "timer_rego_query_compile_ns": 80078, "timer_rego_query_eval_ns": 180560, "timer_server_handler_ns": 466938}, "req_id": 1}]}
|
||||
{"timestamp": "2026-04-10T13:00:53.951689+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "6d1c7b00-69ed-4999-9c2c-f7bb9ab0727f", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:51898", "timestamp": "2026-04-10T13:00:52.910682603Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 56363, "timer_rego_query_eval_ns": 104895, "timer_server_handler_ns": 183538}, "req_id": 2}]}
|
||||
{"timestamp": "2026-04-10T13:05:35.880304+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "728", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "79bd4af9-b001-4a32-9ebd-ae9644b5ed5b", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:36464", "timestamp": "2026-04-10T13:05:34.042562699Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 113161, "timer_rego_query_eval_ns": 213810, "timer_server_handler_ns": 366862}, "req_id": 3}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "d42028ac-6b0e-4e54-b1d2-88cdce949db2", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:36470", "timestamp": "2026-04-10T13:05:34.042718253Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 124025, "timer_rego_query_eval_ns": 160477, "timer_server_handler_ns": 328769}, "req_id": 4}]}
|
||||
{"timestamp": "2026-04-10T13:10:26.348263+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "aa2d1d08-5f17-4ee3-8c90-6e2928bc7cf9", "path": "croplogic/authz/batch_decision", "input": {"action": "edit", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:33764", "timestamp": "2026-04-10T13:10:24.047412541Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 91827, "timer_rego_query_eval_ns": 174690, "timer_server_handler_ns": 301470}, "req_id": 5}]}
|
||||
{"timestamp": "2026-04-10T19:35:13.527185+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "804", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "22c0e059-5faa-4f9a-9157-eaeb62acbb2b", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:54296", "timestamp": "2026-04-10T19:35:11.403480105Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 132185, "timer_rego_query_eval_ns": 213468, "timer_server_handler_ns": 403665}, "req_id": 6}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "34f5000a-5ab9-496c-a874-cae2d1458dc4", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:54324", "timestamp": "2026-04-10T19:35:11.403824522Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 101753, "timer_rego_query_eval_ns": 146739, "timer_server_handler_ns": 278383}, "req_id": 8}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "4f1bb10b-b00a-4dd5-833d-a2aad5157c72", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:54312", "timestamp": "2026-04-10T19:35:11.403840867Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 124451, "timer_rego_query_eval_ns": 238400, "timer_server_handler_ns": 405116}, "req_id": 7}]}
|
||||
{"timestamp": "2026-04-10T19:40:16.822686+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "623", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "a3d8f2a4-6be8-4f81-866a-6fb7dc64f71d", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:34800", "timestamp": "2026-04-10T19:40:13.702403965Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 99787, "timer_rego_query_eval_ns": 136786, "timer_server_handler_ns": 270242}, "req_id": 9}]}
|
||||
{"timestamp": "2026-04-10T19:45:16.830582+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "626", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "4902a064-067a-4397-9deb-2569c4025f0c", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:44536", "timestamp": "2026-04-10T19:45:15.905382397Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 59497, "timer_rego_query_eval_ns": 89688, "timer_server_handler_ns": 171264}, "req_id": 10}]}
|
||||
{"timestamp": "2026-04-10T19:50:21.097707+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "624", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "9d45ae58-8c15-41d6-bdea-8a2d092f5077", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:52928", "timestamp": "2026-04-10T19:50:17.987713407Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 80255, "timer_rego_query_eval_ns": 121141, "timer_server_handler_ns": 229313}, "req_id": 11}]}
|
||||
{"timestamp": "2026-04-10T20:01:13.782745+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "d0212f25-f532-4f88-9f16-7ae26a544d32", "path": "croplogic/authz/batch_decision", "input": {"action": "edit", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:58694", "timestamp": "2026-04-10T20:01:11.127619736Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 62603, "timer_rego_query_eval_ns": 107738, "timer_server_handler_ns": 192904}, "req_id": 12}]}
|
||||
{"timestamp": "2026-04-10T22:43:38.461548+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "813", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "b8740cd7-1fe6-48f7-aff1-b39dc90fb193", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:33582", "timestamp": "2026-04-10T22:43:38.202921222Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 105707, "timer_rego_query_eval_ns": 206860, "timer_server_handler_ns": 358673}, "req_id": 13}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "2f64aa8f-698c-4a32-8859-7ce62e44f95f", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:33590", "timestamp": "2026-04-10T22:43:38.208182493Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 156086, "timer_rego_query_eval_ns": 370544, "timer_server_handler_ns": 591838}, "req_id": 14}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "a8f45f35-f6b1-4e57-9871-aaa0d9b07ef7", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:33588", "timestamp": "2026-04-10T22:43:38.210392032Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 126615, "timer_rego_query_eval_ns": 223747, "timer_server_handler_ns": 396115}, "req_id": 15}]}
|
||||
{"timestamp": "2026-04-10T22:44:04.863487+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "725", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "6e09a8a2-3a53-4178-86aa-f8a4b674fe93", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["crop_zoning"], "resource": {"crop_types": [], "cultivation_types": [], "customization": [], "farm_id": null, "farm_types": [], "power_sensor": [], "sensor_codes": [], "subscription_plan_codes": []}, "route": "/api/crop-zoning/products/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"crop_zoning": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:42600", "timestamp": "2026-04-10T22:44:04.71595662Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 136439, "timer_rego_query_eval_ns": 288419, "timer_server_handler_ns": 479665}, "req_id": 16}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "bd2bdb46-692e-407b-b90c-c4fa95804f2f", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["crop_zoning"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/crop-zoning/area/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"crop_zoning": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:42602", "timestamp": "2026-04-10T22:44:04.760420723Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 163656, "timer_rego_query_eval_ns": 273513, "timer_server_handler_ns": 495728}, "req_id": 17}]}
|
||||
{"timestamp": "2026-04-10T22:48:43.430769+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "624", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "fb2914ca-9008-4ece-b5d5-f330cc3116ee", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:47896", "timestamp": "2026-04-10T22:48:41.674746737Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 151182, "timer_rego_query_eval_ns": 260819, "timer_server_handler_ns": 467222}, "req_id": 18}]}
|
||||
{"timestamp": "2026-04-10T22:53:45.402384+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "625", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "ae549db0-12be-4aa1-a39d-9cbfeb4661ce", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["notifications"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/notifications/long-poll/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"notifications": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:60402", "timestamp": "2026-04-10T22:53:44.711350785Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 289900, "timer_rego_query_eval_ns": 320890, "timer_server_handler_ns": 687822}, "req_id": 19}]}
|
||||
{"timestamp": "2026-04-10T22:53:49.440465+00:00", "path": "/logs/logs", "headers": {"Host": "opa-log-receiver:8282", "User-Agent": "Open Policy Agent/1.15.2 (linux, amd64)", "Content-Length": "710", "Content-Encoding": "gzip", "Content-Type": "application/json", "Accept-Encoding": "gzip"}, "body": [{"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "12ee2ea3-ad7d-4567-8aa3-4ac1197d106b", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard-config/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:38306", "timestamp": "2026-04-10T22:53:48.92124304Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 98580, "timer_rego_query_eval_ns": 174745, "timer_server_handler_ns": 312667}, "req_id": 21}, {"labels": {"app": "croplogic-authz", "id": "dc58e9b5-a435-409a-93f1-71f10cb3133d", "version": "1.15.2"}, "decision_id": "3644f42e-05e5-478a-a420-cb7eb42d705f", "path": "croplogic/authz/batch_decision", "input": {"action": "view", "features": ["farm_dashboard"], "resource": {"crop_types": ["\u0630\u0631\u062a", "\u06af\u0646\u062f\u0645"], "cultivation_types": [], "customization": [], "farm_id": "11111111-1111-1111-1111-111111111111", "farm_types": ["\u0632\u0631\u0627\u0639\u06cc"], "power_sensor": ["solar"], "sensor_codes": ["sensor_7_soil_moisture_sensor_v1_2"], "subscription_plan_codes": []}, "route": "/api/farm-dashboard/", "user": {"email": "admin@example.com", "id": 1, "is_staff": true, "is_superuser": true, "phone_number": "0912345678", "role": "farmer", "username": "admin"}}, "result": {"features": {"farm_dashboard": {"allow": true, "allow_rules": [], "deny_rules": [], "matched_rules": []}}}, "requested_by": "172.29.0.8:38304", "timestamp": "2026-04-10T22:53:48.921478862Z", "metrics": {"counter_server_query_cache_hit": 1, "timer_rego_input_parse_ns": 270515, "timer_rego_query_eval_ns": 303118, "timer_server_handler_ns": 648768}, "req_id": 20}]}
|
||||
@@ -0,0 +1,68 @@
|
||||
package croplogic.authz
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
allow if {
|
||||
decision.allow
|
||||
}
|
||||
|
||||
decision := feature_decision(input.feature)
|
||||
|
||||
batch_decision := {
|
||||
"features": {
|
||||
feature: result |
|
||||
feature := input.features[_]
|
||||
result := feature_decision(feature)
|
||||
},
|
||||
}
|
||||
|
||||
feature_decision(feature) := {
|
||||
"allow": true,
|
||||
"matched_rules": [],
|
||||
"deny_rules": [],
|
||||
"allow_rules": [],
|
||||
} if {
|
||||
not has_feature_rule(feature)
|
||||
}
|
||||
|
||||
feature_decision(feature) := result if {
|
||||
has_feature_rule(feature)
|
||||
rule := feature_rule(feature)
|
||||
matched := [matched_rule | matched_rule := rule; action_match(matched_rule)]
|
||||
deny_rules := [matched_rule | matched_rule := matched[_]; not object.get(matched_rule, "allow", false)]
|
||||
allow_rules := [matched_rule | matched_rule := matched[_]; object.get(matched_rule, "allow", false)]
|
||||
count(deny_rules) == 0
|
||||
result := {
|
||||
"allow": true,
|
||||
"matched_rules": matched,
|
||||
"deny_rules": deny_rules,
|
||||
"allow_rules": allow_rules,
|
||||
}
|
||||
}
|
||||
|
||||
feature_decision(feature) := result if {
|
||||
has_feature_rule(feature)
|
||||
rule := feature_rule(feature)
|
||||
matched := [matched_rule | matched_rule := rule; action_match(matched_rule)]
|
||||
deny_rules := [matched_rule | matched_rule := matched[_]; not object.get(matched_rule, "allow", false)]
|
||||
allow_rules := [matched_rule | matched_rule := matched[_]; object.get(matched_rule, "allow", false)]
|
||||
count(deny_rules) > 0
|
||||
result := {
|
||||
"allow": false,
|
||||
"matched_rules": matched,
|
||||
"deny_rules": deny_rules,
|
||||
"allow_rules": allow_rules,
|
||||
}
|
||||
}
|
||||
|
||||
action_match(rule) if {
|
||||
count(object.get(rule, "actions_any", [])) == 0
|
||||
}
|
||||
|
||||
action_match(rule) if {
|
||||
requested_action := lower(sprintf("%v", [object.get(input, "action", "view")]))
|
||||
action := object.get(rule, "actions_any", [])[_]
|
||||
lower(sprintf("%v", [action])) == requested_action
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authz": {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package croplogic.authz
|
||||
|
||||
import rego.v1
|
||||
|
||||
has_feature_rule(feature) if {
|
||||
is_sensor_7_in_1_feature(feature)
|
||||
}
|
||||
|
||||
feature_rule(feature) := {
|
||||
"code": "sensor-7-in-1-requires-sensor-code",
|
||||
"allow": true,
|
||||
"reason": "sensor-7-in-1 feature requires sensor_codes to include a supported 7-in-1 sensor code",
|
||||
} if {
|
||||
is_sensor_7_in_1_feature(feature)
|
||||
has_any_supported_sensor_7_in_1_code
|
||||
}
|
||||
|
||||
feature_rule(feature) := {
|
||||
"code": "sensor-7-in-1-requires-sensor-code",
|
||||
"allow": false,
|
||||
"reason": "sensor-7-in-1 feature requires sensor_codes to include a supported 7-in-1 sensor code",
|
||||
} if {
|
||||
is_sensor_7_in_1_feature(feature)
|
||||
not has_any_supported_sensor_7_in_1_code
|
||||
}
|
||||
|
||||
is_sensor_7_in_1_feature(feature) if {
|
||||
lower(sprintf("%v", [feature])) == "sensor-7-in-1"
|
||||
}
|
||||
|
||||
has_any_supported_sensor_7_in_1_code if {
|
||||
supported_code := {"sensor-7-in-1", "sensor_7_soil_moisture_sensor_v1_2"}[_]
|
||||
has_sensor_code(supported_code)
|
||||
}
|
||||
|
||||
has_sensor_code(code) if {
|
||||
sensor_codes := object.get(input.resource, "sensor_codes", [])
|
||||
is_array(sensor_codes)
|
||||
sensor_code := sensor_codes[_]
|
||||
lower(sprintf("%v", [sensor_code])) == lower(sprintf("%v", [code]))
|
||||
}
|
||||
|
||||
has_sensor_code(code) if {
|
||||
sensor_code := object.get(input.resource, "sensor_codes", null)
|
||||
sensor_code != null
|
||||
not is_array(sensor_code)
|
||||
lower(sprintf("%v", [sensor_code])) == lower(sprintf("%v", [code]))
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import os
|
||||
import gzip
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
LOG_FILE = os.environ.get("OPA_REQUEST_LOG_FILE", "/logs/opa.log")
|
||||
PORT = int(os.environ.get("OPA_REQUEST_LOG_PORT", "8282"))
|
||||
|
||||
|
||||
class DecisionLogHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
raw_payload = self.rfile.read(content_length) if content_length else b""
|
||||
content_encoding = self.headers.get("Content-Encoding", "").lower()
|
||||
|
||||
if content_encoding == "gzip" and raw_payload:
|
||||
raw_payload = gzip.decompress(raw_payload)
|
||||
|
||||
payload = raw_payload.decode("utf-8") if raw_payload else ""
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"path": self.path,
|
||||
"headers": dict(self.headers.items()),
|
||||
"body": json.loads(payload) if payload else None,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as log_file:
|
||||
log_file.write(json.dumps(entry, ensure_ascii=True) + "\n")
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), DecisionLogHandler)
|
||||
server.serve_forever()
|
||||
@@ -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"])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user